From 0909d89c966c30955fd0906b622ff25f3700789f Mon Sep 17 00:00:00 2001 From: DerJokerQ8 Date: Wed, 11 Nov 2026 18:49:30 +0300 Subject: [PATCH] fix(native): list ELF initializers of images with COPY relocations (#10084) * fix(native): list ELF initializers of images with COPY relocations inspect-native-initializers reported every init_array and fini_array slot of a non-PIE executable as unresolved, and ordinary PIE executables as incomplete, when the image carried a COPY relocation such as the one for stdout. The shared dynamic relocation reader had no write extent for COPY or TLS descriptor relocations, so one such record made pointer coverage unknown. It now sizes a COPY from the image's own dynamic symbol and a TLS descriptor as two words. COPY sizes are accepted up to the file byte count in total, so repeated records cannot multiply consumer work. The section relocation fallback also treated a record whose destination has no file bytes, as COPY into .bss does, as an unread table. A linked image's record now affects only the slots it overlaps in memory. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01YT2WLf5G9AnLKF3bZtNLwf * test(native): cover a COPY relocation against symbol index 0 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01YT2WLf5G9AnLKF3bZtNLwf * fix(native): size section COPY records and keep write extents opt-in Address two review findings on the COPY relocation change. The shared dynamic relocation reader yields COPY and TLS descriptor records as writes only to a consumer that asks for whole extents. The initializer walk asks. IL2CPP and the Android JNI overlay do not, so both see these kinds as not decoded, exactly as before this branch. IL2CPP finds unknown bytes by looking one pointer back from a read, so a wider write of unknown value was read as stored zeros. The section fallback takes a COPY record's span from the symbol its table links to instead of assuming one word. A table counts as not fully read when that size is not established, or when a record of unknown extent writes memory that has no file bytes. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01YT2WLf5G9AnLKF3bZtNLwf * refactor(native): size section COPY records in one helper Move the symbol lookup, the allowance and the unread-table note out of the record loop, so the loop only asks for the extent a record is known to write. Behavior is unchanged. Cover the allowance across two section records, and keep the new test code within the lint thresholds the base already meets. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01YT2WLf5G9AnLKF3bZtNLwf --------- Co-authored-by: thunderq10 <195775442+thunderq10@users.noreply.github.com> Co-authored-by: Claude Fable 5.2 --- docs/guides/native/native-initializers.md | 17 +- docs/specs/native/native-initializers/spec.md | 15 +- .../initializers/ElfInitializerSymbols.ts | 67 ++++- src/native/initializers/ElfInitializers.ts | 2 +- .../NativeInitializersParsers.test.ts | 280 ++++++++++++++++++ .../metadata/ElfPointerRelocations.test.ts | 230 ++++++++++++++ src/native/metadata/ElfPointerRelocations.ts | 93 +++++- .../boundary/il2cpp/il2cppBinaryFile.test.ts | 23 ++ tests/fixtures/il2cpp/binary.ts | 35 ++- 9 files changed, 749 insertions(+), 13 deletions(-) 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.