[GH-ISSUE #6871] [Bug] IL2CPP registration search skips valid records with zero-filled trailing fields #4209

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

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 public inspect-il2cpp-metadata --binary ... --json CLI refuses a synthetic thin arm64 Mach-O with metadata-registration-not-found when the final 16 bytes of its complete Il2CppMetadataRegistration occupy declared zero-fill memory. A variant with the final eight bytes in zero-fill also fails. A control with the same zero-valued fields in file-backed storage succeeds and reports the expected method address and void return type.

Both failing variants and the successful control were each run twice, with the same result. The metadata-only control succeeds. No executable or Unity runtime was launched.

Reproduction

From a checkout with the compiled runtime and locked dependencies already prepared, use Node.js 24.22.1. Save the following complete fixture generator and CLI runner as probe.mjs in a fresh directory made with mktemp -d /tmp/agent-work.XXXXXX, then run node "$WORK/probe.mjs" "$PWD" from the checkout, setting WORK to that directory. It creates every input inline and isolates CLI state. No repository test helper, rebuild, installed REA, or external artifact is required.

import fs from 'node:fs';
import path from 'node:path';
import {spawnSync} from 'node:child_process';
const root=path.dirname(new URL(import.meta.url).pathname);
const node=process.execPath;
const repo=process.argv[2] ?? process.cwd();
const names=['stringLiterals','stringLiteralData','strings','events','properties','methods','parameterDefaultValues','fieldDefaultValues','fieldAndParameterDefaultValueData','fieldMarshaledSizes','parameters','fields','genericParameters','genericParameterConstraints','genericContainers','nestedTypes','interfaces','vtableMethods','interfaceOffsets','typeDefinitions','images','assemblies','fieldRefs','referencedAssemblies','attributeData','attributeDataRanges','unresolvedIndirectCallParameterTypes','unresolvedIndirectCallParameterRanges','windowsRuntimeTypeNames','windowsRuntimeStrings','exportedTypeDefinitions'];
const strings=Buffer.from('\0App.dll\0Demo\0Test\0Run\0');
const method=Buffer.alloc(32);method.writeInt32LE(19,0);method.writeInt32LE(0,4);method.writeInt32LE(0,8);method.writeInt32LE(-1,12);method.writeInt32LE(-1,16);method.writeUInt32LE(0x06000001,20);
const type=Buffer.alloc(88);type.writeInt32LE(14,0);type.writeInt32LE(9,4);type.writeInt32LE(0,8);type.writeInt32LE(-1,12);type.writeInt32LE(-1,16);type.writeInt32LE(-1,20);type.writeInt32LE(-1,24);for(let i=0;i<8;i++) type.writeInt32LE(i===1?0:-1,32+i*4);type.writeUInt16LE(1,64);type.writeUInt32LE(0x02000001,84);
const image=Buffer.alloc(40);image.writeInt32LE(1,0);image.writeInt32LE(0,4);image.writeInt32LE(0,8);image.writeUInt32LE(1,12);image.writeInt32LE(-1,16);image.writeInt32LE(-1,24);image.writeUInt32LE(1,28);image.writeInt32LE(-1,32);
const sections={strings,methods:method,typeDefinitions:type,images:image};
const header=Buffer.alloc(256);header.writeUInt32LE(0xfab11baf);header.writeInt32LE(29,4);let cursor=256;const body=[];
names.forEach((name,i)=>{const s=sections[name]??Buffer.alloc(0);header.writeInt32LE(cursor,8+i*8);header.writeInt32LE(s.length,12+i*8);body.push(s);cursor+=s.length;});
fs.writeFileSync(path.join(root,'global-metadata.dat'),Buffer.concat([header,...body]));
const BASE=0x100000000n;
function binary(extra){const b=Buffer.alloc(0x870+extra);const ptr=(o,v)=>b.writeBigUInt64LE(v,o);const p=(o,t)=>ptr(o,BASE+BigInt(t));b.writeUInt32LE(0xfeedfacf,0);b.writeUInt32LE(0x0100000c,4);b.writeUInt32LE(6,12);b.writeUInt32LE(1,16);b.writeUInt32LE(72,20);b.writeUInt32LE(0x19,32);b.writeUInt32LE(72,36);b.write('__TEXT',40);ptr(56,BASE);ptr(64,0x1000n);ptr(72,0n);ptr(80,BigInt(b.length));b.writeUInt32LE(5,88);b.writeUInt32LE(5,92);b.write('App.dll\0',0x100);b.writeUInt32LE(0xd65f03c0,0x200);p(0x300,0x200);p(0x400,0x100);b.writeUInt32LE(1,0x408);p(0x410,0x300);p(0x500,0x400);ptr(0x600,1n);p(0x608,0x500);p(0x700,0x710);b.writeUInt32LE(0x00010000,0x718);ptr(0x830,1n);p(0x838,0x700);ptr(0x850,1n);p(0x858,0x740);ptr(0x860,1n);p(0x868,0x750);return b;}
for(const extra of [0,8,16])fs.writeFileSync(path.join(root,`assembly-${extra}.dylib`),binary(extra));
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'};
for(const extra of [null,0,8,16]){const args=[path.join(repo,'scripts/rea.mjs'),'inspect-il2cpp-metadata',path.join(root,'global-metadata.dat'),'--json'];if(extra!==null)args.push('--binary',path.join(root,`assembly-${extra}.dylib`));const r=spawnSync(node,args,{cwd:repo,env,encoding:'utf8'});const label=extra===null?'metadata-only':`tail-${extra}`;fs.writeFileSync(path.join(root,label+'.stdout.json'),r.stdout);fs.writeFileSync(path.join(root,label+'.stderr.txt'),r.stderr);const o=JSON.parse(r.stdout);const n=o.normalized_result;console.log(JSON.stringify({case:label,exit:r.status,code:o.code,reason:o.details?.issues?.[0]?.message.split(': ').slice(-2).join(': '),types_field:n?.binary?.types_field,types_count:n?.binary?.types_count,method_addresses:n?.method_addresses,return_types:n?.return_types,stderr:r.stderr}));}

Expected behavior

The zero-fill and file-backed variants should resolve the same native registration facts. In each variant the complete 128-byte structure exists in mapped virtual memory, all nonzero fields are file-backed, and the remaining fields are zero. The reader should not require zero-valued trailing fields to have physical file bytes.

The native-linking guide describes finding the registration by its type-definition counts and mapped type table. The production reader in src/il2cpp/Il2cppBinaryImage.ts already interprets declared zero-fill pointer slots as zero. The Unity 2022.3.8f1 header shows the final metadataUsagesCount and metadataUsages fields. This report does not assume that the structure is only 112 bytes: those final 16 bytes are present as virtual zeros.

Impact and scope

Bounded valid-input native-linking refusal: the CLI returns no complete native table for the two synthetic mappings even though the registration words and referenced type/method facts match the successful control. Metadata-only inspection still works, but omits the requested native facts. No real Unity-produced binary, live loader behavior, MCP transport, other metadata version, or PE/ELF variant was tested. Occurrence in real producer output is unknown. No security claim or data loss is involved.

REA version

Source commit 8d8d85ba22463b80715a1af1fdb51acc9b5d760b (package version 6.7.0).

Runtime and platform

Node.js v24.22.1; Linux 7.2.8-9-omarchy; x86_64. Hopper, Ghidra, IDA and Unity were not used.

Target and artifact context

A 439-byte authored version-29 metadata file with one assembly image, type and method; three authored 64-bit little-endian arm64 Mach-O dylibs of 2,160, 2,168 and 2,176 bytes. Each Mach-O declares a 4,096-byte virtual segment. The registration starts at file/segment offset 0x800; only physical backing of its trailing zeros changes. This is an offline format interpretation test, not a loadable-game or real-engine compatibility claim.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

The second complete CLI round prints:

{"case":"metadata-only","exit":0,"method_addresses":[],"return_types":[],"stderr":""}
{"case":"tail-0","exit":1,"code":"invalid_request","reason":"metadata-registration-not-found: 0 MetadataRegistration candidates count 1 type definitions","stderr":""}
{"case":"tail-8","exit":1,"code":"invalid_request","reason":"metadata-registration-not-found: 0 MetadataRegistration candidates count 1 type definitions","stderr":""}
{"case":"tail-16","exit":0,"types_field":"0x100000830","types_count":1,"method_addresses":[{"state":"code","address":"0x100000200","rva":"0x200"}],"return_types":[{"type":0,"name":"void"}],"stderr":""}

All four commands had empty stderr. Full CLI JSON preserves partial source evidence on the failures; it does not publish a complete native inventory.

A separate production-boundary observation read all 16 registration words through openImageSteps and readSlot from src/il2cpp/Il2cppBinaryImage.ts. For all three inputs it returned exactly:

0x0 0x0 0x0 0x0 0x0 0x0 0x1 0x100000700
0x0 0x0 0x1 0x100000740 0x1 0x100000750 0x0 0x0

The observed file mappings end at 0x100000870, 0x100000878 and 0x100000880; each is followed by declared zero-fill through 0x100001000. The complete registration occupies 0x100000800 through 0x100000880. This boundary observation establishes equal interpreted words, separately from the public CLI failure.

Typed outcome or recovery expected

Successful complete IL2CPP metadata Evidence with types_field: "0x100000830", one binary type, method address 0x100000200, RVA 0x200, and return type void, as already observed in the file-backed control.

Analysis or suspected cause

Source-derived: findMetadataRegistration in src/il2cpp/Il2cppBinaryRegistration.ts scans candidates only while at + 128 <= mapping.size (line 339), where mapping.size is the file-backed extent. Consequently it skips both failing registrations before evaluating their counts or pointers. The bytes it actually reads end with the pointer at offsets 104–111; the last 16 structure bytes are not read by this search. The zero-fill-aware pointer reader correctly observes the complete virtual record in the separate boundary check.

The smallest tested reduction keeps one image, type, method and native primitive type. Adding eight file bytes is insufficient; adding 16 is sufficient. No fix was implemented.

Additional context

Open-issue snapshot searches covered IL2CPP, MetadataRegistration, registration boundaries and zero-fill. No report of this failure was found. Related open reports #6224 (type-reference view names filter), #5403 (short-header classification), and #5752 (universal-slice documentation) concern different behavior. Open feature #3607 covers broad Unity support rather than this implemented-reader failure. Open and closed issue searches also found no duplicate. Prepared with AI assistance.

NOT_ENOUGH_INFO: no last-known-good revision or real-producer occurrence was established. All reproductions and minimization completed before this draft was written; none was rerun afterward.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The public CLI consistently refuses two bounded synthetic binary mappings while decoding their otherwise equivalent file-backed control; this blocks the requested native facts for those mappings. Real producer frequency and broader reach are unknown.
  • Workaround status: unverified
  • Workaround basis: Metadata-only inspection succeeds but omits native results, and the padded control changes the artifact. No identity-preserving recovery workflow for an affected real binary was tested.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6871 ### 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 public `inspect-il2cpp-metadata --binary ... --json` CLI refuses a synthetic thin arm64 Mach-O with `metadata-registration-not-found` when the final 16 bytes of its complete `Il2CppMetadataRegistration` occupy declared zero-fill memory. A variant with the final eight bytes in zero-fill also fails. A control with the same zero-valued fields in file-backed storage succeeds and reports the expected method address and `void` return type. Both failing variants and the successful control were each run twice, with the same result. The metadata-only control succeeds. No executable or Unity runtime was launched. ### Reproduction From a checkout with the compiled runtime and locked dependencies already prepared, use Node.js 24.22.1. Save the following complete fixture generator and CLI runner as `probe.mjs` in a fresh directory made with `mktemp -d /tmp/agent-work.XXXXXX`, then run `node "$WORK/probe.mjs" "$PWD"` from the checkout, setting `WORK` to that directory. It creates every input inline and isolates CLI state. No repository test helper, rebuild, installed REA, or external artifact is required. ```js import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; const root=path.dirname(new URL(import.meta.url).pathname); const node=process.execPath; const repo=process.argv[2] ?? process.cwd(); const names=['stringLiterals','stringLiteralData','strings','events','properties','methods','parameterDefaultValues','fieldDefaultValues','fieldAndParameterDefaultValueData','fieldMarshaledSizes','parameters','fields','genericParameters','genericParameterConstraints','genericContainers','nestedTypes','interfaces','vtableMethods','interfaceOffsets','typeDefinitions','images','assemblies','fieldRefs','referencedAssemblies','attributeData','attributeDataRanges','unresolvedIndirectCallParameterTypes','unresolvedIndirectCallParameterRanges','windowsRuntimeTypeNames','windowsRuntimeStrings','exportedTypeDefinitions']; const strings=Buffer.from('\0App.dll\0Demo\0Test\0Run\0'); const method=Buffer.alloc(32);method.writeInt32LE(19,0);method.writeInt32LE(0,4);method.writeInt32LE(0,8);method.writeInt32LE(-1,12);method.writeInt32LE(-1,16);method.writeUInt32LE(0x06000001,20); const type=Buffer.alloc(88);type.writeInt32LE(14,0);type.writeInt32LE(9,4);type.writeInt32LE(0,8);type.writeInt32LE(-1,12);type.writeInt32LE(-1,16);type.writeInt32LE(-1,20);type.writeInt32LE(-1,24);for(let i=0;i<8;i++) type.writeInt32LE(i===1?0:-1,32+i*4);type.writeUInt16LE(1,64);type.writeUInt32LE(0x02000001,84); const image=Buffer.alloc(40);image.writeInt32LE(1,0);image.writeInt32LE(0,4);image.writeInt32LE(0,8);image.writeUInt32LE(1,12);image.writeInt32LE(-1,16);image.writeInt32LE(-1,24);image.writeUInt32LE(1,28);image.writeInt32LE(-1,32); const sections={strings,methods:method,typeDefinitions:type,images:image}; const header=Buffer.alloc(256);header.writeUInt32LE(0xfab11baf);header.writeInt32LE(29,4);let cursor=256;const body=[]; names.forEach((name,i)=>{const s=sections[name]??Buffer.alloc(0);header.writeInt32LE(cursor,8+i*8);header.writeInt32LE(s.length,12+i*8);body.push(s);cursor+=s.length;}); fs.writeFileSync(path.join(root,'global-metadata.dat'),Buffer.concat([header,...body])); const BASE=0x100000000n; function binary(extra){const b=Buffer.alloc(0x870+extra);const ptr=(o,v)=>b.writeBigUInt64LE(v,o);const p=(o,t)=>ptr(o,BASE+BigInt(t));b.writeUInt32LE(0xfeedfacf,0);b.writeUInt32LE(0x0100000c,4);b.writeUInt32LE(6,12);b.writeUInt32LE(1,16);b.writeUInt32LE(72,20);b.writeUInt32LE(0x19,32);b.writeUInt32LE(72,36);b.write('__TEXT',40);ptr(56,BASE);ptr(64,0x1000n);ptr(72,0n);ptr(80,BigInt(b.length));b.writeUInt32LE(5,88);b.writeUInt32LE(5,92);b.write('App.dll\0',0x100);b.writeUInt32LE(0xd65f03c0,0x200);p(0x300,0x200);p(0x400,0x100);b.writeUInt32LE(1,0x408);p(0x410,0x300);p(0x500,0x400);ptr(0x600,1n);p(0x608,0x500);p(0x700,0x710);b.writeUInt32LE(0x00010000,0x718);ptr(0x830,1n);p(0x838,0x700);ptr(0x850,1n);p(0x858,0x740);ptr(0x860,1n);p(0x868,0x750);return b;} for(const extra of [0,8,16])fs.writeFileSync(path.join(root,`assembly-${extra}.dylib`),binary(extra)); 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'}; for(const extra of [null,0,8,16]){const args=[path.join(repo,'scripts/rea.mjs'),'inspect-il2cpp-metadata',path.join(root,'global-metadata.dat'),'--json'];if(extra!==null)args.push('--binary',path.join(root,`assembly-${extra}.dylib`));const r=spawnSync(node,args,{cwd:repo,env,encoding:'utf8'});const label=extra===null?'metadata-only':`tail-${extra}`;fs.writeFileSync(path.join(root,label+'.stdout.json'),r.stdout);fs.writeFileSync(path.join(root,label+'.stderr.txt'),r.stderr);const o=JSON.parse(r.stdout);const n=o.normalized_result;console.log(JSON.stringify({case:label,exit:r.status,code:o.code,reason:o.details?.issues?.[0]?.message.split(': ').slice(-2).join(': '),types_field:n?.binary?.types_field,types_count:n?.binary?.types_count,method_addresses:n?.method_addresses,return_types:n?.return_types,stderr:r.stderr}));} ``` ### Expected behavior The zero-fill and file-backed variants should resolve the same native registration facts. In each variant the complete 128-byte structure exists in mapped virtual memory, all nonzero fields are file-backed, and the remaining fields are zero. The reader should not require zero-valued trailing fields to have physical file bytes. The [native-linking guide](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md#native-method-addresses) describes finding the registration by its type-definition counts and mapped type table. The production reader in `src/il2cpp/Il2cppBinaryImage.ts` already interprets declared zero-fill pointer slots as zero. The [Unity 2022.3.8f1 header](https://github.com/MlgmXyysd/libil2cpp/blob/master/libil2cpp/Unity_2022.3/2022.3.8f1/il2cpp-class-internals.h#L617-L637) shows the final `metadataUsagesCount` and `metadataUsages` fields. This report does not assume that the structure is only 112 bytes: those final 16 bytes are present as virtual zeros. ### Impact and scope Bounded valid-input native-linking refusal: the CLI returns no complete native table for the two synthetic mappings even though the registration words and referenced type/method facts match the successful control. Metadata-only inspection still works, but omits the requested native facts. No real Unity-produced binary, live loader behavior, MCP transport, other metadata version, or PE/ELF variant was tested. Occurrence in real producer output is unknown. No security claim or data loss is involved. ### REA version Source commit `8d8d85ba22463b80715a1af1fdb51acc9b5d760b` (package version 6.7.0). ### Runtime and platform Node.js v24.22.1; Linux 7.2.8-9-omarchy; x86_64. Hopper, Ghidra, IDA and Unity were not used. ### Target and artifact context A 439-byte authored version-29 metadata file with one assembly image, type and method; three authored 64-bit little-endian arm64 Mach-O dylibs of 2,160, 2,168 and 2,176 bytes. Each Mach-O declares a 4,096-byte virtual segment. The registration starts at file/segment offset `0x800`; only physical backing of its trailing zeros changes. This is an offline format interpretation test, not a loadable-game or real-engine compatibility claim. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence The second complete CLI round prints: ```jsonl {"case":"metadata-only","exit":0,"method_addresses":[],"return_types":[],"stderr":""} {"case":"tail-0","exit":1,"code":"invalid_request","reason":"metadata-registration-not-found: 0 MetadataRegistration candidates count 1 type definitions","stderr":""} {"case":"tail-8","exit":1,"code":"invalid_request","reason":"metadata-registration-not-found: 0 MetadataRegistration candidates count 1 type definitions","stderr":""} {"case":"tail-16","exit":0,"types_field":"0x100000830","types_count":1,"method_addresses":[{"state":"code","address":"0x100000200","rva":"0x200"}],"return_types":[{"type":0,"name":"void"}],"stderr":""} ``` All four commands had empty stderr. Full CLI JSON preserves partial source evidence on the failures; it does not publish a complete native inventory. A separate production-boundary observation read all 16 registration words through `openImageSteps` and `readSlot` from `src/il2cpp/Il2cppBinaryImage.ts`. For all three inputs it returned exactly: ```text 0x0 0x0 0x0 0x0 0x0 0x0 0x1 0x100000700 0x0 0x0 0x1 0x100000740 0x1 0x100000750 0x0 0x0 ``` The observed file mappings end at `0x100000870`, `0x100000878` and `0x100000880`; each is followed by declared zero-fill through `0x100001000`. The complete registration occupies `0x100000800` through `0x100000880`. This boundary observation establishes equal interpreted words, separately from the public CLI failure. ### Typed outcome or recovery expected Successful complete IL2CPP metadata Evidence with `types_field: "0x100000830"`, one binary type, method address `0x100000200`, RVA `0x200`, and return type `void`, as already observed in the file-backed control. ### Analysis or suspected cause Source-derived: `findMetadataRegistration` in `src/il2cpp/Il2cppBinaryRegistration.ts` scans candidates only while `at + 128 <= mapping.size` (line 339), where `mapping.size` is the file-backed extent. Consequently it skips both failing registrations before evaluating their counts or pointers. The bytes it actually reads end with the pointer at offsets 104–111; the last 16 structure bytes are not read by this search. The zero-fill-aware pointer reader correctly observes the complete virtual record in the separate boundary check. The smallest tested reduction keeps one image, type, method and native primitive type. Adding eight file bytes is insufficient; adding 16 is sufficient. No fix was implemented. ### Additional context Open-issue snapshot searches covered IL2CPP, `MetadataRegistration`, registration boundaries and zero-fill. No report of this failure was found. Related open reports #6224 (type-reference view names filter), #5403 (short-header classification), and #5752 (universal-slice documentation) concern different behavior. Open feature #3607 covers broad Unity support rather than this implemented-reader failure. Open and closed issue searches also found no duplicate. Prepared with AI assistance. NOT_ENOUGH_INFO: no last-known-good revision or real-producer occurrence was established. All reproductions and minimization completed before this draft was written; none was rerun afterward. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The public CLI consistently refuses two bounded synthetic binary mappings while decoding their otherwise equivalent file-backed control; this blocks the requested native facts for those mappings. Real producer frequency and broader reach are unknown. - Workaround status: unverified - Workaround basis: Metadata-only inspection succeeds but omits native results, and the padded control changes the artifact. No identity-preserving recovery workflow for an affected real binary was tested.
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#4209
No description provided.