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:
parent
0677468877
commit
8bd5ba2d23
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue