#3930·bunkerweb

Tar member pre-validation does not account for symlink chains, allowing extraction outside the destination on runtimes without the native tarfile filter

Author: Ayushsinha322Created Sep 18, 2026Updated Sep 18, 2026
Labelsbugsecuritycore

Affected area

coresrc/common/utils/common_utils.py, tar member pre-validation.

What happened?

_validate_tar_members() validates each member's declared path, and each symlink's target relative to that declared parent. But TarFile follows symlinks that earlier members already created, so a member's declared path is not necessarily where it is written.

A two-hop chain passes every check and escapes:

dir/                    directory
dir/up       -> ..      normpath("dir/..")       = "."    accepted
dir/up/up2   -> ..      normpath("dir/up/..")    = "dir"  accepted
dir/up/up2/pwned        declared inside the root          accepted

At extraction dir/up points at the destination root, so up2 materialises at <dest>/up2 pointing outside it, and pwned is written through that link.

The docstring states "Checks archive metadata only — no disk access — so PATH_MAX symlink chain attacks are impossible." That guarantee does not currently hold for chains.

How to reproduce?

python
import io, tarfile, tempfile, os, sys
sys.path.insert(0, "src/common/utils")
import common_utils as cu

def m(n, *, k=tarfile.REGTYPE, l=None, d=b""):
    t = tarfile.TarInfo(n); t.type = k
    if l is not None: t.linkname = l
    t.size = len(d) if k == tarfile.REGTYPE else 0
    return t, (io.BytesIO(d) if k == tarfile.REGTYPE else None)

buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as t:
    for ti, f in (m("dir", k=tarfile.DIRTYPE),
                  m("dir/up", k=tarfile.SYMTYPE, l=".."),
                  m("dir/up/up2", k=tarfile.SYMTYPE, l=".."),
                  m("dir/up/up2/pwned", d=b"ESCAPED\n")):
        t.addfile(ti, f)

outer = tempfile.mkdtemp(); dest = os.path.join(outer, "dest"); os.mkdir(dest)
buf.seek(0)
with tarfile.open(fileobj=buf, mode="r:gz") as t:
    cu.safe_tar_extractall(t, dest, tar_filter="auto")
print("escaped:", os.path.exists(os.path.join(outer, "pwned")))

Measured across the supported runtimes

Same archive, tar_filter="auto", against dev (378533c):

Runtime python native tar filter chain + payload chain, links only
Docker / all-in-one (alpine) 3.14.7 yes, CVE-2025-4517 hardened refused extracted
Ubuntu jammy 3.10.12 backported refused extracted
Debian bookworm 3.11.2 absent extracted — file written outside destination extracted

Two distinct consequences:

  1. Where the native filter is absent, the pre-validation is the only defence and the chain writes a file outside the destination. src/linux/fpm-debian-bookworm declares --depends python3.11, and bookworm's python3.11 is 3.11.2, which has neither data_filter nor the extractall(filter=...) parameter — so _supports_tar_filter() is False and safe_tar_extractall falls through to a plain extractall(). The Linux scripts invoke plain python3, so this is the interpreter a Debian install actually runs.
  2. On every runtime, including hardened 3.14, the links-only chain still extracts and leaves a symlink inside the restored directory pointing outside it, because the member lands somewhere other than its declared path. Python's filter permits that — creating such a link is not itself an escape — but it means declared paths and materialised paths diverge, which is the assumption the validator rests on.

What this is not

I want to scope this honestly rather than overstate it.

This is not a remotely exploitable vulnerability, and I am filing it publicly for that reason. Every attacker-adjacent caller uses the default tar_filter="data", which sets allow_symlinks=False and refuses all symlink and hardlink members outright — that covers UI plugin upload (ui/app/routes/plugins.py, ui/app/dependencies.py), download-plugins.py and download-crs-plugins.py. Only two paths pass "auto"/"tar": job-cache restore (cache_restore.restore_directory, reached from scheduler/main.py and utils/jobs.py) and the Let's Encrypt UI restore. In both, the archive bytes are produced by BunkerWeb from local directories rather than supplied by a remote party.

So this is a defence-in-depth control with a gap on exactly the runtime where it is the sole defence. Worth closing, not worth alarming anyone about. If you read the exposure differently and would rather this had gone to [email protected], say so and I'll follow your lead next time.

Suggested fix

Resolve each member's path against the symlink members declared so far, so a member is validated where it actually lands, and resolve a symlink's target from there rather than from its declared parent. Metadata only, no disk access, so the docstring's guarantee is preserved; a hop limit bounds symlink loops.

I have this implemented and tested — PR to follow. Benign layouts are unaffected: Let's Encrypt live/ -> ../archive/ trees, nested siblings and same-directory links all still extract.

BunkerWeb version

dev at 378533c. Also present in 1.6.15~rc3.

What integration are you using?

Docker (all-in-one) and Linux packages — the consequence differs between them, see the table.

Notes

I work on WAF engineering commercially; the numbers here are BunkerWeb measured against itself, no comparative data.