Fla(g)ppy Bird
Points 1000
Solves 1
Flag? Just get 13371337 points!
This Unity/IL2CPP Android challenge ships an APK and a custom libil2cpp.so. The attacker’s usual route—dumping global-metadata.dat and running il2cpp-dumper—has been booby-trapped: the metadata header is “shuffled,” and the body is RC4-like encrypted starting at offset 0x108 (decimal 264). Instead of fighting the static metadata, we pivot to dynamic IL2CPP APIs with Frida, bypass the library’s image-integrity check, and then abuse the game logic to force the score past a magic threshold (0xCC07C8 = 13371336) by writing a consistent (score, encryptedScore) pair. Below is the reverse path, the two bypass methods, and the final scoring hook.
const void *__fastcall sub_2BBE58(
int a1,
int a2,
int a3,
int a4,
int a5,
int a6,
int a7,
int a8,
int a9,
int a10,
int a11,
char a12,
int a13,
__int64 a14,
void *a15,
__int64 a16,
__int64 a17)
{
const void *result; // x0
size_t v18; // x20
const void *v19; // x19
void *v20; // x0
int v21; // w8
const char **v22; // x26
int *v23; // x23
const char *v24; // x19
size_t v25; // x0
size_t v26; // x20
__int64 v27; // x28
__int64 v28; // x27
char *v29; // x21
char *v30; // x8
char *v31; // x0
__int64 v32; // x19
const char *v33; // x1
int *v34; // x8
__int64 v35; // x10
__int64 v36; // x9
__int64 v37; // x20
unsigned int v38; // w21
const char *v39; // x19
unsigned __int64 i; // x22
const char **v41; // x23
__int64 v42; // x8
__int64 v43; // x9
__int64 v44; // x19
__int64 v45; // x20
__int64 j; // x21
__int64 v47; // x11
__int64 v48; // x0
__int64 v49; // x10
__int64 v50; // x19
__int64 v51; // x21
__int64 v52; // x23
unsigned int v53; // w8
unsigned __int64 k; // x26
__int64 v55; // x27
unsigned int v56; // w28
__int64 v57; // x8
int v58; // w8
__int64 v59; // x8
__int64 v60; // x19
__int64 v61; // x20
int *v62; // x9
signed __int64 v63; // x8
int dest; // [xsp+0h] [xbp-A0h]
int v65; // [xsp+8h] [xbp-98h]
char v66; // [xsp+10h] [xbp-90h]
int *v67; // [xsp+10h] [xbp-90h]
__int128 v68; // [xsp+18h] [xbp-88h] BYREF
void *ptr; // [xsp+28h] [xbp-78h]
void *v70; // [xsp+30h] [xbp-70h] BYREF
char *v71; // [xsp+38h] [xbp-68h]
void *v72; // [xsp+40h] [xbp-60h]
size_t size; // [xsp+48h] [xbp-58h] BYREF
size = 0;
result = (const void *)sub_2BC5C8(
(int)"global-metadata.dat",
(int)&size,
a3,
a4,
a5,
a6,
a7,
a8,
dest,
v65,
v66,
v68,
*((void **)&v68 + 1),
(char)ptr,
(int)v70,
v71);
if ( !result )
return result;
v18 = size;
v19 = result;
v20 = malloc(size);
qword_A00A38 = (__int64)v20;
if ( !v20 )
{
sub_309D3C(v19);
return 0;
}
memcpy(v20, v19, v18);
sub_309D3C(v19);
sub_2BBD4C(qword_A00A38 + 264, v18 - 264, qword_A00A38, 264, qword_A00A38 + 264, &size);
sub_301A9C(*(unsigned int *)(qword_A00A30 + 4));
qword_A00A40 = qword_A00A38;
qword_A00A48 = sub_309CF8(*(int *)(qword_A00A28 + 48), 8);
qword_A00A50 = sub_309CF8(*(int *)(qword_A00A40 + 236) / 0x5CuLL, 8);
qword_A00A58 = sub_309CF8((unsigned __int64)*(int *)(qword_A00A40 + 176) >> 5, 8);
qword_A00A60 = sub_309CF8(*(int *)(qword_A00A28 + 64), 8);
dword_A00A68 = *(int *)(qword_A00A40 + 28) / 0x28uLL;
qword_A00A70 = sub_309CF8(dword_A00A68, 80);
v21 = *(_DWORD *)(qword_A00A40 + 156);
dword_A00A78 = v21 >> 6;
qword_A00A80 = sub_309CF8((__int64)v21 >> 6, 88);
if ( dword_A00A68 < 1 )
goto LABEL_37;
v22 = (const char **)qword_A00A70;
v23 = (int *)(qword_A00A38 + *(int *)(qword_A00A40 + 64));
v24 = (const char *)(qword_A00A38 + *(int *)(qword_A00A40 + 92) + *v23);
*(_QWORD *)qword_A00A70 = v24;
v68 = 0u;
ptr = 0;
v25 = strlen(v24);
if ( v25 > 0xFFFFFFFFFFFFFFEFLL )
LABEL_30:
sub_29AB68(&v68);
v26 = v25;
v27 = 0;
v28 = (__int64)v22;
v67 = v23;
while ( 1 )
{
if ( v26 >= 0x17 )
{
v29 = (char *)operator new((v26 + 16) & 0xFFFFFFFFFFFFFFF0LL);
*((_QWORD *)&v68 + 1) = v26;
ptr = v29;
*(_QWORD *)&v68 = (v26 + 16) & 0xFFFFFFFFFFFFFFF0LL | 1;
LABEL_10:
memcpy(v29, v24, v26);
goto LABEL_11;
}
v29 = (char *)&v68 + 1;
LOBYTE(v68) = 2 * v26;
if ( v26 )
goto LABEL_10;
LABEL_11:
v29[v26] = 0;
sub_30A230(&v70, &v68);
if ( (v68 & 1) != 0 )
operator delete(ptr);
if ( ((unsigned __int8)v70 & 1) != 0 )
v30 = v71;
else
v30 = (char *)((unsigned __int64)(unsigned __int8)v70 >> 1);
v31 = (char *)sub_309CF8(v30 + 1, 1);
v32 = v28 + 80 * v27;
*(_QWORD *)(v32 + 8) = v31;
if ( ((unsigned __int8)v70 & 1) != 0 )
v33 = (const char *)v72;
else
v33 = (char *)&v70 + 1;
strcpy(v31, v33);
v34 = &v23[10 * v27];
v35 = v34[1];
v36 = qword_A00A80 + 88 * v35;
if ( (_DWORD)v35 == -1 )
v36 = 0;
*(_QWORD *)(v32 + 16) = v36;
*(_DWORD *)(v32 + 24) = v34[2];
*(_DWORD *)(v32 + 28) = v34[3];
*(_DWORD *)(v32 + 32) = v34[4];
*(_DWORD *)(v32 + 36) = v34[5];
*(_DWORD *)(v32 + 48) = v34[6];
*(_DWORD *)(v32 + 72) = v34[7];
*(_DWORD *)(v32 + 40) = v34[8];
*(_DWORD *)(v32 + 44) = v34[9];
v37 = qword_A00A20;
v38 = *(_DWORD *)(qword_A00A20 + 120);
if ( v38 )
{
v39 = *v22;
for ( i = 0; i < v38; ++i )
{
v41 = *(const char ***)(*(_QWORD *)(v37 + 128) + 8 * i);
if ( !strcmp(v39, *v41) )
{
*(_QWORD *)(v28 + 80 * v27 + 64) = v41;
v38 = *(_DWORD *)(v37 + 120);
}
}
}
*(_BYTE *)(v28 + 80 * v27 + 76) = 0;
if ( ((unsigned __int8)v70 & 1) != 0 )
operator delete(v72);
v42 = qword_A00A38;
v43 = qword_A00A40;
v23 = v67;
if ( ++v27 >= dword_A00A68 )
break;
v28 = qword_A00A70;
v24 = (const char *)(qword_A00A38 + *(int *)(qword_A00A40 + 92) + v67[10 * v27]);
v22 = (const char **)(qword_A00A70 + 80 * v27);
*v22 = v24;
v68 = 0u;
ptr = 0;
v26 = strlen(v24);
if ( v26 > 0xFFFFFFFFFFFFFFEFLL )
goto LABEL_30;
}
if ( dword_A00A68 >= 1 )
{
v44 = 0;
v45 = 1;
for ( j = qword_A00A38 + *(int *)(qword_A00A40 + 8) + 28; ; j += 64 )
{
v47 = *(int *)(j - 28);
v48 = qword_A00A80 + v44;
v49 = qword_A00A70 + 80 * v47;
if ( (_DWORD)v47 == -1 )
v49 = 0;
*(_QWORD *)v48 = v49;
*(_DWORD *)(v48 + 8) = *(_DWORD *)(j - 24);
*(_DWORD *)(v48 + 12) = *(_DWORD *)(j - 20);
*(_DWORD *)(v48 + 16) = *(_DWORD *)(j - 16);
*(_QWORD *)(v48 + 24) = v42 + *(int *)(v43 + 92) + *(int *)(j - 12);
*(_QWORD *)(v48 + 32) = v42 + *(int *)(v43 + 92) + *(int *)(j - 8);
*(_QWORD *)(v48 + 40) = v42 + *(int *)(v43 + 92) + *(int *)(j - 4);
*(_DWORD *)(v48 + 48) = *(_DWORD *)j;
*(_DWORD *)(v48 + 52) = *(_DWORD *)(j + 4);
*(_DWORD *)(v48 + 56) = *(_DWORD *)(j + 8);
*(_DWORD *)(v48 + 60) = *(_DWORD *)(j + 12);
*(_DWORD *)(v48 + 64) = *(_DWORD *)(j + 16);
*(_DWORD *)(v48 + 68) = *(_DWORD *)(j + 20);
*(_DWORD *)(v48 + 72) = *(_DWORD *)(j + 24);
*(_QWORD *)(v48 + 76) = *(_QWORD *)(j + 28);
sub_2AF518();
if ( v45 >= dword_A00A68 )
break;
v42 = qword_A00A38;
v43 = qword_A00A40;
v44 += 88;
++v45;
}
}
LABEL_37:
sub_2BC7A4();
v71 = 0;
v72 = 0;
v70 = 0;
if ( dword_A00A78 >= 1 )
{
v50 = 0;
v51 = qword_A00A38 + *(int *)(qword_A00A40 + 56);
do
{
v52 = *(_QWORD *)(qword_A00A80 + 88 * v50);
v53 = *(_DWORD *)(v52 + 28);
if ( v53 )
{
for ( k = 0; k < v53; ++k )
{
v55 = v51 + 92LL * *(int *)(v52 + 24) + 92 * k;
if ( *(_WORD *)(v55 + 68) )
{
v56 = 0;
do
{
v57 = *(int *)(qword_A00A40 + 116);
DWORD2(v68) = v56 + *(_DWORD *)(v55 + 40);
v58 = *(_DWORD *)(qword_A00A38 + v57 + 32LL * SDWORD2(v68) + 20) & 0xFFFFFF;
if ( v58 )
{
*(_QWORD *)&v68 = *(_QWORD *)(*(_QWORD *)(*(_QWORD *)(v52 + 64) + 16LL) + 8LL * (unsigned int)(v58 - 1));
if ( (_QWORD)v68 )
{
if ( v71 == v72 )
{
sub_2DFBA4(&v70, &v68);
}
else
{
*(_OWORD *)v71 = v68;
v71 += 16;
}
}
}
else
{
*(_QWORD *)&v68 = 0;
}
++v56;
}
while ( v56 < *(unsigned __int16 *)(v55 + 68) );
v53 = *(_DWORD *)(v52 + 28);
}
}
}
++v50;
}
while ( v50 < dword_A00A78 );
}
v59 = qword_A00A28;
if ( *(int *)(qword_A00A28 + 32) >= 1 )
{
v60 = 0;
v61 = 0;
do
{
v62 = (int *)(*(_QWORD *)(v59 + 40) + v60);
DWORD2(v68) = *(_DWORD *)(*(_QWORD *)(v59 + 72) + 12LL * *v62);
*(_QWORD *)&v68 = *(_QWORD *)(*(_QWORD *)(qword_A00A20 + 24) + 8LL * v62[1]);
if ( v71 == v72 )
{
sub_2DFBA4(&v70, &v68);
v59 = qword_A00A28;
}
else
{
*(_OWORD *)v71 = v68;
v71 += 16;
}
++v61;
v60 += 16;
}
while ( v61 < *(int *)(v59 + 32) );
}
sub_313E6C(&v70);
if ( v70 )
{
v63 = (_BYTE *)v70 - v71 - 16;
do
v63 += 16LL;
while ( v63 );
v71 = (char *)v70;
operator delete(v70);
}
return &dword_0 + 1;
}sub_2BBE58 reads global-metadata.dat into heap (qword_A00A38), then calls sub_2BBD4C to decrypt everything after the first 264 bytes.
char *__fastcall sub_2BBD4C(char *result, __int64 a2, __int64 a3, unsigned __int64 a4, _BYTE *a5, _QWORD *a6)
{
int8x16_t v6; // q0
__int64 v7; // x8
int8x16_t v8; // q1
unsigned __int64 v9; // x8
unsigned int v10; // w10
int v11; // w11
int v12; // w10
int v13; // w12
unsigned int v14; // w10
int v15; // w8
int v16; // w11
int v17; // w8
int v18; // w11
int v19; // w10
int v20; // w12
char v21; // t1
_BYTE v22[256]; // [xsp+0h] [xbp-110h]
if ( a6 )
*a6 = a2;
v6 = (int8x16_t)xmmword_7CA7C0;
v7 = 0;
v8.n128_u64[0] = 0x1010101010101010LL;
v8.n128_u64[1] = 0x1010101010101010LL;
do
{
*(int8x16_t *)&v22[v7] = v6;
v7 += 16;
v6 = vaddq_s8(v6, v8);
}
while ( v7 != 256 );
v9 = 0;
v10 = 0;
do
{
v11 = (unsigned __int8)v22[v9];
v12 = v10 + v11 + *(unsigned __int8 *)(a3 + v9 % a4);
v13 = v12 + 255;
if ( v12 >= 0 )
v13 = v12;
v10 = v12 - (v13 & 0xFFFFFF00);
v22[v9++] = v22[v10];
v22[v10] = v11;
}
while ( v9 != 256 );
if ( a2 )
{
v14 = 0;
v15 = 0;
do
{
v16 = v15 + 1;
if ( v15 + 1 >= 0 )
v17 = v15 + 1;
else
v17 = v15 + 256;
v15 = v16 - (v17 & 0xFFFFFF00);
v18 = (unsigned __int8)v22[v15];
v19 = v14 + v18;
v20 = v19 + 255;
if ( v19 >= 0 )
v20 = v19;
v14 = v19 - (v20 & 0xFFFFFF00);
--a2;
v22[v15] = v22[v14];
v22[v14] = v18;
v21 = *result++;
*a5++ = v22[(unsigned __int8)(v22[v15] + v18)] ^ v21;
}
while ( a2 );
}
return result;
}This is effectively RC4. The KSA fills an S-box (v22[256]) using a key that cycles through a4 bytes starting at a3 (i.e., the first 264 bytes of the file). The PRGA then XOR-decrypts a2 bytes from result into a5. In sub_2BBE58, result and a5 point to the same body (qword_A00A38 + 264), so the decrypt happens in place. Takeaway: header stays shuffled; body becomes readable to this process only.
Method 1 — Don’t call the booby-trapped API
__int64 __fastcall il2cpp_assembly_get_image(__int64 a1)
{
unsigned __int64 v1; // x30
__int64 v3; // x0
_QWORD v5[2]; // [xsp+0h] [xbp-20h] BYREF
char v6; // [xsp+1Ch] [xbp-4h] BYREF
if ( qword_9FFEA0 - 1 < v1 && qword_9FFEA8 >= v1 )
return sub_2AF330(a1);
if ( qword_9FFEB0 - 1 < v1 && qword_9FFEB8 >= v1 )
return sub_2AF330(a1);
v6 = 0;
v5[0] = v1;
v5[1] = &v6;
v3 = dl_iterate_phdr((int (*)(struct dl_phdr_info *, size_t, void *))sub_293698, v5);
if ( v6 )
return sub_2AF330(a1);
else
return sub_292148(v3);
}il2cpp_assembly_get_image acts as an anti-hook/anti-introspection gate. It captures the LR (return address) and checks whether the caller lives inside one of two hard-coded address ranges; otherwise it walks ELF program headers via dl_iterate_phdr to decide if the caller belongs to an “approved” module. If not, it refuses to return the true Il2CppImage* and instead calls:
_BYTE *sub_292148()
{
unsigned int v0; // w0
__int64 i; // x19
_BYTE v3[32]; // [xsp+0h] [xbp-30h] BYREF
v0 = time(0);
srand(v0);
for ( i = 0; i != 32; ++i )
v3[i] = rand();
return v3;
}So untrusted callers get junk instead of the image pointer.
import Java from "frida-java-bridge";
const PTR_SIZE = Process.pointerSize;
const SIZE_T = PTR_SIZE === 8 ? "ulong" : "uint";
const MASK64 = ptr("0x00ffffffffffffff"); // strip top-byte tag (AArch64 TBI/MTE)
const untag = (p: NativePointer) =>
(Process.arch === "arm64") ? p.and(MASK64) : p;
function tryReadUtf8String(p: NativePointer): string | null {
if (p.isNull()) {
return "";
}
try {
return p.readUtf8String();
} catch {
return "";
}
}
function looksPrintableId(s: string) {
// tolerate C# identifiers + dots/backticks for generics
return s.length > 0 && s.length < 256 && /^[\w.`$<>+,-]+$/.test(s);
}
// Fast fallback if export fails: scan MethodInfo head for a likely name ptr
function readMethodNameViaStruct(mi: NativePointer): string {
const base = untag(mi);
// scan first 0x80 bytes for a pointer to a short printable string
for (let off = 0; off < 0x80; off += PTR_SIZE) {
const p = base.add(off).readPointer();
const s = tryReadUtf8String(p) || "";
if (looksPrintableId(s) && /[A-Za-z_]/.test(s[0])) {
return s;
}
}
return "";
}
Java.perform(() => {
console.log("[*] Java:", Java.available);
setTimeout(() => {
Java.enumerateLoadedClasses({
onMatch: (className) => {},
onComplete: () => {
const m = Process.getModuleByName("libil2cpp.so");
console.log("[*] libil2cpp.so base address:", m.base);
const il2cpp_thread_attach = new NativeFunction(
m.findExportByName("il2cpp_thread_attach")!,
"pointer",
["pointer"]
);
const il2cpp_domain_get = new NativeFunction(
m.findExportByName("il2cpp_domain_get")!,
"pointer",
[]
);
const il2cpp_domain_get_assemblies = new NativeFunction(
m.findExportByName("il2cpp_domain_get_assemblies")!,
"pointer",
["pointer", "pointer"]
);
const il2cpp_assembly_get_image = new NativeFunction(
m.findExportByName("il2cpp_assembly_get_image")!,
"pointer",
["pointer"]
);
const il2cpp_image_get_name = new NativeFunction(
m.findExportByName("il2cpp_image_get_name")!,
"pointer",
["pointer"]
);
const il2cpp_image_get_class_count = new NativeFunction(
m.findExportByName("il2cpp_image_get_class_count")!,
SIZE_T,
["pointer"]
);
const il2cpp_image_get_class = new NativeFunction(
m.findExportByName("il2cpp_image_get_class")!,
"pointer",
["pointer", SIZE_T]
);
const il2cpp_class_get_type = new NativeFunction(
m.findExportByName("il2cpp_class_get_type")!,
"pointer",
["pointer"]
);
const il2cpp_type_get_name = new NativeFunction(
m.findExportByName("il2cpp_type_get_name")!,
"pointer",
["pointer"]
);
const il2cpp_class_get_methods = new NativeFunction(
m.findExportByName("il2cpp_class_get_methods")!, "pointer", ["pointer", "pointer"]
);
const il2cpp_method_get_name = new NativeFunction(
m.findExportByName("il2cpp_method_get_name")!, "pointer", ["pointer"]
);
const il2cpp_method_get_param_count = new NativeFunction(
m.findExportByName("il2cpp_method_get_param_count")!, "uint", ["pointer"]
);
setTimeout(() => {
try {
const domain = il2cpp_domain_get();
if (!domain) {
return console.error("[!] il2cpp_domain_get() returned null");
}
console.log("[*] il2cpp_domain_get():", domain);
il2cpp_thread_attach(domain);
const assembliesSizePtr = Memory.alloc(PTR_SIZE);
assembliesSizePtr.writeU64(0);
const assembliesPtr = il2cpp_domain_get_assemblies(domain, assembliesSizePtr);
const assembliesCount = Number(assembliesSizePtr.readU64());
console.log(`[*] ${assembliesCount} assemblies loaded`);
for (let i = 0; i < assembliesCount; i++) {
const assembly = assembliesPtr.add(i * PTR_SIZE).readPointer();
console.log(` [*] Assembly ${i}:`, assembly);
/* const image = il2cpp_assembly_get_image(assembly);
console.log(` image:`, image);
const namePtr = il2cpp_image_get_name(image);
console.log(` namePtr:`, namePtr);
const name = namePtr.isNull() ? "" : namePtr.readByteArray(32);
console.log(` name:`, name); */
const image = untag(assembly).readPointer();
const pName = untag(image).readPointer();
const pNameNoExt = untag(image).add(PTR_SIZE).readPointer();
const pAsmName = untag(image).add(2 * PTR_SIZE).readPointer();
const name = tryReadUtf8String(pName);
const nameNoExt = tryReadUtf8String(pNameNoExt);
const asmName = tryReadUtf8String(pAsmName);
console.log(
` [*] Assembly ${i}: ${assembly}\n` +
` image: ${image}\n` +
` name: ${name}\n` +
` nameNoExt: ${nameNoExt}\n` +
` assemblyName: ${asmName}`
);
if (name !== "Assembly-CSharp.dll") {
continue;
}
const classCount = il2cpp_image_get_class_count(image);
console.log(` classCount:`, classCount);
for (let j = 0; j < classCount; j++) {
const klass = il2cpp_image_get_class(image, j);
const pName = untag(klass).add(2 * PTR_SIZE).readPointer();
const name = tryReadUtf8String(pName);
console.log(` [*] Class ${j}: ${klass} - ${name}`);
// if (name !== "GameManager") {
// continue;
// }
const iterPtr = Memory.alloc(PTR_SIZE);
iterPtr.writePointer(NULL);
while (true) {
const methodsPtr = il2cpp_class_get_methods(untag(klass), iterPtr);
if (methodsPtr.isNull()) {
break;
}
const methodNamePtr = il2cpp_method_get_name(untag(methodsPtr));
const methodName = tryReadUtf8String(methodNamePtr);
const methodNameViaStruct = readMethodNameViaStruct(methodsPtr);
const paramCount = il2cpp_method_get_param_count(untag(methodsPtr));
const VA = untag(methodsPtr).readPointer();
const RVA = VA.sub(untag(m.base));
console.log(` - Method: ${VA} (RVA ${untag(RVA)}) ${methodNameViaStruct} (params: ${paramCount})`);
}
}
}
} catch (e) {
return console.error("[!] Failed:", e);
}
}, 1500);
}
});
}, 1000);
});Instead of ever calling the guarded il2cpp_assembly_get_image, we walk the Il2CppAssembly and Il2CppImage layouts directly in memory. On this binary, the first pointer in Il2CppAssembly is the image pointer, and the first three pointers of Il2CppImage are name strings. Because this is ARM64 with Top-Byte-Ignore/MTE tagging, we mask addresses with 0x00ffffffffffffff via untag() before dereferencing. We then enumerate classes/methods using other IL2CPP exports, or even by fishing strings from the structs when helper exports are inconvenient.
Method 2 — Make the booby-trap bless us
__int64 __fastcall il2cpp_assembly_get_image(__int64 a1)Same guard as above. The shortcut is to hook dl_iterate_phdr and force the “seen” byte to 1 so the call always returns the real image.
import "frida-il2cpp-bridge";
import Java from "frida-java-bridge";
Interceptor.attach(Module.getGlobalExportByName("dl_iterate_phdr"), {
onEnter(args) {
this.cb = args[0];
this.data = args[1];
try {
const seenPtr = this.data.add(Process.pointerSize).readPointer();
// console.log('[*] seenPtr =', seenPtr);
if (!seenPtr.isNull()) {
seenPtr.writeU8(1);
// console.log('[*] forced seen=1');
}
} catch (e) {
console.error(e);
}
},
onLeave(retval) {}
});
Java.perform(() => {
console.log("[*] Java:", Java.available);
setTimeout(() => {
Java.enumerateLoadedClasses({
onMatch: (className) => {},
onComplete: () => {
Il2Cpp.perform(() => {
console.log(`Hello, Unity ${Il2Cpp.unityVersion}`);
Il2Cpp.domain.assembly("Assembly-CSharp").image.classes.forEach(klass => {
console.log(`[*] ${klass.name} (${klass.methods.length} methods)`);
klass.methods.forEach(method => {
console.log(` [*] ${method.name} (${method.parameters.length} params) (VA: ${method.virtualAddress}) (RVA: ${method.relativeVirtualAddress})`);
});
});
});
}
});
}, 1000);
});We hook the glibc enumerator, nudge the callback’s seen output, and then it’s safe to use the ergonomic frida-il2cpp-bridge wrappers because il2cpp_assembly_get_image now cooperates.
Getting 13371337 points
With either method we get a clean list, trimmed highlights:
CryptoUtils
EncryptInt RVA 0x6d44f4
DecryptInt RVA 0x6d45d4
...
GameManager
get_score RVA 0x6d501c // LDR W0, [X0,#0x44]
set_score RVA 0x6d5024
get_encryptedScore RVA 0x6d502c // LDR W0, [X0,#0x48]
set_encryptedScore RVA 0x6d5034
IncreaseScore RVA 0x6d5460The GameManager has a score property at offset 0x44 and an “encryptedScore” at 0x48. The IncreaseScore method (RVA 0x6D5460) is the only place that writes to these fields, so let’s look at it in detail.
// Layout guesses from field offsets used here
struct Obj {
uint8_t flag40; // +0x40
uint32_t counter44; // +0x44
uint32_t state48; // +0x48
void* listener28; // +0x28 (vtbl at [listener][0])
};
// Globals / singletons
static uint8_t g_once_flag_9FFAF3; // byte_9FFAF3
static uint32_t *g_init_param_dword_710F00; // via off_9956A8
static void **g_alloc_ctx_qword_A0B1C8; // via off_99AAE8
static void **g_source_ctx_qword_A10A50; // via off_9A4FA8
// vtbl slots (offsets seen):
// listener vtbl: +0x5E0 (fn), +0x5E8 (arg2)
// service vtbl: +0x320 (fn), +0x328 (arg2)
void sub_6D5460(struct Obj *self)
{
// one-time init
if ((g_once_flag_9FFAF3 & 1) == 0) {
sub_3151A8(*g_init_param_dword_710F00);
g_once_flag_9FFAF3 = 1;
}
// bump counter and emit a formatted thingy (sub_492394)
uint32_t cnt = self->counter44 + 1;
self->counter44 = cnt;
uint32_t tmp_on_stack = cnt;
// signature looks like sub_492394(&tmp, 0)
void *fmt = sub_492394(&tmp_on_stack, /*x1*/0);
// If listener present, call listener->fn(this, fmt, listener->arg2)
if (self->listener28) {
void **vtbl = *(void***)self->listener28;
void *fn = *(void **)((uint8_t*)vtbl + 0x5E0);
void *arg2 = *(void **)((uint8_t*)vtbl + 0x5E8);
// BLR X9 with: X0=this->listener28, X1=fmt, X2=arg2
((void (*)(void*,void*,void*))fn)(self->listener28, fmt, arg2);
}
// normalize / advance state vs threshold
const uint32_t THRESH = 0xCC07C8; // W21
uint32_t cur = self->state48;
uint32_t norm = sub_6D45D4(cur);
if (norm <= THRESH) {
// step state via table map then re-normalize
self->state48 = sub_6D44F4(norm + 1);
norm = sub_6D45D4(self->state48);
}
// flag if counter != current normalized value
if (self->counter44 != norm)
self->flag40 = 1;
// Below runs only after norm > THRESH
if (norm > THRESH) {
// allocate ~0x40 bytes from ctx, then bind to a source
void *alloc_ctx = *g_alloc_ctx_qword_A0B1C8;
void *blob = sub_3151B8(alloc_ctx, /*size*/0x40);
void *source_ctx = *g_source_ctx_qword_A10A50;
blob = sub_536844(blob, source_ctx, /*x2*/0);
if (!blob) goto cleanup;
// blob layout: [0x18]=length (W), [0x20..]=data
uint32_t len = *(uint32_t *)((uint8_t*)blob + 0x18);
if (len >= 1) {
uint8_t *p = (uint8_t*)blob + 0x20;
for (uint32_t i = 0; i < len; i++)
p[i] ^= 0xA0; // XOR decode
}
// get a service/singleton and submit blob
void *svc = sub_574830(0);
if (!svc) goto cleanup;
void **svtbl = *(void***)svc;
void *svc_fn = *(void **)((uint8_t*)svtbl + 0x320);
void *svc_a2 = *(void **)((uint8_t*)svtbl + 0x328);
// returns a handle H:
void *H = ((void *(*)(void*,void*,void*))svc_fn)(svc, blob, svc_a2);
// ---- compute size/selector and CALL sub_6D4CBC ----
// W1 = 0x100; X0 = sub_6D45D4(self->state48)
uint32_t sel = sub_6D45D4(self->state48);
void *sz = sub_6D4EC8(sel, /*0x100*/ 0x100);
// X0=H, X1=sz
void *result = sub_6D4CBC(H, sz);
// If listener exists: listener->fn(listener, result, listener->arg2)
if (self->listener28) {
void **lvt = *(void***)self->listener28;
void *lfn = *(void **)((uint8_t*)lvt + 0x5E0);
void *la2 = *(void **)((uint8_t*)lvt + 0x5E8);
((void (*)(void*,void*,void*))lfn)(self->listener28, result, la2);
}
}
cleanup:
// both early-outs funnel here
sub_315270(); // likely leave/cleanup/log
}The decompiled IncreaseScore shows a normalized state (via sub_6D45D4) compared to a threshold:
THRESH = 0xCC07C8(13371336).- If
sub_6D45D4(state48)≤ THRESH, the function advances the state usingsub_6D45D4(norm + 1)and loops naturally. - Once
norm > THRESH, it decodes a small blob (XOR 0xA0), calls into a service, and ultimately lands in the “reward” branch (flag path). - There’s also a mismatch guard: if
counter44 != norm, it setsflag40and you don’t get the prize.
So, we simply write a coherent pair:
score = 13371337encryptedScore = sub_6D45D4(13371337)
and let a single IncreaseScore tick do the rest.
import Java from "frida-java-bridge";
Java.perform(() => {
console.log("Java:", Java.available);
setTimeout(() => {
Java.enumerateLoadedClasses({
onMatch: (className) => { },
onComplete: () => {
const m = Process.getModuleByName('libil2cpp.so');
console.log("[*] libil2cpp.so base address:", m.base);
const SCORE = 13371337;
const fnPtr = m.base.add(0x6d5024); // set_score
const fn = new NativeFunction(fnPtr, 'void', ['pointer', 'int']);
const fn2Ptr = m.base.add(0x6d5034); // set_encryptedScore
const fn2 = new NativeFunction(fn2Ptr, 'void', ['pointer', 'int']);
const fn3Ptr = m.base.add(0x6D44F4); // Encrypt?
const fn3 = new NativeFunction(fn3Ptr, 'int', ['int']);
Interceptor.attach(m.base.add(0x6d5460), {
onEnter(args) {
console.log('IncreaseScore called');
fn(args[0], SCORE);
console.log(`Calling set_encryptedScore with ${fn3(SCORE)}`);
fn2(args[0], Number(fn3(SCORE)));
},
onLeave(retval) { }
});
}
});
}, 1000);
});We resolve RVAs against libil2cpp.so base and build three NativeFunctions: the two setters and sub_6D45D4. We hook IncreaseScore so the game’s own loop calls us at the right time with a valid this pointer. Inside onEnter, we first write the clear score for UI happiness and then compute the matching ciphertext by calling the game’s own sub_6D45D4. Keeping (score, encryptedScore) consistent is crucial; otherwise later checks flip a “mismatch” flag at +0x40 and avoid the prize branch.
INTECHFEST{}