Fix #251: refuse symlink/hardlink members during Tor bundle extraction (#277)

External audit (@tg12) flagged that the Tor Expert Bundle extractor
checked tarinfo.name against path traversal but never inspected
tarinfo.linkname for symlink or hardlink members. Python 3.11's
tarfile.extractall() honors symlinks, so a malicious archive could
ship a member like::

    name     = "innocent.txt"          (passes the path-traversal check)
    type     = SYMTYPE
    linkname = "C:\Windows\System32\config\system"

After extraction, subsequent reads of innocent.txt dereference to that
arbitrary filesystem location; subsequent writes corrupt it. On
Windows (where Tor Expert Bundle extraction actually runs), this is
a host-compromise path of essentially the same severity as the
supply-chain RCE in #231 — gated only by the integrity check we just
hardened in PR #261/#265.

Python 3.12+ added tarfile.extract / extractall filter='data' as a
built-in mitigation; we're on Python 3.11 in production, so we
implement the same idea manually.

Fix in backend/services/tor_hidden_service.py:

  Extract the existing path-traversal-only check into a new
  _extract_tor_bundle_safely() helper that:

  1. Refuses any member with member.issym() or member.islnk() True.
     Tor bundles never legitimately contain symlinks or hardlinks
     so this is non-disruptive. Logs the linkname so an operator
     can see what the malicious archive was trying to alias.
  2. Refuses any member that isn't isfile() or isdir() — no FIFOs,
     no character or block devices, no contiguous-file-type entries.
     None of those belong in a Tor Expert Bundle and accepting them
     is a class of bug we don't need to debug later.
  3. Preserves the original path-traversal guard (member.name must
     resolve under install_dir).
  4. Catches tarfile.TarError so a corrupt archive returns False
     gracefully instead of bubbling out an exception.

Tests: backend/tests/test_tor_bundle_symlink_filter.py (8 tests)
  - Clean archive with only regular files extracts successfully
  - Symlink member is rejected (the core regression)
  - Hardlink member is rejected
  - Symlink with relative target inside install_dir is still rejected
    (we don't allow symlinks at all, not just absolute-target ones)
  - FIFO/device-style member is rejected
  - Path-traversal guard still works under the new shape
  - Malformed/non-tar file is rejected gracefully (no crash)
  - Failure on one member rejects the whole bundle (no half-extract)

Validation:
  pytest backend/tests/test_tor_bundle_symlink_filter.py
         backend/tests/test_tor_bundle_verification.py
  -> 14 passed

UX impact: zero for legitimate Tor releases. Operators installing
a real Tor Expert Bundle continue to see "Tor installed at:" exactly
as before. Only malicious archives are refused, with a clear log
message identifying the rejected linkname.

Credit: @tg12 — the original report was specific enough that the
fix design was immediate.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shadowbroker
2026-05-21 01:41:13 -06:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7f96151e56
commit fb11e0881f
2 changed files with 313 additions and 8 deletions
+91 -8
View File
@@ -173,6 +173,94 @@ def _verify_tor_bundle(archive_path: Path, bundle_url: str) -> tuple[bool, str]:
return True, f"https-only (no digest source reachable, archive={actual_hash[:16]}...)"
def _extract_tor_bundle_safely(archive_path: Path, install_dir: Path) -> bool:
"""Extract a Tor Expert Bundle tar.gz safely.
Issue #251: the previous extractor checked tarinfo.name against path
traversal but never inspected tarinfo.linkname for symlink/hardlink
members. Python 3.11's tarfile honors symlinks during extractall(),
so a malicious archive could ship a member like::
name = "innocent.txt" # passes the path check
type = SYMTYPE
linkname = "C:\\Windows\\System32\\config\\system"
and extractall() would then create that symlink. Subsequent reads
of innocent.txt deference to a sensitive system file; subsequent
writes corrupt one. Tor bundles never legitimately contain symlinks
or hardlinks, so we refuse all link members categorically rather
than trying to validate linkname targets (which has its own pitfalls
around relative path resolution).
Also refuses non-regular-non-directory members (devices, FIFOs,
character/block special files) for completeness — none of those
belong in a Tor Expert Bundle and accepting them is a category of
bug we don't need to debug later.
Returns True on success, False on rejection (and logs the reason).
The caller is responsible for cleaning up the archive file.
"""
import tarfile
install_resolved = install_dir.resolve()
try:
with tarfile.open(str(archive_path), "r:gz") as tar:
for member in tar.getmembers():
# Reject anything that isn't a regular file or directory.
# Symlinks (SYMTYPE) and hardlinks (LNKTYPE) are the
# path-traversal vectors; the others (CHRTYPE, BLKTYPE,
# FIFOTYPE, CONTTYPE) have no legitimate use in a Tor
# Expert Bundle.
if member.issym() or member.islnk():
logger.error(
"Tor bundle extraction blocked: link member %s -> %s "
"(symlinks/hardlinks are not allowed in Tor bundles; "
"this archive is malformed or hostile)",
member.name,
member.linkname,
)
return False
if not (member.isfile() or member.isdir()):
logger.error(
"Tor bundle extraction blocked: unexpected member type "
"for %s (only regular files and directories are allowed)",
member.name,
)
return False
# Path traversal check (preserves the original guard).
try:
member_path = (install_dir / member.name).resolve()
except OSError as exc:
logger.error(
"Tor bundle extraction blocked: cannot resolve member "
"path %s: %s",
member.name,
exc,
)
return False
try:
member_path.relative_to(install_resolved)
except ValueError:
logger.error(
"Tor bundle extraction blocked: path traversal on %s "
"(resolves to %s, outside install dir %s)",
member.name,
member_path,
install_resolved,
)
return False
# All members validated — extract.
tar.extractall(path=str(install_dir))
except tarfile.TarError as exc:
logger.error("Tor bundle extraction failed: malformed tar (%s)", exc)
return False
return True
def _auto_install_tor() -> str | None:
"""Install or download Tor when it is safe to do so."""
if os.name != "nt":
@@ -203,14 +291,9 @@ def _auto_install_tor() -> str | None:
logger.info("Download complete, extracting...")
import tarfile
with tarfile.open(str(archive_path), "r:gz") as tar:
for member in tar.getmembers():
member_path = (TOR_INSTALL_DIR / member.name).resolve()
if not str(member_path).startswith(str(TOR_INSTALL_DIR.resolve())):
logger.error("Tar path traversal blocked: %s", member.name)
archive_path.unlink(missing_ok=True)
return None
tar.extractall(path=str(TOR_INSTALL_DIR))
if not _extract_tor_bundle_safely(archive_path, TOR_INSTALL_DIR):
archive_path.unlink(missing_ok=True)
return None
archive_path.unlink(missing_ok=True)