Appearance
aowlspt_il2cpp_gates.h
Source: abi/aowlspt_il2cpp_gates.h — 484 lines, 15 file-scope functions.
What this header owns
Reproduced verbatim from the header's own banner comment — these notes are frequently the only written record of why the subsystem is shaped the way it is.
text
aowlspt_il2cpp_gates.h -- calling the TOKEN-GATED il2cpp exports correctly.
WHY THIS EXISTS
===============
For months this project recorded "IL2CPP reflection is dead": a handle came
back non-NULL, the nil check passed, and the first dereference killed the
client. `docs/IL2CPP_EXPORTS.md` established the real mechanism and this
header acts on it.
40 of the 241 `il2cpp_*` exports take an EXTRA TRAILING ARGUMENT that stock
IL2CPP does not have: a pointer to 32 bytes. The callee `memcmp`s it before
doing any work. On mismatch it does NOT return NULL and does NOT abort -- it
tail-calls a trap that lazily seeds a per-thread MT19937-64 and returns a
UNIFORM RANDOM NON-ZERO uint64.
That is the whole bug. We were calling these with the stock signature, so the
token argument was whatever junk happened to be in the register, the compare
failed every time, and we got a random number that looked like a pointer.
MEASURED, NOT ASSUMED
=====================
Everything below was verified OFFLINE against
`D:\Games\Tarkov\GameAssembly.dll` (123,891,024 bytes) by mapping it into a
scratch process -- never the running game -- and calling one export against a
STAGED receiver buffer we allocated and filled ourselves, so the correct
answer was known independently of the call.
il2cpp_method_get_param_count(staged, correct_token) -> 7, 7, 7, 7, 7
il2cpp_method_get_param_count(staged, corrupt_token) -> 8551E516, 23DBF07,
DA01ECF3, ...
il2cpp_method_get_param_count(staged, NULL) -> 1AD46590, E65AE66A,
F4201CA4 <- what
we always did
The staged buffer had 7 written at +0x52, which is the byte this export
actually reads (`movzx eax, byte ptr [rbx+0x52]`). Identical-with-token and
all-different-without-token is the falsifiable pair; "it returned something"
proves nothing here, because the trap also returns something.
THE TWO GATE FLAVOURS
=====================
STATIC (22 exports). The expected 32 bytes are a constant in `.rdata`. The
caller passes a pointer to them. Read them FROM THE MAPPED IMAGE at runtime
(`base + token_rva`); never hardcode an absolute address, and never copy the
bytes into source, because they are build-specific.
NONCE (18 exports). The callee reads a 64-bit nonce from a per-API TLS slot,
ZEROES the slot (single use), calls a per-API derivation function, and
memcmps its result against the caller's 32 bytes. The nonce is produced by
the exported, NON-STOCK `il2cpp_nonce(apiId)` @ 0x5B3D60.
The apiId -> slot mapping is not in any symbol; it was recovered mechanically
by calling `il2cpp_nonce(id)` for every id and observing which TLS slot
became non-zero. `il2cpp_nonce` also RETURNS the value it stores, so the
caller never has to read TLS at all.
Verified end to end on `il2cpp_field_get_offset` (apiId 0x58, slot 0x198,
derivation 0x5B93B0) against a staged FieldInfo with 42 at +0x18:
nonce = il2cpp_nonce(0x58); tok = deriv(nonce);
il2cpp_field_get_offset(staged, tok) -> 42, 42, 42, 42, 42
il2cpp_field_get_offset(staged, junk) (no nonce obtained)
-> D960FE44EADCA5E9, 43C9CE620DA369B5, F3999C704C1CD6CA
HONEST LIMIT on the nonce flavour: perturbing the nonce passed to the
derivation function did not change its return pointer, so it is NOT measured
that the derivation is keyed by the nonce value. What IS measured is that the
slot must be non-empty -- calling without first obtaining a nonce traps. Treat
"the derivation is nonce-keyed" as INCONCLUSIVE, not as fact.
SAFETY
======
A wrong token is not a soft failure. It yields a plausible random pointer,
and the caller's first dereference kills the client. So this layer:
- is flag-gated and DEFAULT OFF (`il2cppGates`);
- byte-verifies each export's prologue against the STARTUP SNAPSHOT before
the first call, never against live memory (a verify run after another
feature patched the function reads trampoline bytes and self-rejects);
- VirtualQuery-checks the module base, the token bytes and the receiver;
- runs the call under ONE `aowl_p_p_seh` and never nests one inside it;
- caps iteration everywhere;
- self-disables after AOWL_GATE_MAX_FAULTS faults;
- refuses loudly rather than calling with an unverified token.
A NOTE ON NIL CHECKS, per CLAUDE.md 9b. The two existing nil checks in shared
code -- `ensureCall`'s `if c == nil: return false` and `readCString`'s
`if p == nil` -- are checks that CANNOT FAIL against this trap, because the
trap never returns zero. Callers must stop treating non-NULL as success and
use `aowl_gate_call_ok()`, which reports whether the GATE was satisfied,
separately from whatever the export returned.Constants
AOWLSPT_IL2CPP_GATES_HAOWL_GATE_APIID_COUNTAOWL_GATE_ARITYAOWL_GATE_BAD_ARGAOWL_GATE_BAD_TOKENAOWL_GATE_DISABLEDAOWL_GATE_FAULTEDAOWL_GATE_MAX_FAULTSAOWL_GATE_NOT_GATEDAOWL_GATE_NO_DERIVAOWL_GATE_NO_MODULEAOWL_GATE_NO_NONCEAOWL_GATE_OKAOWL_GATE_PROLOGUEAOWL_GATE_SIG_BYTESAOWL_GATE_TOKEN_BYTESAOWL_GATE_UNKNOWN
Functions
| Signature | Line |
|---|---|
char aowl_gate_why(int32_t w) | 120 |
int32_t aowl_gate_bind_module(void) | 187 |
void aowl_gate_snapshot(void) | 211 |
void aowl_gate_set_enabled(int32_t on) | 226 |
int32_t aowl_gate_find(const char* name) | 228 |
int32_t aowl_gate_is_gated(const char* name) | 238 |
int32_t aowl_gate_is_known_export(const char* name) | 241 |
int32_t aowl_gate_apiid_for_slot(unsigned slot, unsigned* out) | 261 |
void aowl_gate_token(int32_t row, int32_t* why) | 274 |
int32_t aowl_gate_can_arm(int32_t row, int32_t* why) | 331 |
int32_t aowl_gate_nonce_outstanding_count(void) | 357 |
void aowl_gate_invoke_thunk(void* p) | 373 |
int32_t aowl_gate_call(const char* name, void** argv, int32_t argc, aowl_gate_call_t* out) | 388 |
int32_t aowl_gate_call_ok(const aowl_gate_call_t* c) | 475 |
void aowl_gate_note_fault(void) | 479 |

