mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(node-sea): bound ELF note scans by the notes a carrier holds (#8031)
Artifact classification probes every ELF root for a Node SEA note. Each PT_NOTE program header was scanned on its own, one read per note, so headers that declare the same note bytes multiplied the work: a 295 KB ELF with 4,096 such headers kept inspect-artifact busy for about three and a half minutes. Every note header takes at least 12 bytes, so disjoint tables hold at most one note per 12 file bytes. The scan now stops there. Past that count the remaining tables are unreadable: an ELF without a confirmed blob keeps its native classification, and a confirmed blob fails closed.
This commit is contained in:
1 parent
1861059f8b
commit
1f8084c7db
4 files changed
+96
-7
No files matched your search
+6
-2
@@ -82,8 +82,12 @@ override. Asset and argument decoding, padding scans, and inventory construction
|
||||
yield in bounded batches and honor caller cancellation. Asset count and name
|
||||
budgets bound retained metadata independently of stored member body sizes. The PE
|
||||
name probe reads headers from the first 64 KiB of the carrier; a PE whose
|
||||
headers extend beyond that is left to native classification. Snapshot and
|
||||
code-cache bytes remain opaque.
|
||||
headers extend beyond that is left to native classification. ELF note tables
|
||||
are read for at most one note per 12 carrier bytes in total, the most that
|
||||
disjoint tables can hold, so program headers that repeat the same notes do not
|
||||
rescan them once per header. Past that count the remaining tables are
|
||||
unreadable: an ELF without a confirmed blob keeps its native classification,
|
||||
and one with a blob fails closed. Snapshot and code-cache bytes remain opaque.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { extractArtifact } from "./extraction/ArtifactExtraction.js";
|
||||
import {
|
||||
NODE_SEA_BAD_MAGIC,
|
||||
NODE_SEA_DUPLICATE_BLOB,
|
||||
NODE_SEA_ELF_NOTES_REPEATED,
|
||||
NODE_SEA_FUSE_CONFLICT,
|
||||
NODE_SEA_FUSE_ABSENT,
|
||||
NODE_SEA_FUSE_UNSET,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
nodeSeaPe,
|
||||
nodeSeaUniversal,
|
||||
} from "./nodeSea/NodeSea.fixture.js";
|
||||
import { elfNoteFixture } from "../native/elf/ElfNotes.fixture.js";
|
||||
import { elfNote, elfNoteFixture } from "../native/elf/ElfNotes.fixture.js";
|
||||
import { peResourceFixture } from "../native/pe/PeResources.fixture.js";
|
||||
import { locateNodeSea } from "./nodeSea/NodeSeaLocation.js";
|
||||
import { MAX_INSPECT_PE_FILE_BYTES } from "../domain/native/peResources.js";
|
||||
@@ -413,6 +414,69 @@ it("keeps one blob when the same bytes are declared twice", async () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** ELF64 whose `count` PT_NOTE program headers all declare the same notes. */
|
||||
const repeatedNoteElf = (count: number, notes: Buffer): Buffer => {
|
||||
const noteOffset = 64 + count * 56;
|
||||
const bytes = Buffer.alloc(noteOffset + notes.length);
|
||||
bytes.writeUInt32LE(0x464c457f, 0);
|
||||
bytes[4] = 2;
|
||||
bytes[5] = 1;
|
||||
bytes[6] = 1;
|
||||
bytes.writeUInt16LE(3, 0x10);
|
||||
bytes.writeUInt16LE(62, 0x12);
|
||||
bytes.writeUInt32LE(1, 0x14);
|
||||
bytes.writeBigUInt64LE(64n, 0x20);
|
||||
bytes.writeUInt16LE(64, 0x34);
|
||||
bytes.writeUInt16LE(56, 0x36);
|
||||
bytes.writeUInt16LE(count, 0x38);
|
||||
bytes.writeUInt16LE(64, 0x3a);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = 64 + index * 56;
|
||||
bytes.writeUInt32LE(4, at);
|
||||
bytes.writeBigUInt64LE(BigInt(noteOffset), at + 8);
|
||||
bytes.writeBigUInt64LE(BigInt(notes.length), at + 32);
|
||||
bytes.writeBigUInt64LE(BigInt(notes.length), at + 40);
|
||||
bytes.writeBigUInt64LE(4n, at + 48);
|
||||
}
|
||||
notes.copy(bytes, noteOffset);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
it("reads repeated PT_NOTE declarations at most once per note the file holds", async () => {
|
||||
// 4 KiB of zero bytes is 341 empty 12-byte notes.
|
||||
const empty = Buffer.alloc(4096);
|
||||
const unconfirmed = repeatedNoteElf(64, empty);
|
||||
const handle = await open(
|
||||
await writeCarrier("repeated-notes", unconfirmed),
|
||||
"r",
|
||||
);
|
||||
const read = vi.spyOn(handle, "read");
|
||||
try {
|
||||
await expect(
|
||||
locateNodeSea(handle, unconfirmed.length),
|
||||
).resolves.toBeUndefined();
|
||||
// The prefix, the ELF header and 64 program headers, then the notes.
|
||||
expect(read.mock.calls.length).toBeLessThanOrEqual(
|
||||
2 + 64 + Math.floor(unconfirmed.length / 12),
|
||||
);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const blob = buildNodeSeaBlob({ main: script, codePath: "sea.js" });
|
||||
const confirmed = repeatedNoteElf(
|
||||
64,
|
||||
Buffer.concat([elfNote("NODE_SEA_BLOB", 0, blob), empty]),
|
||||
);
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier("repeated-sea-notes", confirmed),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: NODE_SEA_ELF_NOTES_REPEATED,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts the main script and assets without executing them", async () => {
|
||||
const directory = await createTestTempDirectory("rea-node-sea-extract-");
|
||||
const path = join(directory, "sea.elf");
|
||||
|
||||
@@ -43,6 +43,8 @@ export const NODE_SEA_ELF_DESCRIPTOR_TRUNCATED =
|
||||
"Node SEA ELF note descriptor is truncated";
|
||||
export const NODE_SEA_ELF_TABLE_TRUNCATED =
|
||||
"Node SEA ELF note table is truncated";
|
||||
export const NODE_SEA_ELF_NOTES_REPEATED =
|
||||
"Node SEA ELF note tables declare more notes than the file holds";
|
||||
export const NODE_SEA_MACHO_COMMANDS_TRUNCATED =
|
||||
"Node SEA Mach-O load commands are truncated";
|
||||
export const NODE_SEA_MACHO_SEGMENT_TRUNCATED =
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
NODE_SEA_BLOB_TOO_LARGE,
|
||||
NODE_SEA_DUPLICATE_BLOB,
|
||||
NODE_SEA_ELF_DESCRIPTOR_TRUNCATED,
|
||||
NODE_SEA_ELF_NOTES_REPEATED,
|
||||
NODE_SEA_ELF_TABLE_TRUNCATED,
|
||||
NODE_SEA_MACHO_COMMANDS_TRUNCATED,
|
||||
NODE_SEA_MACHO_SEGMENT_TRUNCATED,
|
||||
@@ -358,6 +359,10 @@ const locateElfNotes = async (
|
||||
const minimumSection = elf64 ? 64 : 40;
|
||||
const found: BlobRange[] = [];
|
||||
let opaque = false;
|
||||
// Every note header occupies at least 12 bytes, so disjoint tables hold at
|
||||
// most one note per 12 file bytes. Headers that point at the same bytes
|
||||
// would otherwise rescan them once per declaration.
|
||||
const scan: NoteScan = { little, notes: Math.floor(carrier.fileSize / 12) };
|
||||
const programs = await readElfTables(carrier, {
|
||||
offset: phoff,
|
||||
count: phnum,
|
||||
@@ -392,7 +397,7 @@ const locateElfNotes = async (
|
||||
const scanned = await scanElfNoteTable(
|
||||
carrier,
|
||||
note,
|
||||
little,
|
||||
scan,
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
@@ -440,7 +445,7 @@ const locateElfNotes = async (
|
||||
const scanned = await scanElfNoteTable(
|
||||
carrier,
|
||||
note,
|
||||
little,
|
||||
scan,
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
@@ -485,6 +490,12 @@ const readElfTables = async (
|
||||
return entries;
|
||||
};
|
||||
|
||||
/** One carrier's note byte order and the note headers it may still read. */
|
||||
interface NoteScan {
|
||||
readonly little: boolean;
|
||||
notes: number;
|
||||
}
|
||||
|
||||
const scanElfNoteTable = async (
|
||||
carrier: Carrier,
|
||||
note: {
|
||||
@@ -492,7 +503,7 @@ const scanElfNoteTable = async (
|
||||
readonly length: number | undefined;
|
||||
readonly align: number | undefined;
|
||||
},
|
||||
little: boolean,
|
||||
scan: NoteScan,
|
||||
alreadyFound: boolean,
|
||||
): Promise<BlobRange[] | undefined> => {
|
||||
if (
|
||||
@@ -507,10 +518,18 @@ const scanElfNoteTable = async (
|
||||
let cursor = 0;
|
||||
while (cursor + 12 <= note.length) {
|
||||
abortIfNeeded(carrier.signal);
|
||||
// Past the file's note capacity the tables overlap: an unconfirmed
|
||||
// carrier stays unrecognized and a confirmed one fails closed.
|
||||
if (scan.notes === 0) {
|
||||
if (alreadyFound || found.length > 0)
|
||||
throw formatFailure(NODE_SEA_ELF_NOTES_REPEATED);
|
||||
return undefined;
|
||||
}
|
||||
scan.notes -= 1;
|
||||
const header = await readExact(carrier, note.offset + cursor, 12);
|
||||
if (header === undefined)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
const parsedHeader = readElfNoteHeader(header, little);
|
||||
const parsedHeader = readElfNoteHeader(header, scan.little);
|
||||
if (!parsedHeader.ok)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
const { namesz, descsz } = parsedHeader.value;
|
||||
|
||||
Reference in new issue
Block a user