Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8005cad374 | ||
|
|
75881af7aa | ||
|
|
8d3cfef753 | ||
|
|
975dff2e25 |
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user