Security reference · Archive · DoS

MCP server zip bomb and decompression security

MCP tool handlers that accept archive files (ZIP, tar.gz, tar.bz2) from tool arguments or URLs are exposed to two attack classes that don't exist in traditional web APIs. First, decompression bombs: a 1 MB zip file that expands to 10 GB fills the disk, potentially taking down the entire server. Second, zip-slip path traversal: an archive containing entries with paths like ../../etc/cron.d/backdoor extracts files outside the intended target directory, overwriting arbitrary system files if the MCP server runs with sufficient privilege. Both are trivially exploitable when an LLM agent passes a user-controlled archive URL to a file extraction tool.

Decompression bomb mechanics

A decompression bomb (also called a zip bomb) is an archive that compresses with extremely high ratios by repeating a pattern. The classic example is 42.zip — a 42 KB file that expands to 4.5 petabytes through recursive nesting. Flat bombs (single-level with a large file of repeated bytes) are more practical for attackers because they don't require recursive extraction to detonate: a single file of 4 GB of null bytes compresses to a few kilobytes under DEFLATE.

The critical vulnerability is extracting without checking the uncompressed size first:

// WRONG — extracts without size check
import AdmZip from 'adm-zip';

server.tool('extract_zip', { archivePath: z.string() }, async ({ archivePath }) => {
  const zip = new AdmZip(archivePath); // reads the local zip
  zip.extractAllTo('/tmp/extracted/'); // no size limit — bomb detonates here
  return { extracted: true };
});

The zip format includes the uncompressed size of each entry in the central directory, which appears at the end of the file. This means you can check the total uncompressed size before extracting anything:

import AdmZip from 'adm-zip';
import path from 'node:path';
import fs from 'node:fs';

const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; // 100 MB limit
const MAX_ENTRY_COUNT = 1000;
const MAX_COMPRESSION_RATIO = 100; // flag ratios above 100:1

server.tool('extract_zip', { archivePath: z.string() }, async ({ archivePath }) => {
  // Validate the archive is actually on disk (no path traversal to reach it)
  const resolvedPath = path.resolve('/tmp/uploads/', path.basename(archivePath));
  if (!resolvedPath.startsWith('/tmp/uploads/')) {
    throw new Error('PATH_TRAVERSAL: archive path outside upload directory');
  }

  const zip = new AdmZip(resolvedPath);
  const entries = zip.getEntries();

  if (entries.length > MAX_ENTRY_COUNT) {
    throw new Error('ZIP_ENTRY_LIMIT: archive contains ' + entries.length + ' entries (max ' + MAX_ENTRY_COUNT + ')');
  }

  let totalUncompressed = 0;
  for (const entry of entries) {
    totalUncompressed += entry.header.size;

    // Check per-entry compression ratio (flag suspiciously high ratios)
    const compressedSize = entry.header.compressedSize;
    if (compressedSize > 0 && entry.header.size / compressedSize > MAX_COMPRESSION_RATIO) {
      throw new Error('ZIP_BOMB_SUSPECTED: entry ' + entry.entryName + ' has compression ratio ' +
        Math.round(entry.header.size / compressedSize) + ':1');
    }
  }

  if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
    throw new Error('ZIP_SIZE_LIMIT: uncompressed size ' +
      Math.round(totalUncompressed / 1024 / 1024) + ' MB exceeds limit');
  }

  // Safe to extract — verify destination paths (zip-slip check below)
  zip.extractAllTo('/tmp/extracted/', /* overwrite */ false);
  return { extracted: entries.length };
});

gzip and tar.gz caveat: Unlike ZIP, gzip format does not store the uncompressed size in the header (the original size field is only 4 bytes, overflows for files > 4 GB, and can be forged). For tar.gz bombs, you must limit decompressed bytes during streaming extraction using a counting passthrough stream:

import { createGunzip } from 'node:zlib';
import { extract } from 'tar';
import { createReadStream, createWriteStream } from 'node:fs';
import { Transform } from 'node:stream';

const MAX_BYTES = 100 * 1024 * 1024; // 100 MB

function createSizeGuard(maxBytes) {
  let totalBytes = 0;
  return new Transform({
    transform(chunk, encoding, callback) {
      totalBytes += chunk.length;
      if (totalBytes > maxBytes) {
        callback(new Error('DECOMPRESS_LIMIT: exceeded ' + maxBytes + ' bytes'));
        return;
      }
      callback(null, chunk);
    },
  });
}

async function safeExtractTarGz(archivePath, destDir) {
  await new Promise((resolve, reject) => {
    const sizeGuard = createSizeGuard(MAX_BYTES);
    createReadStream(archivePath)
      .pipe(createGunzip())
      .pipe(sizeGuard)
      .pipe(extract({ cwd: destDir, strict: true, filter: safePathFilter(destDir) }))
      .on('finish', resolve)
      .on('error', reject);
  });
}

Zip-slip path traversal

Zip-slip is an archive path traversal vulnerability. An attacker crafts an archive where one or more entry names contain directory traversal sequences: ../../etc/passwd, ../../../root/.ssh/authorized_keys. When extracted naively, these create or overwrite files outside the intended extraction directory.

// WRONG — no path validation, extracts whatever paths are in the archive
zip.extractAllTo('/tmp/extracted/');

// An entry named '../../etc/cron.d/backdoor' extracts to /etc/cron.d/backdoor
// RIGHT — validate every entry path before extraction
import path from 'node:path';
import fs from 'node:fs/promises';

async function safeExtractZip(zip, destDir) {
  const resolvedDest = path.resolve(destDir);
  const entries = zip.getEntries();

  for (const entry of entries) {
    if (entry.isDirectory) continue; // handle directories separately if needed

    // Resolve the entry's final path and check it's inside destDir
    const entryPath = path.resolve(resolvedDest, entry.entryName);
    if (!entryPath.startsWith(resolvedDest + path.sep)) {
      throw new Error('ZIP_SLIP: entry "' + entry.entryName + '" would extract outside destination');
    }

    // Create parent directories safely
    await fs.mkdir(path.dirname(entryPath), { recursive: true });

    // Extract only this entry
    await fs.writeFile(entryPath, entry.getData());
  }
}

// For tar, the 'tar' package filter option:
function safePathFilter(destDir) {
  const resolvedDest = path.resolve(destDir);
  return function(entryPath) {
    const resolved = path.resolve(resolvedDest, entryPath);
    return resolved.startsWith(resolvedDest + path.sep);
  };
}

Nested archive bombs (recursive decompression)

Recursive zip bombs contain zip files within zip files. A tool handler that recursively extracts archives to "be helpful" will detonate the bomb by extracting each nested layer. The 42.zip family works this way: 16 zips within 16 zips within 16 zips, etc., each compressed at high ratio.

The mitigation is to never automatically recurse into nested archives:

// WRONG — recursively extracts any zip found in extracted content
async function extractAll(dir) {
  const files = await fs.readdir(dir);
  for (const file of files) {
    if (file.endsWith('.zip')) {
      const zip = new AdmZip(path.join(dir, file));
      zip.extractAllTo(path.join(dir, file + '_extracted'));
      await extractAll(path.join(dir, file + '_extracted')); // recursion — bomb
    }
  }
}

Summary: safe archive extraction checklist

CheckWhat it preventsImplementation
Total uncompressed size limitFlat zip bombsSum entry.header.size before extract
Per-entry compression ratio limitHigh-ratio bomb detectionReject entries with ratio > 100:1
Entry count limitMany-file DoSReject archives with > N entries
Streaming byte counter for gzipgzip/tar.gz bombsTransform stream with counter and throw
Path validation per entryZip-slip traversalpath.resolve + prefix check
No recursive extractionNested zip bombsNever auto-recurse into inner archives
Disk quota before extractionDisk exhaustionCheck statvfs free space > uncompressed size

SkillAudit findings: archive security

Critical Archive extraction tool has no uncompressed size limit — zip bomb fills disk and crashes server. Score penalty: −20 points.
Critical No zip-slip path validation — archive entries with ../ paths overwrite arbitrary files on the server. Score penalty: −20 points.
High gzip/tar.gz extracted without byte counter — gzip header size field is unreliable, bomb detonates during stream. Score penalty: −12 points.
High Recursive archive extraction — nested zip bombs multiply storage impact exponentially. Score penalty: −10 points.
Medium No entry count limit — archive with millions of small files exhausts inode quota or directory read performance. Score penalty: −5 points.

Run a SkillAudit scan to detect missing archive size limits and zip-slip vulnerabilities in your MCP server's file processing tool handlers.