[GH-ISSUE #6881] [Bug] IL2CPP renders dyld-bound type-table placeholders as resolved return types #4217

Closed
opened 2026-10-31 00:47:48 +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/6881

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 built inspect-il2cpp-metadata --binary CLI reports concrete return-type names from a Mach-O type-table pointer owned by an unresolved classic dyld bind. Keeping the binding identical but changing the stored placeholder from one mapped type record to another changes all five returned names from System.Größe<int>[] to int; both results succeed with confidence: "derived". The production dyld decoder identifies the same slot as a bind in both variants, with no fixup-decoding failures.

Reproduction

From the tested checkout with its locked dependencies installed and runtime built, save the following as /tmp/rea-il2cpp-dyld-types.mjs and run:

node --import ./node_modules/tsx/dist/loader.mjs /tmp/rea-il2cpp-dyld-types.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const repo=process.cwd();
const root=fs.mkdtempSync(path.join(os.tmpdir(),'rea-il2cpp-dyld-'));
fs.mkdirSync(root,{recursive:true});
const {createLinkedIl2cppMetadataFixture}=await import(pathToFileURL(path.join(repo,'tests/fixtures/il2cpp/metadata.ts')));
const {createIl2cppBinaryFixture,createIl2cppDyldBindFixture,FIXTURE_BINARY_BASE}=await import(pathToFileURL(path.join(repo,'tests/fixtures/il2cpp/binary.ts')));
const {openImageSteps,readSlot,readPointer}=await import(pathToFileURL(path.join(repo,'dist/il2cpp/Il2cppBinaryImage.js')));
fs.writeFileSync(path.join(root,'global-metadata.dat'),createLinkedIl2cppMetadataFixture().bytes);
for(const d of ['home','config','cache','data','state','tmp'])fs.mkdirSync(path.join(root,d),{recursive:true});
const env={...process.env,HOME:path.join(root,'home'),XDG_CONFIG_HOME:path.join(root,'config'),XDG_CACHE_HOME:path.join(root,'cache'),XDG_DATA_HOME:path.join(root,'data'),XDG_STATE_HOME:path.join(root,'state'),TMPDIR:path.join(root,'tmp'),REA_LOG_LEVEL:'silent'};
const standard=createIl2cppBinaryFixture();
for(const [name,bound,raw] of [['control-array',false,FIXTURE_BINARY_BASE+0x980n],['bound-array',true,FIXTURE_BINARY_BASE+0x980n],['bound-int',true,FIXTURE_BINARY_BASE+0x9b0n],['bound-zero',true,0n]]){
 const bytes=bound?createIl2cppDyldBindFixture(standard.readBigUInt64LE(0x300)):Buffer.from(standard);
 bytes.writeBigUInt64LE(raw,0x900);
 bytes.writeUInt32LE(7,88);bytes.writeUInt32LE(7,92); // Writable mapped segment for classic pointer bindings.
 if(bound){const off=0xf00+2+Buffer.byteLength('_FixtureMethod\0')+2;bytes[off]=0x80;bytes[off+1]=0x12;} // ULEB128 0x900; bind type table slot 0 instead of method slot 0.
 const binary=path.join(root,name+'.dylib');fs.writeFileSync(binary,bytes);
 const steps=openImageSteps(bytes,undefined);let step=steps.next();while(!step.done)step=steps.next();
 const image=step.value;const resolution=image.machoPointerFixups?.decode(FIXTURE_BINARY_BASE+0x900n,raw);const slot=readSlot(image,FIXTURE_BINARY_BASE+0x900n),pointer=readPointer(image,FIXTURE_BINARY_BASE+0x900n);
 const r=spawnSync(process.execPath,[path.join(repo,'scripts/rea.mjs'),'inspect-il2cpp-metadata',path.join(root,'global-metadata.dat'),'--binary',binary,'--json'],{cwd:repo,env,encoding:'utf8'});
 fs.writeFileSync(path.join(root,name+'.stdout.json'),r.stdout);fs.writeFileSync(path.join(root,name+'.stderr.txt'),r.stderr);
 const result=JSON.parse(r.stdout);
 console.log(JSON.stringify({name,raw:'0x'+raw.toString(16),readSlot:typeof slot==='bigint'?'0x'+slot.toString(16):slot,readPointer:pointer===null?null:'0x'+pointer.toString(16),fixupKind:resolution?.kind,fixupSymbol:resolution?.symbol,fixupFailures:image.machoPointerFixups?.failures,exit:r.status,code:result.code,confidence:result.confidence,return_types:result.normalized_result?.return_types,error:result.details?.issues,stderr:r.stderr}));
}

This is the executed procedure, with only its evidence-output directory generalized. It uses checked-in synthetic fixture constructors, production image readers and the source-built public CLI. All native buffers are inert; no binary or imported symbol is loaded or executed. The four cases were executed in two pre-draft rounds; the final round explicitly made the mapped segment writable and checked the exact fixup outcome. No post-draft execution was performed.

Expected behavior

A dyld-owned type-table slot must not be interpreted as a resolved Il2CppType address solely from its raw file word. Preserve the unavailable type name or return an explicit typed refusal; changing only that placeholder must not produce different supposedly resolved names. The unbound control must continue to render its observed type normally.

The native type-name contract says these names are rendered from the binary's Il2CppType table. The existing dyld-aware pointer boundary treats a binding as unknown, rather than returning the stored word as the target. This report applies that existing interpretation to type-table pointers, without requiring dynamic symbol resolution or a particular output-schema change.

Impact and scope

Successful native Evidence contains five concrete return-type names that depend on bytes the bind owns, rather than on an observed resolved type. This is a bounded static interpretation defect with no security claim. The reproduction covers synthetic metadata version 29 and a thin arm64 Mach-O with classic LC_DYLD_INFO_ONLY binding. Real Unity-produced binaries, actual loader execution, other metadata versions, other pointer locations, summary/page projection and MCP were not tested; producer frequency is unknown. No files selected for analysis are changed by the reader.

REA version

Source commit 3e7e102a518b9112a2a8aa0590ae6f6951fd0635; package version 6.7.0. This is a source-checkout finding; the installed or published REA package was not tested.

Runtime and platform

Node.js v24.22.1; Linux 7.2.8-9-omarchy; x86_64. No Unity, Hopper, Ghidra or IDA was used.

Target and artifact context

Checked-in createLinkedIl2cppMetadataFixture() metadata and a 4096-byte Mach-O registration fixture. The type-table slot at file offset 0x900 / virtual address 0x100000900 has a classic pointer bind to the synthetic symbol _FixtureMethod. Its stored word is varied between 0x100000980, 0x1000009b0 and zero. The bind opcode stream and symbol stay identical across those three cases. Method-pointer slots remain unbound; no methods are invoked.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Selected observed final-round output, abbreviated only by replacing five identical rows with the common name and row count:

case           fixup  failures  readSlot       CLI exit  confidence  return type name (five rows)
control-array  raw    []        0x100000980    0         derived     System.Größe<int>[]
bound-array    bind   []        unknown        0         derived     System.Größe<int>[]
bound-int      bind   []        unknown        0         derived     int
bound-zero     bind   []        unknown        1         --          invalid-type: type 0 has no Il2CppType record

All four CLI cases had empty stderr. The bound-zero error is an ancillary observation: the primary failure is the two successful bound variants publishing conflicting concrete names. In each bound case, fixupSymbol is _FixtureMethod. The script retains full CLI JSON and prints the complete five-row return_types arrays and independent pointer observations.

  • Expected source: The documented native type-name contract and production dyld-aware pointer boundary linked above.
  • Failure source: Executed built CLI plus openImageSteps, readSlot, readPointer and the image's production fixup decoder on the same bytes.
  • Evidence provenance: observed.
  • Local verification: reproduced.
  • Reproduction completeness: complete for the authored static fixture; real-producer occurrence is not established.

Typed outcome or recovery expected

Do not publish a concrete type name derived solely from an unresolved bound placeholder. A successful result preserving that unknown or an explicit typed refusal can restore the contract. Repeating the three bound inputs must not select different resolved type names from their raw words; the unbound array control must retain its valid rendering. No implementation is prescribed.

Analysis or suspected cause

Source-derived: native type-reference resolution obtains each type-table address using readPointer. That helper checks ELF unresolved slots but returns the Mach-O raw word without consulting classic dyld fixups. readSlot consults the fixup decoder, but type-reference resolution does not call that boundary. This explains why the independently observed unknown slot still yields a concrete return-type name. No fix was implemented.

Additional context

The closest related report is closed #6747, which concerns method-pointer states, addresses and seeds. Its restored behavior is present: the dyld-aware slot reader now returns unknown. This report concerns type-table pointer dereferencing and incorrect type names, a different failing signal that remains after that method-slot repair. Searches across open and closed issues used IL2CPP dyld in:body, IL2CPP "type" "bind" in:body and Il2CppType in:body, as well as the IL2CPP title inventory; no matching type-table report was found.

NOT_ENOUGH_INFO: no last-known-good revision, real-producer sample, or supported identity-preserving workaround was established. Omitting --binary retains metadata names but omits the requested native types. All verification finished before this draft was written. Prepared with AI assistance.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The public CLI publishes conflicting concrete return-type names from two unresolved bound placeholders in the authored fixture, despite the production decoder recognizing the binding. The demonstrated effect is bounded; real-producer prevalence and other input classes are unknown.
  • Workaround status: unverified
  • Workaround basis: Metadata-only inspection omits the requested native type facts. No identity-preserving recovery of those facts was tested; editing the placeholder changes artifact identity and does not resolve the bound symbol.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6881 ### 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 built `inspect-il2cpp-metadata --binary` CLI reports concrete return-type names from a Mach-O type-table pointer owned by an unresolved classic dyld bind. Keeping the binding identical but changing the stored placeholder from one mapped type record to another changes all five returned names from `System.Größe<int>[]` to `int`; both results succeed with `confidence: "derived"`. The production dyld decoder identifies the same slot as a bind in both variants, with no fixup-decoding failures. ### Reproduction From the tested checkout with its locked dependencies installed and runtime built, save the following as `/tmp/rea-il2cpp-dyld-types.mjs` and run: ```sh node --import ./node_modules/tsx/dist/loader.mjs /tmp/rea-il2cpp-dyld-types.mjs ``` ```js import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; const repo=process.cwd(); const root=fs.mkdtempSync(path.join(os.tmpdir(),'rea-il2cpp-dyld-')); fs.mkdirSync(root,{recursive:true}); const {createLinkedIl2cppMetadataFixture}=await import(pathToFileURL(path.join(repo,'tests/fixtures/il2cpp/metadata.ts'))); const {createIl2cppBinaryFixture,createIl2cppDyldBindFixture,FIXTURE_BINARY_BASE}=await import(pathToFileURL(path.join(repo,'tests/fixtures/il2cpp/binary.ts'))); const {openImageSteps,readSlot,readPointer}=await import(pathToFileURL(path.join(repo,'dist/il2cpp/Il2cppBinaryImage.js'))); fs.writeFileSync(path.join(root,'global-metadata.dat'),createLinkedIl2cppMetadataFixture().bytes); for(const d of ['home','config','cache','data','state','tmp'])fs.mkdirSync(path.join(root,d),{recursive:true}); const env={...process.env,HOME:path.join(root,'home'),XDG_CONFIG_HOME:path.join(root,'config'),XDG_CACHE_HOME:path.join(root,'cache'),XDG_DATA_HOME:path.join(root,'data'),XDG_STATE_HOME:path.join(root,'state'),TMPDIR:path.join(root,'tmp'),REA_LOG_LEVEL:'silent'}; const standard=createIl2cppBinaryFixture(); for(const [name,bound,raw] of [['control-array',false,FIXTURE_BINARY_BASE+0x980n],['bound-array',true,FIXTURE_BINARY_BASE+0x980n],['bound-int',true,FIXTURE_BINARY_BASE+0x9b0n],['bound-zero',true,0n]]){ const bytes=bound?createIl2cppDyldBindFixture(standard.readBigUInt64LE(0x300)):Buffer.from(standard); bytes.writeBigUInt64LE(raw,0x900); bytes.writeUInt32LE(7,88);bytes.writeUInt32LE(7,92); // Writable mapped segment for classic pointer bindings. if(bound){const off=0xf00+2+Buffer.byteLength('_FixtureMethod\0')+2;bytes[off]=0x80;bytes[off+1]=0x12;} // ULEB128 0x900; bind type table slot 0 instead of method slot 0. const binary=path.join(root,name+'.dylib');fs.writeFileSync(binary,bytes); const steps=openImageSteps(bytes,undefined);let step=steps.next();while(!step.done)step=steps.next(); const image=step.value;const resolution=image.machoPointerFixups?.decode(FIXTURE_BINARY_BASE+0x900n,raw);const slot=readSlot(image,FIXTURE_BINARY_BASE+0x900n),pointer=readPointer(image,FIXTURE_BINARY_BASE+0x900n); const r=spawnSync(process.execPath,[path.join(repo,'scripts/rea.mjs'),'inspect-il2cpp-metadata',path.join(root,'global-metadata.dat'),'--binary',binary,'--json'],{cwd:repo,env,encoding:'utf8'}); fs.writeFileSync(path.join(root,name+'.stdout.json'),r.stdout);fs.writeFileSync(path.join(root,name+'.stderr.txt'),r.stderr); const result=JSON.parse(r.stdout); console.log(JSON.stringify({name,raw:'0x'+raw.toString(16),readSlot:typeof slot==='bigint'?'0x'+slot.toString(16):slot,readPointer:pointer===null?null:'0x'+pointer.toString(16),fixupKind:resolution?.kind,fixupSymbol:resolution?.symbol,fixupFailures:image.machoPointerFixups?.failures,exit:r.status,code:result.code,confidence:result.confidence,return_types:result.normalized_result?.return_types,error:result.details?.issues,stderr:r.stderr})); } ``` This is the executed procedure, with only its evidence-output directory generalized. It uses checked-in synthetic fixture constructors, production image readers and the source-built public CLI. All native buffers are inert; no binary or imported symbol is loaded or executed. The four cases were executed in two pre-draft rounds; the final round explicitly made the mapped segment writable and checked the exact fixup outcome. No post-draft execution was performed. ### Expected behavior A dyld-owned type-table slot must not be interpreted as a resolved `Il2CppType` address solely from its raw file word. Preserve the unavailable type name or return an explicit typed refusal; changing only that placeholder must not produce different supposedly resolved names. The unbound control must continue to render its observed type normally. The [native type-name contract](https://github.com/morluto/rea/blob/3e7e102a518b9112a2a8aa0590ae6f6951fd0635/docs/il2cpp-metadata.md#L258-L268) says these names are rendered from the binary's `Il2CppType` table. The [existing dyld-aware pointer boundary](https://github.com/morluto/rea/blob/3e7e102a518b9112a2a8aa0590ae6f6951fd0635/src/il2cpp/Il2cppBinaryImage.ts#L340-L363) treats a binding as `unknown`, rather than returning the stored word as the target. This report applies that existing interpretation to type-table pointers, without requiring dynamic symbol resolution or a particular output-schema change. ### Impact and scope Successful native Evidence contains five concrete return-type names that depend on bytes the bind owns, rather than on an observed resolved type. This is a bounded static interpretation defect with no security claim. The reproduction covers synthetic metadata version 29 and a thin arm64 Mach-O with classic `LC_DYLD_INFO_ONLY` binding. Real Unity-produced binaries, actual loader execution, other metadata versions, other pointer locations, summary/page projection and MCP were not tested; producer frequency is unknown. No files selected for analysis are changed by the reader. ### REA version Source commit `3e7e102a518b9112a2a8aa0590ae6f6951fd0635`; package version 6.7.0. This is a source-checkout finding; the installed or published REA package was not tested. ### Runtime and platform Node.js v24.22.1; Linux 7.2.8-9-omarchy; x86_64. No Unity, Hopper, Ghidra or IDA was used. ### Target and artifact context Checked-in `createLinkedIl2cppMetadataFixture()` metadata and a 4096-byte Mach-O registration fixture. The type-table slot at file offset `0x900` / virtual address `0x100000900` has a classic pointer bind to the synthetic symbol `_FixtureMethod`. Its stored word is varied between `0x100000980`, `0x1000009b0` and zero. The bind opcode stream and symbol stay identical across those three cases. Method-pointer slots remain unbound; no methods are invoked. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Selected observed final-round output, abbreviated only by replacing five identical rows with the common name and row count: ```text case fixup failures readSlot CLI exit confidence return type name (five rows) control-array raw [] 0x100000980 0 derived System.Größe<int>[] bound-array bind [] unknown 0 derived System.Größe<int>[] bound-int bind [] unknown 0 derived int bound-zero bind [] unknown 1 -- invalid-type: type 0 has no Il2CppType record ``` All four CLI cases had empty stderr. The bound-zero error is an ancillary observation: the primary failure is the two successful bound variants publishing conflicting concrete names. In each bound case, `fixupSymbol` is `_FixtureMethod`. The script retains full CLI JSON and prints the complete five-row `return_types` arrays and independent pointer observations. - Expected source: The documented native type-name contract and production dyld-aware pointer boundary linked above. - Failure source: Executed built CLI plus `openImageSteps`, `readSlot`, `readPointer` and the image's production fixup decoder on the same bytes. - Evidence provenance: observed. - Local verification: reproduced. - Reproduction completeness: complete for the authored static fixture; real-producer occurrence is not established. ### Typed outcome or recovery expected Do not publish a concrete type name derived solely from an unresolved bound placeholder. A successful result preserving that unknown or an explicit typed refusal can restore the contract. Repeating the three bound inputs must not select different resolved type names from their raw words; the unbound array control must retain its valid rendering. No implementation is prescribed. ### Analysis or suspected cause Source-derived: [native type-reference resolution](https://github.com/morluto/rea/blob/3e7e102a518b9112a2a8aa0590ae6f6951fd0635/src/il2cpp/Il2cppBinaryFile.ts#L174-L190) obtains each type-table address using `readPointer`. That helper checks ELF unresolved slots but returns the Mach-O raw word without consulting classic dyld fixups. `readSlot` consults the fixup decoder, but type-reference resolution does not call that boundary. This explains why the independently observed `unknown` slot still yields a concrete return-type name. No fix was implemented. ### Additional context The closest related report is closed [#6747](https://github.com/morluto/rea/issues/6747), which concerns method-pointer states, addresses and seeds. Its restored behavior is present: the dyld-aware slot reader now returns `unknown`. This report concerns type-table pointer dereferencing and incorrect type names, a different failing signal that remains after that method-slot repair. Searches across open and closed issues used `IL2CPP dyld in:body`, `IL2CPP "type" "bind" in:body` and `Il2CppType in:body`, as well as the IL2CPP title inventory; no matching type-table report was found. NOT_ENOUGH_INFO: no last-known-good revision, real-producer sample, or supported identity-preserving workaround was established. Omitting `--binary` retains metadata names but omits the requested native types. All verification finished before this draft was written. Prepared with AI assistance. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The public CLI publishes conflicting concrete return-type names from two unresolved bound placeholders in the authored fixture, despite the production decoder recognizing the binding. The demonstrated effect is bounded; real-producer prevalence and other input classes are unknown. - Workaround status: unverified - Workaround basis: Metadata-only inspection omits the requested native type facts. No identity-preserving recovery of those facts was tested; editing the placeholder changes artifact identity and does not resolve the bound 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#4217
No description provided.