Files
rea/src/dotnet/ManagedCilDecoding.test.ts
T
97268f40ea fix(dotnet): name the truncated instruction when an operand leaves the method body (#2704)
* fix(dotnet): name the truncated instruction when an operand leaves the method body

The extent guard ran after the operand read, so a truncated operand threw a raw Buffer RangeError that replaced the intended diagnostic with the runtime message and lost the instruction identity.

Bound the operand extent before reading its value and carry the width beside each fixed reader so the two facts cannot drift.

Fixes #2677

* test(dotnet): consolidate truncated operand coverage

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 02:52:47 +08:00

743 lines
23 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import {
buildManagedPeFixture,
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
const managedBodyWithSections = (...sections: Buffer[]) => {
const header = Buffer.alloc(12);
header.writeUInt16LE(0x300b, 0);
header.writeUInt16LE(8, 2);
header.writeUInt32LE(1, 4);
const bytes = buildManagedPeFixture({
ilBody: Buffer.concat([
header,
Buffer.from([0x2a]),
Buffer.alloc(3),
...sections,
]),
});
return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes))
.methods[0]?.body;
};
const managedBodyWithRegion = ({
il,
flags = 0,
tryOffset = 0,
tryLength = 1,
handlerOffset,
handlerLength = 1,
extra = 0x0200_0001,
}: {
readonly il: Buffer;
readonly flags?: number;
readonly tryOffset?: number;
readonly tryLength?: number;
readonly handlerOffset: number;
readonly handlerLength?: number;
readonly extra?: number;
}) => {
const header = Buffer.alloc(12);
header.writeUInt16LE(0x300b, 0);
header.writeUInt16LE(8, 2);
header.writeUInt32LE(il.length, 4);
const section = Buffer.alloc(16);
section[0] = 0x01;
section[1] = section.length;
section.writeUInt16LE(flags, 4);
section.writeUInt16LE(tryOffset, 6);
section[8] = tryLength;
section.writeUInt16LE(handlerOffset, 9);
section[11] = handlerLength;
section.writeUInt32LE(extra, 12);
const bytes = buildManagedPeFixture({
ilBody: Buffer.concat([
header,
il,
Buffer.alloc((4 - (il.length % 4)) % 4),
section,
]),
});
return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes))
.methods[0]?.body;
};
const fatBody = (il: Buffer) => {
const header = Buffer.alloc(12);
header.writeUInt16LE(0x3013, 0);
header.writeUInt16LE(8, 2);
header.writeUInt32LE(il.length, 4);
const bytes = buildManagedPeFixture({
ilBody: Buffer.concat([header, il]),
});
return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes))
.methods[0]?.body;
};
/** Build a tiny method body whose declared IL extent matches its supplied bytes. */
const tinyBody = (il: readonly number[]) => {
const bytes = buildManagedPeFixture({
ilBody: Buffer.from([(il.length << 2) | 0x02, ...il]),
});
const inspection = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
return { body: inspection.methods[0]?.body, coverage: inspection.coverage };
};
describe("managed CIL decoding", () => {
it("reads fat method header size from the full flags-and-size word", () => {
const il = Buffer.from([
0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0x22, 0x00,
0x00, 0x80, 0x3f, 0x26, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0,
0x3f, 0x26, 0xfe, 0x06, 0x01, 0x00, 0x00, 0x06, 0x26, 0x2a,
]);
const header = Buffer.alloc(12);
header.writeUInt16LE(0x3013, 0);
header.writeUInt16LE(8, 2);
header.writeUInt32LE(il.length, 4);
const bytes = buildManagedPeFixture({
ilBody: Buffer.concat([header, il]),
});
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "present",
header_format: "fat",
il_size: il.length,
il_sha256: createHash("sha256").update(il).digest("hex"),
opcode_counts: {
"ldc.i8": 1,
pop: 4,
"ldc.r4": 1,
"ldc.r8": 1,
ldftn: 1,
ret: 1,
},
issue: null,
});
});
it("does not normalize reserved CIL opcodes as operand-free instructions", () => {
const bytes = buildManagedPeFixture({
ilBody: Buffer.from([0x0a, 0x24, 0x2a]),
});
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "malformed",
il_size: 2,
normalized_il_sha256: null,
decoded_instruction_count: 0,
issue: "Unsupported CIL opcode 0x24 at IL offset 0",
});
});
it("does not decode overlay bytes after the method's file-backed PE section", () => {
const original = buildManagedPeFixture();
const bytes = Buffer.concat([original, Buffer.from([0x2a])]);
const parsed = inspectManagedMembersBytes(
original,
managedPeFixtureTarget(original),
);
const methodRow = parsed.methods[0]?.row_offset;
expect(methodRow).toBeDefined();
if (methodRow === undefined) return;
bytes.writeUInt32LE(0x2dff, methodRow);
bytes[0xfff] = 0x06;
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "malformed",
header_format: "unknown",
issue: "Method body leaves file-backed PE section data",
anchors: [],
});
});
it("does not decode overlay exception sections after the file-backed PE section", () => {
const original = buildManagedPeFixture();
const overlay = Buffer.from([0x01, 0x04, 0x00, 0x00]);
const bytes = Buffer.concat([original, overlay]);
const parsed = inspectManagedMembersBytes(
original,
managedPeFixtureTarget(original),
);
const methodRow = parsed.methods[0]?.row_offset;
expect(methodRow).toBeDefined();
if (methodRow === undefined) return;
const body = Buffer.alloc(16);
body.writeUInt16LE(0x301b, 0);
body.writeUInt16LE(8, 2);
body.writeUInt32LE(1, 4);
body[12] = 0x2a;
body.copy(bytes, 0xff0);
bytes.writeUInt32LE(0x2df0, methodRow);
overlay.copy(bytes, 0x1000);
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "malformed",
issue: "Exception section header leaves artifact",
exception_regions: [],
});
});
it("keeps the documented decoded-CIL v1 golden vector stable", () => {
const il = Buffer.from([0x00, 0x2a]);
const tinyBytes = buildManagedPeFixture({
ilBody: Buffer.from([0x0a, ...il]),
});
const fatHeader = Buffer.alloc(12);
fatHeader.writeUInt16LE(0x3013, 0);
fatHeader.writeUInt16LE(32, 2);
fatHeader.writeUInt32LE(il.length, 4);
fatHeader.writeUInt32LE(0x1100_0001, 8);
const fatBytes = buildManagedPeFixture({
ilBody: Buffer.concat([fatHeader, il]),
});
const tiny = inspectManagedMembersBytes(
tinyBytes,
managedPeFixtureTarget(tinyBytes),
);
const fat = inspectManagedMembersBytes(
fatBytes,
managedPeFixtureTarget(fatBytes),
);
expect(tiny.methods[0]?.body).toMatchObject({
status: "present",
header_format: "tiny",
max_stack: 8,
init_locals: false,
local_var_sig_token: null,
il_size: 2,
il_sha256: createHash("sha256").update(il).digest("hex"),
normalized_il_sha256:
"5e5fad7741cb44bca3a4f045546b7449990da343f612f5f34c0ca30e9eee0636",
opcode_counts: { nop: 1, ret: 1 },
exception_regions: [],
});
expect(fat.methods[0]?.body).toMatchObject({
header_format: "fat",
max_stack: 32,
init_locals: true,
local_var_sig_token: "0x11000001",
il_sha256: tiny.methods[0]?.body.il_sha256,
normalized_il_sha256: tiny.methods[0]?.body.normalized_il_sha256,
});
});
});
describe("managed operand extent diagnostics", () => {
it.each([
[[0x20], 0, "Instruction ldc.i4 at IL offset 0 leaves method body"],
[
[0x00, 0x20, 0x01],
1,
"Instruction ldc.i4 at IL offset 1 leaves method body",
],
[[0x2b], 0, "Instruction br.s at IL offset 0 leaves method body"],
[[0x45, 0x02], 0, "Instruction switch at IL offset 0 leaves method body"],
[
[0xfe, 0x09, 0x00],
0,
"Instruction ldarg at IL offset 0 leaves method body",
],
[[0x00, 0xfe], 1, "Two-byte CIL opcode at IL offset 1 leaves method body"],
] as const)(
"reports the instruction and IL offset for tiny IL %j",
(il, decoded, issue) => {
const { body, coverage } = tinyBody(il);
expect(body).toMatchObject({
status: "malformed",
header_format: "tiny",
il_size: il.length,
decoded_instruction_count: decoded,
normalized_il_sha256: null,
issue,
});
expect(coverage.state).toBe("partial");
},
);
it.each([
["short variable", Buffer.from([0x0e]), "ldarg.s"],
["wide variable", Buffer.from([0xfe, 0x09]), "ldarg"],
["int32 constant", Buffer.from([0x20]), "ldc.i4"],
["int64 constant", Buffer.from([0x21]), "ldc.i8"],
["float32 constant", Buffer.from([0x22]), "ldc.r4"],
["float64 constant", Buffer.from([0x23]), "ldc.r8"],
["short branch", Buffer.from([0x2b]), "br.s"],
["long branch", Buffer.from([0x38]), "br"],
["prefixed short constant", Buffer.from([0xfe, 0x12]), "unaligned."],
["string token", Buffer.from([0x72]), "ldstr"],
["type token", Buffer.from([0x71]), "ldobj"],
["field token", Buffer.from([0x7b]), "ldfld"],
["method token", Buffer.from([0x28]), "call"],
] as const)(
"reports a truncated %s operand at its own instruction",
(_label, il, name) => {
expect(fatBody(il)).toMatchObject({
status: "malformed",
decoded_instruction_count: 0,
normalized_il_sha256: null,
issue: `Instruction ${name} at IL offset 0 leaves method body`,
});
},
);
// A width declared too small still passes the extent check when no operand
// bytes are present, so the zero-byte cases above cannot catch it. Supplying
// one byte less than the real width makes a wrong width let the read run,
// which surfaces as the raw Buffer error rather than the diagnostic.
it.each([
["wide variable", Buffer.from([0xfe, 0x09]), 2, "ldarg"],
["int32 constant", Buffer.from([0x20]), 4, "ldc.i4"],
["int64 constant", Buffer.from([0x21]), 8, "ldc.i8"],
["float32 constant", Buffer.from([0x22]), 4, "ldc.r4"],
["float64 constant", Buffer.from([0x23]), 8, "ldc.r8"],
["long branch", Buffer.from([0x38]), 4, "br"],
["method token", Buffer.from([0x28]), 4, "call"],
["type token", Buffer.from([0x71]), 4, "ldobj"],
["string token", Buffer.from([0x72]), 4, "ldstr"],
["field token", Buffer.from([0x7b]), 4, "ldfld"],
] as const)(
"rejects a %s operand one byte short of its declared width",
(_label, opcode, width, name) => {
expect(
fatBody(Buffer.concat([opcode, Buffer.alloc(width - 1)])),
).toMatchObject({
status: "malformed",
decoded_instruction_count: 0,
normalized_il_sha256: null,
issue: `Instruction ${name} at IL offset 0 leaves method body`,
});
},
);
// The extent check must also reject nothing that fits, so every multi-byte
// width is exercised at exactly its own size with a known value. Each body
// ends at its operand, so a width declared too large turns a valid operand
// into the leaves-method-body diagnostic and these value assertions catch it.
// The -0 constants keep the floating-point sign fix covered through this same
// path.
it.each([
["wide variable", [0xfe, 0x09], [0x01, 0x00], "variable", "1"],
["int32 constant", [0x20], [0x01, 0x00, 0x00, 0x00], "constant", "1"],
[
"int64 constant",
[0x21],
[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
"constant",
"1",
],
["float32 constant", [0x22], [0x00, 0x00, 0x00, 0x80], "constant", "-0"],
[
"float64 constant",
[0x23],
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80],
"constant",
"-0",
],
["long branch", [0x38], [0x00, 0x00, 0x00, 0x00], "branch", "5"],
["method token", [0x28], [0x01, 0x00, 0x00, 0x00], "method", "0x00000001"],
["type token", [0x71], [0x02, 0x00, 0x00, 0x00], "type", "0x00000002"],
["string token", [0x72], [0x03, 0x00, 0x00, 0x00], "string", "0x00000003"],
["field token", [0x7b], [0x04, 0x00, 0x00, 0x00], "field", "0x00000004"],
] as const)(
"decodes a %s operand at exactly its declared width",
(_label, opcode, operand, operandKind, value) => {
expect(fatBody(Buffer.from([...opcode, ...operand]))).toMatchObject({
status: "present",
issue: null,
decoded_instruction_count: 1,
anchors: [{ il_offset: 0, operand_kind: operandKind, operand: value }],
});
},
);
it("reports a switch whose target table is missing", () => {
expect(
fatBody(Buffer.from([0x00, 0x45, 0x02, 0x00, 0x00, 0x00])),
).toMatchObject({
status: "malformed",
decoded_instruction_count: 1,
opcode_counts: { nop: 1 },
issue: "Instruction switch at IL offset 1 leaves method body",
});
});
it("reports a switch whose target count is itself truncated", () => {
expect(fatBody(Buffer.from([0x45, 0x02]))).toMatchObject({
status: "malformed",
decoded_instruction_count: 0,
issue: "Instruction switch at IL offset 0 leaves method body",
});
});
it("preserves instructions decoded before a truncated operand", () => {
expect(fatBody(Buffer.from([0x00, 0x20]))).toMatchObject({
status: "malformed",
il_size: 2,
decoded_instruction_count: 1,
opcode_counts: { nop: 1 },
issue: "Instruction ldc.i4 at IL offset 1 leaves method body",
});
});
it("keeps a complete operand-bearing body decodable", () => {
expect(
fatBody(Buffer.from([0x00, 0x20, 0x01, 0x00, 0x00, 0x00, 0x2a])),
).toMatchObject({
status: "present",
decoded_instruction_count: 3,
opcode_counts: { nop: 1, "ldc.i4": 1, ret: 1 },
issue: null,
});
});
it("still decodes a switch whose targets fit", () => {
const il = Buffer.from([
0x00, 0x45, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a,
]);
expect(fatBody(il)).toMatchObject({
status: "present",
il_size: il.length,
opcode_counts: { nop: 1, switch: 1, ret: 1 },
issue: null,
});
});
});
describe("managed exception region bounds", () => {
it("marks out-of-method ranges malformed and preserves the raw EH clause", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x2a]),
tryOffset: 0,
tryLength: 2,
handlerOffset: 0,
}),
).toMatchObject({
status: "malformed",
il_size: 1,
exception_regions: [
{
flags: 0,
try_offset: 0,
try_length: 2,
handler_offset: 0,
handler_length: 1,
class_token: "0x02000001",
},
],
issue: "Exception clause try range leaves the method IL body",
});
});
it("uses half-open method bounds and permits zero-length ranges", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x00, 0x00, 0x2a]),
tryOffset: 0,
tryLength: 1,
handlerOffset: 1,
handlerLength: 2,
}),
).toMatchObject({ status: "present", il_size: 3, issue: null });
expect(
managedBodyWithRegion({
il: Buffer.from([0x2a]),
tryOffset: 0,
tryLength: 0,
handlerOffset: 0,
handlerLength: 0,
}),
).toMatchObject({ status: "present", il_size: 1, issue: null });
});
it.each([
["try start", { tryOffset: 1, tryLength: 4, handlerOffset: 5 }],
["try end", { tryOffset: 0, tryLength: 1, handlerOffset: 5 }],
["handler start", { tryLength: 5, handlerOffset: 1, handlerLength: 5 }],
["handler end", { tryLength: 5, handlerOffset: 0, handlerLength: 1 }],
] as const)(
"preserves clauses with a mid-instruction %s",
(_label, region) => {
const body = managedBodyWithRegion({
il: Buffer.from([0x20, 0, 0, 0, 0, 0x2a]),
...region,
});
expect(body).toMatchObject({
status: "malformed",
exception_regions: [expect.any(Object)],
issue:
"Exception clause " +
(_label.startsWith("try") ? "try" : "handler") +
" range does not align with CIL instruction boundaries",
});
},
);
it("rejects a filter start inside an instruction and retains the clause", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x20, 0, 0, 0, 0, 0x2a]),
flags: 1,
tryLength: 5,
handlerOffset: 5,
extra: 1,
}),
).toMatchObject({
status: "malformed",
exception_regions: [expect.objectContaining({ filter_offset: 1 })],
issue:
"Exception clause filter range does not align with CIL instruction boundaries",
});
});
});
describe("managed exception region prefix boundaries", () => {
it("treats a CIL prefix chain and its opcode as one boundary", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0xfe, 0x12, 1, 0xfe, 0x13, 0x4a, 0x2a]),
tryOffset: 0,
tryLength: 6,
handlerOffset: 6,
}),
).toMatchObject({ status: "present", issue: null, il_size: 7 });
});
it("keeps constrained callvirt as a single region boundary", () => {
const il = Buffer.from([0xfe, 0x16, 1, 0, 0, 1, 0x6f, 1, 0, 0, 6, 0x2a]);
expect(
managedBodyWithRegion({
il,
tryOffset: 0,
tryLength: 11,
handlerOffset: 11,
}),
).toMatchObject({ status: "present", issue: null, il_size: 12 });
expect(
managedBodyWithRegion({
il,
tryOffset: 6,
tryLength: 1,
handlerOffset: 11,
}),
).toMatchObject({
status: "malformed",
issue:
"Exception clause try range does not align with CIL instruction boundaries",
});
});
it("decodes the no. prefix and keeps it with its opcode", () => {
// ldarg.0; no. nullcheck; ldfld 0x04000001; pop; ret
const il = Buffer.from([2, 0xfe, 0x19, 4, 0x7b, 1, 0, 0, 4, 0x26, 0x2a]);
expect(
managedBodyWithRegion({ il, tryLength: 10, handlerOffset: 10 }),
).toMatchObject({
status: "present",
issue: null,
decoded_instruction_count: 5,
opcode_counts: { "ldarg.0": 1, "no.": 1, ldfld: 1, pop: 1, ret: 1 },
});
expect(
managedBodyWithRegion({
il,
tryOffset: 4,
tryLength: 5,
handlerOffset: 10,
}),
).toMatchObject({
status: "malformed",
issue:
"Exception clause try range does not align with CIL instruction boundaries",
});
});
it.each([
["start", { tryOffset: 5, tryLength: 1, handlerOffset: 6 }],
[
"end after first prefix",
{ tryOffset: 0, tryLength: 3, handlerOffset: 6 },
],
[
"end after second prefix",
{ tryOffset: 0, tryLength: 5, handlerOffset: 6 },
],
] as const)(
"rejects a region %s between a prefix and its opcode",
(_edge, region) => {
expect(
managedBodyWithRegion({
il: Buffer.from([0xfe, 0x12, 1, 0xfe, 0x13, 0x4a, 0x2a]),
...region,
}),
).toMatchObject({
status: "malformed",
exception_regions: [expect.any(Object)],
issue:
"Exception clause try range does not align with CIL instruction boundaries",
});
},
);
});
describe("managed exception region flags and filters", () => {
it("marks reserved clause flags malformed without dropping its bytes", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x00, 0x2a]),
flags: 8,
tryLength: 1,
handlerOffset: 1,
}),
).toMatchObject({
status: "malformed",
exception_regions: [expect.objectContaining({ flags: 8 })],
issue: "Exception clause has unsupported flags 0x8",
});
});
it.each([2, 4])(
"accepts the defined finally/fault clause flag %s",
(flags) => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x00, 0x00, 0x2a]),
flags,
tryOffset: 0,
tryLength: 1,
handlerOffset: 1,
handlerLength: 2,
}),
).toMatchObject({ status: "present", issue: null });
},
);
it("validates filter ordering", () => {
expect(
managedBodyWithRegion({
il: Buffer.from([0x00, 0x00, 0xfe, 0x11, 0x2a]),
flags: 1,
tryOffset: 0,
tryLength: 1,
handlerOffset: 4,
extra: 1,
}),
).toMatchObject({ status: "present", il_size: 5, issue: null });
expect(
managedBodyWithRegion({
il: Buffer.from([0x00, 0x00, 0x2a]),
flags: 1,
tryOffset: 0,
tryLength: 1,
handlerOffset: 1,
extra: 1,
}),
).toMatchObject({
status: "malformed",
issue:
"Exception clause filter must start before its handler within the method IL body",
});
});
});
describe("managed exception section decoding", () => {
it("marks an exception section with a partial clause as malformed", () => {
const section = Buffer.alloc(17);
section[0] = 0x01;
section[1] = section.length;
expect(managedBodyWithSections(section)).toMatchObject({
status: "malformed",
exception_regions: [],
issue: "Exception section size does not contain whole clauses",
});
});
it("reads every chained exception section", () => {
const first = Buffer.alloc(16);
first[0] = 0x81;
first[1] = first.length;
const second = Buffer.alloc(16);
second[0] = 0x01;
second[1] = second.length;
expect(managedBodyWithSections(first, second)).toMatchObject({
status: "present",
issue: null,
exception_regions: [
{ flags: 0, try_offset: 0, try_length: 0, handler_offset: 0 },
{ flags: 0, try_offset: 0, try_length: 0, handler_offset: 0 },
],
});
});
it("preserves decoded clauses when a chained section has an unsupported kind", () => {
const first = Buffer.alloc(16);
first[0] = 0x81;
first[1] = first.length;
const second = Buffer.from([0x02, 0x04, 0x00, 0x00]);
expect(managedBodyWithSections(first, second)).toMatchObject({
status: "malformed",
exception_regions: [expect.objectContaining({ flags: 0 })],
issue: "Unsupported method data section kind 2",
});
});
it("preserves decoded clauses when a later section has a partial clause", () => {
const first = Buffer.alloc(16);
first[0] = 0x81;
first[1] = first.length;
first.writeUInt16LE(2, 4);
const later = Buffer.alloc(17);
later[0] = 0x01;
later[1] = later.length;
expect(managedBodyWithSections(first, later)).toMatchObject({
status: "malformed",
exception_regions: [expect.objectContaining({ flags: 2 })],
issue: "Exception section size does not contain whole clauses",
});
});
it.each([
{
name: "unsupported section kind",
section: Buffer.from([0x02, 0x04, 0x00, 0x00]),
issue: "Unsupported method data section kind 2",
},
{
name: "section size outside artifact",
section: Buffer.from([0x41, 0xff, 0xff, 0xff]),
issue: "Exception section size leaves artifact",
},
])("rejects $name", ({ section, issue }) => {
expect(managedBodyWithSections(section)).toMatchObject({
status: "malformed",
exception_regions: [],
issue,
});
});
});