mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
refactor(pe): centralize resource tree facts across readers
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in new issue
Block a user