Skip to content

Commit 295e4cd

Browse files
authored
zlib: reject ambiguous ZIP archive ends
ZIP archives can contain more than one structurally plausible EOCD record. Selecting based on whether a comment reaches EOF can make the chosen central directory depend on trailing padding. Inspect all candidates in the common tail window and reject archives with multiple plausible interpretations. Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #65007 Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent ee64033 commit 295e4cd

2 files changed

Lines changed: 143 additions & 17 deletions

File tree

lib/internal/zip/headers.js

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const {
2727
MADE_BY_UNIX,
2828
SENTINEL16,
2929
SENTINEL32,
30+
TAIL_LENGTH,
3031
ZIP64_EOCD_MAX_LENGTH,
3132
S_IFLNK,
3233
S_IFMT,
@@ -310,6 +311,51 @@ class LocalFileHeader {
310311
}
311312
}
312313

314+
// Returns whether an EOCD-looking record could describe an archive this
315+
// implementation supports. This is deliberately only a cheap preflight: the
316+
// selected record still receives the complete Zip64 and central-directory
317+
// validation below.
318+
function isPlausibleArchiveEnd(buffer, eocdPos, scanStart) {
319+
const diskNumber = buffer.readUInt16LE(eocdPos + 4);
320+
const centralDirectoryDiskNumber = buffer.readUInt16LE(eocdPos + 6);
321+
const diskRecords = buffer.readUInt16LE(eocdPos + 8);
322+
const totalRecords = buffer.readUInt16LE(eocdPos + 10);
323+
const centralDirectorySize = buffer.readUInt32LE(eocdPos + 12);
324+
const centralDirectoryOffset = buffer.readUInt32LE(eocdPos + 16);
325+
const needsZip64 =
326+
diskNumber === SENTINEL16 ||
327+
centralDirectoryDiskNumber === SENTINEL16 ||
328+
diskRecords === SENTINEL16 ||
329+
totalRecords === SENTINEL16 ||
330+
centralDirectorySize === SENTINEL32 ||
331+
centralDirectoryOffset === SENTINEL32;
332+
333+
const locatorPos = eocdPos - 20;
334+
const hasZip64Locator = locatorPos >= 0 &&
335+
buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR;
336+
if (needsZip64) return hasZip64Locator;
337+
// A Zip64 end record may accompany authoritative, non-sentinel classic
338+
// fields. Its central directory does not immediately precede this EOCD.
339+
if (hasZip64Locator) return true;
340+
if (
341+
diskNumber !== 0 ||
342+
centralDirectoryDiskNumber !== 0 ||
343+
diskRecords !== totalRecords ||
344+
totalRecords * 46 > centralDirectorySize
345+
) {
346+
return false;
347+
}
348+
349+
const centralDirectoryPos = eocdPos - centralDirectorySize;
350+
if (centralDirectoryPos < scanStart) {
351+
// Use the same logical tail window for memory- and file-backed archives.
352+
// The directory is not available for this cheap preflight in either case.
353+
return true;
354+
}
355+
if (totalRecords === 0) return centralDirectorySize === 0;
356+
return buffer.readUInt32LE(centralDirectoryPos) === SIG_CENTRAL_FILE_HEADER;
357+
}
358+
313359
/**
314360
* Locates and validates the end-of-archive structures (EOCD, and the Zip64
315361
* EOCD locator/record when present) in `buffer`. `base` is the absolute
@@ -334,26 +380,30 @@ function findArchiveEnd(buffer, base = 0) {
334380
if (buffer.length < 22) {
335381
throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found');
336382
}
337-
const min = MathMax(0, buffer.length - (22 + SENTINEL16));
383+
// Use the same tail-sized search window for full buffers and ZipFile's tail
384+
// reads. Besides keeping candidate selection consistent, the extra tail
385+
// slack permits a maximum-length comment followed by modest writer padding.
386+
const min = MathMax(0, buffer.length - TAIL_LENGTH);
338387
let eocdPos = -1;
339-
// Pass 1: the comment must reach exactly to the end of the buffer (this
340-
// rejects a stray EOCD-looking signature inside an earlier comment).
388+
let fallbackPos = -1;
389+
let exactFallbackPos = -1;
341390
for (let pos = buffer.length - 22; pos >= min; pos--) {
342391
if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue;
343-
if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue;
392+
const end = pos + 22 + buffer.readUInt16LE(pos + 20);
393+
if (end > buffer.length) continue;
394+
if (fallbackPos < 0) fallbackPos = pos;
395+
if (end === buffer.length && exactFallbackPos < 0) exactFallbackPos = pos;
396+
if (!isPlausibleArchiveEnd(buffer, pos, min)) continue;
397+
if (eocdPos >= 0) {
398+
throw new ERR_ZIP_INVALID_ARCHIVE(
399+
'ambiguous end of central directory records');
400+
}
344401
eocdPos = pos;
345-
break;
346402
}
403+
// Preserve the targeted validation errors for a sole malformed or
404+
// unsupported candidate. Plausible candidates always take precedence.
347405
if (eocdPos < 0) {
348-
// Pass 2: tolerate trailing padding after the EOCD (some streaming
349-
// writers pad their output to a fixed block size); take the last
350-
// candidate found.
351-
for (let pos = buffer.length - 22; pos >= min; pos--) {
352-
if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue;
353-
if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue;
354-
eocdPos = pos;
355-
break;
356-
}
406+
eocdPos = exactFallbackPos >= 0 ? exactFallbackPos : fallbackPos;
357407
}
358408
if (eocdPos < 0) {
359409
throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found');
@@ -474,7 +524,10 @@ function findArchiveEnd(buffer, base = 0) {
474524
if (prefix < 0) {
475525
throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive');
476526
}
477-
if (totalRecords * 46 > centralDirectorySize) {
527+
if (
528+
(totalRecords === 0 && centralDirectorySize !== 0) ||
529+
totalRecords * 46 > centralDirectorySize
530+
) {
478531
throw new ERR_ZIP_INVALID_ARCHIVE(
479532
'central directory record count is inconsistent with its size');
480533
}

test/parallel/test-zlib-zip-hardening.js

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
require('../common');
44

55
const assert = require('node:assert');
6+
const fs = require('node:fs');
67
const zlib = require('node:zlib');
78
const { test } = require('node:test');
9+
const tmpdir = require('../common/tmpdir');
810

911
async function buildArchive(entries, comment) {
1012
const chunks = [];
@@ -47,13 +49,71 @@ test('an EOCD-looking signature inside a trailing comment is not mistaken for th
4749
// before it reaches the genuine EOCD signature; embedding 4 bytes that
4850
// look like one partway through must not be mistaken for the real record.
4951
const fakeSignature = String.fromCharCode(0x50, 0x4b, 0x05, 0x06);
50-
const archive = await buildArchive([entry], `before ${fakeSignature} after`);
52+
const archive = await buildArchive(
53+
[entry], `before ${fakeSignature} this is not a valid EOCD record after`);
5154

5255
const read = [...zlib.ZipEntry.read(archive)];
5356
assert.strictEqual(read.length, 1);
5457
assert.strictEqual(read[0].name, 'f.txt');
5558
});
5659

60+
test('multiple plausible EOCD records describing different archives are rejected', async () => {
61+
const first = await buildArchive([
62+
await zlib.ZipEntry.create('install.sh', Buffer.from('malicious'), { method: 'store' }),
63+
]);
64+
const second = await buildArchive([
65+
await zlib.ZipEntry.create('install.sh', Buffer.from('benign'), { method: 'store' }),
66+
]);
67+
const archive = Buffer.concat([first, second, Buffer.from([0])]);
68+
const firstEocd = first.length - 22;
69+
70+
// Make the first EOCD exact-to-EOF by treating the second archive and its
71+
// padding as a comment. The second EOCD remains a plausible archive end for
72+
// readers which tolerate trailing padding and select the rightmost record.
73+
archive.writeUInt16LE(archive.length - firstEocd - 22, firstEocd + 20);
74+
75+
const expected = {
76+
code: 'ERR_ZIP_INVALID_ARCHIVE',
77+
message: /ambiguous end of central directory/,
78+
};
79+
assert.throws(() => [...zlib.ZipEntry.read(archive)], expected);
80+
assert.throws(() => new zlib.ZipBuffer(archive), expected);
81+
82+
tmpdir.refresh();
83+
const file = tmpdir.resolve('ambiguous.zip');
84+
fs.writeFileSync(file, archive);
85+
await assert.rejects(zlib.ZipFile.open(file), expected);
86+
assert.throws(() => zlib.ZipFile.openSync(file), expected);
87+
});
88+
89+
test('an exact EOCD embedded in a genuine comment is rejected as ambiguous', async () => {
90+
const archive = await buildArchive([
91+
await zlib.ZipEntry.create('f.txt', Buffer.from('content'), { method: 'store' }),
92+
]);
93+
const nested = Buffer.concat([archive, buildEocd()]);
94+
nested.writeUInt16LE(22, archive.length - 2);
95+
96+
assert.throws(() => [...zlib.ZipEntry.read(nested)], {
97+
code: 'ERR_ZIP_INVALID_ARCHIVE',
98+
message: /ambiguous end of central directory/,
99+
});
100+
});
101+
102+
test('multiple padded EOCD records are rejected as ambiguous', async () => {
103+
const first = await buildArchive([
104+
await zlib.ZipEntry.create('a.txt', Buffer.from('first'), { method: 'store' }),
105+
]);
106+
const second = await buildArchive([
107+
await zlib.ZipEntry.create('b.txt', Buffer.from('second'), { method: 'store' }),
108+
]);
109+
const archive = Buffer.concat([first, second, Buffer.from('\0\0')]);
110+
111+
assert.throws(() => new zlib.ZipBuffer(archive), {
112+
code: 'ERR_ZIP_INVALID_ARCHIVE',
113+
message: /ambiguous end of central directory/,
114+
});
115+
});
116+
57117
test('a declared-size mismatch is rejected as corrupt', async () => {
58118
const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
59119
const archive = await buildArchive([entry]);
@@ -212,7 +272,7 @@ test('every possible truncation of an archive is rejected, deterministically', a
212272

213273
test('trailing padding after the EOCD is tolerated', async () => {
214274
// Some streaming writers pad their output to a block size; CPython
215-
// tolerates trailing newlines/NULs and so does the pass-2 EOCD scan.
275+
// tolerates trailing newlines/NULs and so does the EOCD scan.
216276
const archive = await buildArchive(
217277
[await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })]);
218278
const padded = Buffer.concat([archive, Buffer.from('\r\n\0\0\0')]);
@@ -221,6 +281,15 @@ test('trailing padding after the EOCD is tolerated', async () => {
221281
assert.strictEqual((await entry.content()).toString(), 'hi');
222282
});
223283

284+
test('a maximum-length comment followed by block padding is tolerated', async () => {
285+
const comment = 'x'.repeat(0xffff);
286+
const archive = await buildArchive([], comment);
287+
const padded = Buffer.concat([archive, Buffer.alloc(4096)]);
288+
const zip = new zlib.ZipBuffer(padded);
289+
290+
assert.strictEqual(zip.comment, comment);
291+
});
292+
224293
test('junk appended past a declared comment is tolerated and the comment preserved', async () => {
225294
const archive = await buildArchive(
226295
[await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })],
@@ -262,6 +331,10 @@ test('a record count inconsistent with the directory size is rejected', () => {
262331
const archive = Buffer.concat([Buffer.alloc(46), eocd]);
263332
assert.throws(() => [...zlib.ZipEntry.read(archive)],
264333
{ code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ });
334+
335+
const zeroRecords = Buffer.concat([Buffer.alloc(46), buildEocd({ cdSize: 46 })]);
336+
assert.throws(() => [...zlib.ZipEntry.read(zeroRecords)],
337+
{ code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ });
265338
});
266339

267340
test('a corrupted or overrunning central directory header is rejected', async () => {

0 commit comments

Comments
 (0)