Files
reverse-engineering-anything/src/dotnet/ManagedMetadataHeaps.ts
T
26525706fc feat(managed): decode ldstr user strings in member inspection (#2162)
* feat(managed): decode ldstr user strings in member inspection

inspect_managed_members reported every ldstr operand as a bare 0x70 token,
so an analyst could see that a method loads a literal but not which one.
Answering "where is this path or key used" required a second tool.

Read the optional #US stream and add a top-level user_strings inventory:
each distinct token loaded by an inspected method body, once, in token
order, with its #US file offset and decoded UTF-16 value. Instruction
anchors and normalized CIL digests keep their build-local tokens. A missing
#US stream or malformed item is reported against its token as partial
coverage. Evidence recorded before this field reads user_strings as null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): collect user strings only from ldstr

A malformed ldtoken can carry a 0x70 token, which the operand decoder
classifies as a string operand. Only ldstr loads a literal, so collect
user strings by opcode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): admit user-string tokens and failures before collecting them

Charge each distinct ldstr token to the decode budget before adding it to
the collection set, and admit each #US failure before retaining it. A
missing #US stream is now one stream-level issue rather than one per
token.

Document why the #US terminal flag is not validated: Mono and Roslyn
assemblies disagree with ECMA-335 II.24.2.4 and the runtime ignores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(managed): describe missing #US reporting and the unvalidated flag byte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(managed): cover #US items that run past the stream end

An odd length whose payload crosses the end of #US, and a two-byte
length prefix that starts on the last #US byte, are both rejected as
"User string leaves #US" beside a readable literal. The test fails if
that bounds check is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): report reserved #US length prefixes as string failures

The compressed-length reader shared by #Blob and #US hard-coded
invalid-blob and "Metadata blob" for a reserved prefix, so a malformed
user string was published as a blob failure. The caller now names the
item it reads: #Blob keeps its code and message, and #US reports
invalid-string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): append member inspection issues without argument spreading

inspectManagedMembersBytes gathered every producer's issues with
issues.push(...list), passing one argument per issue. A large malformed
input, such as hundreds of thousands of distinct bad ldstr operands or
MemberRef rows, overflows the stack with an untyped RangeError instead of
returning partial coverage. All six sites now use
appendManagedParseIssues, which appends one issue at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): classify truncated #US and #Blob length prefixes by item

A two- or four-byte compressed-length prefix cut off by the end of the
artifact went through the generic range check and was reported as
invalid-row. The prefix reader now uses the item's own code for both
reserved and truncated prefixes: invalid-string for #US, invalid-blob for
#Blob. The unused generic range helper is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): retain user-string terminal flag conformance issues

* test(managed): write ECMA-335 #US terminal flags and cover flag issues

d5b7b0704 reports a #US terminal flag that differs from ECMA-335
II.24.2.4. The fixture heap always wrote 0, so its non-Latin-1 literals
now read as nonconforming. The fixture writes the spec flag, and a new
test flips one flag each way ("ok" set to 1, "it's" set to 0) and checks
that both literals remain beside one issue each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): classify ldstr operands before collapsing a missing #US

With no #US stream, every collected ldstr operand was counted as a #US
reference and reported only as missing-stream, while the same non-0x70
operand got invalid-heap-index when the stream existed. parseUserStrings
now owns which operands are user-string tokens: any other operand is
invalid-heap-index either way, and only 0x70 tokens count toward the
missing-stream issue. The heap reader drops its duplicate kind check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): state user-string limits in member comparisons

compare_managed_members ignored user_strings, so a method whose ldstr
literal changed at the same token, or legacy Evidence that never examined
literals, could read as unchanged with no caveat. The comparison now adds
an unknown limitation when either side predates user strings and a
not-compared limitation when literals were observed. ExportedType and
user-string inventories share one uncomparedInventory rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(managed): advertise ldstr literals in member inspection help

The MCP tool description, the type_names input, and the CLI command and
--type help now say that inspect_managed_members returns the #US string
literals that ldstr loads, and that a type selection limits them to the
literals the selected types' method bodies load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): read the empty #US entry at offset 0

ECMA-335 II.24.2.4 makes the first #US entry the empty blob: a zero
length with no terminal flag byte. ldstr 0x70000000 loads it, but the
odd-length check rejected it as invalid-string, dropping the literal and
making coverage partial. Offset 0 with length 0 now reads as "".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): reserve #US offset 0 and admit literals before scanning

Offset 0 is the reserved empty #US entry, so a nonempty item there is now
invalid-string instead of a published literal. The literal's projection
is reserved before the terminal-flag scan, so an inadmissible item fails
at the budget without reading every code unit. The scan's loop index no
longer shadows the token's heap index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): report #US length prefixes wider than ECMA-335 assigns

compressedLength accepted an overlong encoding, such as 0x80 0x05 for a
5-byte item, without comment. It now reports whether the width is the
one ECMA-335 II.23.2 assigns, and the #US reader reports a non-minimal
prefix as an invalid-string conformance issue beside the readable
literal, like the terminal flag. readMetadataUserString returns every
issue it observes. #Blob behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): check the #US length width before the empty entry

The offset-0 empty entry returned "" before the length-width check, so an
overlong empty entry such as 0x80 0x00 read as complete. The width issue
is now collected first and returned with the empty literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): admit #US width issues only for returned literals

The overlong-prefix issue was reserved before the fatal shape checks, so
an item that then failed, such as 0x80 0x00 away from offset 0, leaked
512 bytes of budget per token. The width issue is now built and admitted
only on paths that return the literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): keep #US issues out of member metadata status

User-string issues, including terminal-flag and length-prefix
conformance differences, marked metadata.status partial, so
compare_managed_members turned one-sided methods and fields into unknown
even though every member row was examined. #US issues now make coverage
partial while metadata.status describes the member inventory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): give user strings their own coverage

#US issues entered the member coverage, so every consumer that reads it
as member coverage (application graph member facts, comparison status)
reported examined members as partial over a literal conformance
difference. user_strings is now { state, literals, issues }: #US issues
live there and never change the member coverage or metadata.status.
parseUserStrings returns that inventory directly.

compare_managed_members inspects with user strings skipped, since it
never reads literals, so a large literal cannot exhaust the decode budget
of a comparison. Its limitations state once that literals are not
compared, and the ExportedType limitations return to their main form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): mark user strings partial when a body is not fully decoded

With user strings owning their coverage, an inspected method body that
was partial, malformed, or truncated no longer reached their state, so a
literal after the decode stopped was missing from a "complete" inventory.
parseUserStrings now marks the inventory partial unless every inspected
body was fully decoded, keeping the literals it did observe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): mark user strings partial when a method row is omitted

A MethodDef row that failed before its body was read was recorded as an
issue and omitted, so its ldstr instructions were never scanned, yet the
user-string inventory could still read as complete. parseUserStrings now
takes the parseMethods result and stays partial whenever a method row
failed, as well as when an inspected body was not fully decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): count only method rows that may hide a selected body

Any parseMethods issue marked user strings partial, including an
unreadable name on a method outside the type selection, so a fully
scanned selection read as incomplete. parseMethods now reads each row's
owner first and reports unscannedRows: rows that produced no method while
their owner is selected or unknown. parseUserStrings uses that count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): treat unreadably named types as possibly selected

Under a type_names selection, a type whose name could not be read may be
a requested type, but its methods were skipped without counting toward
unscannedRows, so an unexamined body left user strings complete. A
method whose owner's name is unreadable now counts as possibly selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): judge user-string scans by CIL decoding, not body status

body.status is malformed for an invalid exception section even when
every instruction decoded, so user strings read as partial although every
ldstr was examined. The body reader now reports whether its CIL was fully
scanned, independent of exception-region validity, and parseMethods folds
unscanned bodies and unproduced rows into one unscannedMethods count that
parseUserStrings uses instead of reinterpreting body.status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): stop deduplicating user-string issues

The inventory passed its issues through uniqueManagedParseIssues, which
serializes every issue into an unbudgeted map: about 42 MB of heap and
113 ms for 125,000 issues on Node 24. The issues are unique by
construction (one failure or per-offset conformance issues per distinct
token, one missing-stream issue), so the inventory now returns them, and
its literals, without copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(managed): preserve selected string coverage unknowns

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 03:01:23 +08:00

415 lines
13 KiB
TypeScript

import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { createHash } from "node:crypto";
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import {
metadataRowOffset,
type ManagedMetadataLayout,
} from "./ManagedMetadataLayout.js";
import { managedFailure } from "./ManagedReaderFailure.js";
/** Cursor for checked fixed-width metadata row decoding. */
export class MetadataRowCursor {
#offset: number;
constructor(
private readonly bytes: Buffer,
readonly start: number,
private readonly end: number,
private readonly scope: string,
) {
this.#offset = start;
}
get offset(): number {
return this.#offset;
}
readUInt16(): number {
this.require(2);
const value = this.bytes.readUInt16LE(this.#offset);
this.#offset += 2;
return value;
}
readUInt32(): number {
this.require(4);
const value = this.bytes.readUInt32LE(this.#offset);
this.#offset += 4;
return value;
}
readIndex(size: 2 | 4): number {
return size === 2 ? this.readUInt16() : this.readUInt32();
}
private require(length: number): void {
if (this.#offset < this.start || this.#offset > this.end - length)
throw managedFailure(
"invalid-row",
this.scope,
`${this.scope} row ended before all declared columns`,
this.#offset,
);
}
}
// Issue code and label for the heap item a compressed length prefixes.
const PREFIXED_ITEMS = {
blob: { code: "invalid-blob", label: "Metadata blob" },
userString: { code: "invalid-string", label: "User string" },
} as const;
const compressedLength = (
bytes: Buffer,
offset: number,
scope: string,
item: keyof typeof PREFIXED_ITEMS,
): {
readonly length: number;
readonly prefix: number;
/** Whether ECMA-335 II.23.2 assigns this width to the value. */
readonly minimal: boolean;
} => {
const { code, label } = PREFIXED_ITEMS[item];
const requirePrefix = (size: number): void => {
if (offset < 0 || offset > bytes.length - size)
throw managedFailure(
code,
scope,
`${label} length prefix leaves the artifact byte range`,
offset >= 0 ? offset : null,
);
};
requirePrefix(1);
const first = bytes[offset] ?? 0;
if ((first & 0x80) === 0) return { length: first, prefix: 1, minimal: true };
if ((first & 0xc0) === 0x80) {
requirePrefix(2);
const length = ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0);
return { length, prefix: 2, minimal: length >= 0x80 };
}
if ((first & 0xe0) === 0xc0) {
requirePrefix(4);
const length =
(first & 0x1f) * 0x01_00_00_00 +
(bytes[offset + 1] ?? 0) * 0x01_00_00 +
(bytes[offset + 2] ?? 0) * 0x0100 +
(bytes[offset + 3] ?? 0);
return { length, prefix: 4, minimal: length >= 0x4000 };
}
throw managedFailure(
code,
scope,
`${label} uses a reserved compressed-length prefix`,
offset,
);
};
/** Read one UTF-8 value from #Strings within its PE/CLI stream extent. */
export const readMetadataString = (
bytes: Buffer,
layout: ManagedMetadataLayout,
index: number,
heapExtent: number,
): string => {
if (index === 0) return "";
if (index < 0 || index >= layout.strings.size)
throw managedFailure(
"invalid-heap-index",
"metadata.#Strings",
"String heap index is outside #Strings",
layout.strings.offset + Math.max(index, 0),
);
const start = layout.strings.offset + index;
const cached = managedDecodeBudget(layout).cachedString(index);
if (cached !== undefined && cached.encodedBytes <= heapExtent) {
managedDecodeBudget(layout).reserveString(cached.encodedBytes, start);
return cached.value;
}
const maximum = Math.min(
layout.strings.offset + layout.strings.size,
start + heapExtent + 1,
);
let end = start;
while (end < maximum && bytes[end] !== 0) end += 1;
if (end === maximum)
throw managedFailure(
"invalid-string",
"metadata.#Strings",
"String heap item has no terminator within #Strings",
start,
);
managedDecodeBudget(layout).reserveString(end - start, start);
try {
const value = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(bytes.subarray(start, end));
managedDecodeBudget(layout).cacheString(index, value, end - start);
return value;
} catch (cause: unknown) {
void cause;
throw managedFailure(
"invalid-string",
"metadata.#Strings",
"String heap item is not valid UTF-8",
start,
);
}
};
/** Read one #Blob item while validating its encoded extent against the heap. */
export const readMetadataBlob = (
bytes: Buffer,
layout: ManagedMetadataLayout,
index: number,
heapExtent: number,
): Buffer => {
if (index === 0) return Buffer.alloc(0);
if (index < 0 || index >= layout.blob.size)
throw managedFailure(
"invalid-heap-index",
"metadata.#Blob",
"Blob heap index is outside #Blob",
layout.blob.offset + Math.max(index, 0),
);
const start = layout.blob.offset + index;
const decoded = compressedLength(bytes, start, "metadata.#Blob", "blob");
if (decoded.length > heapExtent)
throw managedFailure(
"invalid-blob",
"metadata.#Blob",
"Blob length exceeds the admitted heap extent",
start,
);
const content = start + decoded.prefix;
if (content > layout.blob.offset + layout.blob.size - decoded.length)
throw managedFailure(
"invalid-blob",
"metadata.#Blob",
"Blob heap item leaves #Blob",
start,
);
return bytes.subarray(content, content + decoded.length);
};
/**
* Read the #US literal named by a `0x70` user-string token: a compressed byte
* length, UTF-16LE code units, then one ECMA-335 terminal flag byte. Code units
* are kept as stored, including unpaired surrogates. `parseUserStrings` owns
* which `ldstr` operands are user-string tokens.
*/
export const readMetadataUserString = (
bytes: Buffer,
layout: ManagedMetadataLayout,
token: string,
): {
readonly offset: number;
readonly value: string;
/** Conformance differences observed beside the readable literal. */
readonly issues: readonly ManagedParseIssue[];
} => {
const heap = layout.userStrings;
const scope = `metadata.#US:${token}`;
if (heap === null)
throw managedFailure(
"missing-stream",
scope,
"ldstr references #US, but the metadata has no #US stream",
);
const index = Number.parseInt(token.slice(4), 16);
if (index >= heap.size)
throw managedFailure(
"invalid-heap-index",
scope,
"ldstr operand does not name an item inside #US",
heap.offset + index,
);
const start = heap.offset + index;
const decoded = compressedLength(bytes, start, scope, "userString");
const content = start + decoded.prefix;
if (content > heap.offset + heap.size - decoded.length)
throw managedFailure(
"invalid-string",
scope,
"User string leaves #US",
start,
);
const budget = managedDecodeBudget(layout);
// A wider length prefix than the value needs still reads unambiguously, so
// it is reported like the flag rather than discarding the literal. It is
// admitted only on paths that return the literal, so a fatal item leaves
// no budget behind.
const conformance = (): ManagedParseIssue[] => {
if (decoded.minimal) return [];
budget.reserve(512, start);
return [
{
code: "invalid-string",
scope,
offset: start,
detail: `User string length ${String(decoded.length)} uses a ${String(decoded.prefix)}-byte prefix wider than ECMA-335 II.23.2 assigns; readable UTF-16 is preserved and runtime acceptance is not assessed`,
},
];
};
// ECMA-335 II.24.2.4: offset 0 is the heap's empty entry, a zero length
// with no terminal flag byte; every other item carries the flag.
if (index === 0) {
if (decoded.length === 0)
return { offset: start, value: "", issues: conformance() };
throw managedFailure(
"invalid-string",
scope,
`#US offset 0 is the reserved empty entry, but it encodes ${String(decoded.length)} byte(s)`,
start,
);
}
if (decoded.length % 2 !== 1)
throw managedFailure(
"invalid-string",
scope,
"User string length is not UTF-16 code units plus one terminal flag byte",
start,
);
const units = (decoded.length - 1) / 2;
// Admit the literal before scanning it: JSON spends at most six bytes per
// UTF-16 code unit.
budget.reserveString(units, start);
// Check the serialized flag independently of literal decoding. A difference
// from ECMA-335 is observable metadata, not proof of runtime rejection.
let expectedFlag = 0;
for (let unitIndex = 0; unitIndex < units; unitIndex++) {
const unit = bytes.readUInt16LE(content + unitIndex * 2);
if (
unit > 0xff ||
(unit >= 0x01 && unit <= 0x08) ||
(unit >= 0x0e && unit <= 0x1f) ||
unit === 0x27 ||
unit === 0x2d ||
unit === 0x7f
) {
expectedFlag = 1;
break;
}
}
const flagOffset = content + units * 2;
const actualFlag = bytes[flagOffset];
const issues = conformance();
if (actualFlag !== expectedFlag) {
budget.reserve(512, flagOffset);
issues.push({
code: "invalid-string",
scope,
offset: flagOffset,
detail: `User string terminal flag ${String(actualFlag)} differs from ECMA-335 II.24.2.4 value ${String(expectedFlag)}; readable UTF-16 is preserved and runtime acceptance is not assessed`,
});
}
return {
offset: start,
value: bytes.subarray(content, flagOffset).toString("utf16le"),
issues,
};
};
/** Read one conventional GUID value from the one-based #GUID heap. */
export const readMetadataGuid = (
bytes: Buffer,
layout: ManagedMetadataLayout,
index: number,
): string | null => {
if (index === 0) return null;
const relative = (index - 1) * 16;
if (relative < 0 || relative > layout.guid.size - 16)
throw managedFailure(
"invalid-guid",
"metadata.#GUID",
"GUID heap index is outside #GUID",
layout.guid.offset + Math.max(relative, 0),
);
const offset = layout.guid.offset + relative;
const part1 = bytes.readUInt32LE(offset).toString(16).padStart(8, "0");
const part2 = bytes
.readUInt16LE(offset + 4)
.toString(16)
.padStart(4, "0");
const part3 = bytes
.readUInt16LE(offset + 6)
.toString(16)
.padStart(4, "0");
const part4 = bytes.subarray(offset + 8, offset + 10).toString("hex");
const part5 = bytes.subarray(offset + 10, offset + 16).toString("hex");
return `${part1}-${part2}-${part3}-${part4}-${part5}`;
};
/** Format one metadata table/row pair as an exact build-local token. */
export const metadataToken = (table: number, row: number): string =>
`0x${(table * 0x01_00_00_00 + row).toString(16).padStart(8, "0")}`;
/** Hash bytes without exposing their contents. */
export const sha256Bytes = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
/** Create a cursor bounded to one admitted metadata table row. */
export const metadataRowCursor = (
bytes: Buffer,
layout: ManagedMetadataLayout,
table: number,
row: number,
): MetadataRowCursor => {
const descriptor = layout.table(table);
const start = metadataRowOffset(layout, table, row);
if (descriptor === undefined)
throw managedFailure(
"invalid-row",
`metadata.${String(table)}`,
"Metadata table is absent",
start,
);
// Temporary cursors may revisit a row while resolving owners or references.
// Bind fixed retained bookkeeping to that row's identity, not each lookup.
managedDecodeBudget(layout).reserveRow(table, row, start);
return new MetadataRowCursor(
bytes,
start,
start + descriptor.rowSize,
`metadata.${descriptor.name}`,
);
};
/** Decode a metadata coded index without signed 32-bit coercion. */
export const metadataCodedToken = (
raw: number,
bits: number,
tables: readonly (number | undefined)[],
rowCounts: readonly number[],
): string | null => {
if (raw === 0) return null;
const tag = raw & (2 ** bits - 1);
const row = Math.floor(raw / 2 ** bits);
const table = tables[tag];
return table === undefined || row === 0 || row > (rowCounts[table] ?? 0)
? null
: metadataToken(table, row);
};
/** Explain why a non-null coded index cannot name an admitted metadata row. */
export const metadataCodedTokenInvalidReason = (
raw: number,
bits: number,
tables: readonly (number | undefined)[],
rowCounts: readonly number[],
): string | null => {
if (raw === 0) return null;
const tag = raw & (2 ** bits - 1);
const row = Math.floor(raw / 2 ** bits);
const table = tables[tag];
if (table === undefined) return `coded index tag ${tag} is not defined`;
if (row === 0) return `coded index selects row zero in table ${table}`;
const count = rowCounts[table] ?? 0;
return row > count
? `coded index selects row ${row} in table ${table}, which has ${count} rows`
: null;
};