refactor(pe): centralize resource tree facts across readers

This commit is contained in:
morluto committed 2026-10-27 01:02:21 +08:00
1 parent 9d2c087d83
commit f6805e29c3
6 files changed
+284 -45

No files matched your search

@@ -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();
}
});
+3 -1
View File
@@ -591,8 +591,9 @@ const locatePeResources = async (carrier: Carrier): Promise<BlobRange[]> => {
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<typeof readPeResourceLayout>;
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<BlobRange[]> => {
max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES,
},
carrier.signal,
layout,
);
return resources.resources.flatMap((resource) => {
if (
+42 -16
View File
@@ -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<Buffer | undefined> => {
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;
}
}
+101
View File
@@ -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.",
});
});
+61 -28
View File
@@ -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<typeof readPeResourceLayout>;
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<PeResources> => {
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<number>,
): Promise<void> => {
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.",
+49
View File
@@ -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 };
};