diff --git a/src/artifacts/NodeSeaArtifactReader.test.ts b/src/artifacts/NodeSeaArtifactReader.test.ts index 58b7f2b449..5249a5c98b 100644 --- a/src/artifacts/NodeSeaArtifactReader.test.ts +++ b/src/artifacts/NodeSeaArtifactReader.test.ts @@ -745,3 +745,31 @@ it.each([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe])( } }, ); + +it("confirms SEA naming before rejecting an unrelated cyclic PE resource branch", async () => { + const fixture = peResourceFixture(true, [ + { type: 3, name: 1, language: 1033, payload: Buffer.from("other") }, + { + type: 10, + name: "NODE_SEA_BLOB", + language: 1033, + payload: buildNodeSeaBlob(populated), + }, + ]); + const other = fixture.entryOffsets.find( + (at) => fixture.bytes.readUInt32LE(at) === 3, + ); + if (other === undefined) throw new Error("Expected unrelated resource type"); + fixture.bytes.writeUInt32LE(0x80000000, other + 4); + const handle = await open( + await writeCarrier("sea-with-unrelated-cycle", fixture.bytes), + "r", + ); + try { + await expect( + locateNodeSea(handle, fixture.bytes.length), + ).rejects.toMatchObject({ reason: "format" }); + } finally { + await handle.close(); + } +}); diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts index cfa25719c9..0d3a1c81b4 100644 --- a/src/artifacts/nodeSea/NodeSeaLocation.ts +++ b/src/artifacts/nodeSea/NodeSeaLocation.ts @@ -591,8 +591,9 @@ const locatePeResources = async (carrier: Carrier): Promise => { if (bytes === undefined) throw formatFailure(NODE_SEA_TRUNCATED); // A native-looking file need not be an admitted PE image. Once the layout // is admitted, resource errors remain failures rather than inferred absence. + let layout: ReturnType; try { - readPeResourceLayout(bytes); + layout = readPeResourceLayout(bytes); } catch (cause) { if (cause instanceof ArtifactReaderFailure && cause.reason === "format") return []; @@ -611,6 +612,7 @@ const locatePeResources = async (carrier: Carrier): Promise => { max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES, }, carrier.signal, + layout, ); return resources.resources.flatMap((resource) => { if ( diff --git a/src/native/pe/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index 269a126916..857378183a 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -1,3 +1,11 @@ +import { + PE_RESOURCE_DIRECTORY_BYTES, + PE_RESOURCE_ENTRY_BYTES, + PE_RESOURCE_NAME_PREFIX_BYTES, + readPeResourceDirectoryFacts, + readPeResourceEntryFacts, + readPeResourceNameFacts, +} from "./PeResourceTreeFacts.js"; import { observeMzWindowsHeader } from "../../domain/dosMz.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; @@ -260,7 +268,7 @@ export const hasPeResourceName = async ( let root: number; try { layout = readPeResourceLayout(headers, fileSize); - if (layout.size < 16) return false; + if (layout.size < PE_RESOURCE_DIRECTORY_BYTES) return false; root = layout.map(layout.rva, layout.size); } catch (cause) { if (cause instanceof ArtifactReaderFailure && cause.reason === "format") @@ -274,29 +282,47 @@ export const hasPeResourceName = async ( : read(root + at, length); let examined = 0; const directory = async (at: number): Promise => { - const header = await relative(at, 16); + const header = await relative(at, PE_RESOURCE_DIRECTORY_BYTES); if (header === undefined) return undefined; - const entries = header.readUInt16LE(12) + header.readUInt16LE(14); + const { entryCount: entries } = readPeResourceDirectoryFacts(header); if (entries > maxEntries - examined) return undefined; examined += entries; - return relative(at + 16, entries * 8); + return relative( + at + PE_RESOURCE_DIRECTORY_BYTES, + entries * PE_RESOURCE_ENTRY_BYTES, + ); }; const types = await directory(0); if (types === undefined) return false; const expected = Buffer.from(name, "utf16le"); - for (let at = 0; at < types.length; at += 8) { - if (types.readUInt32LE(at) !== typeId) continue; - const target = types.readUInt32LE(at + 4); - if ((target & 0x80000000) === 0) continue; - const names = await directory(target & 0x7fffffff); + for (let at = 0; at < types.length; at += PE_RESOURCE_ENTRY_BYTES) { + const type = readPeResourceEntryFacts( + types.subarray(at, at + PE_RESOURCE_ENTRY_BYTES), + ); + if (type.identity.kind !== "id" || type.identity.id !== typeId) continue; + if (type.target.kind !== "directory") continue; + const names = await directory(type.target.offset); if (names === undefined) continue; - for (let entry = 0; entry < names.length; entry += 8) { - const encoded = names.readUInt32LE(entry); - if ((encoded & 0x80000000) === 0) continue; - const nameAt = encoded & 0x7fffffff; - const length = await relative(nameAt, 2); - if (length?.readUInt16LE(0) !== name.length) continue; - const candidate = await relative(nameAt + 2, expected.length); + for ( + let entry = 0; + entry < names.length; + entry += PE_RESOURCE_ENTRY_BYTES + ) { + const { identity } = readPeResourceEntryFacts( + names.subarray(entry, entry + PE_RESOURCE_ENTRY_BYTES), + ); + if (identity.kind !== "name") continue; + const nameAt = identity.offset; + const length = await relative(nameAt, PE_RESOURCE_NAME_PREFIX_BYTES); + if ( + length === undefined || + readPeResourceNameFacts(length).utf16Units !== name.length + ) + continue; + const candidate = await relative( + nameAt + PE_RESOURCE_NAME_PREFIX_BYTES, + expected.length, + ); if (candidate?.equals(expected)) return true; } } diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index 94afbbbd89..ecf8b0a17d 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -4,6 +4,7 @@ import { inspectPeResourcesInputSchema, peResourcesSchema, } from "../../domain/native/peResources.js"; +import { hasPeResourceName } from "./PeResourceLayout.js"; import { parsePeResources } from "./PeResourceParser.js"; import { peResourceFixture } from "./PeResources.fixture.js"; @@ -560,3 +561,103 @@ describe("PE resource ownership boundaries", () => { }); }); }); + +const probeSeaName = (bytes: Buffer) => + hasPeResourceName( + bytes, + bytes.length, + async (offset, length) => + offset < 0 || length > bytes.length - offset + ? undefined + : bytes.subarray(offset, offset + length), + 10, + "NODE_SEA_BLOB", + 4096, + ); + +it("matches resource names by their exact UTF-16 bytes without losing source identity", async () => { + const fixture = peResourceFixture(false, [ + { + type: 10, + name: "NODE_SEA_BLOB", + language: 1033, + payload: Buffer.from("data"), + }, + ]); + expect(await probeSeaName(fixture.bytes)).toBe(true); + const entry = fixture.entryOffsets.find( + (at) => (fixture.bytes.readUInt32LE(at) & 0x80000000) !== 0, + ); + if (entry === undefined) throw new Error("Expected named resource fixture"); + const nameAt = 0x400 + (fixture.bytes.readUInt32LE(entry) & 0x7fffffff); + fixture.bytes[nameAt + 3] = 1; + expect(await probeSeaName(fixture.bytes)).toBe(false); + const report = await parse(fixture.bytes); + expect(report.resources[0]?.name).toMatchObject({ + kind: "name", + name: "\u014eODE_SEA_BLOB", + utf16le_hex: fixture.bytes + .subarray(nameAt + 2, nameAt + 2 + 26) + .toString("hex"), + }); + fixture.bytes.writeUInt16LE(0xffff, nameAt); + expect(await probeSeaName(fixture.bytes)).toBe(false); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + }); +}); + +it("keeps name probing independent of full resource name/ID partition admission", async () => { + const fixture = peResourceFixture(false, [ + { + type: 10, + name: "NODE_SEA_BLOB", + language: 1033, + payload: Buffer.from("data"), + }, + ]); + const names = fixture.directoryOffsets[1]; + if (names === undefined) throw new Error("Expected resource names directory"); + fixture.bytes.writeUInt16LE(0, names + 12); + fixture.bytes.writeUInt16LE(1, names + 14); + expect(await probeSeaName(fixture.bytes)).toBe(true); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + message: "Resource name/ID entry counts disagree with encoded identities.", + }); +}); + +it("keeps raw name probing separate from full resource-name alignment admission", async () => { + const fixture = peResourceFixture(false, [ + { + type: 10, + name: "NODE_SEA_BLOB", + language: 1033, + payload: Buffer.from("data"), + }, + ]); + const entry = fixture.entryOffsets.find( + (at) => (fixture.bytes.readUInt32LE(at) & 0x80000000) !== 0, + ); + if (entry === undefined) throw new Error("Expected named resource fixture"); + const original = 0x400 + (fixture.bytes.readUInt32LE(entry) & 0x7fffffff); + const nameBytes = 2 + fixture.bytes.readUInt16LE(original) * 2; + const previousEnd = fixture.bytes.readUInt32LE(fixture.directoryAt + 4); + const unaligned = previousEnd % 2 === 0 ? previousEnd + 1 : previousEnd; + const extendedEnd = unaligned + nameBytes; + expect(0x400 + extendedEnd).toBeLessThanOrEqual(fixture.bytes.length); + fixture.bytes.copy( + fixture.bytes, + 0x400 + unaligned, + original, + original + nameBytes, + ); + fixture.bytes.writeUInt32LE(0x80000000 + unaligned, entry); + fixture.bytes.writeUInt32LE(extendedEnd, fixture.directoryAt + 4); + fixture.bytes.writeUInt32LE(extendedEnd, fixture.sectionAt + 8); + expect(await probeSeaName(fixture.bytes)).toBe(true); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + message: "Unaligned PE resource name.", + }); +}); diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 0cd3f83f21..0d96cd158f 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -1,3 +1,12 @@ +import { + PE_RESOURCE_DIRECTORY_BYTES, + PE_RESOURCE_ENTRY_BYTES, + PE_RESOURCE_NAME_PREFIX_BYTES, + readPeResourceDirectoryFacts, + readPeResourceEntryFacts, + readPeResourceNameFacts, + type PeResourceIdentityDeclaration, +} from "./PeResourceTreeFacts.js"; import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; @@ -15,6 +24,8 @@ import { requirePeRange, } from "./PeResourceLayout.js"; +type PeResourceLayout = ReturnType; + type Range = { offset: number; bytes: number }; type OccupiedRange = Range & { kind: "directory" | "name" | "data-entry" | "payload"; @@ -43,9 +54,11 @@ export const parsePeResources = async ( artifact: PeResources["artifact"], input: InspectPeResourcesInput, signal?: AbortSignal, + /** Already admitted from these same bytes; used by carrier probes that own layout failure policy. */ + admittedLayout?: PeResourceLayout, ): Promise => { assertArtifactReadActive(signal); - const layout = readPeResourceLayout(bytes); + const layout = admittedLayout ?? readPeResourceLayout(bytes); const directories: PeResources["directories"] = []; const resources: PeResources["resources"] = []; const occupied: OccupiedRange[] = []; @@ -92,23 +105,33 @@ export const parsePeResources = async ( if (size > 0) occupied.push({ ...range, kind }); return range; }; - const readIdentity = (encoded: number): PeResourceIdentity => { - if ((encoded & 0x80000000) === 0) return { kind: "id", id: encoded }; - const at = encoded & 0x7fffffff; + const readIdentity = ( + declaration: PeResourceIdentityDeclaration, + ): PeResourceIdentity => { + if (declaration.kind === "id") return { kind: "id", id: declaration.id }; + const at = declaration.offset; const previous = names.get(at); if (previous !== undefined) return previous; if (at % 2 !== 0) peFailure("Unaligned PE resource name."); - const start = relative(at, 2); - const utf16Units = bytes.readUInt16LE(start); + const start = relative(at, PE_RESOURCE_NAME_PREFIX_BYTES); + const { utf16Units, payloadBytes: length } = readPeResourceNameFacts( + bytes.subarray(start, start + PE_RESOURCE_NAME_PREFIX_BYTES), + ); reserveDecodedName(utf16Units); - const length = utf16Units * 2; - relative(at, 2 + length); - const raw = bytes.subarray(start + 2, start + 2 + length); + relative(at, PE_RESOURCE_NAME_PREFIX_BYTES + length); + const raw = bytes.subarray( + start + PE_RESOURCE_NAME_PREFIX_BYTES, + start + PE_RESOURCE_NAME_PREFIX_BYTES + length, + ); const value: PeResourceIdentity = { kind: "name", name: raw.toString("utf16le"), utf16le_hex: raw.toString("hex"), - location: recordRange(start, 2 + length, "name"), + location: recordRange( + start, + PE_RESOURCE_NAME_PREFIX_BYTES + length, + "name", + ), }; names.set(at, value); return value; @@ -120,25 +143,30 @@ export const parsePeResources = async ( ancestors: ReadonlySet, ): Promise => { if (ancestors.has(at)) peFailure("Cyclic PE resource directory."); - const header = relative(at, 16); - const named = bytes.readUInt16LE(header + 12); - const ids = bytes.readUInt16LE(header + 14); - const count = named + ids; + const header = relative(at, PE_RESOURCE_DIRECTORY_BYTES); + const facts = readPeResourceDirectoryFacts( + bytes.subarray(header, header + PE_RESOURCE_DIRECTORY_BYTES), + ); + const { namedEntries: named, idEntries: ids, entryCount: count } = facts; if (count > input.max_entries - examined) limit( "PE resource directory entry budget exceeded; no partial inventory was returned.", "capacity", ); examined += count; - relative(at, 16 + 8 * count); + relative(at, PE_RESOURCE_DIRECTORY_BYTES + PE_RESOURCE_ENTRY_BYTES * count); const directory = { path, entry_locations: entryLocations, - location: recordRange(header, 16 + 8 * count, "directory"), - characteristics: bytes.readUInt32LE(header), - timestamp: bytes.readUInt32LE(header + 4), - major_version: bytes.readUInt16LE(header + 8), - minor_version: bytes.readUInt16LE(header + 10), + location: recordRange( + header, + PE_RESOURCE_DIRECTORY_BYTES + PE_RESOURCE_ENTRY_BYTES * count, + "directory", + ), + characteristics: facts.characteristics, + timestamp: facts.timestamp, + major_version: facts.majorVersion, + minor_version: facts.minorVersion, named_entries: named, id_entries: ids, }; @@ -151,22 +179,27 @@ export const parsePeResources = async ( await setImmediate(); assertArtifactReadActive(signal); } - const entry = header + 16 + index * 8; - const encoded = bytes.readUInt32LE(entry); - if (((encoded & 0x80000000) !== 0) !== index < named) + const entry = + header + PE_RESOURCE_DIRECTORY_BYTES + index * PE_RESOURCE_ENTRY_BYTES; + const record = readPeResourceEntryFacts( + bytes.subarray(entry, entry + PE_RESOURCE_ENTRY_BYTES), + ); + if ((record.identity.kind === "name") !== index < named) peFailure( "Resource name/ID entry counts disagree with encoded identities.", ); - const identity = readIdentity(encoded); + const identity = readIdentity(record.identity); const key = identityKey(identity); if (keys.has(key)) peFailure("Duplicate identity within a resource directory."); keys.add(key); const next = [...path, identity]; - const locations = [...entryLocations, { offset: entry, bytes: 8 }]; - const target = bytes.readUInt32LE(entry + 4); - const offset = target & 0x7fffffff; - if ((target & 0x80000000) !== 0) { + const locations = [ + ...entryLocations, + { offset: entry, bytes: PE_RESOURCE_ENTRY_BYTES }, + ]; + const { offset } = record.target; + if (record.target.kind === "directory") { if (next.length >= 3) peFailure( "Unsupported resource tree deeper than type/name/language.", diff --git a/src/native/pe/PeResourceTreeFacts.ts b/src/native/pe/PeResourceTreeFacts.ts new file mode 100644 index 0000000000..536c49de53 --- /dev/null +++ b/src/native/pe/PeResourceTreeFacts.ts @@ -0,0 +1,49 @@ +/** Fixed PE resource records; callers own interval, budget and tree admission. */ +export const PE_RESOURCE_DIRECTORY_BYTES = 16; +export const PE_RESOURCE_ENTRY_BYTES = 8; +export const PE_RESOURCE_NAME_PREFIX_BYTES = 2; + +/** Decode an already-admitted IMAGE_RESOURCE_DIRECTORY record. */ +export const readPeResourceDirectoryFacts = (bytes: Buffer) => { + const namedEntries = bytes.readUInt16LE(12); + const idEntries = bytes.readUInt16LE(14); + return { + characteristics: bytes.readUInt32LE(0), + timestamp: bytes.readUInt32LE(4), + majorVersion: bytes.readUInt16LE(8), + minorVersion: bytes.readUInt16LE(10), + namedEntries, + idEntries, + entryCount: namedEntries + idEntries, + }; +}; + +export type PeResourceIdentityDeclaration = + | { readonly kind: "id"; readonly id: number } + | { readonly kind: "name"; readonly offset: number }; + +/** Offsets remain relative to the resource root; numeric IDs retain all source bits. */ +export const readPeResourceEntryFacts = (bytes: Buffer) => { + const encodedIdentity = bytes.readUInt32LE(0); + const encodedTarget = bytes.readUInt32LE(4); + const identity: PeResourceIdentityDeclaration = + (encodedIdentity & 0x80000000) === 0 + ? { kind: "id", id: encodedIdentity } + : { kind: "name", offset: encodedIdentity & 0x7fffffff }; + return { + identity, + target: { + kind: + (encodedTarget & 0x80000000) === 0 + ? ("data" as const) + : ("directory" as const), + offset: encodedTarget & 0x7fffffff, + }, + }; +}; + +/** Observe the length only; callers admit decoded-name costs before reading or decoding payload. */ +export const readPeResourceNameFacts = (bytes: Buffer) => { + const utf16Units = bytes.readUInt16LE(0); + return { utf16Units, payloadBytes: utf16Units * 2 }; +};