Compare commits

...
Author SHA1 Message Date
N0zoM1z0 8005cad374 fix(ghidra): preserve explicit annotation naming semantics 2026-10-10 23:24:52 +00:00
N0zoM1z0 75881af7aa fix(ghidra): reject names owned by another address 2026-10-10 23:18:07 +00:00
morluto 8d3cfef753 fix(plist): disclose binary prototype-key omissions
Reuse the bounded binary preflight to observe dictionary keys before the pinned decoder drops them. Keyed archive results disclose the omission and Interface Builder marks archive decoding partial. XML keeps its existing exact counts; binary reporting deliberately leaves the count unknown because overwritten subtrees and decoder projection can change visibility.

Remove the superseded size-only preflight contract. Cover ASCII, UTF-16 and extended lengths, shared references, nested and shadowed dictionaries, and the value-only negative control. Synthetic plist and filesystem fixtures; no plutil or ibtool verification.

Fixes #1594. Supersedes #1593.
2026-10-11 07:02:39 +08:00
morluto 975dff2e25 fix(dotnet): bound RVA reads by section virtual size
Use the shared resolver for virtual containment, overlap checks, and file-backed extent. Raw alignment padding no longer supplies method bytes or shadows a later virtual section. Preserve malformed body coverage and suppress CIL hashes outside the admitted range.

Verify zero, exact-end, truncated-body, padding, and true-overlap cases through compiled CLI and MCP consumers. Synthetic PE fixtures only; no Windows loader or real managed-provider verification.

Fixes #1596. Supersedes #1595.
2026-10-11 07:02:16 +08:00
15 changed files with 599 additions and 80 deletions
+21 -11
View File
@@ -1105,6 +1105,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
return requested;
}
private void requireAvailableAddressName(Address address,
ghidra.program.model.symbol.Namespace namespace, String name)
throws ghidra.util.exception.DuplicateNameException {
for (Symbol owner : currentProgram.getSymbolTable().getSymbols(name, namespace)) {
if (!owner.getAddress().equals(Address.NO_ADDRESS) && !owner.getAddress().equals(address))
throw new ghidra.util.exception.DuplicateNameException(
"Symbol name " + name + " is already owned at " +
canonicalAddress(owner.getAddress()) + " in namespace " + namespace.getName(true));
}
}
private static String requireText(JsonObject params, String field) {
JsonElement value = params.get(field);
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString())
@@ -1434,20 +1445,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
boolean commit = false;
try {
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
String expected = name;
var table = currentProgram.getSymbolTable();
Symbol primary = function == null ? table.getPrimarySymbol(address) : function.getSymbol();
boolean existing = primary != null && !primary.isDynamic();
String expected = existing ? symbolLeafName(primary, name) : name;
requireAvailableAddressName(address,
existing ? primary.getParentNamespace() : currentProgram.getGlobalNamespace(), expected);
if (function != null) {
expected = symbolLeafName(function.getSymbol(), name);
function.setName(expected, SourceType.USER_DEFINED);
} else if (existing) {
primary.setName(expected, SourceType.USER_DEFINED);
} else {
var table = currentProgram.getSymbolTable();
Symbol primary = table.getPrimarySymbol(address);
if (primary != null && !primary.isDynamic()) {
expected = symbolLeafName(primary, name);
primary.setName(expected, SourceType.USER_DEFINED);
} else {
Symbol created = table.createLabel(address, name, SourceType.USER_DEFINED);
created.setPrimary();
}
Symbol created = table.createLabel(address, expected, SourceType.USER_DEFINED);
created.setPrimary();
}
invalidateAnalysisCaches();
Symbol readback = currentProgram.getSymbolTable().getPrimarySymbol(address);
+5 -2
View File
@@ -400,8 +400,11 @@ GUI authority, and REA never falls back automatically to Hopper.
`set_address_name` and `set_addresses_names` give Ghidra sessions the same
compact naming results as Hopper: a local function entry renames that function,
and any other mapped address, such as a global, receives a new or renamed primary
label. A batch applies each address separately and reports one success boolean
per address. Use them to name many globals and helpers cheaply; use
label. Names owned by another address in the same namespace are rejected;
the same leaf name remains valid in a different namespace. A batch applies each
address separately and reports one success boolean per address, preserving the
other entries when one fails. Ghidra batches do not move or swap names. Use them
to name many globals and helpers cheaply; use
`annotate_native_function` when you also want comments and a refreshed dossier.
Ghidra name edits preserve the existing namespace. Supply either a leaf name
such as `renamed` or a fully qualified name in that namespace, such as
@@ -62,6 +62,7 @@ export async function verifyGhidraNamespaceAnnotations({
assert.ok(symbol, `Missing demangled fixture global ${namespace}::value`);
return { namespace, address: symbol.address };
});
await verifyAddressNameCollisions(call, procedures, names);
const ambiguous = await reject("procedure_address", { procedure: "same" });
assert.equal(ambiguous.code, "invalid_request");
for (const { address } of entries)
@@ -133,7 +134,11 @@ export async function verifyGhidraNamespaceAnnotations({
});
assert.deepEqual(unchanged.annotations, qualified.annotations);
}
await verifyAddressNames(call, [entries[0], entries[2], ...globals]);
await verifyAddressNames(call, [
{ ...entries[0], kind: "function" },
{ ...entries[2], kind: "function" },
...globals.map((symbol) => ({ ...symbol, kind: "label" })),
]);
for (const { namespace, address } of entries) {
assert.equal(
await call("set_address_name", {
@@ -177,13 +182,141 @@ export async function verifyGhidraNamespaceAnnotations({
}
}
async function verifyAddressNames(call, symbols) {
for (const { namespace, address } of symbols) {
async function verifyAddressNameCollisions(call, procedures, names) {
const find = (items, name) => {
const symbol = items.find(
(item) => item.value === name || item.value === `_${name}`,
);
assert.ok(symbol, `Missing naming collision fixture ${name}`);
return symbol;
};
const first = find(procedures, "rea_name_first");
const second = find(procedures, "rea_name_second");
const firstLabel = find(names, "rea_name_data_first");
const secondLabel = find(names, "rea_name_data_second");
for (const [owner, other, name] of [
[first, second, "owned_function"],
[firstLabel, secondLabel, "owned_label"],
]) {
assert.equal(
await call("set_address_name", { address, name: "address_renamed" }),
await call("set_address_name", { address: owner.address, name }),
true,
);
const qualified = `${namespace}::address_qualified`;
assert.equal(
await call("set_address_name", { address: other.address, name }),
false,
"A different address must not acquire the existing name",
);
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
assert.equal(
await call("set_address_name", { address: owner.address, name }),
true,
"The owning address may repeat the same name",
);
}
for (const [address, name, original] of [
[first.address, "owned_label", "owned_function"],
[firstLabel.address, "owned_function", "owned_label"],
]) {
assert.equal(await call("set_address_name", { address, name }), false);
assert.equal(await call("address_name", { address }), original);
}
const alphaFunction = find(procedures, "alpha::same");
const alphaLabel = find(names, "alpha::value");
for (const name of ["value", "alpha::value"]) {
assert.equal(
await call("set_address_name", { address: alphaFunction.address, name }),
false,
);
assert.equal(
await call("address_name", { address: alphaFunction.address }),
alphaFunction.value,
);
}
assert.equal(
await call("set_address_name", {
address: alphaLabel.address,
name: "same",
}),
false,
);
assert.equal(
await call("address_name", { address: alphaLabel.address }),
alphaLabel.value,
);
assert.deepEqual(
await call("set_addresses_names", {
names: {
[secondLabel.address]: "batch_available",
[second.address]: "owned_function",
[firstLabel.address]: "owned_label",
},
}),
{
[secondLabel.address]: true,
[second.address]: false,
[firstLabel.address]: true,
},
);
assert.equal(
await call("address_name", { address: second.address }),
second.value,
);
assert.equal(
await call("address_name", { address: first.address }),
"owned_function",
);
assert.equal(
await call("address_name", { address: secondLabel.address }),
"batch_available",
);
const before = await call("annotate_native_function", {
procedure: second.address,
comment: "Keep this comment",
inline_comment: "Keep this inline comment",
});
assert.equal(
await call("set_address_name", {
address: second.address,
name: "owned_function",
}),
false,
);
const unchanged = await call("annotate_native_function", {
procedure: second.address,
name: second.value,
});
assert.deepEqual(unchanged.annotations, before.annotations);
assert.equal(
await call("set_address_name", {
address: first.address,
name: "released_function",
}),
true,
);
assert.equal(
await call("set_address_name", {
address: second.address,
name: "owned_function",
}),
true,
"A name becomes available after its old owner releases it",
);
}
async function verifyAddressNames(call, symbols) {
for (const { namespace, address, kind } of symbols) {
assert.equal(
await call("set_address_name", {
address,
name: `address_renamed_${kind}`,
}),
true,
);
const qualified = `${namespace}::address_qualified_${kind}`;
assert.equal(
await call("set_address_name", { address, name: qualified }),
true,
@@ -247,14 +380,14 @@ async function verifyAddressNames(call, symbols) {
);
assert.equal(
await call("address_name", { address: invalid.address }),
`${invalid.namespace}::address_qualified`,
`${invalid.namespace}::address_qualified_${invalid.kind}`,
);
const names = await call("list_names");
for (const { namespace, address } of symbols) {
for (const { namespace, address, kind } of symbols) {
const expected =
address === valid.address
? `${namespace}::batch_renamed`
: `${namespace}::address_qualified`;
: `${namespace}::address_qualified_${kind}`;
assert.ok(
names.some(
(item) =>
+23 -13
View File
@@ -9,7 +9,7 @@ import {
} from "../../domain/propertyListKeys.js";
import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js";
import {
estimatePropertyListDecodeBytes,
preflightPropertyListDecode,
InterfaceBuilderDecodeBudget,
InterfaceBuilderDecodeBudgetExceeded,
} from "./InterfaceBuilderDecodeBudget.js";
@@ -47,7 +47,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const omittedArchives: Array<{ path: string; reason: string }> = [];
let unreportedOmittedArchives = 0;
const incompleteHierarchies = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number | null>();
let omitted = 0;
let attempted = 0;
let aggregateInputBytes = 0;
@@ -93,6 +93,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
bytes.subarray(0, 10).toString("ascii") === "NIBArchive";
let nib: ReturnType<typeof projectNibArchive> | null = null;
let xmlText: string | undefined;
let binaryPrototypeKeyObserved = false;
if (isNibArchive) {
const documentBudget = new InterfaceBuilderDecodeBudget(
decodeBudget.remainingBytes,
@@ -109,23 +110,25 @@ export const analyzeInterfaceBuilderBundle = async (input: {
} else {
if (bytes.subarray(0, 8).toString("ascii") !== "bplist00")
xmlText = decodeXmlPlistText(bytes);
const decodedReservation = estimatePropertyListDecodeBytes(
const decodedReservation = preflightPropertyListDecode(
bytes,
decodeBudget.remainingBytes,
xmlText,
);
decodeBudget.reserve(
decodedReservation,
decodedReservation.estimatedBytes,
"plist representation exceeds the aggregate Interface Builder decode budget",
);
binaryPrototypeKeyObserved =
decodedReservation.binaryPrototypeKeyObserved;
}
const { value: raw, omittedPrototypeKeys } =
nib === null
? decodePlist(bytes, xmlText)
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
: { value: nib.raw, omittedPrototypeKeys: 0 };
if (nib !== null && nib.omitted > 0)
incompleteHierarchies.set(entry.path, nib.omitted);
if (omittedPrototypeKeys > 0)
if (omittedPrototypeKeys !== 0)
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
const documentHash = createHash("sha256").update(bytes).digest("hex");
documents.push({
@@ -226,7 +229,7 @@ const finalizeHierarchyCoverage = (
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
counts: {
incompleteHierarchies: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
invalid: readonly string[];
omittedArchives: readonly { path: string; reason: string }[];
unreportedOmittedArchives: number;
@@ -641,11 +644,18 @@ const readEntry = async (
/** Project decoded plist data and dates while retaining omitted-key coverage. */
const decodePlist = (
bytes: Buffer,
xmlText?: string,
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
const { value, omittedPrototypeKeys } =
bytes.subarray(0, 8).toString("ascii") === "bplist00"
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
xmlText: string | undefined,
binaryPrototypeKeyObserved: boolean,
): {
readonly value: JsonValue;
readonly omittedPrototypeKeys: number | null;
} => {
const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00";
const { value, omittedPrototypeKeys } = binary
? {
value: parseBinary(bytes),
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
}
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
return { value: projectPlistValue(value).value, omittedPrototypeKeys };
};
@@ -89,21 +89,24 @@ export class InterfaceBuilderDecodeBudget {
}
/** Preflight the representation created by the pinned plist decoder. */
export const estimatePropertyListDecodeBytes = (
export const preflightPropertyListDecode = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
): number => {
const estimate =
): PlistDecodePreflight => {
const expansion =
bytes.subarray(0, 8).toString("ascii") === "bplist00"
? estimateBinaryPlistExpansion(bytes, maxBytes)
: estimateXmlPlistExpansion(bytes, maxBytes, xmlText);
if (estimate > maxBytes)
: {
estimatedBytes: estimateXmlPlistExpansion(bytes, maxBytes, xmlText),
binaryPrototypeKeyObserved: false,
};
if (expansion.estimatedBytes > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"plist representation exceeds the aggregate Interface Builder decode budget",
);
return estimate;
return expansion;
};
const estimateXmlPlistExpansion = (
@@ -173,6 +176,14 @@ export interface BinaryPlistExpansionOptions {
readonly bounds?: "strict" | "decoder";
}
/** Preflight size and binary-key observations; XML keys are inspected by decode. */
export interface PlistDecodePreflight {
readonly estimatedBytes: number;
readonly binaryPrototypeKeyObserved: boolean;
}
const PROTOTYPE_KEY = "__proto__";
/**
* Count every expanded binary-plist reference before calling plist.parseBinary,
* which recurses on each reference and copies shared containers. Malformed
@@ -186,7 +197,7 @@ export const estimateBinaryPlistExpansion = (
fail = interfaceBuilderBudgetFailure,
bounds = "strict",
}: BinaryPlistExpansionOptions = {},
): number => {
): PlistDecodePreflight => {
if (bytes.length < 40)
throw new TypeError("binary plist trailer is truncated");
const trailer = bytes.length - 32;
@@ -218,13 +229,17 @@ export const estimateBinaryPlistExpansion = (
)
throw new TypeError("binary plist trailer or object table is invalid");
let estimate = bytes.length * 2 + objectCount * 16;
let binaryPrototypeKeyObserved = false;
if (estimate > maxBytes)
throw fail("expansion", `binary plist object table exceeds ${budget}`);
const active = new Set<number>();
const pending: Array<{ object: number; depth: number; exit: boolean }> = [
{ object: topObject, depth: 0, exit: false },
];
const pending: Array<{
object: number;
depth: number;
exit: boolean;
dictionaryKey: boolean;
}> = [{ object: topObject, depth: 0, exit: false, dictionaryKey: false }];
let pendingObjectCount = 1;
while (pending.length > 0) {
const entry = pending.pop();
@@ -269,6 +284,26 @@ export const estimateBinaryPlistExpansion = (
const byteLength = type === 6 ? size * 2 : size;
if (cursor + byteLength > objectsEnd)
throw new TypeError("binary plist scalar object is truncated");
if (
!binaryPrototypeKeyObserved &&
entry.dictionaryKey &&
(type === 5 || type === 6) &&
size === PROTOTYPE_KEY.length
) {
let matches = true;
for (let index = 0; index < PROTOTYPE_KEY.length; index += 1) {
const codeUnit =
type === 5
? (bytes[cursor + index] ?? 0)
: ((bytes[cursor + index * 2] ?? 0) << 8) |
(bytes[cursor + index * 2 + 1] ?? 0);
if (codeUnit !== PROTOTYPE_KEY.charCodeAt(index)) {
matches = false;
break;
}
}
if (matches) binaryPrototypeKeyObserved = true;
}
estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2;
if (estimate > maxBytes)
throw fail(
@@ -289,7 +324,12 @@ export const estimateBinaryPlistExpansion = (
`binary plist reference expansion exceeds ${budget}`,
);
active.add(entry.object);
pending.push({ object: entry.object, depth: entry.depth, exit: true });
pending.push({
object: entry.object,
depth: entry.depth,
exit: true,
dictionaryKey: false,
});
for (let index = childCount - 1; index >= 0; index -= 1) {
const reference = readInteger(
cursor + index * referenceSize,
@@ -297,9 +337,14 @@ export const estimateBinaryPlistExpansion = (
);
if (reference < 0 || reference >= objectCount)
throw new TypeError("binary plist reference is invalid");
pending.push({ object: reference, depth: entry.depth + 1, exit: false });
pending.push({
object: reference,
depth: entry.depth + 1,
exit: false,
dictionaryKey: type === 13 && index < size,
});
}
pendingObjectCount += childCount;
}
return estimate;
return { estimatedBytes: estimate, binaryPrototypeKeyObserved };
};
@@ -65,6 +65,40 @@ describe("inert keyed archive decoding", () => {
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
);
});
it("reports a binary archive dictionary keyed __proto__ as omitted", () => {
const placeholder = "proto_key";
const encoded = Buffer.from(
buildBinary({
...archive,
[placeholder]: "hidden",
$objects: ["$null", "value"],
}),
);
const start = encoded.indexOf(placeholder);
expect(start).toBeGreaterThanOrEqual(0);
expect(encoded.indexOf(placeholder, start + placeholder.length)).toBe(-1);
Buffer.from("__proto__").copy(encoded, start);
const graph = decodeKeyedArchiveBytes(encoded, { offset: 0, limit: 2 });
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
it("does not report a binary __proto__ value as an omitted key", () => {
const graph = decodeKeyedArchiveBytes(
Buffer.from(
buildBinary({
...archive,
note: "__proto__",
$objects: ["$null", "value"],
}),
),
{ offset: 0, limit: 2 },
);
expect(graph.limitations).not.toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
expect(() =>
decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), {
@@ -133,6 +167,20 @@ const plistArray = (references: readonly number[]): Buffer => {
};
const plistString = (value: string): Buffer =>
Buffer.concat([Buffer.from([0x50 | value.length]), Buffer.from(value)]);
const plistUtf16String = (value: string): Buffer => {
const bytes = Buffer.alloc(1 + value.length * 2);
bytes[0] = 0x60 | value.length;
for (let index = 0; index < value.length; index += 1)
bytes.writeUInt16BE(value.charCodeAt(index), 1 + index * 2);
return bytes;
};
const plistExtendedUtf16String = (value: string): Buffer => {
const bytes = Buffer.alloc(3 + value.length * 2);
bytes.set([0x6f, 0x10, value.length]);
for (let index = 0; index < value.length; index += 1)
bytes.writeUInt16BE(value.charCodeAt(index), 3 + index * 2);
return bytes;
};
// {"$archiver": "NSKeyedArchiver", "$top": {"root": UID 1}, "$objects": object 8}
const keyedRoot = (payload: readonly Buffer[]): Buffer =>
binaryPlist([
@@ -187,4 +235,91 @@ describe("binary plist reference preflight", () => {
[2, "$null"],
]);
});
it("recognizes a shared key object used by separate dictionaries", () => {
const graph = decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9, 12]),
Buffer.from([0xd1, 0, 10, 0, 11]),
plistString("__proto__"),
plistString("first"),
Buffer.from([0xd1, 0, 10, 0, 13]),
plistString("second"),
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
it("reports binary omission presence without claiming a count", () => {
const placeholder = "proto_key";
const nested = Buffer.from(
buildBinary({
...archive,
[placeholder]: { [placeholder]: "hidden", visible: true },
$objects: ["$null", "value"],
}),
);
let index = nested.indexOf(placeholder);
while (index !== -1) {
Buffer.from("__proto__").copy(nested, index);
index = nested.indexOf(placeholder, index + placeholder.length);
}
const nestedGraph = decodeKeyedArchiveBytes(nested, selection);
expect(nestedGraph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
const shadowedValue = keyedRoot([
plistArray([9]),
Buffer.from([0xd2, 0, 10, 0, 10, 0, 11, 0, 12]),
plistString("shadow"),
Buffer.from([0xd1, 0, 13, 0, 14]),
plistString("replacement"),
plistString(placeholder),
plistString("hidden"),
]);
const keyOffset = shadowedValue.indexOf(Buffer.from(placeholder));
expect(keyOffset).toBeGreaterThanOrEqual(0);
Buffer.from("__proto__").copy(shadowedValue, keyOffset);
const shadowedGraph = decodeKeyedArchiveBytes(shadowedValue, selection);
expect(shadowedGraph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
expect(shadowedGraph.limitations).not.toContain(
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
);
});
it("detects UTF-16 binary dictionary keys", () => {
const graph = decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9]),
Buffer.from([0xd1, 0, 10, 0, 11]),
plistUtf16String("__proto__"),
plistString("hidden"),
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
it("detects extended-length UTF-16 binary dictionary keys", () => {
const graph = decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9]),
Buffer.from([0xd1, 0, 10, 0, 11]),
plistExtendedUtf16String("__proto__"),
plistString("hidden"),
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
});
+21 -15
View File
@@ -6,7 +6,6 @@ import {
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { projectPlistValue } from "../../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import {
@@ -51,20 +50,27 @@ export const decodeKeyedArchiveBytes = (
// plist.parseBinary recurses on every object reference and copies shared
// containers, so a few hundred bytes can exhaust memory or the stack.
// RangeError reports a resource limit; a reference cycle is malformed.
if (binary)
estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
budget: "the keyed archive decode budget",
fail: (kind, message) =>
kind === "cycle"
? new TypeError(
"binary plist object references form a cycle, which a property list cannot represent",
)
: new RangeError(message),
bounds: "decoder",
});
const parsed = binary
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
: parseXmlPropertyList(xmlText ?? "");
const binaryExpansion = binary
? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
budget: "the keyed archive decode budget",
fail: (kind, message) =>
kind === "cycle"
? new TypeError(
"binary plist object references form a cycle, which a property list cannot represent",
)
: new RangeError(message),
bounds: "decoder",
})
: undefined;
const parsed =
binaryExpansion !== undefined
? {
value: parseBinary(bytes),
omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved
? null
: 0,
}
: parseXmlPropertyList(xmlText ?? "");
const { value: plistValue, unknownRealCount } = projectPlistValue(
parsed.value,
);
+4 -2
View File
@@ -10,8 +10,10 @@ export interface PropertyListWithoutPrototypeKeys {
}
/** Limitation for results whose source plist held `__proto__` entries. */
export const omittedPrototypeKeysLimitation = (count: number): string =>
`${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
export const omittedPrototypeKeysLimitation = (count: number | null): string =>
count === null
? "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key."
: `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
const withoutKey = (
value: unknown,
+3 -1
View File
@@ -32,6 +32,7 @@ interface ManagedPeFixtureOptions {
readonly targetFramework?: string | null;
readonly typeName?: string;
readonly typeNamespace?: string;
readonly virtualSectionSize?: number;
}
const textEncoder = new TextEncoder();
@@ -846,7 +847,8 @@ const buildManagedFixtureImage = (
readyToRunOffset === null ? 0 : readyToRunOffset - 0x0200 + 4,
) / 0x0200,
) * 0x0200;
const virtualSectionSize = Math.max(0x1000, rawSectionSize);
const virtualSectionSize =
options.virtualSectionSize ?? Math.max(0x1000, rawSectionSize);
const image = Buffer.alloc(0x0200 + rawSectionSize);
image.write("MZ", 0, "ascii");
image.writeUInt32LE(0x80, 0x3c);
+8 -12
View File
@@ -139,14 +139,14 @@ const resolveRva = (
scope,
`${scope} has an invalid RVA range`,
);
// VirtualSize owns RVA containment; raw size also includes alignment padding.
const selected = sections.find((section) => {
const within = rva - section.virtualAddress;
const mappedSize = Math.max(section.virtualSize, section.rawSize);
return (
mappedSize > 0 &&
section.virtualSize > 0 &&
within >= 0 &&
within <= mappedSize &&
size <= mappedSize - within
within < section.virtualSize &&
size <= section.virtualSize - within
);
});
if (selected === undefined)
@@ -156,13 +156,8 @@ const resolveRva = (
`${scope} RVA is not covered by one PE section`,
);
for (const section of sections) {
if (
section === selected ||
Math.max(section.virtualSize, section.rawSize) === 0
)
continue;
const end =
section.virtualAddress + Math.max(section.virtualSize, section.rawSize);
if (section === selected || section.virtualSize === 0) continue;
const end = section.virtualAddress + section.virtualSize;
const intersects =
size === 0
? rva >= section.virtualAddress && rva < end
@@ -184,6 +179,7 @@ const resolveRva = (
const offset = add(selected.rawOffset, within, scope);
requireRange(bytes, offset, size, scope);
let available = Math.min(
selected.virtualSize - within,
selected.rawSize - within,
bytes.length - offset,
0x1_0000_0000 - rva,
@@ -193,7 +189,7 @@ const resolveRva = (
if (
section !== selected &&
section.virtualAddress > rva &&
Math.max(section.virtualSize, section.rawSize) > 0
section.virtualSize > 0
)
available = Math.min(available, section.virtualAddress - rva);
return { offset, available };
+2 -2
View File
@@ -55,8 +55,8 @@ export const limitationsFor = (operation: string): readonly string[] => {
case "set_addresses_names":
return [
...common,
"Names use Ghidra USER_DEFINED source. A local function entry renames that function; any other mapped address receives a new or renamed primary label. Existing symbols retain their namespace and accept leaf names or qualified names within that namespace; qualified readback can be reused without adding namespace prefixes. Readback compares the primary symbol name.",
"set_addresses_names applies each address in its own transaction and reports per-address success; invalid names, unmapped addresses, and readback mismatches roll back only that address.",
"Names use Ghidra USER_DEFINED source. A local function entry renames that function; any other mapped address receives a new or renamed primary label. Existing symbols retain their namespace and accept leaf names or qualified names within that namespace; qualified readback can be reused without adding namespace prefixes. Names owned by another address in that namespace are rejected; the same leaf in a different namespace remains valid. Readback compares the primary symbol name.",
"set_addresses_names applies each address in its own transaction and reports per-address success; invalid or occupied names, unmapped addresses, and readback mismatches roll back only that address. Names are not moved or swapped between addresses.",
"Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.",
];
case "inspect_native_load_image":
+99
View File
@@ -60,3 +60,102 @@ cliTest(
expect(jsonl.json).toBe("inspect_managed_members");
},
);
cliTest(
"managed CLI bounds CIL claims by section virtual ranges",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-managed-virtual-size-");
for (const virtualSectionSize of [0, 0x800, 0x801, 0x802]) {
// The two-byte tiny method starts at RVA 0x2800. Its header alone fits
// at size 0x801, and its full CIL fits exactly at size 0x802.
const bytes = buildManagedPeFixture({
virtualSectionSize,
ilBody: Buffer.from([0x06, 0x2a]),
});
const path = join(root, `virtual-${virtualSectionSize}.dll`);
await writeFile(path, bytes);
const result = await cli.run({
arguments: ["inspect-managed-members", path, "--json"],
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result:
virtualSectionSize === 0
? {
methods: [],
metadata: { status: "malformed" },
coverage: { state: "unavailable" },
}
: {
methods: [
{
rva: 0x2800,
body:
virtualSectionSize === 0x802
? {
status: "present",
normalized_il_sha256: expect.any(String),
}
: {
status: "malformed",
normalized_il_sha256: null,
},
},
],
coverage: {
state: virtualSectionSize === 0x802 ? "complete" : "partial",
},
},
});
}
},
);
cliTest(
"managed CLI distinguishes raw padding from overlapping virtual sections",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-managed-section-identity-");
for (const virtualSectionSize of [0x800, 0x900]) {
const bytes = buildManagedPeFixture({ virtualSectionSize });
const coff = bytes.readUInt32LE(0x3c) + 4;
const firstSection = coff + 20 + bytes.readUInt16LE(coff + 16);
const secondSection = firstSection + 40;
bytes.writeUInt16LE(2, coff + 2);
bytes.write(".il", secondSection, "ascii");
bytes.writeUInt32LE(0x100, secondSection + 8);
bytes.writeUInt32LE(0x2800, secondSection + 12);
bytes.writeUInt32LE(0x100, secondSection + 16);
bytes.writeUInt32LE(0x0a00, secondSection + 20);
const path = join(root, `sections-${virtualSectionSize}.dll`);
await writeFile(path, bytes);
const result = await cli.run({
arguments: ["inspect-managed-members", path, "--json"],
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
methods: [
{
rva: 0x2800,
body:
virtualSectionSize === 0x800
? {
status: "present",
file_offset: 0x0a00,
normalized_il_sha256: expect.any(String),
}
: {
status: "malformed",
normalized_il_sha256: null,
issue: expect.stringContaining("ambiguous overlapping"),
},
},
],
coverage: {
state: virtualSectionSize === 0x800 ? "complete" : "partial",
},
},
});
}
},
);
@@ -774,6 +774,46 @@ cliTest(
},
);
describe("binary Interface Builder archive omissions", () => {
it("reports omitted __proto__ entries as partial decoding", async () => {
const placeholder = "proto_key";
const encoded = Buffer.from(
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null"],
$top: {},
[placeholder]: "hidden",
}),
);
const start = encoded.indexOf(placeholder);
expect(start).toBeGreaterThanOrEqual(0);
expect(encoded.indexOf(placeholder, start + placeholder.length)).toBe(-1);
Buffer.from("__proto__").copy(encoded, start);
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await writeFile(join(resources, "Prototype.nib"), encoded);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "dictionary_entries_omitted",
}),
);
expect(result.limitations).toContain(
"Contents/Resources/Prototype.nib: Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
});
});
describe("aggregate Interface Builder archive retention budget", () => {
it("keeps earlier documents when the aggregate decode reservation is exhausted", async () => {
const root = await createTestTempDirectory("rea-ib-budget-test-");
+29
View File
@@ -29,8 +29,16 @@ it("runs every managed static inspection independently of an active native targe
);
const path = join(directory, "fixture.dll");
const otherPath = join(directory, "other-fixture.dll");
const partialPath = join(directory, "partial-method.dll");
await writeFile(path, buildManagedPeFixture());
await writeFile(otherPath, buildManagedPeFixture({ methodName: "Other" }));
await writeFile(
partialPath,
buildManagedPeFixture({
virtualSectionSize: 0x801,
ilBody: Buffer.from([0x06, 0x2a]),
}),
);
const session = composeBinarySession(new AnalysisProviderRegistry([]), [
new ManagedStaticProvider(),
]);
@@ -116,6 +124,27 @@ it("runs every managed static inspection independently of an active native targe
provider: { id: "rea-dotnet-static" },
subject: { local_path: path, format: "pe" },
});
const partial = structured(
await client.callTool({
name: "inspect_managed_members",
arguments: { path: partialPath },
}),
);
expect(partial).toMatchObject({
subject: { local_path: partialPath, format: "pe" },
normalized_result: {
methods: [
{
body: {
status: "malformed",
normalized_il_sha256: null,
issue: "Method body leaves file-backed PE section data",
},
},
],
coverage: { state: "partial" },
},
});
} finally {
await Promise.all([client.close(), server.close()]);
await session.close();
+10 -1
View File
@@ -9,6 +9,15 @@ volatile int rea_namespace_alpha_value
__asm__(REA_CPP_SYMBOL("_ZN5alpha5valueE")) = 3;
volatile int rea_namespace_nested_value
__asm__(REA_CPP_SYMBOL("_ZN5outer5inner5valueE")) = 1;
volatile int rea_name_data_first = 5;
volatile int rea_name_data_second = 9;
__attribute__((noinline, used)) int rea_name_first(int value) {
return value + rea_name_data_first;
}
__attribute__((noinline, used)) int rea_name_second(int value) {
return value + rea_name_data_second;
}
__attribute__((noinline, used)) int rea_namespace_alpha(int value)
__asm__(REA_CPP_SYMBOL("_ZN5alpha4sameEi"));
@@ -23,5 +32,5 @@ int rea_namespace_nested(int value) { return value - rea_namespace_nested_value;
int main(void) {
return rea_namespace_alpha(1) + rea_namespace_beta(2) +
rea_namespace_nested(3);
rea_namespace_nested(3) + rea_name_first(4) + rea_name_second(5);
}