From 8bd5ba2d234cbd9a5bb9dd5daeaa191c3a1cad24 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:29:19 +0800 Subject: [PATCH] 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 --- bridge/pwntools/layout_impl.py | 5 ++ docs/native-investigation.md | 16 ++-- scripts/verify-binary-layout.mjs | 23 +++++ .../managed/managedWorkflowExamples.ts | 5 +- src/domain/native/nativeInspection.ts | 50 ++++++++++- src/native/NativeMachoInspection.ts | 84 ++++++++++--------- .../native/nativeSelectedArchitecture.test.ts | 28 +++++-- 7 files changed, 154 insertions(+), 57 deletions(-) diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 0cae2b46f..57ba9880e 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -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 diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 4df241546..dc7d6987f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 40e7e798d..e4718e1df 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -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"], diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 2a0176dfe..ff81122d0 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -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( { diff --git a/src/domain/native/nativeInspection.ts b/src/domain/native/nativeInspection.ts index 072e8d272..c0b7a3d46 100644 --- a/src/domain/native/nativeInspection.ts +++ b/src/domain/native/nativeInspection.ts @@ -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), diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index d2619f8bd..9980fc241 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -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, + architectures: ReturnType, + 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 = ( const fileOffsetLocations = ( macho: ReturnType, - 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, - 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 { diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index 04f88284b..0fd9e6f17 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -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 }, + }); }, );