Files
rea/src/dotnet/ManagedMemberRows.ts
T
morlutoandPrabhuram Karki 435efe7afc fix(frida): share exact JSON string byte accounting
Count the serialized JSON representation at its owner, including escaped
lone UTF-16 surrogates. Frida now retains the correct bounded prefix and
reports truncation in script results and subsequent session Evidence.

Move the existing exact managed string counter into the domain layer and
use it for Frida, managed projections and Ghidra inline PDB caveats. Remove
the superseded counters while keeping Ghidra's bounded length guards and
NativeAOT's distinct raw-byte admission before string allocation.

Validated with 296 focused tests, check, docs and exact test discovery.
The Unicode oracle checks every UTF-16 code unit and adjacent fragments
against native JSON encoding. Frida execution uses simulated native handles
through the real MCP handlers/client; real target instrumentation is unverified.

Fixes #5097

Co-authored-by: Prabhuram Karki <prabhuramkarki07@gmail.com>
2026-10-19 17:50:50 +08:00

505 lines
16 KiB
TypeScript

import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
import {
admitManagedProjection,
managedDecodeBudget,
type ManagedDecodeBudget,
} from "./ManagedDecodeBudget.js";
import type { ManagedPeLayout } from "./ManagedPeReader.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
signature,
signatureGenericArity,
type ManagedCallEdge,
type ManagedExportedType,
type ManagedField,
type ManagedFieldAccess,
type ManagedMemberRef,
type ManagedMethod,
type MemberCore,
type SignatureTypeName,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
import {
metadataToken,
metadataRowCursor,
metadataCodedToken,
metadataCodedTokenInvalidReason,
readMetadataBlob,
readMetadataString,
} from "./ManagedMetadataHeaps.js";
import { readManagedValue } from "./ManagedReaderFailure.js";
import { methodBody } from "./ManagedMethodBodyReader.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
/**
* Declaring-type tokens whose members are decoded in full; null selects every
* type. Unselected members keep only their name so edges stay labelled.
*/
export type SelectedTypeTokens = ReadonlySet<string> | null;
const isSelected = (
selected: SelectedTypeTokens,
declared: { readonly token: string } | undefined,
): boolean =>
selected === null || (declared !== undefined && selected.has(declared.token));
export const parseFields = (
bytes: Buffer,
layout: ManagedMetadataLayout,
ranges: readonly TypeRange[],
memberLists: TypeMemberListFacts,
{
signatureTypeName,
selected = null,
}: {
readonly signatureTypeName: SignatureTypeName;
readonly selected?: SelectedTypeTokens;
},
): {
readonly fields: readonly ManagedField[];
readonly core: ReadonlyMap<string, MemberCore>;
readonly issues: readonly ManagedParseIssue[];
} => {
const fields: ManagedField[] = [];
const core = new Map<string, MemberCore>();
const issues: ManagedParseIssue[] = [];
const table = layout.table(4);
const ownerRow = readManagedValue(
() => memberDeclaringTypeRowLookup(bytes, layout, 4, memberLists),
issues,
);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
readManagedValue(() => {
const cursor = metadataRowCursor(bytes, layout, 4, row);
const flags = cursor.readUInt16();
const name = readMetadataString(
bytes,
layout,
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const owner =
ownerRow === undefined
? 0
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
const declared = ranges[owner - 1];
const token = metadataToken(4, row);
core.set(token, {
token,
name,
declaringType: declared?.fullName ?? null,
kind: "field",
});
if (!isSelected(selected, declared)) return;
const sig = readMetadataBlob(
bytes,
layout,
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
fields.push({
token,
row_offset: cursor.start,
declaring_type_token: declared?.token ?? null,
declaring_type: declared?.fullName ?? null,
name,
flags,
signature: budgetedSignature(sig, layout, signatureTypeName),
});
}, issues);
}
return { fields, core, issues };
};
const TYPE_FORWARDER_FLAG = 0x0020_0000;
/** Read ExportedType rows, including type forwarders, as declared. */
export const parseExportedTypes = (
bytes: Buffer,
layout: ManagedMetadataLayout,
): {
readonly exportedTypes: readonly ManagedExportedType[];
readonly issues: readonly ManagedParseIssue[];
} => {
const exportedTypes: ManagedExportedType[] = [];
const issues: ManagedParseIssue[] = [];
const table = layout.table(39);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
readManagedValue(() => {
const cursor = metadataRowCursor(bytes, layout, 39, row);
const flags = cursor.readUInt32();
const typeDefId = cursor.readUInt32();
const name = readMetadataString(
bytes,
layout,
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const namespace = readMetadataString(
bytes,
layout,
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const implementationOffset = cursor.offset;
const implementationRaw = cursor.readIndex(
layout.codedIndexSize("Implementation"),
);
const token = metadataToken(39, row);
const implementationReason = metadataCodedTokenInvalidReason(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
);
if (implementationReason !== null || implementationRaw === 0)
issues.push({
code: "invalid-row",
scope: `metadata.ExportedType:${token}`,
offset: implementationOffset,
detail:
implementationReason === null
? "ExportedType implementation coded index 0x0 is null, but the Implementation column must reference a row"
: `ExportedType implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementationReason}`,
});
exportedTypes.push({
token,
row_offset: cursor.start,
namespace,
name,
flags,
forwarder: (flags & TYPE_FORWARDER_FLAG) !== 0,
type_def_id: typeDefId,
implementation_token: metadataCodedToken(
implementationRaw,
2,
[38, 35, 39],
layout.rowCounts,
),
});
}, issues);
}
return { exportedTypes, issues };
};
export const parseMemberRefs = (
bytes: Buffer,
layout: ManagedMetadataLayout,
typeName: (token: string | null) => string | null,
signatureTypeName: SignatureTypeName,
): {
readonly refs: readonly ManagedMemberRef[];
readonly core: ReadonlyMap<string, MemberCore>;
readonly issues: readonly ManagedParseIssue[];
} => {
const refs: ManagedMemberRef[] = [];
const core = new Map<string, MemberCore>();
const issues: ManagedParseIssue[] = [];
const table = layout.table(10);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
readManagedValue(() => {
const cursor = metadataRowCursor(bytes, layout, 10, row);
const parentRaw = cursor.readIndex(
layout.codedIndexSize("MemberRefParent"),
);
const name = readMetadataString(
bytes,
layout,
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const sig = readMetadataBlob(
bytes,
layout,
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
const token = metadataToken(10, row);
const parentReason = metadataCodedTokenInvalidReason(
parentRaw,
3,
[2, 1, 26, 6, 27],
layout.rowCounts,
);
if (parentReason !== null || parentRaw === 0)
issues.push({
code: "invalid-row",
scope: `metadata.MemberRef:${token}`,
offset: cursor.start,
detail:
parentReason === null
? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row"
: `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`,
});
const parentToken = metadataCodedToken(
parentRaw,
3,
[2, 1, 26, 6, 27],
layout.rowCounts,
);
const parentName = typeName(parentToken);
const decoded = budgetedSignature(sig, layout, signatureTypeName);
refs.push({
token,
row_offset: cursor.start,
parent_token: parentToken,
parent_name: parentName,
name,
signature: decoded,
});
core.set(token, {
token,
name,
declaringType: parentName,
kind: decoded.kind,
genericArity: signatureGenericArity(decoded),
});
}, issues);
}
return { refs, core, issues };
};
interface ParseMethodsInput {
readonly bytes: Buffer;
readonly layout: ManagedMetadataLayout;
readonly pe: ManagedPeLayout;
readonly ranges: readonly TypeRange[];
readonly signatureTypeName: SignatureTypeName;
readonly memberLists: TypeMemberListFacts;
readonly selected?: SelectedTypeTokens;
readonly methodOffset?: number;
readonly methodLimit?: number;
}
export const parseMethods = ({
bytes,
layout,
pe,
ranges,
signatureTypeName,
memberLists,
selected = null,
methodOffset = 0,
methodLimit = Number.MAX_SAFE_INTEGER,
}: ParseMethodsInput): {
readonly methods: readonly ManagedMethod[];
readonly core: ReadonlyMap<string, MemberCore>;
readonly issues: readonly ManagedParseIssue[];
/**
* Methods that may belong to a selected type but whose CIL was not fully
* scanned: rows that produced no method, or bodies whose decoding stopped.
*/
readonly unscannedMethods: number;
} => {
const methods: ManagedMethod[] = [];
const core = new Map<string, MemberCore>();
const issues: ManagedParseIssue[] = [];
let unscannedMethods = 0;
const table = layout.table(6);
const ownerRow = readManagedValue(
() => memberDeclaringTypeRowLookup(bytes, layout, 6, memberLists),
issues,
);
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
// The owner is read first: a row that produced no method hides a selected
// body only when its owner is selected, unknown, or unreadably named.
const owner =
ownerRow === undefined
? undefined
: readManagedValue(() => ownerRow(row), issues);
const declared = owner === undefined ? undefined : ranges[owner - 1];
const inPage = row > methodOffset && row - methodOffset <= methodLimit;
const mayBeSelected =
inPage &&
(selected === null ||
owner === undefined ||
isSelected(selected, declared) ||
// A type whose name is unreadable may be one the caller requested.
declared?.fullName === null);
const produced = methods.length;
readManagedValue(() => {
const cursor = metadataRowCursor(bytes, layout, 6, row);
const rva = cursor.readUInt32();
const implFlags = cursor.readUInt16();
const flags = cursor.readUInt16();
const name = readMetadataString(
bytes,
layout,
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const token = metadataToken(6, row);
const member = {
token,
name,
declaringType: declared?.fullName ?? null,
kind: "method",
} as const;
core.set(token, member);
if (!inPage) return;
if (!isSelected(selected, declared)) return;
const sig = readMetadataBlob(
bytes,
layout,
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
cursor.readIndex(layout.tableIndexSize(8));
const decoded = budgetedSignature(sig, layout, signatureTypeName);
core.set(token, {
...member,
genericArity: signatureGenericArity(decoded),
});
const read = methodBody(
bytes,
pe,
rva,
{ implFlags, flags },
managedDecodeBudget(layout),
);
if (!read.scanned) unscannedMethods += 1;
methods.push({
token,
row_offset: cursor.start,
declaring_type_token: declared?.token ?? null,
declaring_type: declared?.fullName ?? null,
name,
rva,
impl_flags: implFlags,
flags,
signature: decoded,
body: read.body,
});
}, issues);
if (mayBeSelected && methods.length === produced) unscannedMethods += 1;
}
return { methods, core, issues, unscannedMethods };
};
/**
* Member kinds each operand kind may name. A MemberRef whose signature does
* not decode keeps its name on calls, as before, but is never named a field.
*/
const NAMEABLE_KINDS = new Map<string, ReadonlySet<MemberCore["kind"]>>([
["method", new Set(["method", "unknown"])],
["field", new Set(["field"])],
]);
const targetKind = (token: string): ManagedCallEdge["target_kind"] =>
token.startsWith("0x06")
? "method-def"
: token.startsWith("0x0a")
? "member-ref"
: token.startsWith("0x2b")
? "method-spec"
: "unknown";
/** The member an operand names, if the operand kind can name that member's kind. */
const namedTarget = (
members: ReadonlyMap<string, MemberCore>,
operandKind: string,
operand: string,
): MemberCore | undefined => {
const member = members.get(operand);
return member !== undefined &&
NAMEABLE_KINDS.get(operandKind)?.has(member.kind) === true
? member
: undefined;
};
export const edges = (
methods: readonly ManagedMethod[],
members: ReadonlyMap<string, MemberCore>,
budget: ManagedDecodeBudget,
): {
readonly callEdges: readonly ManagedCallEdge[];
readonly fieldAccesses: readonly ManagedFieldAccess[];
} => {
const callEdges: ManagedCallEdge[] = [];
const fieldAccesses: ManagedFieldAccess[] = [];
for (const method of methods) {
const derives = method.body.anchors.some(
(anchor) =>
anchor.opcode !== "ldtoken" &&
anchor.operand !== null &&
(anchor.operand_kind === "method" || anchor.operand_kind === "field"),
);
if (derives) budget.admitTransient(callerNameBytes(method), null);
// Built once per method, only after its exact size was admitted.
const caller = derives ? callerName(method) : method.name;
for (const anchor of method.body.anchors) {
if (anchor.opcode === "ldtoken") continue;
const { operand } = anchor;
if (operand === null) continue;
const target = namedTarget(members, anchor.operand_kind, operand);
if (anchor.operand_kind === "method") {
// Each derived record is admitted before the graph grows further.
callEdges.push(
admitManagedProjection(
{
caller_token: method.token,
caller,
opcode: anchor.opcode,
target_token: operand,
target_kind: targetKind(operand),
target_name: target?.name ?? null,
target_declaring_type: target?.declaringType ?? null,
},
budget,
),
);
}
if (anchor.operand_kind === "field") {
fieldAccesses.push(
admitManagedProjection(
{
method_token: method.token,
method: caller,
opcode: anchor.opcode,
field_token: operand,
field_name: target?.name ?? null,
field_declaring_type: target?.declaringType ?? null,
},
budget,
),
);
}
}
// Every record now reserves the name it carries.
if (derives) budget.releaseTransient();
}
return { callEdges, fieldAccesses };
};
const callerName = (method: ManagedMethod): string =>
method.declaring_type === null
? method.name
: `${method.declaring_type}.${method.name}`;
/** JSON bytes of `callerName`, from its parts: the joining dot adds one byte. */
const callerNameBytes = (method: ManagedMethod): number =>
method.declaring_type === null
? jsonStringByteLength(method.name)
: jsonStringByteLength(method.declaring_type) +
jsonStringByteLength(method.name) -
1;
const budgetedSignature = (
bytes: Buffer,
layout: ManagedMetadataLayout,
signatureTypeName: SignatureTypeName,
): ReturnType<typeof signature> => {
const budget = managedDecodeBudget(layout);
budget.admitTransient(bytes.length * 256, layout.blob.offset);
try {
return admitManagedProjection(
signature(bytes, budget.digest(bytes, "sha256"), signatureTypeName),
budget,
);
} finally {
budget.releaseTransient();
}
};