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
| Check | What it prevents | Implementation |
|---|---|---|
| Total uncompressed size limit | Flat zip bombs | Sum entry.header.size before extract |
| Per-entry compression ratio limit | High-ratio bomb detection | Reject entries with ratio > 100:1 |
| Entry count limit | Many-file DoS | Reject archives with > N entries |
| Streaming byte counter for gzip | gzip/tar.gz bombs | Transform stream with counter and throw |
| Path validation per entry | Zip-slip traversal | path.resolve + prefix check |
| No recursive extraction | Nested zip bombs | Never auto-recurse into inner archives |
| Disk quota before extraction | Disk exhaustion | Check statvfs free space > uncompressed size |
SkillAudit findings: archive security
Run a SkillAudit scan to detect missing archive size limits and zip-slip vulnerabilities in your MCP server's file processing tool handlers.