CVE-2026-41589: Path Traversal in Wish SCP Middleware
The Wish SSH server's SCP middleware fails to sanitize ../sequences in client-supplied filenames, allowing arbitrary read/write and directory creation outside the configured root.
A popular piece of server software called Wish, used to securely transfer files between computers, has a critical flaw that lets hackers read, modify, or delete files they shouldn't have access to. Think of it like a bank teller who checks your ID at the counter but then lets you walk straight into the vault without checking again.
The problem is in how the software handles file paths. When someone requests a file using a specific naming trick (adding ../ sequences, which basically means "go up one folder"), the software doesn't properly verify that they're staying within their allowed area. It's like giving someone access to a locked filing cabinet, but the lock doesn't actually prevent them from using a crowbar on the drawers next to it.
An attacker could exploit this to access sensitive files on the server, steal data, plant malicious code, or destroy important information. For companies running this software—especially those handling confidential documents or managing web servers—this is a serious threat. Your personal data could be exposed if a service you use relies on this vulnerable software.
The good news is that there's no evidence hackers are actively exploiting this yet, which means there's a window to fix it before bad actors catch on.
If you run servers or manage IT infrastructure, check immediately whether you're using Wish versions 2.0.0 or 2.0.1. Update to the patched version as soon as it's available. If you can't update right away, restrict network access to your servers so only trusted computers can connect. Contact your software vendor or check their security advisories for specific guidance on fixing this in your setup.
Want the full technical analysis? Click "Technical" above.
CVE-2026-41589 is a path traversal vulnerability (CVSS 9.6 Critical) in the SCP middleware shipped with charm.land/wish/v2, the Go-based SSH server framework maintained by Charmbracelet. Versions 2.0.0 up to (but not including) 2.0.1 are affected. A malicious SCP client can supply filenames containing ../ sequences that are never sanitized before being passed to filesystem operations, granting the attacker unrestricted read access, unrestricted write access, and the ability to mkdir at arbitrary locations on the host filesystem — regardless of the configured SCP root directory.
Because the SCP protocol is a thin wrapper around shell-invoked file operations, the server-side handler bears sole responsibility for validating supplied paths. Wish delegated that responsibility to the middleware, which trusted the client entirely.
Root cause: The Wish SCP middleware passes client-controlled filenames directly to filepath.Join (and subsequently to os.Open / os.Create / os.MkdirAll) without first stripping or rejecting ../ components, allowing path escape from the configured root directory.
Affected Component
The vulnerable code lives in the scp sub-package of the Wish module:
charm.land/wish/v2/scp
└── handler.go ← path join without sanitization
└── scp.go ← SCP protocol state machine, parses filenames from wire
The SCP state machine in scp.go reads a control message from the client, parses the filename field from it, and passes that string — verbatim — to the handler. The handler then constructs a host path by joining the configured root with the attacker-supplied name. No canonicalization, no filepath.Clean-then-prefix-check, no rejection of .. components occurs before the 2.0.1 patch.
Root Cause Analysis
The SCP protocol uses a simple ASCII control channel. For a file copy, the client sends a line such as:
C0644 1024 ../../../../etc/passwd\n
The middleware parses this into three fields — mode, size, name — and then hands name to the filesystem handler. The vulnerable Go pseudocode reconstructed from the pre-patch source and patch diff:
// scp/handler.go — vulnerable, wish v2.0.0
// rootDir is the operator-configured jail root, e.g. "/srv/scp-root"
func (h *handler) WriteFile(rootDir string, info FileInfo) (io.WriteCloser, error) {
// info.Filename() is parsed directly from the SCP control message.
// BUG: filepath.Join resolves ".." components silently; no prefix
// check is performed before opening the destination path.
dst := filepath.Join(rootDir, info.Filename()) // BUG: path traversal here
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return nil, err
}
return os.Create(dst) // creates file at attacker-controlled absolute path
}
func (h *handler) ReadFile(rootDir string, info FileInfo) (io.ReadCloser, error) {
// BUG: same pattern for reads — no containment check
src := filepath.Join(rootDir, info.Filename()) // BUG: path traversal here
return os.Open(src)
}
func (h *handler) MakeDir(rootDir string, info FileInfo) error {
// BUG: directory creation also unsanitized
dir := filepath.Join(rootDir, info.Filename()) // BUG: path traversal here
return os.MkdirAll(dir, info.Mode())
}
The subtlety here is that filepath.Joindoes call filepath.Clean internally, which resolves ../ sequences lexically. The result is a clean, absolute path that simply points outside rootDir. Developers often conflate "clean path" with "safe path"; they are not equivalent. The missing check is a post-join prefix assertion:
// The check that should have been here:
if !strings.HasPrefix(dst, filepath.Clean(rootDir) + string(os.PathSeparator)) {
return nil, fmt.Errorf("scp: path escape detected: %q", dst)
}
Exploitation Mechanics
EXPLOIT CHAIN — Arbitrary File Write (e.g., SSH authorized_keys injection):
1. Attacker authenticates to the Wish SSH server (credentials may be
publicly available, or the server may allow anonymous access).
2. Attacker invokes SCP in sink mode, targeting the server:
scp -t /ignored_by_server
3. Server enters SCP sink mode; Wish SCP middleware awaits control messages
on the SSH channel's stdio streams.
4. Attacker sends a crafted 'D' (mkdir) control message:
D0755 0 ../../../../home/victim/.ssh\n
→ Wish calls MakeDir(); filepath.Join resolves to /home/victim/.ssh
→ os.MkdirAll("/home/victim/.ssh", 0755) succeeds.
5. Attacker sends a crafted 'C' (file) control message:
C0600 571 ../../../../home/victim/.ssh/authorized_keys\n
followed by 571 bytes of attacker-controlled SSH public key material.
→ Wish calls WriteFile(); filepath.Join resolves to
/home/victim/.ssh/authorized_keys
→ os.Create() truncates and overwrites the file.
6. Attacker disconnects SCP session.
7. Attacker SSH-authenticates directly to the host as 'victim' using the
injected private key — full shell access achieved.
EXPLOIT CHAIN — Arbitrary File Read (e.g., private key exfiltration):
1. Authenticate to Wish SSH server (same as above).
2. Invoke SCP in source mode:
scp -f /ignored
3. Send a crafted filename in the SCP source request:
../../../../etc/ssh/ssh_host_ed25519_key
4. Wish calls ReadFile(); os.Open() returns a handle to the host private key.
5. SCP protocol streams file contents to the attacker's local machine.
No memory corruption is involved. The vulnerability is pure logic: the server unconditionally trusts the client-supplied filename. Exploitation requires only a standard scp binary and SSH credentials (or a custom SCP client for unauthenticated scenarios if the server is configured with wish.WithPublicKeyAuth accepting any key).
Memory Layout
This is a filesystem logic bug, not a memory corruption issue. The relevant "layout" is the filesystem namespace the process operates in:
FILESYSTEM STATE — Configured jail (operator intent):
rootDir = /srv/scp-root/
├── uploads/
│ └── userfile.txt
└── (no access outside this tree)
FILESYSTEM STATE — After path traversal (attacker reality):
filepath.Join("/srv/scp-root", "../../../../etc/passwd")
→ filepath.Clean("/srv/scp-root/../../../../etc/passwd")
→ "/etc/passwd" ← escapes root entirely
filepath.Join("/srv/scp-root", "../../../../home/victim/.ssh/authorized_keys")
→ "/home/victim/.ssh/authorized_keys" ← write target
Process effective UID determines actual access; if Wish runs as root
(common in containerized SSH deployments), ALL paths are reachable.
PATH TRAVERSAL DEPTH vs. REQUIRED ../ COUNT:
rootDir depth 3 (/srv/scp-root): needs ≥3x "../" → "../../../etc/passwd"
rootDir depth 2 (/srv/uploads): needs ≥2x "../" → "../../etc/passwd"
rootDir depth 1 (/uploads): needs ≥1x "../" → "../etc/passwd"
Extra "../" beyond filesystem root are harmless (os.Open normalizes them),
so "../../../../../../../../etc/passwd" is universally effective.
Patch Analysis
The fix introduced in v2.0.1 adds a path containment check immediately after the filepath.Join call in every filesystem operation exposed by the SCP handler. The pattern is consistent across read, write, and mkdir paths:
// BEFORE (vulnerable — MakeDir):
func (h *handler) MakeDir(rootDir string, info FileInfo) error {
dir := filepath.Join(rootDir, info.Filename())
return os.MkdirAll(dir, info.Mode())
}
// AFTER (patched — MakeDir):
func (h *handler) MakeDir(rootDir string, info FileInfo) error {
dir := filepath.Join(rootDir, info.Filename())
cleanRoot := filepath.Clean(rootDir)
if !strings.HasPrefix(dir, cleanRoot+string(os.PathSeparator)) &&
dir != cleanRoot {
return fmt.Errorf("scp: invalid path %q escapes root %q",
dir, rootDir)
}
return os.MkdirAll(dir, info.Mode())
}
The fix correctly uses filepath.Clean on rootDir before the prefix check to normalize any trailing slashes or redundant separators, preventing a bypass via rootDir itself containing ../. The dst == cleanRoot arm handles the edge case where the destination path exactly equals the root directory (a valid, non-escaping case).
Detection and Indicators
In SSH server logs, look for SCP sessions where the transmitted filename contains .. sequences. Wish itself (pre-patch) emits no warning; detection requires external log analysis or auditd rules.
INDICATORS OF COMPROMISE:
SSH audit log patterns (sshd / wish access log):
- SCP sessions with abnormally short session duration (file grab and exit)
- Multiple SCP sessions from same source IP in rapid succession
- SCP connections to a server that doesn't advertise SCP as intended use
auditd rules to catch the traversal at the kernel level:
-a always,exit -F arch=b64 -S openat -F dir=/etc -F success=1 \
-k scp_traversal_read
-a always,exit -F arch=b64 -S openat -F dir=/home -F success=1 \
-F a2&0100 -k scp_traversal_write # O_WRONLY or O_RDWR
Go runtime detection (if you control the binary):
// Instrument filepath.Join call sites in the scp package:
if strings.Contains(info.Filename(), "..") {
log.Warnf("SCP traversal attempt: filename=%q remote=%s",
info.Filename(), session.RemoteAddr())
}
File integrity monitoring targets (post-exploitation artifacts):
/home/*/.ssh/authorized_keys — mtime change during SCP session
/root/.ssh/authorized_keys
/etc/cron.d/* — dropper persistence
/etc/sudoers.d/*
Remediation
Immediate: Upgrade charm.land/wish/v2 to v2.0.1 or later.
go get charm.land/wish/v2@v2.0.1
go mod tidy
Verification: After upgrading, confirm the patched containment check is present:
Run Wish in a container or VM with a filesystem namespace that physically cannot reach sensitive paths — the process root should be the SCP root via chroot or a mount namespace.
Drop process privileges to a dedicated UID with no access outside the SCP root before accepting connections.
Apply a seccomp profile that restricts openat/open to paths under the intended root using Landlock LSM (Linux 5.13+).
If SCP is not required, remove the SCP middleware from the Wish handler chain entirely — the vulnerability surface is zero without it.