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

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

  1. Step 1.

    List what the archive says it holds.

    unzip -l report-bundle.zip
    
    Archive:  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 files

    Six entries, 80 bytes declared in total. Two names refuse any destination you pick: one climbs four levels, the other starts at the root.

  2. Step 2.

    Ask the same tool whether the archive is intact.

    unzip -t report-bundle.zip
    
    Archive:  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.

  3. 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: True

    Five names occur in the archive verbatim. The sixth does not: namelist() reports docs/../../startup.bat for bytes that read docs\..\..\startup.bat. Python replaces os.sep when it builds a ZipInfo, and os.sep here is a backslash, so the list is a normalised view.

  4. 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. Length is the declared size and CompressedLength the stored size: 12 against 216 for inventory.csv.

  5. Step 5.

    Read the central directory yourself and measure what each entry inflates to.

    node zipgate.mjs report-bundle.zip
    
    name                           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 reject

    inventory.csv declares 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. Mode 120777 on docs/latest is the symlink bit: its 11 bytes are the path /etc/shadow, where a later write would land.

  6. 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 422

    The clean archive returns 200 with an empty flagged list 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.

  7. Step 7.

    Extract through a resolver that refuses anything outside one directory.

    node zipgate.mjs --extract report-bundle.zip out
    
    wrote   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 out rather than inside it, because a backslash is a separator here. inventory.csv writes 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

Sign: The traversal assertion runs on namelist() and the name it inspects is not the name in the archive.Cause: Python replaces os.sep inside ZipInfo, so step 3 printed False for the one entry whose separators are backslashes and True for the other five. Step 4 read the same file with .NET and got the stored name back.
Sign: An integrity test reports no errors, and the archive is passed on as checked.Cause: Step 2 ran that test against an archive holding a traversal entry, an absolute path, a symlink and a size field wrong by 204788 bytes, and it printed No errors detected. It compares stored data against stored checksums and reads nothing else.
Sign: A quota check sums the declared sizes from the listing and admits the archive.Cause: Step 5 measured 80 bytes declared against 204868 real across the same six entries. Python's own read() refuses that entry with BadZipFile: Bad CRC-32 for file 'inventory.csv', which names the checksum rather than the size, so the size lie arrives disguised as corruption.
Sign: The path check passes, then the file is written to the destination joined with the entry name.Cause: Joining is not resolving. Step 7 resolves each name against the destination root first and compares the result with it, which is the form that catches both the backslash entry and the absolute one. A check on the raw string catches neither reliably.

What to check next

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.

intermediate10 minpublished updated Maks Verny