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 }, + }); }, );