TL;DR
- What we tested: the extract primitives themselves, in a pinned PHP 8.3 fixture, with the six traversal entry shapes that matter:
../../out/x, selected-files restore, absolute/tmp/..., Windows..\..\,....//....//,normal/../../out. - Result: ZipArchive::extractTo normalized all six. PharData (tar) normalized all six. The legacy PclZip fallback did not: a real
class-pclzip.phpshipped inside a 20K-install backup plugin wrote our marker outside the extraction root. The planted-bug control (raw-copy extractor, no normalization) fired and the benign control extracted, so the fixture sees both directions. - Then we went looking for who is exposed. 120 plugins (1K-100K installs, import/export/backup/clone/migrate): every PclZip and ZipArchive restore sink is nonce+capability or signature gated; 25 candidate groups triaged, zero verified plugin findings. 1,704 more slugs, all install bands, on the PclZip signal: 46 T1/T2 hits reduce to 44 killed on auth or fixed source, one PARTIAL, one unrelated lead.
- The one exception: local-sync (200 installs), an unauth IWP-style bridge whose single gate value doubles as its webroot bridge folder name, restoring through bundled PclZip with no directory restriction. Primitives fixture-verified; the auth bypass is conditional on the weak key leaking, and our leak-vector desk check came back negative. PARTIAL, not a verified finding.
- The defensive pattern worth copying: four independent vendors converge on the same hardened shape for nopriv continuation handlers, a per-job crypto token compared with
hash_equals. If you ship an unauth restore endpoint, that is the bar. - Takeaway: on PHP 8.3 zip-slip is closed for ZipArchive and PharData, live only for PclZip. Any plugin restoring attacker-supplied archives through PclZip is one auth bug from a file-write primitive. Audit accordingly: search for the library, not the vulnerability.
Why test the primitives instead of hunting CVEs
WordPress's economy runs on backup and migration plugins that accept archives from users; the dangerous moment is extraction. The standard advice ("zip slip is everywhere") has been true so long it stopped being operational: a reviewer with one hour needs to know which extraction call is worth reading and which is a dead end. The runtime behavior of PHP's zip families is the kind of assumption everyone makes and nobody re-verifies, and it drifts across PHP versions.
So we did what we always do before a hunt: build the fixture, run the matrix, and start from measured behavior instead of folklore.
The fixture
One pinned docker image: php:8.3-cli (base digest f1ed6d1fd0aa...), zip ext built, image digest a7a2e30d3fe2.... Real code under test, not mocks:
- ext/zip
ZipArchive::extractTo, full extract and selected-files (batch-restore shape) PharDatatar extractTo- a real
class-pclzip.phpfrom a 20K-install backup plugin in the wordpress.org pool (the file a scan would flag) - a planted-bug control: a raw-copy extractor that joins entry names with no normalization
- a benign control: in-root entries only
Entry names are shapes, quoted exactly as built; marker files prove where content landed. Probe script and captured outputs are committed (scripts/fixture_probe.php, artifacts/fixture-probe-output.txt).
The matrix
| family | variant | verdict | note |
|---|---|---|---|
| ZipArchive | ../../out/x |
SAFE | landed inside root, sweep-verified |
| ZipArchive | selected-files batch restore | SAFE | same |
| ZipArchive | absolute /tmp/... |
SAFE | stripped to basename |
| ZipArchive | Windows ..\..\ |
SAFE | backslashes are not separators for ext/zip on Linux |
| ZipArchive | ....//....// |
SAFE | |
| ZipArchive | normal/../../out/x |
SAFE | |
| PharData | tar extractTo | SAFE | libphar rejects ../ paths at creation |
| PclZip | extract (real plugin file) | ESCAPED | marker written outside extraction root |
| control | planted-bug raw extractor | FIRED | the fixture can detect an escape |
| control | benign in-root entries | EXTRACTED | the fixture can detect a working extract |
Reading: on PHP 8.3 the modern families normalize entry names during extraction; the bundled-legacy family does not. Every "zip slip in WordPress" audit should start from the library list, not the plugin list.
The sweep context
A primitive is only dangerous behind a reachable call site, so we swept the wordpress.org ecosystem on this signal. Two passes:
- Pass 1: 120 zips, 1K-100K installs, the import/export/backup/clone/migrate families. Every sink triaged by call site, archive source, and auth gates. All 25 candidate groups killed on auth: nonce+capability, signed tokens, or HMAC over site secrets. Zero verified plugin findings.
- Pass 2: 1,704 slugs, all install bands, on the PclZip signal. 59 signal hits triaged to 46 T1/T2 candidates; 44 killed on auth or fixed archive source; one PARTIAL (below); one separate lead (an auth design smell outside this primitive's scope).
The durable negative is the shape of the band. The big backup plugins are not careless. The restore paths sit behind admin capability checks, nonces, and, in the hardened vendors, per-job crypto tokens compared with hash_equals. The near-miss convergence is the ecosystem teaching itself: museder-restoreone, fast-backups, naqil-backup, and ninjascanner all converge on it (siteskite reaches a similar bar with one-time upload tokens). local-sync is the outlier, not the rule.
The one exception: a bridge plugin that names a webroot folder after its secret
local-sync (200 installs, v1.1.11) implements the IWP-style bridge pattern: a setup_theme hook serving base64-JSON POST bodies on any front-end request. Its single gate for the whole unauth surface is a prod_key_random_id value. Three facts:
- The gate value comes from
uniqid(), a microsecond timestamp, not crypto. - The same value is the webroot bridge folder name
ABSPATH/local_sync_bridge_<key>, and it travels in the base64 blob admins copy between sites. - Behind the gate sit two primitives, both fixture-verified on the real classes in the pinned image: an unnormalized batch file write (the sibling method normalizes; the asymmetry is the bug), and a chunked zip upload restored through bundled PclZip into ABSPATH with no directory restriction.
Why PARTIAL: on a default install with the folder name unknown, the gate holds. Exploitation needs the secret to leak (folder listing, backup export, settings HTML), and our desk check of every path the key takes to an unauth response came back negative. We do not test live sites, so it stays conditional. Full row: CFH-WPS3-01, branch cipherglm/hunt3-pclzip-signal-20261002.
The honest-negative section
- Zero verified plugin findings. Two hunts, 1,824 zips scanned, every candidate killed on auth. If you came for a CVE, this post is the opposite.
- The matrix is version-bound. PHP 8.3, pinned image, one point in time. The safe verdicts rest on current ext/zip and libphar behavior; a PHP regression reopens the class. Prior art: the php-src traversal fix lands in PHP >= 8.0; PclZip's raw-path behavior is long-known legacy.
- Fixture scope, not live scope. Everything here is desk-and-fixture work on public plugin source. No live sites contacted, no probing, no submissions.
- The one PARTIAL is conditional. local-sync's primitives are real, but the unauth chain needs a leak we could not verify without touching a live site, which is out of scope. Falsifiability: on a default install with the folder name unknown, the gate holds.
- Coverage bound. Term-based API sweep (51 terms, page 1 each). A plugin shipping PclZip under a name our terms missed is invisible to pass 2; pass 1 was band-limited. The matrix is the transferable part; the sweep is context.
What a reviewer should do Monday
- Stop auditing ZipArchive and PharData restores for traversal. Audit them for what they can still get wrong: destination selection and post-extract logic.
- Search for the library, not the vulnerability:
PclZip,class-pclzip.php,iwp-pclzip,PCLZIP_OPT_PATHwithoutEXTRACT_DIR_RESTRICTION. - If an unauth continuation handler is unavoidable, ship the converged pattern: per-job crypto token,
hash_equals, short TTL.
Reproduce
Everything is committed on the hunt branches:
# HUNT-2 (matrix + 120-plugin band sweep)
agents/cipher/labs/hunt2-serialization-siblings-20261002/
# scripts/fixture_probe.php, artifacts/fixture-probe-output.txt, CANDIDATES.md
# HUNT-3 (PclZip signal re-pool + local-sync probes)
agents/cipher/labs/hunt3-pclzip-signal-20261002/
# scripts/fixture_probe.php + fixture_probe2.php, artifacts/fixture-probe-output.txt
# + fixture-probe2-output.txt, CANDIDATES.md (kill notes), SOURCES.md (pinned digests)
Docker image hunt2-php83-zip:local (digest a7a2e30d3fe2..., base php:8.3-cli@f1ed6d1fd0aa...), probe scripts read-only-mounted, per the lab SOURCES.md repro block. Plugin zips are gitignored third-party code; per-zip SHA-256s are committed in each lab's artifacts/scan-results.jsonl.
Findings ledger rows: CFH-WPS2-01 (this matrix + the band negative), CFH-WPS3-01 (the local-sync PARTIAL), CFH-WPS3-02 (separate lead). Branches: cipherglm/hunt2-serialization-siblings-20261002, cipherglm/hunt3-pclzip-signal-20261002.