fix(native): clarify Mach-O offset origins and reject partial ELF tables

Expose the selected Mach-O slice and its container offset, derive that identity once, and map entrypoint and nonempty segment Evidence to container coordinates. Reject relocation sections whose byte length is not divisible by the entry size instead of silently accepting a floored count.

Validation: native parser and selected-architecture boundaries, actual public CLI and stdio MCP on a compiled universal Mach-O, and a pyelftools 0.33 malformed-table probe. Full Linux pwntools bridge verification was unavailable on this host.

Refs #1973
This commit is contained in:
morluto 2026-10-12 06:29:19 +08:00
parent 0677468877
commit 8bd5ba2d23
7 changed files with 154 additions and 57 deletions

View File

@ -206,6 +206,11 @@ def inspect_elf(path, cache):
"visibility": raw.st_other.visibility, "section_index": raw.st_shndx,
})
if h.sh_type in ("SHT_REL", "SHT_RELA"):
if h.sh_size % h.sh_entsize != 0:
raise LayoutFailure(
"format",
f"ELF relocation section {index} size is not a multiple of its {h.sh_entsize}-byte entries.",
)
if image.header.e_type == "ET_REL" and h.sh_info >= len(all_sections):
raise LayoutFailure("format", f"ELF relocation section {index} references missing target section {h.sh_info}.")
symbol_table = None

View File

@ -6,11 +6,17 @@ coverage and unknowns. The CLI and MCP use the same application workflows.
## Static inspection
`inspect_macho` retains otool segment `file_offset` values relative to the
selected Mach-O slice. Its evidence file-offset ranges address the original
input file and include the observed lipo slice offset for universal binaries.
If that offset is unavailable, segment evidence locations are omitted with an
explicit limitation; architecture inventory locations remain available.
`inspect_macho.selected_architecture` identifies the exact selected lipo slice
and its `container_file_offset` in the input file. A thin Mach-O has a known
offset of zero; an unknown universal-slice offset stays null. Architecture
`file_offset` values are container-relative. Entrypoint, segment and section
`file_offset` values retain their source meaning and are relative to the
selected Mach-O slice. Raw load-command fields retain their original values.
Evidence locations map the LC_MAIN entrypoint and nonempty file-backed segment
ranges to container offsets by adding the observed slice offset. Zero-fill
segments do not produce file ranges. When the selected slice's container offset
is unknown, those mapped locations are omitted with an explicit limitation;
architecture inventory locations remain available.
Hopper's `address_to_file_offset` combines the native image-relative mapping with
original-file coordinates only after checking the source against the session's

View File

@ -662,6 +662,16 @@ try {
bytes.writeBigUInt64LE(8n, header + 56);
return [`undersized-entry-${type}`, bytes, "invalid_input"];
});
const nonintegralSymbolEntries = ["SHT_SYMTAB", "SHT_DYNSYM"].map((type) => {
const section = protectedReport.sections.find((item) => item.type === type);
assert.notEqual(section, undefined);
const bytes = Buffer.from(sectionBearingBytes);
const header = Number(BigInt(section.header_location.offset));
const size = bytes.readBigUInt64LE(header + 32);
assert.ok(size + 1n <= BigInt(bytes.length) - BigInt(section.offset));
bytes.writeBigUInt64LE(size + 1n, header + 32);
return [`nonintegral-entry-count-${type}`, bytes, "invalid_input"];
});
const relocationSection = relocatable.sections.find(
(item) => item.type === "SHT_RELA",
);
@ -677,6 +687,17 @@ try {
bytes.writeBigUInt64LE(8n, header + 56);
return [`undersized-entry-${type}`, bytes, "invalid_input"];
});
const nonintegralRelocationEntries = [
["RELA", 4, 24],
["REL", 9, 16],
].map(([type, sectionType, entrySize]) => {
const bytes = Buffer.from(object);
const header = Number(BigInt(relocationSection.header_location.offset));
bytes.writeUInt32LE(sectionType, header + 4);
bytes.writeBigUInt64LE(BigInt(entrySize + 1), header + 32);
bytes.writeBigUInt64LE(BigInt(entrySize), header + 56);
return [`nonintegral-entry-count-${type}`, bytes, "invalid_input"];
});
const invalidRelocationReferences = [
["missing-symbol-table", relocatable.sections.length + 100],
["wrong-symbol-table-kind", 1],
@ -1066,7 +1087,9 @@ try {
],
...zeroSymbolEntries,
...undersizedSymbolEntries,
...nonintegralSymbolEntries,
...undersizedRelocationEntries,
...nonintegralRelocationEntries,
...invalidRelocationReferences,
["unterminated-dynamic-segment", shortDynamic, "invalid_input"],
["unterminated-interpreter", unterminatedInterpreter, "invalid_input"],

View File

@ -224,6 +224,7 @@ const boundaryEvidence = () => {
const nativeExportEvidence = () => {
const result: InspectMacho = {
format: "mach-o",
selected_architecture: { name: "x86_64", container_file_offset: null },
endian: "little",
word_size: 64,
file_type: "dylib",
@ -246,7 +247,9 @@ const nativeExportEvidence = () => {
]),
segments: coverage([]),
provenance: [],
limitations: [],
limitations: [
"Selected Mach-O slice container offset is unknown in this synthetic example.",
],
};
return createEvidence(
{

View File

@ -28,7 +28,12 @@ const architectureSchema = z.object({
name: z.string(),
cpu_type: z.string().nullable(),
cpu_subtype: z.string().nullable(),
file_offset: z.number().int().min(0).nullable(),
file_offset: z
.number()
.int()
.min(0)
.nullable()
.describe("Offset from the start of the input Mach-O container."),
size: z.number().int().min(0).nullable(),
alignment: z.number().int().min(0).nullable(),
});
@ -43,7 +48,12 @@ const sectionSchema = z.object({
name: z.string(),
address: hexAddress.nullable(),
size: z.number().int().min(0).nullable(),
file_offset: z.number().int().min(0).nullable(),
file_offset: z
.number()
.int()
.min(0)
.nullable()
.describe("Offset from the start of the selected Mach-O slice."),
alignment: z.number().int().min(0).nullable(),
flags: z.array(z.string()),
});
@ -51,7 +61,12 @@ const segmentSchema = z.object({
name: z.string(),
vm_address: hexAddress.nullable(),
vm_size: z.number().int().min(0).nullable(),
file_offset: z.number().int().min(0).nullable(),
file_offset: z
.number()
.int()
.min(0)
.nullable()
.describe("Offset from the start of the selected Mach-O slice."),
file_size: z.number().int().min(0).nullable(),
maximum_permissions: permissionSchema,
initial_permissions: permissionSchema,
@ -84,12 +99,39 @@ const buildMetadataSchema = z.object({
export const inspectMachoSchema = z.object({
format: z.literal("mach-o"),
/** Exact lipo slice selected for inspection and its offset in the input container. */
selected_architecture: z.object({
name: z
.string()
.nullable()
.describe(
"Exact architecture name selected from lipo output; null when selection is unknown.",
),
container_file_offset: z
.number()
.int()
.min(0)
.nullable()
.describe(
"Offset of the selected slice from the start of the input Mach-O container; null when unknown.",
),
}),
endian: z.enum(["little", "big"]).nullable(),
word_size: z.union([z.literal(32), z.literal(64)]).nullable(),
file_type: z.string().nullable(),
flags: z.array(z.string()),
uuid: z.string().nullable(),
entrypoints: coverage(z.object({ file_offset: z.number().int().min(0) })),
entrypoints: coverage(
z.object({
file_offset: z
.number()
.int()
.min(0)
.describe(
"LC_MAIN entry offset from the start of the selected Mach-O slice.",
),
}),
),
architectures: coverage(architectureSchema),
build_metadata: coverage(buildMetadataSchema),
load_commands: coverage(loadCommandSchema),

View File

@ -67,9 +67,8 @@ export const inspectNativeMacho = async (
}
const lipo = captures.find(({ tool }) => tool === "lipo");
if (lipo === undefined) throw new TypeError("Missing lipo capture");
const availableArchitectures = parseLipoArchitectures(lipo.stdout).map(
({ name }) => name,
);
const architectures = parseLipoArchitectures(lipo.stdout);
const availableArchitectures = architectures.map(({ name }) => name);
const selection = selectArchitecture(context.target, availableArchitectures);
if (selection.status === "absent")
return err(
@ -133,31 +132,43 @@ export const inspectNativeMacho = async (
),
);
}
const selectedArchitectureName =
selectedArchitecture ??
(architectures.length === 1 ? (architectures[0]?.name ?? null) : null);
const containerOffset =
selectedArchitectureName === null
? undefined
: /^Non-fat file:/mu.test(lipo.stdout)
? 0
: (architectures.find(({ name }) => name === selectedArchitectureName)
?.file_offset ?? undefined);
const normalized = normalizeMacho(
captures,
context.invocation,
limitations,
load,
);
const segmentOffset = segmentContainerOffset(
normalized,
selectedArchitecture,
/^Non-fat file:/mu.test(lipo.stdout),
architectures,
{
name: selectedArchitectureName,
container_file_offset: containerOffset ?? null,
},
);
const result =
segmentOffset === undefined
containerOffset === undefined
? inspectMachoSchema.parse({
...normalized,
limitations: [
...normalized.limitations,
"Segment evidence file offsets are unavailable because the selected Mach-O slice's container offset was not observed.",
selectedArchitectureName === null
? "The selected Mach-O slice is unknown; slice-based evidence locations are unavailable."
: "The selected Mach-O slice's container file offset was not observed; slice-based evidence locations are unavailable.",
],
})
: normalized;
return ok({
result: jsonValueSchema.parse(result),
limitations: result.limitations,
locations: fileOffsetLocations(result, segmentOffset),
locations: fileOffsetLocations(result, containerOffset),
});
};
@ -166,9 +177,13 @@ const normalizeMacho = (
toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation,
additionalLimitations: readonly string[],
load: ReturnType<typeof parseOtoolLoadCommands>,
architectures: ReturnType<typeof parseLipoArchitectures>,
selectedArchitecture: {
readonly name: string | null;
readonly container_file_offset: number | null;
},
) => {
const byTool = captureLookup(captures);
const architectures = parseLipoArchitectures(byTool("lipo").stdout);
const imports = parseDyldSymbols(
sliceOutput(byTool("dyld_info", 0)),
"imports",
@ -217,6 +232,7 @@ const normalizeMacho = (
];
return inspectMachoSchema.parse({
format: "mach-o",
selected_architecture: selectedArchitecture,
endian: parseEndian(byTool("file").stdout),
word_size: parseWordSize(byTool("file").stdout),
file_type: load.fileType,
@ -307,45 +323,31 @@ const uniqueSymbols = <Value extends { readonly name: string }>(
const fileOffsetLocations = (
macho: ReturnType<typeof inspectMachoSchema.parse>,
segmentOffset: number | undefined,
containerOffset: number | undefined,
): EvidenceLocation[] => {
const locations = architectureLocations(macho.architectures.items);
if (segmentOffset === undefined) return locations;
if (containerOffset === undefined) return locations;
for (const entrypoint of macho.entrypoints.items)
locations.push({
kind: "file-offset",
offset: containerOffset + entrypoint.file_offset,
});
for (const segment of macho.segments.items) {
if (segment.file_offset === null || segment.file_size === null) continue;
if (
segment.file_offset === null ||
segment.file_size === null ||
segment.file_size === 0
)
continue;
locations.push({
kind: "file-offset-range",
start: segmentOffset + segment.file_offset,
end: segmentOffset + segment.file_offset + segment.file_size,
start: containerOffset + segment.file_offset,
end: containerOffset + segment.file_offset + segment.file_size,
});
}
return locations;
};
/** otool segment offsets are slice-relative; evidence addresses the input file. */
const segmentContainerOffset = (
macho: ReturnType<typeof inspectMachoSchema.parse>,
selectedArchitecture: string | null,
thinInput: boolean,
): number | undefined => {
const architectures = macho.architectures.items;
const slice =
selectedArchitecture === null
? architectures.length === 1
? architectures[0]
: undefined
: architectures.find(({ name }) => name === selectedArchitecture);
if (slice?.file_offset !== null && slice?.file_offset !== undefined)
return slice.file_offset;
// A thin lipo observation has no separate slice offset. Unknown FAT slice
// offsets must not silently become zero and identify unrelated container bytes.
return thinInput &&
selectedArchitecture === null &&
architectures.length === 1
? 0
: undefined;
};
/** Project architecture slices into evidence file-offset locations. */
export const architectureLocations = (
architectures: readonly {

View File

@ -32,6 +32,10 @@ it("does not attribute the first universal slice's UUID and segments to the sele
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
uuid: "01234567-89AB-CDEF-0123-456789ABCDEF",
selected_architecture: {
name: "arm64",
container_file_offset: 32768,
},
architectures: { total: 2 },
});
const normalized = inspectMachoSchema.parse(result.value.result);
@ -125,16 +129,19 @@ it("projects universal segment evidence into the selected slice's container offs
start: 32768,
end: 40960,
});
expect(result.value.locations).toContainEqual({
kind: "file-offset",
offset: 36864,
});
expect(result.value.locations).not.toContainEqual({
kind: "file-offset-range",
start: 0,
end: 8192,
});
// The normalized tool field remains slice-relative; only evidence is projected.
expect(
inspectMachoSchema.parse(result.value.result).segments.items[0]
?.file_offset,
).toBe(0);
// Normalized tool fields keep their slice-relative meaning.
const normalized = inspectMachoSchema.parse(result.value.result);
expect(normalized.segments.items[0]?.file_offset).toBe(0);
expect(normalized.entrypoints.items[0]?.file_offset).toBe(4096);
});
it.each([
@ -168,6 +175,12 @@ it.each([
start: offset,
end: offset + 8192,
});
expect(result.value.result).toMatchObject({
selected_architecture: {
name: "arm64",
container_file_offset: offset,
},
});
if (offset > 0)
expect(result.value.locations).not.toContainEqual({
kind: "file-offset-range",
@ -206,7 +219,10 @@ it.each([
end: 8192,
});
expect(result.value.limitations).toContain(
"Segment evidence file offsets are unavailable because the selected Mach-O slice's container offset was not observed.",
"The selected Mach-O slice's container file offset was not observed; slice-based evidence locations are unavailable.",
);
expect(result.value.result).toMatchObject({
selected_architecture: { container_file_offset: null },
});
},
);