diff --git a/docs/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index 002b871be4..acd425439d 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -91,12 +91,27 @@ relocations are decoded for x86-64, i386, AArch64 and 32-bit ARM. A slot is also `unresolved` when a dynamic relocation writes only part of it, starts inside it, or writes it more than once with different values. +A `COPY` relocation writes as many bytes as the image's own dynamic symbol +records, and a TLS descriptor relocation writes two words. A slot outside +those bytes keeps its stored word, so a non-PIE executable that copies `stdout` +or `environ` into `.bss` still lists its `init_array` targets as `raw`. When a +`COPY` symbol's size cannot be read, or the `COPY` sizes add up to more than +the file's byte count, relocation coverage is unknown and stored words are +`unresolved`. + Section `REL` / `RELA` tables are applied to slots that no dynamic relocation covers. When such a table is skipped for an unexpected entry size, cannot be read, or lies past the record budget, every slot it could reach that has no relocation found is `unresolved`, and a `.ctors` slot is not dropped as a sentinel. A section record that writes only part of a slot, starts inside it, -or repeats it also leaves the slot `unresolved`. In a relocatable object a +or repeats it also leaves the slot `unresolved`. In a linked image, a section +`COPY` record covers the size that its table's linked symbol records, as a +dynamic one does. When that symbol was not read, or the section `COPY` sizes +add up to more than the file's byte count, the table counts as not fully read. +A record that writes memory with no file bytes, such as zero-fill `.bss`, +changes only the slots it overlaps in memory, provided its extent is known: a +kind with a fixed width, or a sized `COPY`. A record of any other kind there +leaves its table not fully read. In a relocatable object a table reaches only the section named by its `sh_info`. In a linked image, a relocation section without `SHF_ALLOC` (such as one kept by `--emit-relocs`) was already applied by the static linker, so the slot's stored word is used rather than applying the record diff --git a/docs/specs/native/native-initializers/spec.md b/docs/specs/native/native-initializers/spec.md index 253c7651b5..0b5371c669 100644 --- a/docs/specs/native/native-initializers/spec.md +++ b/docs/specs/native/native-initializers/spec.md @@ -32,7 +32,14 @@ slot's location, resolved target and how that target was obtained. The Thumb bit is cleared for target section and file-offset lookup only. A slot with no relocation found is `unresolved` when a dynamic table, or a section table that could reach it, was not fully read; such a `.ctors` slot is not dropped - as a sentinel. A linked image's non-`SHF_ALLOC` relocation sections were + as a sentinel. A `COPY` relocation covers the bytes of its symbol's size and + a TLS descriptor two words. The size comes from the dynamic symbol table for + a dynamic record and from the table's linked symbols for a section record of + a linked image; an unread size leaves coverage unknown. A linked image's + section record that writes memory with no file bytes covers only the slots + it overlaps there when its extent is established, by a fixed width or a + `COPY` size, and otherwise leaves its table not fully read. A + linked image's non-`SHF_ALLOC` relocation sections were applied at link time and are not reapplied. A Mach-O stored pointer word is used only when no undecoded bind stream or external relocation could replace it. @@ -74,8 +81,10 @@ ELF32 REL in-place addends, `ET_REL` section relocations and sentinels, unsupported relocation machines, slot, symbol and relocation budgets, over-long names, tallied repeated observations, a near-maximal section table with a full slot budget, cancellation in the relocation walks, skipped section -relocation tables, partly overwritten slots, retained link-time relocations, -unreadable names, Mach-O chained fixups, `LC_DYLD_INFO` rebases, 32-bit images +relocation tables, partly overwritten slots, `COPY` and TLS descriptor extents, +section `COPY` records sized by their symbol, section records that write +memory without file bytes, retained link-time +relocations, unreadable names, Mach-O chained fixups, `LC_DYLD_INFO` rebases, 32-bit images with undecoded binds, `MH_OBJECT` section relocations, `__init_offsets`, the shared and repeated `LC_SYMTAB` budget, and universal slices. CLI acceptance, filesystem boundary and MCP boundary tests exercise the public workflows and Evidence output. diff --git a/src/native/initializers/ElfInitializerSymbols.ts b/src/native/initializers/ElfInitializerSymbols.ts index 3f19b46f24..824a1a2665 100644 --- a/src/native/initializers/ElfInitializerSymbols.ts +++ b/src/native/initializers/ElfInitializerSymbols.ts @@ -10,6 +10,7 @@ import { MAX_NATIVE_INITIALIZER_NAME_BYTES } from "../../domain/native/nativeIni import type { NativeInitializerResolution } from "../../domain/native/nativeInitializers.js"; import { elfAbsoluteRelocationType, + elfCopyRelocationType, elfRelativeRelocationType, elfRelocationWidth, } from "../metadata/ElfPointerRelocations.js"; @@ -22,6 +23,8 @@ export interface ElfInitializerSymbol { readonly value: bigint; readonly section: number; readonly type: number; + /** `st_size`: the bytes a COPY relocation against this symbol writes. */ + readonly size: bigint; } /** Defined exact-address names plus per-section symbol arrays. */ @@ -191,6 +194,7 @@ const readTable = ( value: parsed.value.value, section: parsed.value.section_index, type: parsed.value.type, + size: parsed.value.size, }); } return { symbols, truncated, incomplete: nameMissing || strings === null }; @@ -408,6 +412,16 @@ const firstOverlap = ( return low; }; +/** + * Bytes a linked image's COPY record writes: its symbol's `st_size`. Undefined + * when that symbol was not read, so the write's extent is not established. + */ +const copySize = ( + symbols: readonly ElfInitializerSymbol[] | undefined, + symbolIndex: number, +): bigint | undefined => + symbolIndex === 0 ? undefined : symbols?.[symbolIndex]?.size; + /** Section relocations found for wanted slots, and whether every applicable record was read. */ export interface SectionRelocationScan { readonly found: ReadonlyMap; @@ -480,6 +494,34 @@ export const sectionRelocations = async ( }; // One budget across every header, so many headers over one range cannot multiply the work. let budget = SECTION_RELOCATION_RECORD_SCAN; + const relative = elfRelativeRelocationType(input.machine); + const copy = elfCopyRelocationType(input.machine); + // Slot visits grow with each COPY span, so the spans together are limited to + // the file size, as in the dynamic walk. + let copyAllowance = input.bytes.length; + /** + * Bytes a linked image's COPY record writes, taken from its symbol. Null when + * that size is not established: the word assumed instead is no proof of what + * else the record reaches, so its table counts as not fully read. + */ + const copySpan = ( + section: NativeSection, + symbols: readonly ElfInitializerSymbol[] | undefined, + symbolIndex: number, + ): number | null => { + const size = copySize(symbols, symbolIndex); + if (size !== undefined && size <= BigInt(copyAllowance)) { + copyAllowance -= Number(size); + return Number(size); + } + input.notes.add( + size === undefined + ? "A section COPY relocation's write size is not established; slots without a relocation are unresolved." + : "Section COPY relocation sizes exceed the file byte count allowance; slots without a relocation are unresolved.", + ); + skip(section); + return null; + }; for (const [position, section] of input.sections.entries()) { const encoding = applicable(input, section); if (encoding === null) continue; @@ -535,14 +577,33 @@ export const sectionRelocations = async ( skip(section); break; } + const copied = + !input.relocatable && parsed.value.type === copy + ? copySpan(section, symbols, parsed.value.symbolIndex) + : null; + // Bytes the record is known to write: a sized COPY's symbol, one word + // for RELATIVE, or the fixed width of another decoded kind. + const extent = + copied ?? + (parsed.value.type === relative + ? word + : elfRelocationWidth(input.machine, parsed.value.type)); // An unknown type is assumed to write a whole word, so it still marks what it may touch. - const span = elfRelocationWidth(input.machine, parsed.value.type) ?? word; + const span = extent ?? word; if (span === 0) continue; + const written = parsed.value.offset + (target?.address ?? 0n); if (recordTarget(input, target, parsed.value.offset, 1) === null) { - skip(section); + // A linked image's write to memory without file bytes, such as COPY + // into .bss, cannot change a slot outside its known extent. + const reached = wanted[firstOverlap(wanted, written, word)]; + if ( + input.relocatable || + extent === undefined || + (reached !== undefined && reached.va < written + BigInt(span)) + ) + skip(section); continue; } - const written = parsed.value.offset + (target?.address ?? 0n); const writtenFileOffset = recordTarget( input, target, diff --git a/src/native/initializers/ElfInitializers.ts b/src/native/initializers/ElfInitializers.ts index 79263f7e02..9ae8e111b6 100644 --- a/src/native/initializers/ElfInitializers.ts +++ b/src/native/initializers/ElfInitializers.ts @@ -621,7 +621,7 @@ const loadWrites = async ( mappings: image.mappings, zeroFills: image.zero_fills, }, - { unsupported: "record" }, + { unsupported: "record", wholeExtents: true }, ); let next = steps.next(); while (!next.done) { diff --git a/src/native/initializers/NativeInitializersParsers.test.ts b/src/native/initializers/NativeInitializersParsers.test.ts index e449ea2cf4..791396ce5f 100644 --- a/src/native/initializers/NativeInitializersParsers.test.ts +++ b/src/native/initializers/NativeInitializersParsers.test.ts @@ -1266,6 +1266,286 @@ describe("ELF relocation coverage", () => { }); }); +/** + * elf64PieInitializers whose leading RELA record is read only as a section + * record: DT_RELA starts at the second one. With `fileEnd`, the load is also + * split at the preinit_array slot (0x240), so memory from `fileEnd` up to that + * slot is zero-fill. + */ +const sectionOnlyRecord = ( + offset: bigint, + info: bigint, + fileEnd?: bigint, +): Buffer => { + const bytes = elf64PieInitializers(); + bytes.writeBigUInt64LE(0x498n, 0x300 + 8 * 16 + 8); + bytes.writeBigUInt64LE(72n, 0x300 + 9 * 16 + 8); + bytes.writeBigUInt64LE(offset, 0x480); + bytes.writeBigUInt64LE(info, 0x488); + if (fileEnd === undefined) return bytes; + bytes.writeUInt16LE(3, 56); + bytes.writeBigUInt64LE(fileEnd, 64 + 32); + bytes.writeBigUInt64LE(0x240n, 64 + 40); + bytes.writeUInt32LE(1, 176); + bytes.writeUInt32LE(6, 180); + for (const field of [184, 192, 200]) bytes.writeBigUInt64LE(0x240n, field); + for (const field of [208, 216]) + bytes.writeBigUInt64LE(BigInt(bytes.length - 0x240), field); + bytes.writeBigUInt64LE(1n, 224); + return bytes; +}; + +/** R_X86_64_COPY of dynamic symbol 1, whose `st_size` is at 0x548. */ +const COPY_OF_SYMBOL_1 = (1n << 32n) | 5n; +const SYMBOL_1_SIZE = 0x538 + 16; + +describe("ELF COPY relocations and writes without file bytes", () => { + it.each([ + ["file-backed bytes", 0x700n], + ["zero-fill memory", 0xc10n], + ])( + "keeps a stored word when a COPY relocation writes %s elsewhere", + async (_where, destination) => { + const base = elf64PieInitializers(); + const bytes = Buffer.from(base); + // The load segment gains a zero-fill tail from 0xc00, as .bss does. + bytes.writeBigUInt64LE(0xd00n, 64 + 40); + // The leading null RELA record becomes R_X86_64_COPY of dynamic symbol + // 1, which now records an 8-byte object. + bytes.writeBigUInt64LE(destination, 0x480); + bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488); + bytes.writeBigUInt64LE(8n, 0x538 + 16); + const report = await read(bytes); + expect(row(report)).toEqual(row(await read(base))); + expect(report.status).toBe("parsed"); + expect(report.limitations).toEqual((await read(base)).limitations); + }, + ); + + it("keeps a slot inside the bytes a COPY relocation writes unresolved", async () => { + const bytes = elf64PieInitializers(); + // A 16-byte COPY at 0x238 runs through the preinit_array slot at 0x240. + bytes.writeBigUInt64LE(0x238n, 0x480); + bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488); + bytes.writeBigUInt64LE(16n, 0x538 + 16); + const report = await read(bytes); + expect( + report.entries.map((entry) => [entry.slot_va, entry.resolution]), + ).toEqual([ + ["0x240", "unresolved"], + ["0x308", "raw"], + ["0x200", "relative_addend"], + ["0x208", "relative_addend"], + ["0x210", "unresolved_import"], + ["0x318", "raw"], + ["0x220", "relr"], + ]); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "1 initializer slot(s) are partly overwritten or written more than once by dynamic relocations and are listed as unresolved.", + ); + }); + + it("keeps stored words unresolved when a COPY relocation's size is unreadable", async () => { + const bytes = elf64PieInitializers(); + bytes.writeBigUInt64LE(0x700n, 0x480); + bytes.writeBigUInt64LE((1n << 32n) | 5n, 0x488); + bytes.writeBigUInt64LE(8n, 0x538 + 16); + // DT_SYMENT is the eighteenth dynamic entry; 16 is not an ELF64 symbol size. + bytes.writeBigUInt64LE(16n, 0x300 + 17 * 16 + 8); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + value_va: null, + resolution: "unresolved", + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "ELF COPY relocation write size is not established", + ); + }); + + it.each([ + ["an 8-byte write that runs into the slot", 1n, "unresolved", "incomplete"], + ["a 4-byte write that ends at the slot", 10n, "raw", "parsed"], + ] as const)( + "judges a section record without file bytes by the memory it overlaps: %s", + async (_case, kind, resolution, status) => { + // The first segment ends in four zero-fill bytes at 0x23c, where the + // record writes, just below the slot at 0x240. + const report = await read( + sectionOnlyRecord(0x23cn, (1n << 32n) | kind, 0x23cn), + ); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + slot_va: "0x240", + value_va: resolution === "raw" ? "0x100" : null, + resolution, + }); + expect(report.status).toBe(status); + }, + ); +}); + +describe("ELF COPY relocations read as section records", () => { + it.each([ + ["ends at the slot", 16n, "raw", "parsed"], + ["reaches the slot's first byte", 17n, "unresolved", "incomplete"], + ["runs through the slot", 24n, "unresolved", "incomplete"], + ] as const)( + "sizes a COPY into zero-fill memory by its symbol: %s", + async (_case, size, resolution, status) => { + // Zero-fill runs from the record at 0x230 up to the slot at 0x240. + const bytes = sectionOnlyRecord(0x230n, COPY_OF_SYMBOL_1, 0x230n); + bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + slot_va: "0x240", + value_va: resolution === "raw" ? "0x100" : null, + resolution, + }); + expect(report.status).toBe(status); + }, + ); + + it.each([ + ["ends at the slot", 8n, "raw", "parsed"], + ["runs through the slot", 16n, "unresolved", "incomplete"], + ] as const)( + "sizes a COPY over file bytes by its symbol: %s", + async (_case, size, resolution, status) => { + const bytes = sectionOnlyRecord(0x238n, COPY_OF_SYMBOL_1); + bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + slot_va: "0x240", + value_va: resolution === "raw" ? "0x100" : null, + resolution, + }); + expect(report.status).toBe(status); + expect( + report.limitations.includes( + "1 initializer slot(s) are partly overwritten or relocated more than once by section relocations and are listed as unresolved.", + ), + ).toBe(resolution === "unresolved"); + }, + ); + + it.each([ + ["names symbol 0", (bytes: Buffer) => bytes.writeBigUInt64LE(5n, 0x488)], + [ + "names a symbol past its table", + (bytes: Buffer) => bytes.writeBigUInt64LE((2n << 32n) | 5n, 0x488), + ], + [ + "links to a section that is not a symbol table", + // .rela.dyn is section 6; .dynstr is section 8. + (bytes: Buffer) => bytes.writeUInt32LE(8, 0x800 + 6 * 64 + 40), + ], + ])( + "keeps stored words unresolved when a section COPY %s", + async (_case, change) => { + // Neither destination is within a word of a slot: file bytes at 0x700 + // and zero-fill memory at 0xc10. + for (const destination of [0x700n, 0xc10n]) { + const bytes = sectionOnlyRecord(destination, COPY_OF_SYMBOL_1); + bytes.writeBigUInt64LE(0xd00n, 64 + 40); + bytes.writeBigUInt64LE(8n, SYMBOL_1_SIZE); + change(bytes); + const report = await read(bytes); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + value_va: null, + resolution: "unresolved", + }); + expect(report.status).toBe("incomplete"); + expect(report.limitations).toContain( + "A section COPY relocation's write size is not established; slots without a relocation are unresolved.", + ); + } + }, + ); + + it("limits section COPY sizes together to the file byte count", async () => { + // One or two section-only COPY records of dynamic symbol 1 write zero-fill + // memory far above every slot of this 0xc00-byte file. + const sized = (size: bigint, records: 1 | 2) => { + const bytes = sectionOnlyRecord(0xc10n, COPY_OF_SYMBOL_1); + bytes.writeBigUInt64LE(0x4000n, 64 + 40); + bytes.writeBigUInt64LE(size, SYMBOL_1_SIZE); + if (records === 2) { + // DT_RELA starts at the third record, so the second one is read only + // as a section record too. + bytes.writeBigUInt64LE(0x4b0n, 0x300 + 8 * 16 + 8); + bytes.writeBigUInt64LE(48n, 0x300 + 9 * 16 + 8); + bytes.writeBigUInt64LE(0x2000n, 0x498); + bytes.writeBigUInt64LE(COPY_OF_SYMBOL_1, 0x4a0); + } + return read(bytes); + }; + for (const [size, records] of [ + [0xc00n, 1], + [0x600n, 2], + ] as const) { + const within = await sized(size, records); + expect(within.entries[0]?.resolution).toBe("raw"); + expect(within.status).toBe("parsed"); + } + for (const [size, records] of [ + [0xc01n, 1], + [0x601n, 2], + ] as const) { + const beyond = await sized(size, records); + expect(beyond.entries[0]?.resolution).toBe("unresolved"); + expect(beyond.status).toBe("incomplete"); + expect(beyond.limitations).toContain( + "Section COPY relocation sizes exceed the file byte count allowance; slots without a relocation are unresolved.", + ); + } + }); + + it("takes the one word of a RELATIVE record as an established extent", async () => { + // R_X86_64_RELATIVE into zero-fill memory, ending a word below the slot. + const report = await read(sectionOnlyRecord(0x230n, 8n, 0x230n)); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + slot_va: "0x240", + value_va: "0x100", + resolution: "raw", + }); + expect(report.status).toBe("parsed"); + }); + + it("does not take the word assumed for an unknown kind as proof that a slot beside zero-fill memory is untouched", async () => { + // Kind 99 has no known width. Its record sits 16 bytes below the slot. + const report = await read( + sectionOnlyRecord(0x230n, (1n << 32n) | 99n, 0x230n), + ); + expect(report.entries[0]).toMatchObject({ + kind: "preinit_array", + slot_va: "0x240", + value_va: null, + resolution: "unresolved", + }); + expect(report.status).toBe("incomplete"); + }); + + it("keeps the one-word assumption for a COPY kind in a relocatable object", async () => { + const bytes = elfRelocatableCtors(); + // The .ctors record against `ctor`, a symbol of size 0, becomes kind 5. + bytes.writeBigUInt64LE(COPY_OF_SYMBOL_1, 0x308); + const report = await read(bytes); + expect( + report.entries.map((entry) => [entry.kind, entry.resolution]), + ).toEqual([ + ["init_array", "symbol_addend"], + ["ctors", "unresolved"], + ]); + }); +}); + describe("Unreadable symbol names", () => { it("reports an ELF name offset outside its string table as incomplete", async () => { const bytes = elf64PieInitializers(); diff --git a/src/native/metadata/ElfPointerRelocations.test.ts b/src/native/metadata/ElfPointerRelocations.test.ts index a731a1691e..e88bc10035 100644 --- a/src/native/metadata/ElfPointerRelocations.test.ts +++ b/src/native/metadata/ElfPointerRelocations.test.ts @@ -1,6 +1,9 @@ import { expect, it, vi } from "vitest"; import { + ElfPointerRelocationFailure, elfAbsoluteRelocationType, + elfCopyRelocationType, + elfRelocationWidth, readElfRelocationWritesSteps, readElfPointerValuesSteps, type ElfPointerWrite, @@ -115,6 +118,233 @@ it("owns the word-sized absolute relocation type per machine", () => { expect(elfAbsoluteRelocationType(8)).toBeUndefined(); }); +it("owns the COPY relocation type per machine and gives it no fixed width", () => { + expect(elfCopyRelocationType(62)).toBe(5); + expect(elfCopyRelocationType(3)).toBe(5); + expect(elfCopyRelocationType(183)).toBe(1024); + expect(elfCopyRelocationType(40)).toBe(20); + expect(elfCopyRelocationType(8)).toBeUndefined(); + expect(elfRelocationWidth(62, 5)).toBeUndefined(); + expect(elfRelocationWidth(183, 1024)).toBeUndefined(); +}); + +/** + * `records` RELA records at `destination` against dynamic symbol + * `symbolIndex` (1 unless given). Symbol 1 records `symbolSize` as its + * `st_size`, and `symbolTags` declares where the loader finds it. + */ +const symbolicImage = ( + kind: bigint, + symbolSize: bigint, + layout: { + readonly symbolTags?: readonly [bigint, bigint][]; + readonly destination?: bigint; + readonly records?: number; + readonly symbolIndex?: number; + } = {}, +): Buffer => { + const records = layout.records ?? 1; + const bytes = Buffer.alloc(0x400); + const dynamic: [bigint, bigint][] = [ + [7n, 0x100n], + [8n, BigInt(24 * records)], + [9n, 24n], + ...(layout.symbolTags ?? [ + [6n, 0x200n], + [11n, 24n], + ]), + [0n, 0n], + ]; + dynamic.forEach(([tag, value], index) => { + bytes.writeBigUInt64LE(tag, index * 16); + bytes.writeBigUInt64LE(value, index * 16 + 8); + }); + for (let record = 0; record < records; record += 1) { + bytes.writeBigUInt64LE(layout.destination ?? 0x300n, 0x100 + record * 24); + bytes.writeBigUInt64LE( + (BigInt(layout.symbolIndex ?? 1) << 32n) | kind, + 0x108 + record * 24, + ); + } + bytes.writeBigUInt64LE(symbolSize, 0x200 + 24 + 16); + return bytes; +}; + +/** The ELF32 REL form of {@link symbolicImage}: 8-byte records, 16-byte symbols. */ +const symbolicImage32 = (kind: number, symbolSize: number): Buffer => { + const bytes = Buffer.alloc(0x400); + const dynamic = [ + [17, 0x100], + [18, 8], + [19, 8], + [6, 0x200], + [11, 16], + [0, 0], + ] as const; + dynamic.forEach(([tag, value], index) => { + bytes.writeInt32LE(tag, index * 8); + bytes.writeUInt32LE(value, index * 8 + 4); + }); + bytes.writeUInt32LE(0x300, 0x100); + bytes.writeUInt32LE((1 << 8) | kind, 0x104); + bytes.writeUInt32LE(symbolSize, 0x200 + 16 + 8); + return bytes; +}; + +const walker = + (wholeExtents: boolean) => + ( + bytes: Buffer, + unsupported?: "record", + machine = EM_X86_64, + bits: 32 | 64 = 64, + ) => { + const writes: ElfPointerWrite[] = []; + const steps = readElfRelocationWritesSteps( + bytes, + { + bits, + little: true, + machine, + dynamicSegments: [{ offset: 0n, size: 0x100n }], + mappings: [{ address: 0n, offset: 0, size: bytes.length }], + zeroFills: [], + }, + { wholeExtents, ...(unsupported === undefined ? {} : { unsupported }) }, + ); + let next = steps.next(); + for (; !next.done; next = steps.next()) + if (next.value !== undefined) writes.push(next.value); + return { writes, result: next.value }; + }; +/** A consumer that asked for whole COPY and TLS descriptor extents. */ +const walk = walker(true); +/** A consumer that tracks one pointer per write. */ +const walkPointers = walker(false); + +it.each([ + ["an x86-64 COPY", symbolicImage(5n, 8n), [62, 64, 5]], + ["an x86-64 TLS descriptor", symbolicImage(36n, 0n), [62, 64, 36]], + ["an AArch64 TLS descriptor", symbolicImage(1031n, 0n), [183, 64, 1031]], + ["an i386 TLS descriptor", symbolicImage32(41, 0), [3, 32, 41]], + ["an ARM TLS descriptor", symbolicImage32(13, 0), [40, 32, 13]], +] as const)( + "leaves %s undecoded for a consumer that did not ask for whole extents", + (_name, bytes, [machine, bits, kind]) => { + const { writes, result } = walkPointers(bytes, "record", machine, bits); + expect(writes).toEqual([]); + expect(result).toMatchObject({ + issues: [`ELF relocation kind ${String(kind)} is not decoded`], + pointer_coverage_known: false, + }); + expect(() => walkPointers(bytes, undefined, machine, bits)).toThrow( + ElfPointerRelocationFailure, + ); + }, +); + +it.each([ + ["x86-64", 62, 64, 5], + ["AArch64", 183, 64, 1024], + ["i386", 3, 32, 5], + ["ARM", 40, 32, 20], +] as const)( + "sizes a %s COPY write from the image's own dynamic symbol", + (_name, machine, bits, kind) => { + const { writes, result } = walk( + bits === 64 + ? symbolicImage(BigInt(kind), 0x28n) + : symbolicImage32(kind, 0x28), + undefined, + machine, + bits, + ); + expect(writes).toMatchObject([ + { + address: 0x300n, + width: 0x28, + value: null, + source: { encoding: bits === 64 ? "rela" : "rel", kind }, + symbol_index: 1, + }, + ]); + expect(result).toMatchObject({ issues: [], pointer_coverage_known: true }); + }, +); + +it("writes nothing for a COPY of a zero-size symbol", () => { + const { writes, result } = walk(symbolicImage(5n, 0n)); + expect(writes).toEqual([]); + expect(result.pointer_coverage_known).toBe(true); +}); + +it.each([ + ["no DT_SYMTAB", symbolicImage(5n, 8n, { symbolTags: [[11n, 24n]] })], + [ + "a nonstandard DT_SYMENT", + symbolicImage(5n, 8n, { + symbolTags: [ + [6n, 0x200n], + [11n, 16n], + ], + }), + ], + [ + "a redeclared DT_SYMTAB", + symbolicImage(5n, 8n, { + symbolTags: [ + [6n, 0x200n], + [6n, 0x200n], + [11n, 24n], + ], + }), + ], + [ + "an unmapped symbol", + symbolicImage(5n, 8n, { + symbolTags: [ + [6n, 0x10000n], + [11n, 24n], + ], + }), + ], + ["symbol index 0", symbolicImage(5n, 8n, { symbolIndex: 0 })], + [ + "a span past the address space", + symbolicImage(5n, 0x10n, { destination: 0xffff_ffff_ffff_fff8n }), + ], +])("leaves coverage unknown for a COPY with %s", (_reason, bytes) => { + const { writes, result } = walk(bytes, "record"); + expect(writes).toEqual([]); + expect(result).toMatchObject({ + issues: ["ELF COPY relocation write size is not established"], + pointer_coverage_known: false, + }); + expect(() => walk(bytes)).toThrow(ElfPointerRelocationFailure); +}); + +it("stops sizing COPY writes once they cover more than the file byte count", () => { + // Two COPY records of a 0x300-byte symbol in a 0x400-byte file. + const bytes = symbolicImage(5n, 0x300n, { destination: 0x80n, records: 2 }); + const { writes, result } = walk(bytes, "record"); + expect(writes).toMatchObject([{ address: 0x80n, width: 0x300 }]); + expect(result).toMatchObject({ + issues: ["ELF COPY relocation sizes exceed the file byte count allowance"], + pointer_coverage_known: false, + }); + expect(() => walk(bytes)).toThrow(ElfPointerRelocationFailure); +}); + +it("reports a TLS descriptor as a two-word write", () => { + const { writes, result } = walk(symbolicImage(36n, 0n)); + expect(writes).toMatchObject([{ address: 0x300n, width: 16, value: null }]); + expect(result.pointer_coverage_known).toBe(true); + expect(elfRelocationWidth(62, 36)).toBe(16); + expect(elfRelocationWidth(183, 1031)).toBe(16); + expect(elfRelocationWidth(40, 13)).toBe(8); + expect(elfRelocationWidth(3, 41)).toBe(8); +}); + /** Three fully decoded relative values; empty REL declarations add no state. */ const relativeValues = (duplicate = false): Buffer => { const bytes = image(); diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index d1affe57d0..b0e557c5a0 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -4,6 +4,7 @@ import type { } from "../../domain/virtualFileMapping.js"; import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; import { readElfRelocationEntry } from "../../domain/elfRelocation.js"; +import { readElfSymbol } from "../../domain/elfSymbol.js"; import { BinaryMappingFailure, createFileMappingIndex, @@ -83,6 +84,8 @@ const DT_PLTRELSZ = 2n, const DT_RELRSZ = 35n, DT_RELR = 36n, DT_RELRENT = 37n; +const DT_SYMTAB = 6n, + DT_SYMENT = 11n; const PACKED_TAGS = [0x6000000fn, 0x60000010n, 0x60000011n, 0x60000012n]; const ANDROID_RELR_TAGS = [0x6fffe000n, 0x6fffe001n, 0x6fffe003n]; const UNIQUE_TAGS = new Set([ @@ -101,6 +104,9 @@ const UNIQUE_TAGS = new Set([ ...PACKED_TAGS, ...ANDROID_RELR_TAGS, ]); +// Only COPY extents read the dynamic symbol table, so a redeclared tag leaves +// those extents unknown without rejecting unambiguous relocation tables. +const SYMBOL_TABLE_TAGS = new Set([DT_SYMTAB, DT_SYMENT]); const malformed = (message: string): never => { throw new ElfPointerRelocationFailure("malformed-container", message); }; @@ -111,6 +117,7 @@ const RELOCATION_KINDS = new Map([ absolute: 257, bits: 64, relative: 1027, + copy: 1024, widths: new Map([ [257, 8], [258, 4], @@ -123,6 +130,7 @@ const RELOCATION_KINDS = new Map([ [1028, 8], [1029, 8], [1030, 8], + [1031, 16], [1032, 8], ]), }, @@ -133,6 +141,7 @@ const RELOCATION_KINDS = new Map([ absolute: 1, bits: 64, relative: 8, + copy: 5, widths: new Map([ [1, 8], [2, 4], @@ -144,6 +153,7 @@ const RELOCATION_KINDS = new Map([ [17, 8], [18, 8], [24, 8], + [36, 16], [37, 8], ]), }, @@ -154,9 +164,11 @@ const RELOCATION_KINDS = new Map([ absolute: 2, bits: 32, relative: 23, + copy: 20, widths: new Map([ [2, 4], [3, 4], + [13, 8], [17, 4], [18, 4], [19, 4], @@ -172,6 +184,7 @@ const RELOCATION_KINDS = new Map([ absolute: 1, bits: 32, relative: 8, + copy: 5, widths: new Map([ [1, 4], [2, 4], @@ -196,6 +209,7 @@ const RELOCATION_KINDS = new Map([ [36, 4], [37, 4], [38, 4], + [41, 8], [42, 4], [43, 4], ]), @@ -213,6 +227,10 @@ export const elfAbsoluteRelocationType = ( machine: number, ): number | undefined => RELOCATION_KINDS.get(machine)?.absolute; +/** COPY relocation type, whose write is as wide as its symbol's `st_size`. */ +export const elfCopyRelocationType = (machine: number): number | undefined => + RELOCATION_KINDS.get(machine)?.copy; + /** Bytes written by a relocation; type zero is a no-op and unknown types remain undefined. */ export const elfRelocationWidth = ( machine: number, @@ -242,6 +260,7 @@ function* dynamicTags( ) malformed("PT_DYNAMIC is truncated or outside the file"); const tags = new Map(); + const redeclared = new Set(); const start = Number(declaration.offset), end = start + Number(declaration.size); for (let at = start; at < end; at += width) { @@ -253,7 +272,15 @@ function* dynamicTags( ); if (!parsed.ok) return malformed(parsed.error.message); const { tag, value } = parsed.value; - if (tag === 0n) return tags; + if (tag === 0n) { + for (const symbolTag of redeclared) tags.delete(symbolTag); + return tags; + } + if (SYMBOL_TABLE_TAGS.has(tag)) { + if (tags.has(tag)) redeclared.add(tag); + else tags.set(tag, value); + continue; + } if (UNIQUE_TAGS.has(tag) && tags.has(tag)) malformed(`duplicate dynamic tag ${tag}`); if (UNIQUE_TAGS.has(tag)) tags.set(tag, value); @@ -273,6 +300,12 @@ export interface ElfRelocationStreamResult { type RelocationOptions = { readonly unsupported?: "reject" | "record"; readonly requireDynamic?: boolean; + /** + * Yield COPY and TLS descriptor records as writes of unknown value over + * their whole extent, which can exceed one pointer. Unset, they stay + * undecoded kinds, for consumers that track one pointer per write. + */ + readonly wholeExtents?: boolean; }; /** Decode once from immutable bytes; consumers choose streaming or sparse retention. */ @@ -288,6 +321,8 @@ export function* readElfRelocationWritesSteps( "ELF relative pointers require a matching AArch64, x86-64, ARM or i386 image", ); const relative = kinds.relative; + const wholeExtents = options.wholeExtents === true; + const copy = wholeExtents ? kinds.copy : undefined; const unknownWidths = kinds.widths; if (options.requireDynamic === true && input.dynamicSegments.length === 0) throw new ElfPointerRelocationFailure( @@ -355,6 +390,46 @@ export function* readElfRelocationWritesSteps( value === null ? fileOffset(address, width) : target(address, width), ...record, }); + const symbolBytes = input.bits === 64 ? 24 : 16; + const symbolTable = + tags.get(DT_SYMENT) === BigInt(symbolBytes) + ? tags.get(DT_SYMTAB) + : undefined; + /** + * Bytes a COPY relocation can write: the loader copies at most the size this + * image's own dynamic symbol records. Undefined when that size is unreadable + * or the span leaves the address space. + */ + const copyWidth = ( + destination: bigint, + symbolIndex: number, + ): number | undefined => { + if (symbolTable === undefined || symbolIndex === 0) return undefined; + let at: number; + try { + at = index.offset( + symbolTable + BigInt(symbolIndex) * BigInt(symbolBytes), + symbolBytes, + ); + } catch (cause) { + if (cause instanceof BinaryMappingFailure) return undefined; + throw cause; + } + const symbol = readElfSymbol( + bytes.subarray(at, at + symbolBytes), + input.bits, + input.little, + ); + if (!symbol.ok) return undefined; + const { size } = symbol.value; + return size <= BigInt(Number.MAX_SAFE_INTEGER) && + destination + size <= 1n << BigInt(input.bits) + ? Number(size) + : undefined; + }; + // Consumers do work across each COPY span, which repeated or overlapping + // records would multiply, so the spans together are limited to the file size. + let copyAllowance = bytes.length; if (PACKED_TAGS.some((tag) => tags.has(tag))) unsupported( "Android packed APS2 relocation write locations and addends are not decoded", @@ -414,9 +489,23 @@ export function* readElfRelocationWritesSteps( symbol_index: symbolIndex, addend: explicitAddend ?? null, }; + if (kind === copy) { + const width = copyWidth(destination, symbolIndex); + if (width === undefined) + unsupported("ELF COPY relocation write size is not established"); + else if (width > copyAllowance) + unsupported( + "ELF COPY relocation sizes exceed the file byte count allowance", + ); + else if (width > 0) { + copyAllowance -= width; + yield write(destination, width, null, record); + } + continue; + } if (kind !== relative || symbolIndex !== 0) { const width = unknownWidths.get(kind); - if (width === undefined) { + if (width === undefined || (width > word && !wholeExtents)) { unsupported(`ELF relocation kind ${kind} is not decoded`); continue; } diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index 9c12c85fd0..12b1f55460 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -1244,6 +1244,29 @@ it.each([ }, ); +it.each([ + ["an x86-64 TLS descriptor", 62, 36, undefined], + ["an AArch64 TLS descriptor", 183, 1031, undefined], + ["an x86-64 COPY of a pointer-sized symbol", 62, 5, 8n], + ["an x86-64 COPY of a 24-byte symbol", 62, 5, 24n], + ["an AArch64 COPY of a 24-byte symbol", 183, 1024, 24n], +] as const)( + "fails closed on %s instead of reading the bytes it writes as stored words", + (_, machine, kind, symbolSize) => { + // The write starts at 0x2f8, eight bytes below the method pointer at + // 0x300, which a two-word write or a 24-byte copy runs through. + const bytes = createElfFixture(createIl2cppBinaryFixture(), { + machine, + unresolvedRelocations: [{ offset: 0x2f8, kind }], + ...(symbolSize === undefined ? {} : { symbolSize }), + }); + expect(failure(bytes)).toEqual({ + reason: "unsupported-pointer-encoding", + kind: "unsupported", + }); + }, +); + it("reads a relative relocation into a zero-filled pointer array as a known value", () => { const bytes = createElfFixture(createIl2cppBinaryFixture(), { zeroFilledTable: true, diff --git a/tests/fixtures/il2cpp/binary.ts b/tests/fixtures/il2cpp/binary.ts index 3cdb0ad376..a8b5a33558 100644 --- a/tests/fixtures/il2cpp/binary.ts +++ b/tests/fixtures/il2cpp/binary.ts @@ -507,6 +507,8 @@ export interface ElfFixtureOptions { readonly relaEntry?: bigint; /** Move mscorlib.dll's method pointers into zero-fill past the file end. */ readonly zeroFilledTable?: boolean; + /** Declare DT_SYMTAB / DT_SYMENT with dynamic symbol 1 of this `st_size`, which sizes a COPY. */ + readonly symbolSize?: bigint; } /** A valid large RELA image; extra file-backed pointers do not add IL2CPP registrations. */ @@ -560,6 +562,26 @@ export const createLargeElfRelativeFixture = ( * base: every pointer slot into the image is zeroed on disk and restored by * an R_AARCH64_RELATIVE entry, as a position-independent libil2cpp.so stores it. */ +/** The null symbol, then symbol 1 with only its size set; empty when no size is asked for. */ +const dynamicSymbols = (size: bigint | undefined): Buffer => { + if (size === undefined) return Buffer.alloc(0); + const symbols = Buffer.alloc(48); + symbols.writeBigUInt64LE(size, 24 + 16); + return symbols; +}; + +/** DT_SYMTAB and DT_SYMENT for a symbol table at `address`; none for an empty table. */ +const symbolTableTags = ( + symbols: Buffer, + address: bigint, +): [bigint, bigint][] => + symbols.length === 0 + ? [] + : [ + [6n, address], + [11n, 24n], + ]; + export const createElfFixture = ( image: Buffer, options: ElfFixtureOptions = {}, @@ -583,11 +605,14 @@ export const createElfFixture = ( 0n, relocation.kind, ]); + const symbols = dynamicSymbols(options.symbolSize); const tagCount = 4 + (options.duplicateRela === true ? 1 : 0) + - (options.androidPacked === true ? 1 : 0); - const fileSize = SIZE + relocations.length * 24 + tagCount * 16; + (options.androidPacked === true ? 1 : 0) + + symbolTableTags(symbols, 0n).length; + const fileSize = + SIZE + relocations.length * 24 + tagCount * 16 + symbols.length; if (options.zeroFilledTable === true) { // The second module record's methodPointers field (MODULES + 0x40 + 16). const slot = FIXTURE_BINARY_BASE + BigInt(0x400 + 0x40 + 16); @@ -615,6 +640,10 @@ export const createElfFixture = ( ...(options.androidPacked === true ? [[0x60000011n, 0n] as [bigint, bigint]] : []), + ...symbolTableTags( + symbols, + FIXTURE_BINARY_BASE + BigInt(fileSize - symbols.length), + ), [0n, 0n], ]; const dynamic = Buffer.alloc(tags.length * 16); @@ -622,7 +651,7 @@ export const createElfFixture = ( dynamic.writeBigInt64LE(tag, index * 16); dynamic.writeBigUInt64LE(value, index * 16 + 8); }); - const file = Buffer.concat([body, rela, dynamic]); + const file = Buffer.concat([body, rela, dynamic, symbols]); if (file.length !== fileSize) throw new Error("ELF fixture size drifted"); const withDynamic = options.dynamic !== false; // ELF64 header: little-endian shared object, no section headers.