{"repro_id":"REPRO-2026-00149","version":8,"title":"PraisonAI: ZipSlip path traversal via unchecked tar symlink linkname in _safe_extractall","repro_type":"security","status":"published","severity":"high","description":"`PraisonAI`'s `_safe_extractall` helper — used by all recipe\npull / publish / unpack flows — validates each tar member's **name** for\nabsolute paths, `..` components, and resolved-path escape. However, it never\nvalidates `member.linkname`.\n\nAn attacker can therefore include a symlink member whose link target points\n**outside** `dest_dir`. That symlink passes the name-only check and is\nextracted unchecked. A subsequent write entry in the same archive, traversing\nthrough the just-created symlink, then writes its contents **outside the\ndestination directory** — a classic ZipSlip path traversal.\n\nAny code path that pulls, publishes, or unpacks an attacker-supplied recipe\narchive is exploitable, since the recipe tar is the untrusted input.","root_cause":"# RCA Report: CVE-2026-44340 — PraisonAI ZipSlip via unchecked tar symlink member\n\n## Summary\n\nPraisonAI's `_safe_extractall` helper function in `src/praisonai/praisonai/recipe/registry.py` validates tar archive member names to prevent path traversal, but it does not validate `member.linkname` for symlink or hardlink members. This allows a malicious `.praison` recipe bundle containing a symlink with a relative `linkname` that escapes the destination directory (e.g., `../../outside`) to be extracted unchecked. The extracted symlink points outside the intended extraction directory, enabling a ZipSlip-style path traversal attack.\n\n## Impact\n\n- **Package**: `praisonai` (PyPI)\n- **Repository**: https://github.com/MervinPraison/PraisonAI\n- **Affected versions**: `< 4.6.37`\n- **Fixed version**: `4.6.37`\n- **CWE**: CWE-22 (Path Traversal)\n- **Severity**: High — CVSS 8.7\n- **Consequences**: Any code path that downloads, publishes, or unpacks an attacker-supplied recipe archive (via `LocalRegistry.pull()`, `HttpRegistry.pull()`, etc.) can be exploited to create symlinks outside the intended destination directory, potentially leading to arbitrary file write or overwrite on subsequent operations.\n\n## Root Cause\n\nThe `_safe_extractall` function iterates over all tar members and checks:\n1. `member.name` is not absolute\n2. `member.name` does not contain `..` components\n3. The resolved path of `member.name` stays within `dest_dir`\n\nHowever, it never inspects `member.linkname`. When a tar member is a symlink (`tarfile.SYMTYPE`) or hardlink (`tarfile.LNKTYPE`), `tarfile.TarFile.extractall()` creates the link on disk using `member.linkname` as the target. If that target points outside `dest_dir`, the link escapes the sandbox.\n\n**Fix commit**: `0cec9fd` — \"refactor: harden archive extraction and tool resolution boundary\"\n- https://github.com/MervinPraison/PraisonAI/commit/0cec9fd\n\nThe fix adds validation for symlink and hardlink members:\n- Rejects absolute `linkname` targets\n- Resolves the relative `linkname` against `dest_dir` and rejects targets that escape it\n- Also switches to `tar.extractall(dest_dir, filter=\"data\")` on Python 3.12+ for additional hardening\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following:\n\n1. Creates two isolated virtualenvs and installs `praisonai==4.6.36` (vulnerable) and `praisonai==4.6.37` (fixed).\n2. Builds a malicious `.praison` bundle (a gzipped tar) containing:\n   - A valid `manifest.json`\n   - A symlink member named `escape` with `linkname = ../../outside`\n3. Sets up a minimal `LocalRegistry` filesystem structure pointing at the malicious bundle.\n4. Invokes the user-facing `LocalRegistry.pull(\"testrecipe\", \"1.0.0\", output_dir=..., verify_checksum=False)` API on both versions.\n5. Captures whether the symlink was extracted and whether it resolves outside the output directory.\n\n**Expected evidence**:\n- **Vulnerable (4.6.36)**: `pull()` succeeds without error. A symlink `escape` is created inside the output directory, resolving to a path outside it (`../../outside`).\n- **Fixed (4.6.37)**: `pull()` raises `RegistryError: Refusing to extract link escaping target directory: escape -> ../../outside`. No symlink is extracted.\n\n## Evidence\n\n- **Vulnerable run log**: `logs/vulnerable.log`\n  - `RESULT: no_exception`\n  - `PATH: .../repro/output/vuln/testrecipe`\n  - Filesystem: symlink `escape -> ../../outside` exists and resolves outside the destination directory.\n\n- **Fixed run log**: `logs/fixed.log`\n  - `RESULT: registry_error`\n  - `ERROR: Refusing to extract link escaping target directory: escape -> ../../outside`\n  - Filesystem: no symlink created.\n\n- **Runtime manifest**: `repro/runtime_manifest.json`\n  - Contains `verdict: confirmed`, structured pass/fail booleans, and captured logs for both versions.\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `praisonai >= 4.6.37`.\n2. **Validate archive extraction logic** across the entire codebase. Any custom `extractall` wrapper should validate both member names and link targets.\n3. **Add regression tests** for symlink and hardlink traversal, as the upstream project did in `test_safe_extractall_symlink.py`.\n4. **Use `tarfile.extractall(filter=\"data\")`** on Python 3.12+ to benefit from built-in data-only extraction filters.\n5. **Consider disabling symlink/hardlink extraction entirely** in recipe bundles if they are not required for functionality.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was run twice consecutively and produced identical results both times.\n- **Limitations**: The reproduction demonstrates symlink extraction outside `dest_dir`. The actual write-through follow-up attack (a regular file member whose path traverses the created symlink) depends on `tarfile.extractall()` behavior and Python version; the core vulnerability is the unchecked symlink extraction itself, which is what the upstream fix addresses.\n- **Environment**: Python 3.11, Ubuntu-based container.\n","ghsa_id":"GHSA-9q28-ghcr-c4x3","cve_id":"CVE-2026-44340","cwe_id":"CWE-22 (Path Traversal / ZipSlip)","source_url":"MervinPraison/PraisonAI","package":{"name":"praisonai","ecosystem":"pip","affected_versions":"< 4.6.37 (specifically < 4.6.36; earliest affected at least 2.7.2)","fixed_version":"4.6.37","tested_patched":"4.6.37"},"reproduced_at":"2026-05-22T18:09:17.939640+00:00","duration_secs":1062.0284807682035,"tool_calls":158,"turns":134,"handoffs":2,"total_cost_usd":1.5616192300000005,"agent_costs":{"repro":0.32365211,"support":0.0279299,"vuln_variant":1.2100372199999998},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.32365211},"support":{"accounts/fireworks/models/kimi-k2p6":0.0279299},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.2100372199999998}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:09:20.096463+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5020,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6568,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":6558,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":2240,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2892,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":704,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3399,"category":"ticket"},{"path":"repro/.venv_fix/pyvenv.cfg","filename":"pyvenv.cfg","size":216,"category":"other"},{"path":"repro/.venv_fix/bin/distro","filename":"distro","size":272,"category":"other"},{"path":"repro/.venv_fix/bin/idna","filename":"idna","size":267,"category":"other"},{"path":"repro/.venv_fix/bin/markdown-it","filename":"markdown-it","size":280,"category":"other"},{"path":"repro/.venv_fix/bin/tqdm","filename":"tqdm","size":267,"category":"other"},{"path":"repro/.venv_fix/bin/pip3","filename":"pip3","size":281,"category":"other"},{"path":"repro/.venv_fix/bin/tiny-agents","filename":"tiny-agents","size":291,"category":"other"},{"path":"repro/.venv_fix/bin/activate.fish","filename":"activate.fish","size":2245,"category":"other"},{"path":"repro/.venv_fix/bin/pip3.11","filename":"pip3.11","size":281,"category":"other"},{"path":"repro/.venv_fix/bin/hf","filename":"hf","size":281,"category":"other"},{"path":"repro/.venv_fix/bin/litellm","filename":"litellm","size":278,"category":"other"},{"path":"repro/.venv_fix/bin/normalizer","filename":"normalizer","size":293,"category":"other"},{"path":"repro/.venv_fix/bin/huggingface-cli","filename":"huggingface-cli","size":293,"category":"other"},{"path":"repro/.venv_fix/bin/praisonai","filename":"praisonai","size":277,"category":"other"},{"path":"repro/.venv_fix/bin/markdown_py","filename":"markdown_py","size":274,"category":"other"},{"path":"repro/.venv_fix/bin/httpx","filename":"httpx","size":264,"category":"other"},{"path":"repro/.venv_fix/bin/activate.csh","filename":"activate.csh","size":969,"category":"other"},{"path":"repro/.venv_fix/bin/Activate.ps1","filename":"Activate.ps1","size":9033,"category":"other"},{"path":"repro/.venv_fix/bin/pip","filename":"pip","size":281,"category":"other"},{"path":"repro/.venv_fix/bin/dotenv","filename":"dotenv","size":272,"category":"other"},{"path":"repro/.venv_fix/bin/uvicorn","filename":"uvicorn","size":271,"category":"other"},{"path":"repro/.venv_fix/bin/setup-conda-env","filename":"setup-conda-env","size":290,"category":"other"},{"path":"repro/.venv_fix/bin/activate","filename":"activate","size":1745,"category":"other"},{"path":"repro/.venv_fix/bin/pygmentize","filename":"pygmentize","size":275,"category":"other"},{"path":"repro/.venv_fix/bin/mcp","filename":"mcp","size":264,"category":"other"},{"path":"repro/.venv_fix/bin/praisonai-call","filename":"praisonai-call","size":277,"category":"other"},{"path":"repro/.venv_fix/bin/litellm-proxy","filename":"litellm-proxy","size":281,"category":"other"},{"path":"repro/.venv_fix/bin/typer","filename":"typer","size":268,"category":"other"},{"path":"repro/.venv_fix/bin/jsonschema","filename":"jsonschema","size":273,"category":"other"},{"path":"repro/output/vuln/testrecipe/manifest.json","filename":"manifest.json","size":78,"category":"other"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":502,"category":"other"},{"path":"repro/malicious.tar.gz","filename":"malicious.tar.gz","size":101,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1408,"category":"other"},{"path":"repro/registry/recipes/testrecipe/1.0.0/testrecipe-1.0.0.praison","filename":"testrecipe-1.0.0.praison","size":193,"category":"other"},{"path":"repro/registry/index.json","filename":"index.json","size":304,"category":"other"},{"path":"repro/write_manifest.py","filename":"write_manifest.py","size":788,"category":"script"},{"path":"repro/test_zipslip.py","filename":"test_zipslip.py","size":990,"category":"script"},{"path":"repro/.venv_vuln/pyvenv.cfg","filename":"pyvenv.cfg","size":217,"category":"other"},{"path":"repro/.venv_vuln/bin/distro","filename":"distro","size":273,"category":"other"},{"path":"repro/.venv_vuln/bin/idna","filename":"idna","size":268,"category":"other"},{"path":"repro/.venv_vuln/bin/markdown-it","filename":"markdown-it","size":281,"category":"other"},{"path":"repro/.venv_vuln/bin/tqdm","filename":"tqdm","size":268,"category":"other"},{"path":"repro/.venv_vuln/bin/pip3","filename":"pip3","size":282,"category":"other"},{"path":"repro/.venv_vuln/bin/tiny-agents","filename":"tiny-agents","size":292,"category":"other"},{"path":"repro/.venv_vuln/bin/activate.fish","filename":"activate.fish","size":2248,"category":"other"},{"path":"repro/.venv_vuln/bin/pip3.11","filename":"pip3.11","size":282,"category":"other"},{"path":"repro/.venv_vuln/bin/hf","filename":"hf","size":282,"category":"other"},{"path":"repro/.venv_vuln/bin/litellm","filename":"litellm","size":279,"category":"other"},{"path":"repro/.venv_vuln/bin/normalizer","filename":"normalizer","size":294,"category":"other"},{"path":"repro/.venv_vuln/bin/huggingface-cli","filename":"huggingface-cli","size":294,"category":"other"},{"path":"repro/.venv_vuln/bin/praisonai","filename":"praisonai","size":278,"category":"other"},{"path":"repro/.venv_vuln/bin/markdown_py","filename":"markdown_py","size":275,"category":"other"},{"path":"repro/.venv_vuln/bin/httpx","filename":"httpx","size":265,"category":"other"},{"path":"repro/.venv_vuln/bin/activate.csh","filename":"activate.csh","size":972,"category":"other"},{"path":"repro/.venv_vuln/bin/Activate.ps1","filename":"Activate.ps1","size":9033,"category":"other"},{"path":"repro/.venv_vuln/bin/pip","filename":"pip","size":282,"category":"other"},{"path":"repro/.venv_vuln/bin/dotenv","filename":"dotenv","size":273,"category":"other"},{"path":"repro/.venv_vuln/bin/uvicorn","filename":"uvicorn","size":272,"category":"other"},{"path":"repro/.venv_vuln/bin/setup-conda-env","filename":"setup-conda-env","size":291,"category":"other"},{"path":"repro/.venv_vuln/bin/activate","filename":"activate","size":1748,"category":"other"},{"path":"repro/.venv_vuln/bin/pygmentize","filename":"pygmentize","size":276,"category":"other"},{"path":"repro/.venv_vuln/bin/mcp","filename":"mcp","size":265,"category":"other"},{"path":"repro/.venv_vuln/bin/praisonai-call","filename":"praisonai-call","size":278,"category":"other"},{"path":"repro/.venv_vuln/bin/litellm-proxy","filename":"litellm-proxy","size":282,"category":"other"},{"path":"repro/.venv_vuln/bin/typer","filename":"typer","size":269,"category":"other"},{"path":"repro/.venv_vuln/bin/jsonschema","filename":"jsonschema","size":274,"category":"other"},{"path":"vuln_variant/.venv_latest/pyvenv.cfg","filename":"pyvenv.cfg","size":226,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/distro","filename":"distro","size":282,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/idna","filename":"idna","size":277,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/markdown-it","filename":"markdown-it","size":290,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/tqdm","filename":"tqdm","size":277,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/pip3","filename":"pip3","size":291,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/tiny-agents","filename":"tiny-agents","size":301,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/activate.fish","filename":"activate.fish","size":2261,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/pip3.11","filename":"pip3.11","size":291,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/hf","filename":"hf","size":291,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/litellm","filename":"litellm","size":288,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/normalizer","filename":"normalizer","size":303,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/huggingface-cli","filename":"huggingface-cli","size":303,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/praisonai","filename":"praisonai","size":287,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/markdown_py","filename":"markdown_py","size":284,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/httpx","filename":"httpx","size":274,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/activate.csh","filename":"activate.csh","size":985,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/Activate.ps1","filename":"Activate.ps1","size":9033,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/pip","filename":"pip","size":291,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/dotenv","filename":"dotenv","size":282,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/uvicorn","filename":"uvicorn","size":281,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/setup-conda-env","filename":"setup-conda-env","size":300,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/activate","filename":"activate","size":1761,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/pygmentize","filename":"pygmentize","size":285,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/mcp","filename":"mcp","size":274,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/praisonai-call","filename":"praisonai-call","size":287,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/litellm-proxy","filename":"litellm-proxy","size":291,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/typer","filename":"typer","size":278,"category":"other"},{"path":"vuln_variant/.venv_latest/bin/jsonschema","filename":"jsonschema","size":283,"category":"other"},{"path":"vuln_variant/test_variants.py","filename":"test_variants.py","size":6804,"category":"script"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":5168,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2432,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1373,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":755,"category":"other"},{"path":"logs/variant_attempts.log","filename":"variant_attempts.log","size":2329,"category":"log"},{"path":"logs/vulnerable.log","filename":"vulnerable.log","size":105,"category":"log"},{"path":"logs/fixed.log","filename":"fixed.log","size":106,"category":"log"}]}