home intel cve-2026-41589-wish-scp-path-traversal
CVE Analysis 2026-05-07 · 8 min read

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.

#path-traversal#scp-protocol#directory-escape#ssh-server#file-disclosure
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-41589 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-41589CRITICALSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

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.Join does 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 — wish v2.0.0):

func (h *handler) WriteFile(rootDir string, info FileInfo) (io.WriteCloser, error) {
    dst := filepath.Join(rootDir, info.Filename())
    // No containment check. Any filename accepted.
    if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
        return nil, err
    }
    return os.Create(dst)
}

// AFTER (patched — wish v2.0.1):

func (h *handler) WriteFile(rootDir string, info FileInfo) (io.WriteCloser, error) {
    dst := filepath.Join(rootDir, info.Filename())

    // PATCH: ensure resolved path is still inside rootDir.
    // filepath.Clean(rootDir) handles trailing-slash inconsistencies.
    cleanRoot := filepath.Clean(rootDir)
    if !strings.HasPrefix(dst, cleanRoot+string(os.PathSeparator)) &&
        dst != cleanRoot {
        return nil, fmt.Errorf("scp: invalid path %q escapes root %q",
            dst, rootDir)
    }

    if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
        return nil, err
    }
    return os.Create(dst)
}
// 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:

grep -n "HasPrefix" $(go env GOPATH)/pkg/mod/charm.land/wish/v2@v2.0.1/scp/handler.go
# Expected: lines showing the strings.HasPrefix containment check

Defense-in-depth (regardless of patch status):

  • 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.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →