[GH-ISSUE #6747] [Bug] IL2CPP Mach-O links treat dyld-bound method slots as no-body or resolved code #4109

Closed
opened 2026-10-31 00:46:53 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6747

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

The IL2CPP native linker ignores classic Mach-O LC_DYLD_INFO_ONLY bindings on method-pointer slots. With a valid bind covering a slot, it reports no-body for a stored zero, or reports the stored nonzero word as resolved code. The stored word is not the bound symbol's resolved address.

This was reproduced with synthetic version-29 metadata and an arm64 Mach-O fixture. REA's existing Mach-O pointer reader independently recognizes the slot as a binding to _FixtureMethod, with no decoding failures. The real Il2cppMetadataService nevertheless returns successful complete Evidence with the incorrect method state.

Reproduction

I ran the following reduced source-level reproduction with Node.js 24.22.1 and the checkout's installed tsx loader. It constructs inert buffers only and does not load or execute a native image. Save it as /tmp/il2cpp-bind.mjs, then run from the source checkout after its normal locked dependencies are installed:

node --import "$PWD/node_modules/tsx/dist/loader.mjs" /tmp/il2cpp-bind.mjs "$PWD"
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const root = resolve(process.argv[2]);
const load = (p) => import(pathToFileURL(`${root}/${p}`).href);
const { createIl2cppBinaryFixture } = await load('tests/fixtures/il2cpp/binary.ts');
const { createIl2cppMetadataFixture } = await load('tests/fixtures/il2cpp/metadata.ts');
const { readIl2cppMetadataForLinking } = await load('src/il2cpp/Il2cppMetadataFile.ts');
const { linkIl2cppBinary } = await load('src/il2cpp/Il2cppBinaryFile.ts');
const { parseMachoLayout } = await load('src/native/AppleMachoSelection.ts');
const { parsePointerFixups } = await load('src/native/AppleMachoFixups.ts');
const { metadata, link } = readIl2cppMetadataForLinking(createIl2cppMetadataFixture({version: 29}).bytes);
const bytes = createIl2cppBinaryFixture();
const bind = Buffer.from([
  0x11, 0x40, ...Buffer.from('_FixtureMethod\0'), // ordinal 1, symbol
  0x51, 0x70, 0x88, 0x06, 0x90, 0x00, // pointer, segment 0 + 0x308, bind, done
]);
bytes.writeUInt32LE(3, 16); // Segment, DYLD_INFO_ONLY, LOAD_DYLIB
bytes.writeUInt32LE(72 + 48 + 56, 20);
bytes.writeUInt32LE(7, 88); bytes.writeUInt32LE(7, 92); // Writable segment
bytes.writeUInt32LE(0x80000022, 104); bytes.writeUInt32LE(48, 108);
bytes.writeUInt32LE(0xe00, 120); bytes.writeUInt32LE(bind.length, 124);
bytes.writeUInt32LE(0xc, 152); bytes.writeUInt32LE(56, 156);
bytes.writeUInt32LE(24, 160); bytes.write('/fixture/libMethods.dylib\0', 176);
bind.copy(bytes, 0xe00);
const layout = parseMachoLayout(bytes, 'arm64');
const fixups = parsePointerFixups(bytes, layout.fixups, layout.segments, layout.baseAddress);
assert.deepEqual(fixups.failures, []);
for (const raw of [0n, 0x100000210n]) {
  bytes.writeBigUInt64LE(raw, 0x308); // Method 0's MethodDef token selects slot 1.
  const oracle = fixups.decode(0x100000308n, raw);
  assert.equal(oracle.kind, 'bind');
  const actual = linkIl2cppBinary(bytes, metadata, link).method_addresses[0];
  console.log(JSON.stringify({raw: `0x${raw.toString(16)}`, oracle: oracle.kind, symbol: oracle.symbol, actual}));
}

Both variants reproduced in the reduced probe. Before reducing it, a separate probe also exercised Il2cppMetadataService.inspect({path, binary_path, detail: "complete"}), the real reader, summary projection, and Ghidra seed formatting on these same synthetic layouts. No CLI/MCP adapter or Apple loader execution was performed.

Expected behavior

Preserve the method's unresolved binding as unknown with null address/rva, or explicitly refuse the unsupported pointer encoding. Do not infer no body from an on-disk zero that a loader binding owns, and do not publish its placeholder as an exact code address.

The method-address state contract explicitly includes unresolved relocations in unknown. Apple's definition of dyld_info_command describes binding records as initializing pointers to symbols during loading. REA already has a classic binding decoder that identifies the fixture's slot correctly.

Impact and scope

Incorrect native associations propagate into complete Evidence and summary state counts. In the service probe, the baseline summary was code: 4, no_body: 1, unknown: 0; binding a zero-valued method slot changed it to code: 3, no_body: 2, unknown: 0 instead of preserving an unknown slot. Seed formatting also counted that method as no_body. With a nonzero bound placeholder, it generated seeds using the unbound address and treated an address collision as folding.

Observed scope is these tiny synthetic Mach-O fixtures and their metadata. Real Unity-produced binaries and other binding streams were not tested; prevalence is unknown. Metadata names and owners remain readable. This report concerns static file interpretation, not target runtime behavior or a security weakness.

REA version

Source 76b56957e94d804667f7890b244639ecc4725b32; package.json is 6.7.0.

Main-only finding: latest published tag rea-agents-6.7.0 resolves to 27dc42d10249f0410904a75c0997842eb03b931c and has no IL2CPP implementation under src/il2cpp, src/application/il2cpp, or src/domain/il2cpp. This is not a reproduced bug in the published 6.7.0 package.

Runtime and platform

Node.js v24.22.1; Linux x64 (Linux 7.2.8-9-omarchy). No Unity, Hopper, Ghidra, IDA, or native target execution.

Target and artifact context

Source-authored metadata version 29 plus a 4096-byte arm64 Mach-O registration fixture. A regular dyld binding at virtual address 0x100000308 covers method index 0's token-selected slot. The dependency name /fixture/libMethods.dylib and symbol _FixtureMethod are synthetic fixture data; no dependency is opened.

Interface

Other — source-level production reader and shared application service; CLI/MCP adapters not invoked.

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Actual reduced-probe output:

{"raw":"0x0","oracle":"bind","symbol":"_FixtureMethod","actual":{"state":"no-body","address":null,"rva":null}}
{"raw":"0x100000210","oracle":"bind","symbol":"_FixtureMethod","actual":{"state":"code","address":"0x100000210","rva":"0x210"}}

The separate integration probe observed successful shared-service Evidence, independent pointer decoding without failures, summary counts, and seed counts for an unbound control and both bound cases. The reproduction above includes all bytes needed to reconstruct the changed Mach-O records from the checked-in synthetic fixture.

Typed outcome or recovery expected

Return method state unknown, or a typed unsupported-pointer-encoding refusal if classic bindings cannot be preserved safely. No native-code association should be claimed solely from the bound slot's raw bytes.

Analysis or suspected cause

Source-derived: openImageSteps only populates unresolvedPointerSlots from ELF relocations. For Mach-O it refuses chained fixups, but classic dyld bindings are not passed to the pointer owner. readSlot consequently reads the raw word, and methodAddress converts zero to no-body or accepts a mapped nonzero word as code.

Additional context

Open and closed issue searches before reproduction used IL2CPP bind, IL2CPP dyld, and method bind Mach-O; none returned a matching report. Related #6701 concerns ELF relocation widths and #6715 concerns ELF load aliases, not Mach-O binding applicability.

All proof was completed before this draft was written. No source edits, fixes, installs, builds, full suites, or issue mutations were performed.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The synthetic supported-container reproduction shows incorrect method associations in successful native Evidence, summary counts, and seed formatting. The failure is bounded to dyld-bound slots; metadata inventory remains available, and real-producer prevalence is unmeasured.
  • Workaround status: unverified
  • Workaround basis: Omitting binary_path retains metadata inventory but does not recover native associations. No end-user workaround preserving the native result was tested; the existing source-level pointer decoder confirms binding ownership but does not resolve the target symbol.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6747 ## Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ## Area Native analysis or decompilation ## Observed behavior The IL2CPP native linker ignores classic Mach-O `LC_DYLD_INFO_ONLY` bindings on method-pointer slots. With a valid bind covering a slot, it reports `no-body` for a stored zero, or reports the stored nonzero word as resolved `code`. The stored word is not the bound symbol's resolved address. This was reproduced with synthetic version-29 metadata and an arm64 Mach-O fixture. REA's existing Mach-O pointer reader independently recognizes the slot as a binding to `_FixtureMethod`, with no decoding failures. The real `Il2cppMetadataService` nevertheless returns successful complete Evidence with the incorrect method state. ## Reproduction I ran the following reduced source-level reproduction with Node.js 24.22.1 and the checkout's installed `tsx` loader. It constructs inert buffers only and does not load or execute a native image. Save it as `/tmp/il2cpp-bind.mjs`, then run from the source checkout after its normal locked dependencies are installed: ```sh node --import "$PWD/node_modules/tsx/dist/loader.mjs" /tmp/il2cpp-bind.mjs "$PWD" ``` ```js import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const root = resolve(process.argv[2]); const load = (p) => import(pathToFileURL(`${root}/${p}`).href); const { createIl2cppBinaryFixture } = await load('tests/fixtures/il2cpp/binary.ts'); const { createIl2cppMetadataFixture } = await load('tests/fixtures/il2cpp/metadata.ts'); const { readIl2cppMetadataForLinking } = await load('src/il2cpp/Il2cppMetadataFile.ts'); const { linkIl2cppBinary } = await load('src/il2cpp/Il2cppBinaryFile.ts'); const { parseMachoLayout } = await load('src/native/AppleMachoSelection.ts'); const { parsePointerFixups } = await load('src/native/AppleMachoFixups.ts'); const { metadata, link } = readIl2cppMetadataForLinking(createIl2cppMetadataFixture({version: 29}).bytes); const bytes = createIl2cppBinaryFixture(); const bind = Buffer.from([ 0x11, 0x40, ...Buffer.from('_FixtureMethod\0'), // ordinal 1, symbol 0x51, 0x70, 0x88, 0x06, 0x90, 0x00, // pointer, segment 0 + 0x308, bind, done ]); bytes.writeUInt32LE(3, 16); // Segment, DYLD_INFO_ONLY, LOAD_DYLIB bytes.writeUInt32LE(72 + 48 + 56, 20); bytes.writeUInt32LE(7, 88); bytes.writeUInt32LE(7, 92); // Writable segment bytes.writeUInt32LE(0x80000022, 104); bytes.writeUInt32LE(48, 108); bytes.writeUInt32LE(0xe00, 120); bytes.writeUInt32LE(bind.length, 124); bytes.writeUInt32LE(0xc, 152); bytes.writeUInt32LE(56, 156); bytes.writeUInt32LE(24, 160); bytes.write('/fixture/libMethods.dylib\0', 176); bind.copy(bytes, 0xe00); const layout = parseMachoLayout(bytes, 'arm64'); const fixups = parsePointerFixups(bytes, layout.fixups, layout.segments, layout.baseAddress); assert.deepEqual(fixups.failures, []); for (const raw of [0n, 0x100000210n]) { bytes.writeBigUInt64LE(raw, 0x308); // Method 0's MethodDef token selects slot 1. const oracle = fixups.decode(0x100000308n, raw); assert.equal(oracle.kind, 'bind'); const actual = linkIl2cppBinary(bytes, metadata, link).method_addresses[0]; console.log(JSON.stringify({raw: `0x${raw.toString(16)}`, oracle: oracle.kind, symbol: oracle.symbol, actual})); } ``` Both variants reproduced in the reduced probe. Before reducing it, a separate probe also exercised `Il2cppMetadataService.inspect({path, binary_path, detail: "complete"})`, the real reader, summary projection, and Ghidra seed formatting on these same synthetic layouts. No CLI/MCP adapter or Apple loader execution was performed. ## Expected behavior Preserve the method's unresolved binding as `unknown` with null `address`/`rva`, or explicitly refuse the unsupported pointer encoding. Do not infer no body from an on-disk zero that a loader binding owns, and do not publish its placeholder as an exact code address. The [method-address state contract](https://github.com/morluto/rea/blob/76b56957e94d804667f7890b244639ecc4725b32/src/domain/il2cpp/il2cppMetadata.ts#L184) explicitly includes unresolved relocations in `unknown`. Apple's [definition of `dyld_info_command`](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/EXTERNAL_HEADERS/mach-o/loader.h) describes binding records as initializing pointers to symbols during loading. REA already has a [classic binding decoder](https://github.com/morluto/rea/blob/76b56957e94d804667f7890b244639ecc4725b32/src/native/AppleMachoFixups.ts#L1157) that identifies the fixture's slot correctly. ## Impact and scope Incorrect native associations propagate into complete Evidence and summary state counts. In the service probe, the baseline summary was `code: 4, no_body: 1, unknown: 0`; binding a zero-valued method slot changed it to `code: 3, no_body: 2, unknown: 0` instead of preserving an unknown slot. Seed formatting also counted that method as `no_body`. With a nonzero bound placeholder, it generated seeds using the unbound address and treated an address collision as folding. Observed scope is these tiny synthetic Mach-O fixtures and their metadata. Real Unity-produced binaries and other binding streams were not tested; prevalence is unknown. Metadata names and owners remain readable. This report concerns static file interpretation, not target runtime behavior or a security weakness. ## REA version Source `76b56957e94d804667f7890b244639ecc4725b32`; `package.json` is 6.7.0. **Main-only finding:** latest published tag `rea-agents-6.7.0` resolves to `27dc42d10249f0410904a75c0997842eb03b931c` and has no IL2CPP implementation under `src/il2cpp`, `src/application/il2cpp`, or `src/domain/il2cpp`. This is not a reproduced bug in the published 6.7.0 package. ## Runtime and platform Node.js v24.22.1; Linux x64 (`Linux 7.2.8-9-omarchy`). No Unity, Hopper, Ghidra, IDA, or native target execution. ## Target and artifact context Source-authored metadata version 29 plus a 4096-byte arm64 Mach-O registration fixture. A regular dyld binding at virtual address `0x100000308` covers method index 0's token-selected slot. The dependency name `/fixture/libMethods.dylib` and symbol `_FixtureMethod` are synthetic fixture data; no dependency is opened. ## Interface Other — source-level production reader and shared application service; CLI/MCP adapters not invoked. ## Earliest failure stage Native analysis or reconstruction ## Relevant output or evidence Actual reduced-probe output: ```jsonl {"raw":"0x0","oracle":"bind","symbol":"_FixtureMethod","actual":{"state":"no-body","address":null,"rva":null}} {"raw":"0x100000210","oracle":"bind","symbol":"_FixtureMethod","actual":{"state":"code","address":"0x100000210","rva":"0x210"}} ``` The separate integration probe observed successful shared-service Evidence, independent pointer decoding without failures, summary counts, and seed counts for an unbound control and both bound cases. The reproduction above includes all bytes needed to reconstruct the changed Mach-O records from the checked-in synthetic fixture. ## Typed outcome or recovery expected Return method state `unknown`, or a typed `unsupported-pointer-encoding` refusal if classic bindings cannot be preserved safely. No native-code association should be claimed solely from the bound slot's raw bytes. ## Analysis or suspected cause Source-derived: [`openImageSteps`](https://github.com/morluto/rea/blob/76b56957e94d804667f7890b244639ecc4725b32/src/il2cpp/Il2cppBinaryImage.ts#L116) only populates `unresolvedPointerSlots` from ELF relocations. For Mach-O it refuses chained fixups, but classic dyld bindings are not passed to the pointer owner. [`readSlot`](https://github.com/morluto/rea/blob/76b56957e94d804667f7890b244639ecc4725b32/src/il2cpp/Il2cppBinaryImage.ts#L305) consequently reads the raw word, and [`methodAddress`](https://github.com/morluto/rea/blob/76b56957e94d804667f7890b244639ecc4725b32/src/il2cpp/Il2cppBinaryFile.ts#L134) converts zero to `no-body` or accepts a mapped nonzero word as code. ## Additional context Open and closed issue searches before reproduction used `IL2CPP bind`, `IL2CPP dyld`, and `method bind Mach-O`; none returned a matching report. Related #6701 concerns ELF relocation widths and #6715 concerns ELF load aliases, not Mach-O binding applicability. All proof was completed before this draft was written. No source edits, fixes, installs, builds, full suites, or issue mutations were performed. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The synthetic supported-container reproduction shows incorrect method associations in successful native Evidence, summary counts, and seed formatting. The failure is bounded to dyld-bound slots; metadata inventory remains available, and real-producer prevalence is unmeasured. - Workaround status: unverified - Workaround basis: Omitting binary_path retains metadata inventory but does not recover native associations. No end-user workaround preserving the native result was tested; the existing source-level pointer decoder confirms binding ownership but does not resolve the target symbol.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4109
No description provided.