189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const outputArgument = process.argv.indexOf("--output");
|
|
const outputRoot = outputArgument === -1
|
|
? path.join(repoRoot, "tests/files/web/archive-security")
|
|
: path.resolve(process.argv[outputArgument + 1] ?? "");
|
|
if (!outputRoot) throw new Error("--output requires a directory");
|
|
|
|
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
const text = (value) => Buffer.from(value, "utf8");
|
|
|
|
const crcTable = Array.from({ length: 256 }, (_, input) => {
|
|
let value = input;
|
|
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
|
|
return value >>> 0;
|
|
});
|
|
|
|
function crc32(bytes) {
|
|
let value = 0xffffffff;
|
|
for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ value >>> 8;
|
|
return (value ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function zipArchive(entries) {
|
|
const localParts = [];
|
|
const centralParts = [];
|
|
let localOffset = 0;
|
|
for (const entry of entries) {
|
|
const name = text(entry.path);
|
|
const data = Buffer.from(entry.data);
|
|
const uncompressedBytes = entry.uncompressedBytes ?? data.length;
|
|
const crc = crc32(data);
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0x0800, 6);
|
|
local.writeUInt16LE(0, 8);
|
|
local.writeUInt32LE(crc, 14);
|
|
local.writeUInt32LE(data.length, 18);
|
|
local.writeUInt32LE(uncompressedBytes, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
localParts.push(local, name, data);
|
|
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(0x0314, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0x0800, 8);
|
|
central.writeUInt16LE(0, 10);
|
|
central.writeUInt32LE(crc, 16);
|
|
central.writeUInt32LE(data.length, 20);
|
|
central.writeUInt32LE(uncompressedBytes, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
central.writeUInt32LE(localOffset, 42);
|
|
centralParts.push(central, name);
|
|
localOffset += local.length + name.length + data.length;
|
|
}
|
|
const centralDirectory = Buffer.concat(centralParts);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
end.writeUInt32LE(localOffset, 16);
|
|
return Buffer.concat([...localParts, centralDirectory, end]);
|
|
}
|
|
|
|
function writeTarText(header, value, offset, length) {
|
|
const bytes = text(value);
|
|
if (bytes.length > length) throw new Error(`tar field exceeds ${length} bytes`);
|
|
bytes.copy(header, offset);
|
|
}
|
|
|
|
function writeTarOctal(header, value, offset, length) {
|
|
const encoded = value.toString(8).padStart(length - 2, "0");
|
|
writeTarText(header, `${encoded}\0 `, offset, length);
|
|
}
|
|
|
|
function tarHeader(entry) {
|
|
const header = Buffer.alloc(512);
|
|
writeTarText(header, entry.path, 0, 100);
|
|
writeTarOctal(header, entry.type === "DIRECTORY" ? 0o755 : 0o644, 100, 8);
|
|
writeTarOctal(header, 0, 108, 8);
|
|
writeTarOctal(header, 0, 116, 8);
|
|
const data = entry.type === "FILE" ? Buffer.from(entry.data) : Buffer.alloc(0);
|
|
writeTarOctal(header, data.length, 124, 12);
|
|
writeTarOctal(header, 0, 136, 12);
|
|
header.fill(0x20, 148, 156);
|
|
header[156] = { FILE: 0x30, SYMLINK: 0x32, HARDLINK: 0x31, DIRECTORY: 0x35 }[entry.type];
|
|
if (entry.target) writeTarText(header, entry.target, 157, 100);
|
|
writeTarText(header, "ustar\0", 257, 6);
|
|
writeTarText(header, "00", 263, 2);
|
|
writeTarText(header, "root", 265, 32);
|
|
writeTarText(header, "root", 297, 32);
|
|
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
|
writeTarOctal(header, checksum, 148, 8);
|
|
return { header, data };
|
|
}
|
|
|
|
function tarArchive(entries) {
|
|
const parts = [];
|
|
for (const entry of entries) {
|
|
const { header, data } = tarHeader(entry);
|
|
parts.push(header, data);
|
|
if (data.length % 512 !== 0) parts.push(Buffer.alloc(512 - data.length % 512));
|
|
}
|
|
parts.push(Buffer.alloc(1024));
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
const definitions = [
|
|
{
|
|
id: "ZIP_PATH_TRAVERSAL",
|
|
format: "ZIP",
|
|
file: "zip-path-traversal.zip",
|
|
threat: "ARCHIVE_ROOT_ESCAPE",
|
|
gate: "LINK_SAFETY",
|
|
bytes: zipArchive([{ path: "../outside.txt", data: text("escape") }]),
|
|
},
|
|
{
|
|
id: "ZIP_COMPRESSION_BOMB",
|
|
format: "ZIP",
|
|
file: "zip-compression-bomb.zip",
|
|
threat: "COMPRESSION_RATIO",
|
|
gate: "CONFLICTS",
|
|
bytes: zipArchive([{ path: "bomb.bin", data: Buffer.from([0]), uncompressedBytes: 101 }]),
|
|
},
|
|
{
|
|
id: "ZIP_DUPLICATE_PATH",
|
|
format: "ZIP",
|
|
file: "zip-duplicate-path.zip",
|
|
threat: "DUPLICATE_PATH",
|
|
gate: "LINK_SAFETY",
|
|
bytes: zipArchive([{ path: "same.bin", data: text("one") }, { path: "same.bin", data: text("two") }]),
|
|
},
|
|
{
|
|
id: "TAR_PATH_TRAVERSAL",
|
|
format: "TAR",
|
|
file: "tar-path-traversal.tar",
|
|
threat: "ARCHIVE_ROOT_ESCAPE",
|
|
gate: "LINK_SAFETY",
|
|
bytes: tarArchive([{ path: "../../outside.txt", type: "FILE", data: text("escape") }]),
|
|
},
|
|
{
|
|
id: "TAR_SYMLINK_ESCAPE",
|
|
format: "TAR",
|
|
file: "tar-symlink-escape.tar",
|
|
threat: "SYMLINK_ESCAPE",
|
|
gate: "LINK_SAFETY",
|
|
bytes: tarArchive([
|
|
{ path: "safe", type: "DIRECTORY" },
|
|
{ path: "safe/link", type: "SYMLINK", target: "../../outside" },
|
|
]),
|
|
},
|
|
{
|
|
id: "TAR_PREFIX_CONFLICT",
|
|
format: "TAR",
|
|
file: "tar-prefix-conflict.tar",
|
|
threat: "FILE_DIRECTORY_PREFIX_CONFLICT",
|
|
gate: "CONFLICTS",
|
|
bytes: tarArchive([
|
|
{ path: "folder", type: "FILE", data: text("one") },
|
|
{ path: "folder/payload.bin", type: "FILE", data: text("two") },
|
|
]),
|
|
},
|
|
];
|
|
|
|
fs.mkdirSync(outputRoot, { recursive: true });
|
|
for (const definition of definitions) fs.writeFileSync(path.join(outputRoot, definition.file), definition.bytes);
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
task: "M12-04J",
|
|
generator: "tools/web/generate-malicious-archive-fixtures.mjs",
|
|
extractionAllowed: false,
|
|
cases: definitions.map(({ bytes, ...definition }) => ({
|
|
...definition,
|
|
byteLength: bytes.length,
|
|
sha256: sha256(bytes),
|
|
expectedCode: "IO_ARCHIVE_UNSAFE",
|
|
})),
|
|
};
|
|
fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
process.stdout.write(`malicious-archive-fixtures-generated cases=${definitions.length} output=${outputRoot}\n`);
|