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:
Dragolone authored and GitHub committed 2026-11-04 19:31:14 +08:00
1 parent 1861059f8b
commit 1f8084c7db
4 files changed
+96 -7

No files matched your search

+6 -2
View File
@@ -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
+65 -1
View File
@@ -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");
+2
View File
@@ -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 =
+23 -4
View File
@@ -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;