What is a ZIP bomb?

A ZIP bomb (also called a decompression bomb) is a maliciously crafted archive that appears small on disk but expands to an enormous size when extracted. The classic example is 42.zip: 42 kilobytes compressed, 4.5 petabytes decompressed.

If your application or antivirus scanner tries to extract the archive before scanning it, the process can exhaust disk space, RAM, or CPU — taking down your server or the scanner itself. There are two common variants:

  • Recursive (nested) ZIP bombs. A chain of ZIP files, each containing another ZIP. Depth is limited so each level is small, but total expansion is enormous.
  • Flat (non-recursive) ZIP bombs. A single ZIP whose entries overlap on the same compressed stream, achieving extreme compression ratios (up to 28 million to one). These evade many scanners that only check recursion depth.

ClamAV's built-in ZIP bomb detection

ClamAV has specific heuristics and signatures for ZIP bombs. When it detects one, it returns exit code 1 (malware found) with a signature name such as:

  • Heuristics.Zip.DoubleCompression
  • Heuristics.Zip.OverlappingFiles
  • Heuristics.Zip.ExceedsMax

pompelmi maps exit code 1 to Verdict.Malicious, so a confirmed ZIP bomb is returned as a malicious file. Your existing Verdict.Malicious rejection logic handles it with no code changes.

ClamAV also enforces internal limits on maximum recursion depth, scan size, and file count within archives. When an archive exceeds these limits, ClamAV returns exit code 2, which pompelmi maps to Verdict.ScanError.

Why ScanError is your second line of defense

A zip bomb sophisticated enough to evade ClamAV's heuristics may still cause the scan to hit resource limits and terminate abnormally. pompelmi returns Verdict.ScanError in that case.

Never silently pass a Verdict.ScanError to storage. An attacker can sometimes force a scan error intentionally — with an oversized or corrupted archive — as a bypass technique. Treat ScanError as untrusted and reject the file.

This is already the correct behaviour if you follow the standard pompelmi verdict-handling pattern:

const verdict = await scan(tmpPath);

switch (verdict) {
  case Verdict.Clean:
    // Proceed — file is safe
    break;

  case Verdict.Malicious:
    // Confirmed threat (includes detected ZIP bombs)
    fs.unlinkSync(tmpPath);
    return res.status(400).json({ error: 'Malicious file rejected.' });

  case Verdict.ScanError:
    // Scan hit a limit or failed — treat as untrusted
    fs.unlinkSync(tmpPath);
    return res.status(422).json({ error: 'Scan incomplete. File rejected.' });
}

Pre-scan validation layer

The best defence against ZIP bombs is to validate the upload before the file reaches ClamAV, minimising the work the scanner has to do:

  1. Enforce a strict file size limit. Multer's limits.fileSize option rejects oversized uploads before they are written to disk. A 10 MB limit is reasonable for most applications.
  2. Check the MIME type. If your application only needs PDFs and images, reject ZIP files entirely. Most applications do not need to accept arbitrary archive uploads.
  3. Validate magic bytes. A ZIP file always starts with the bytes 50 4B 03 04. Read the first four bytes and compare against your allowlist of permitted file types.
const MAGIC_BYTES = {
  // Permitted types
  pdf:  [0x25, 0x50, 0x44, 0x46],   // %PDF
  png:  [0x89, 0x50, 0x4E, 0x47],   // PNG
  jpeg: [0xFF, 0xD8, 0xFF],          // JPEG
  gif:  [0x47, 0x49, 0x46, 0x38],   // GIF8
  // Dangerous types — reject before scanning
  zip:  [0x50, 0x4B, 0x03, 0x04],   // PK\x03\x04
  rar:  [0x52, 0x61, 0x72, 0x21],   // Rar!
};

function getFileMagic(filePath) {
  const fd  = require('fs').openSync(filePath, 'r');
  const buf = Buffer.alloc(4);
  require('fs').readSync(fd, buf, 0, 4, 0);
  require('fs').closeSync(fd);
  return buf;
}

function isArchive(filePath) {
  const magic = getFileMagic(filePath);
  return (
    magic.slice(0, 4).equals(Buffer.from(MAGIC_BYTES.zip)) ||
    magic.slice(0, 4).equals(Buffer.from(MAGIC_BYTES.rar))
  );
}

Complete defended upload endpoint

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const os = require('os');

const app    = express();
const upload = multer({
  dest: os.tmpdir(),
  limits: {
    fileSize:  10 * 1024 * 1024,  // 10 MB — hard limit before disk write
    files:     1,
  },
});

// Reject archives by magic bytes before scanning
function isArchiveFile(filePath) {
  const buf = Buffer.alloc(4);
  const fd  = fs.openSync(filePath, 'r');
  fs.readSync(fd, buf, 0, 4, 0);
  fs.closeSync(fd);
  // ZIP: PK\x03\x04  RAR: Rar!  7z: 7z\xBC\xAF
  return buf[0] === 0x50 && buf[1] === 0x4B ||
         buf[0] === 0x52 && buf[1] === 0x61 ||
         buf[0] === 0x37 && buf[1] === 0x7A;
}

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file provided.' });

  const tmpPath = req.file.path;

  try {
    // Layer 1 — reject archives before ClamAV sees them
    if (isArchiveFile(tmpPath)) {
      return res.status(400).json({ error: 'Archive files are not accepted.' });
    }

    // Layer 2 — ClamAV scan
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return res.status(400).json({ error: 'Malicious file rejected.' });
    }
    if (verdict === Verdict.ScanError) {
      return res.status(422).json({ error: 'Scan incomplete. File rejected.' });
    }

    return res.json({ status: 'ok' });

  } finally {
    if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
  }
});

OS-level resource limits

As a final layer of defence, constrain the resources available to the clamscan process and to your Node.js process itself.

In a containerised environment (Docker, Kubernetes) set memory and CPU limits on the container. If clamscan attempts to decompress a ZIP bomb and exhausts memory, the container's OOM killer will terminate the process, and pompelmi will throw rather than hanging indefinitely. Handle the thrown error in your catch block:

try {
  const verdict = await scan(tmpPath);
  // ...
} catch (err) {
  // Catches ENOENT (ClamAV not installed), OOM kill (SIGKILL), etc.
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
  return res.status(500).json({ error: 'Scan failed: ' + err.message });
}

In a Kubernetes Deployment, the resource limits you set in the pod spec provide this guarantee automatically. See Setting up pompelmi with ClamAV on Kubernetes for a full example with memory and CPU limits.

Next steps