How to check zip file contents
Read the archive's own claims before an extractor acts on them. unzip -l report-bundle.zip prints six files and 80 bytes, yet one entry inflates to 204800 bytes and another is named ../../../../etc/cron.d/report. Names, declared sizes and the symlink bit decide whether the upload is opened at all.
Why check this
Run this against any endpoint that accepts an archive, before release and whenever the extraction library or the storage path changes. The check sits between the upload and the extraction, while the archive is still one opaque file.
The failure it prevents is an extractor that trusts an entry name. Step 7 resolves ../../../../etc/cron.d/report four levels above the upload folder, and a cron file written there runs on a schedule nobody reviewed. The second failure is a worker that sizes its job from the header: this archive declares 80 bytes and writes 204868.
Prerequisites
- Python 3.13.1 for the specimen and the zipfile module.
- Node 22 for the reader and the gate. No packages.
- Info-ZIP unzip 6.00 for steps 1 and 2, and .NET for step 4.
- Port 8931 free for step 6. Stop the gate by its process id afterwards.
- An empty temporary directory. The specimen names entries pointing outside it, and nothing here hands it to an extractor that writes where the entry says.
Save the builder as make-specimen.py and run it once.
import zipfile, struct
def info(name, symlink=False):
zi = zipfile.ZipInfo(name, date_time=(2026, 9, 12, 10, 0, 0))
zi.create_system, zi.compress_type = 3, zipfile.ZIP_DEFLATED
zi.external_attr = (0o120777 if symlink else 0o100644) << 16
return zi
with zipfile.ZipFile("report-bundle.zip", "w") as z:
z.writestr(info("docs/summary.txt"), "quarterly summary\n")
z.writestr(info("inventory.csv"), "A" * 204800)
z.writestr(info("../../../../etc/cron.d/report"), "* * * * * root /tmp/x\n")
z.writestr(info("/var/www/html/report.php"), "<?php ?>\n")
z.writestr(info(r"docs\..\..\startup.bat"), "echo hi\n")
z.writestr(info("docs/latest", symlink=True), "/etc/shadow")
with zipfile.ZipFile("quarterly.zip", "w") as z:
z.writestr(info("docs/summary.txt"), "quarterly summary\n")
z.writestr(info("docs/figures.csv"), "q,value\n1,42\n")
# Two edits to the bytes of report-bundle.zip. The declared uncompressed size of
# inventory.csv becomes 12 in the local header and in the central directory, and
# the backslash name goes back in, because Python rewrites os.sep to "/" here.
d = bytearray(open("report-bundle.zip", "rb").read())
for sig, size_at, nlen_at, name_at in ((b"PK\x03\x04", 22, 26, 30), (b"PK\x01\x02", 24, 28, 46)):
i = d.find(sig)
while i >= 0:
nlen = struct.unpack_from("<H", d, i + nlen_at)[0]
if bytes(d[i + name_at : i + name_at + nlen]) == b"inventory.csv":
struct.pack_into("<I", d, i + size_at, 12)
i = d.find(sig, i + 4)
open("report-bundle.zip", "wb").write(bytes(d).replace(b"docs/../../startup.bat", rb"docs\..\..\startup.bat"))
print("report-bundle.zip and quarterly.zip written")
Save the reader as zipgate.mjs. It parses the central directory, measures each entry under a cap, serves the verdict with --serve, and writes only inside one directory with --extract.
import { createServer } from 'node:http';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve, sep, dirname } from 'node:path';
import { inflateRawSync } from 'node:zlib';
const CAP = 1 << 20; // no entry may inflate past this while we measure it
export function inspect(buf) {
let e = buf.length - 22;
while (e >= 0 && buf.readUInt32LE(e) !== 0x06054b50) e -= 1;
if (e < 0) throw new Error('no end of central directory record');
const count = buf.readUInt16LE(e + 10);
let p = buf.readUInt32LE(e + 16);
const entries = [];
for (let i = 0; i < count; i += 1) {
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`entry ${i}: bad central header`);
const nameLen = buf.readUInt16LE(p + 28);
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
const mode = buf.readUInt32LE(p + 38) >>> 16;
const lo = buf.readUInt32LE(p + 42);
const at = lo + 30 + buf.readUInt16LE(lo + 26) + buf.readUInt16LE(lo + 28);
const stored = buf.readUInt32LE(p + 20);
const raw = buf.subarray(at, at + stored);
let body = null;
try {
body = buf.readUInt16LE(p + 10) === 0 ? raw : inflateRawSync(raw, { maxOutputLength: CAP });
} catch { body = null; }
entries.push({
name,
declared: buf.readUInt32LE(p + 24),
stored,
real: body ? body.length : `over ${CAP}`,
mode: mode.toString(8),
body,
flags: [
(mode & 0xf000) === 0xa000 && 'symlink',
/^([/\\]|[A-Za-z]:)/.test(name) && 'absolute',
name.split(/[/\\]/).includes('..') && 'traversal',
name.includes('\\') && 'backslash',
body && body.length !== buf.readUInt32LE(p + 24) && 'size-lie',
].filter(Boolean),
});
p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32);
}
const declaredTotal = entries.reduce((s, x) => s + x.declared, 0);
const realTotal = entries.reduce((s, x) => s + (typeof x.real === 'number' ? x.real : 0), 0);
const flagged = entries.filter((x) => x.flags.length);
return { count, declaredTotal, realTotal, entries, flagged, verdict: flagged.length ? 'reject' : 'accept' };
}
export function extractSafely(buf, dest) {
const root = resolve(dest);
for (const x of inspect(buf).entries) {
const target = resolve(root, x.name);
if (x.flags.includes('symlink')) { console.log(`refused ${x.name} (symlink entry)`); continue; }
if (!target.startsWith(root + sep)) { console.log(`refused ${x.name} -> ${target}`); continue; }
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, x.body ?? Buffer.alloc(0));
console.log(`wrote ${x.name} -> ${target} (${(x.body ?? []).length} bytes)`);
}
}
const [mode, a, b] = process.argv.slice(2);
if (mode === '--serve') {
createServer((req, res) => {
const parts = [];
req.on('data', (d) => parts.push(d));
req.on('end', () => {
let out;
try { out = inspect(Buffer.concat(parts)); } catch (err) { out = { verdict: 'reject', error: err.message, flagged: [] }; }
res.writeHead(out.verdict === 'accept' ? 200 : 422, { 'content-type': 'application/json' });
res.end(JSON.stringify({
verdict: out.verdict,
entries: out.count,
declaredTotal: out.declaredTotal,
realTotal: out.realTotal,
flagged: out.flagged.map((x) => `${x.name} declared ${x.declared} real ${x.real} [${x.flags}]`),
}, null, 1));
});
}).listen(8931, '127.0.0.1', () => console.log('gate listening on 8931'));
} else if (mode === '--extract') {
extractSafely(readFileSync(a), b);
} else if (mode) {
const r = inspect(readFileSync(mode));
console.log('name'.padEnd(30), 'declared'.padStart(8), 'stored'.padStart(7), 'real'.padStart(7), ' flags');
for (const x of r.entries) {
console.log(x.name.padEnd(30), String(x.declared).padStart(8), String(x.stored).padStart(7), String(x.real).padStart(7), '', x.flags.join(',') || '-');
}
console.log(`entries ${r.count} declared ${r.declaredTotal} real ${r.realTotal} verdict ${r.verdict}`);
}
Steps
- Step 1.
List what the archive says it holds.
unzip -l report-bundle.zipArchive: report-bundle.zip Length Date Time Name --------- ---------- ----- ---- 18 2026-09-12 10:00 docs/summary.txt 12 2026-09-12 10:00 inventory.csv 22 2026-09-12 10:00 ../../../../etc/cron.d/report 9 2026-09-12 10:00 /var/www/html/report.php 8 2026-09-12 10:00 docs\..\..\startup.bat 11 2026-09-12 10:00 docs/latest --------- ------- 80 6 filesSix entries, 80 bytes declared in total. Two names refuse any destination you pick: one climbs four levels, the other starts at the root.
- Step 2.
Ask the same tool whether the archive is intact.
unzip -t report-bundle.zipArchive: report-bundle.zip testing: docs/summary.txt OK testing: inventory.csv OK testing: ../../../../etc/cron.d/report OK testing: /var/www/html/report.php OK testing: docs\..\..\startup.bat OK testing: docs/latest OK No errors detected in compressed data of report-bundle.zip.Every entry passes. The test compares each stored stream against the checksum beside it, so it reports that the file arrived whole. It has no opinion about a name, a mode bit, or the size field step 5 catches.
- Step 3.
Compare the names a library reports against the bytes in the file.
python -c 'import zipfile; raw=open("report-bundle.zip","rb").read(); [print("%-34s present in the file bytes: %s" % (repr(n), n.encode() in raw)) for n in zipfile.ZipFile("report-bundle.zip").namelist()]''docs/summary.txt' present in the file bytes: True 'inventory.csv' present in the file bytes: True '../../../../etc/cron.d/report' present in the file bytes: True '/var/www/html/report.php' present in the file bytes: True 'docs/../../startup.bat' present in the file bytes: False 'docs/latest' present in the file bytes: TrueFive names occur in the archive verbatim. The sixth does not:
namelist()reportsdocs/../../startup.batfor bytes that readdocs\..\..\startup.bat. Python replacesos.sepwhen it builds aZipInfo, andos.sephere is a backslash, so the list is a normalised view. - Step 4.
Read the same archive with a second library.
Add-Type -AssemblyName System.IO.Compression.FileSystem; $z=[IO.Compression.ZipFile]::OpenRead("$PWD\report-bundle.zip"); $z.Entries | Format-Table FullName, Length, CompressedLength -AutoSize; $z.Dispose()FullName Length CompressedLength -------- ------ ---------------- docs/summary.txt 18 20 inventory.csv 12 216 ../../../../etc/cron.d/report 22 18 /var/www/html/report.php 9 11 docs\..\..\startup.bat 8 10 docs/latest 11 13.NET keeps the name as stored, so two readers on one machine disagree about one entry.
Lengthis the declared size andCompressedLengththe stored size: 12 against 216 forinventory.csv. - Step 5.
Read the central directory yourself and measure what each entry inflates to.
node zipgate.mjs report-bundle.zipname declared stored real flags docs/summary.txt 18 20 18 - inventory.csv 12 216 204800 size-lie ../../../../etc/cron.d/report 22 18 22 traversal /var/www/html/report.php 9 11 9 absolute docs\..\..\startup.bat 8 10 8 traversal,backslash docs/latest 11 13 11 symlink entries 6 declared 80 real 204868 verdict rejectinventory.csvdeclares 12 bytes and produces 204800. The declared size is a field whoever built the archive wrote, and inflating is the only way to know it. Mode120777ondocs/latestis the symlink bit: its 11 bytes are the path/etc/shadow, where a later write would land. - Step 6.
Start the gate in a second shell with
node zipgate.mjs --serve, then send it both archives.for f in quarterly.zip report-bundle.zip; do echo "--- $f ---"; curl -s -w "\nHTTP %{http_code}\n" --data-binary @$f http://127.0.0.1:8931/upload; done--- quarterly.zip --- { "verdict": "accept", "entries": 2, "declaredTotal": 31, "realTotal": 31, "flagged": [] } HTTP 200 --- report-bundle.zip --- { "verdict": "reject", "entries": 6, "declaredTotal": 80, "realTotal": 204868, "flagged": [ "inventory.csv declared 12 real 204800 [size-lie]", "../../../../etc/cron.d/report declared 22 real 22 [traversal]", "/var/www/html/report.php declared 9 real 9 [absolute]", "docs\\..\\..\\startup.bat declared 8 real 8 [traversal,backslash]", "docs/latest declared 11 real 11 [symlink]" ] } HTTP 422The clean archive returns 200 with an empty
flaggedlist and matching totals. The specimen returns 422 and names five entries with their reasons, so a failing upload test reads as one line, not as a stack trace. - Step 7.
Extract through a resolver that refuses anything outside one directory.
node zipgate.mjs --extract report-bundle.zip outwrote docs/summary.txt -> C:\Users\khark\AppData\Local\Temp\zipcheck\out\docs\summary.txt (18 bytes) wrote inventory.csv -> C:\Users\khark\AppData\Local\Temp\zipcheck\out\inventory.csv (204800 bytes) refused ../../../../etc/cron.d/report -> C:\Users\khark\AppData\etc\cron.d\report refused /var/www/html/report.php -> C:\var\www\html\report.php refused docs\..\..\startup.bat -> C:\Users\khark\AppData\Local\Temp\zipcheck\startup.bat refused docs/latest (symlink entry)Two entries land and four are refused, each refusal printing the absolute path the name resolves to. The backslash entry resolves beside
outrather than inside it, because a backslash is a separator here.inventory.csvwrites 204800 bytes after declaring 12.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A .. segment in an entry name | The entry chooses a target above your directory | Reject the upload. See How to check path traversal in an uploaded file name. |
| A name starting with / or a drive letter | Resolving it discards your destination, as step 7 shows | Reject the upload rather than stripping the prefix. |
| Mode 120777 on an entry | The symlink bit is set and the content is a path | Refuse it, or write it as a regular file holding that text. |
| declared and real differ | The header field is a claim, not a measurement | Measure by inflating under a cap. See How to check archive expansion limits on upload. |
| No errors detected from a test run | Every stored checksum matched | Read names, sizes and modes separately. Completeness is not safety. |
| Two readers report different names | One of them normalised the separators | Assert against the central directory bytes, and name the reader in the test. |
Common mistakes
What to check next
- How to check archive expansion limits on upload: what to cap once the real size is known.
- How to check path traversal in an uploaded file name: the same question about the archive's own name.
- How to check file type: whether the upload is a zip.
- How to check if file type is validated on the server: where the endpoint decides.
- How to check sha256 of a file: the integrity question step 2 half answers.
FAQ
How do I check the contents of a zip file without extracting it?
Two reads. unzip -l gives the names and the declared sizes. A central directory read gives what that listing hides: the mode bits and the stored size. Nothing reaches disk until step 7.
Which zip file size should I check?
Three of them, and they disagree. The stored size is what arrived, the declared size is what the header claims, the real size is what inflating produces: 216, 12 and 204800 for one entry in step 5.
Does a passing integrity test mean the archive is safe?
No. Step 2 printed No errors detected for an archive carrying a traversal entry, an absolute path, a symlink and a wrong size field. It answers whether the data matches its checksums, not where the entries want to write.
Why do two tools report different entry names?
One of them normalised the bytes. Steps 3 and 4 read the same file: Python turned the backslashes into forward slashes, .NET returned them unchanged. When the name is under test, assert on the central directory bytes.
Verified
Verified by Maks Vernyunzip 6.00Python 3.13.1node 22.23.2curl 8.21.0PowerShell 5.1.22621.6133
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny