A reversal of the GameSafe ANO anticheat stack inside the Garena Free Fire APK: the com.ano.gshell loader, ROT-5-obfuscated Java shim, and the libanogs/libanort/libGGP native trio.
This is the first entry in my Reversals series, a native focused reverse engineering write up of the anticheat stack shipped in Garena Free Fire (com.dts.freefireth, build 1.132.1, arm64-v8a). It comes from the dex in the Ghidra DB plus the real native libraries pulled from the same APK set (libanogs.so, libanort.so, libGGP.so). To be clear about scope: this is the APK reversed, not a live server or traffic analysis.
Almost everything here is byte verified against that exact build. The few things I only reconstructed from public RE of the same engine family (Arena Breakout / CODM adjacent builds) are marked recon; those offsets shift per build.
Also note: offsets and strings are this build’s. They will move in the next Free Fire update, so treat them as per build evidence, not a stable cross version map.
The stack at a glanceh2
| Layer | Component | Evidence |
|---|---|---|
| Java shim | com.gamesafe.ano.AnoSdk (loads anogs), AnoInfoPublisher, AnoJavaMethod, TouchListenerProxy, MainThreadDispatcher2, gshell loader com.ano.gshell.AnoApplication | dex |
| Native core | libanogs.so 5.5 MB, ANO SDK, VM, checks, telemetry | native |
| Native runtime | libanort.so 1.7 MB, JNI glue for com/ano/gshell/*, syscall interposition (tp_syscall_imp), hook tables (g_acf_array / g_aco_array) | native |
| Garena layer | libGGP.so 9.4 MB, packed/encrypted loader (0 exports, custom entry), ~8 MB obfuscated blob | native |
| Antibot (net) | DataDome SDK + WebAuthn / Play Integrity attestation | dex |
The short version, and it shapes everything below: the client is a reporter, not an enforcer. Detection verdicts stream up to a server, get scored cumulatively, and come back as delayed bans. Patching a local check so that nothing visibly happens buys you nothing.
Headline findingsh2
All eleven are verified in this build.
- Raw syscall bridge.
libanort.so:tp_syscall_imp@0x146940is a genericsvc #0trampoline (syscall number inx8, args reshuffled, Linux errno normalized). It’s a direct kernel call, so libc PLT/GOT interposition can’t touch it. - Opaque obfuscated exports.
AnoSDKIoctl(0x1c6e40, 1264 B) andAnoSDKForExport(0x1c8fbc, 108 B) are real handlers wrapped in opaque predicates and constant hiding XOR pairs.AnoSDKForExportends with abintoAnoSDKInit’s region, cross function instruction splicing. - String vault is encrypted. No plaintext
su/frida/zygisk/blacklist strings in rodata. A running XOR decoder atlibanogs.so:0x1d0628decrypts them at runtime. Byte verified recoveries:frida-agent-32.so,libfrida-gadget.so,/proc/self/mounts. Stored as ciphertext, absent as plaintext. - Mrpcs/worker channel is real.
%s/ano_tmp/mpmc.dat(0x9423d) plusmrpcs_common_data_not_match!(0x91340) confirm the on disk MPMC queue used for worker IPC in this engine family. - The VM core. Build machine RTTI path names the VM “MVM”.
.textis ~3.4 MB and the sensitive logic runs as VM bytecode, not arm64. - ISA/SoC adaptive runtime. The first
.init_arrayctors readro.archandgetauxval(AT_HWCAP), then pick atomic implementations (ldaddalvsldaxr/stlxr) and SoC specific behavior (exynos9810). - Command tables built by constructors.
0x1cbd00and siblings resolve ~40 opaque IDs through an ID→fn bank into global handler arrays, the SDK’s dispatch tables, the same family asg_acf_array/g_aco_arrayinlibanort.so. - JNI lazy arm.
JNI_OnLoad(0x1cb85c) checks a global init flag ([0x513f38]); if it’s already set it takes an indirectblrfast path, otherwise it boots the core, registers natives, then runs engine init. - Antidebug scanner clusters verified.
waitpid@0x257f8c,PTRACE_ATTACH@0x258b40, and a 3× retry verify then report loop at0x258a80.getuid/geteuidparity @0x25d7e0. Guarded syscall micro thunks aroundmincore.__system_property_get@0x2b1d94. - Dex: ANO strings are native anchored. Strings like
dec_tss_info:%s,gp6ioctl,frida detection error,, isRooted=exist in the dex string tables, but they have zeroconst stringreferences in this build’s code. They’re native consumed or R8 dead. Dex presence here is not Java side usage. - JNI dispatch, resolved 229/229.
libanort.so:0x13bce8lazy builds an RB tree of{CRC32(name), JNINativeInterface slot×8}from inlinemov/movkconstants;ACE_CRC32is standard zlib CRC32 (crc32("FindClass")=0x26616EC1) and0x4CCF1A0FisCRC32("GetVersion"), not a key. All 229 VM→JNI entries statically resolved; bytecode reaches JNI via__ff_96(tree) +__ff_97(call through), imported 11× each by the gp7 modules.
Bootstrap & lifecycleh2
ELF bootstraph3
.init_array (64 entries @ 0x50e7e8) and .fini_array (2 entries @ 0x50e7d8) are file zeroed; the loader fills them from .rela.dyn R_AARCH64_RELATIVE addends. .fini_array points at the first two functions of .text (0x1b12c0, 0x1b12d8): the engine registers its own teardown as the very first code in the binary.
I stared at those empty arrays for a while before I got it. The trick is that the loader fills them via relocations. A cookie cutter static pass will misread this library.
Ctor[0] & Ctor[1], ISA/SoC feature gateh3
0x4fc440 (ctor[0], paciasp protected) calls getauxval(AT_HWCAP), reads ro.arch, compares against "exynos9810", and writes a feature byte to .bss [0x5693f8]. Ghidra shows the ATOMIC gate precisely. Bit 8 of AT_HWCAP (HWCAP_ATOMICS) decides whether the atomic fast path is even eligible; then comes the SoC carve out:
void _INIT_0(void) { uint hwcap; int iVar2;
hwcap = getauxval(0x10); // AT_HWCAP if (((hwcap >> 8) & 1) == 0) { // HWCAP_ATOMICS (bit 8) g_feature_byte = 0; // no armv8.1 atomics → fall back return; } iVar2 = __system_property_get("ro.arch", buf); if (0 < iVar2) { iVar2 = strncmp(buf, "exynos9810", 10); // [0x8f209] g_feature_byte = iVar2 != 0; // exynos9810 → 0 (special path) return; } g_feature_byte = 1; return;}0x4fca74 (ctor[1], idempotent) reads ro.arch, then getauxval(AT_HWCAP) and getauxval(AT_HWCAP2), builds a feature word, and feeds it to the bitmask dispatch tree at 0x4fc4d8 (tbnz chains across bits 0x3e, 7, 4, 0x1b, 9, …), resolving per feature handlers:
void _INIT_1(void) { ... if (g_idempotent_guard != 0) return; // run once latch [0x569400] iVar1 = __system_property_get("ro.arch", buf); if ((iVar1 < 1) || (strncmp(buf, "exynos9810", 10) != 0)) { // non-exynos uVar2 = getauxval(0x10); // AT_HWCAP uStack_60 = getauxval(0x1a); // AT_HWCAP2 ... build feature word ... FUN_005fc4d8(uVar2 | 0x4000000000000000, &feat); // bitmask dispatch tree 0x4fc4d8 } return;}Downstream the engine picks ldaddal (armv8.1 atomics) vs ldaxr/stlxr retry loops, and enables exynos9810 specific behavior. This is runtime variation machinery, not a security check. Still, it’s the first code that runs and it colors everything after.
Constructor building the command tablesh3
0x1cbd00 (and siblings like 0x1d05c0) resolves opaque IDs through an ID→fn bank and populates global handler arrays:
0x1cbd00: mov w0,#0xb9a; bl 0x4ddf80 ; resolve ID>fn adrp x19,#0x54f000+0xc80 ; global table anchor str x0,[x19]; bl 0x2c5a04 ; vtable slot constructor adr x8,#0x1ca418; stp x0,x8,[x19,#8] ; handler fn ptr → slot ; … repeats for 0xba1/0xbaf/0xbbf/0xbcf/0xbe0/0xc44/0xbf5/0xc3b/0xc05/0xc15 …ID → handler bank (0x4d9c3c/0x4ddf80/0x4de760/…) resolves through opaque indices. 0x1d05c0 fills another table (IDs 0xaea6...0xaff9). Together these are the SDK’s dispatch/command tables.
JNI_OnLoad, the lazy armh3
libanogs.so:0x1cb85c:
001cb878: bl 0x2c5ca4 ; engine core boot001cb884: adrp x8,#0x513000; add x8,x8,#0xf38 ; global INIT flag [0x513f38]001cb88c: ldr w8,[x8]001cb890: cbz w8,#0x1cb924 ; already armed?001cb8ac: mov x0,x19; mov x1,x20; blr x8 ; FAST PATH: indirect call into armed slot001cb8c0: bl 0x222798 ; slow path: build natives table001cb8cc: mov w0,#0xc2a3; bl 0x4daf48 ; resolve table fn by ID001cb8e0: bl 0x222a34 ; RegisterNatives class callLoading is idempotent and lazy. First load boots and registers; subsequent loads take the indirect blr fast path. Engine core, natives table, and gshell loader (the libanort JNI_OnLoad binds com/ano/gshell/AnoApplication) all arm here.
The decompiler gives the full two table registration, and names the tables by address: RegisterNatives(class, 0x514250, 3) and RegisterNatives(class, 0x54fc80, 0xb=11). Both JNI method banks are live data in the binary, not runtime strings:
undefined4 JNI_OnLoad(long *vm, undefined8 version) { ... uVar5 = engine_core_boot(); engine_init(uVar5, vm); if (_DAT_00613f38 != 0) goto armed; // global INIT flag ... clazz = (*env->FindClass)(env, name); // slot +0x30 ... ExceptionCheck (slot +0x720) failed → env->DeleteLocalRef (slot +0x88) (*env->RegisterNatives)(env, clazz, 0x614250, 3); // table A: 3 natives ... clazz = (*env->FindClass)(env, name2); // second bank (*env->RegisterNatives)(env, clazz, 0x64fc80, 0xb); // table B: 11 nativesarmed: if ((_DAT_0064fc70 == 0) || (*(code **)(_DAT_0064fc70 + 0x70) == 0)) { // armed slot check ... slow init: resolve + RegisterNatives helper, engine run ... } else { return (*(_DAT_0064fc70 + 0x70))(vm, version); // FAST PATH: indirect call }}Note the indirect slot _DAT_0064fc70 + 0x70 is the armed fast path; _DAT_00613f38 is the init done flag. Both globals are 0x54fc70/0x513f38 as file offsets, the exactly byte verified asm above being the same code Ghidra decompiled.
Dex side launch flowh3
com.dts.freefireth.FFApplicationsubclassescom.ano.gshell.AnoApplication(the loader; its superclass isandroid.app.Application). There is nocom.gamesafe.ano.AnoApplicationclass in this build. Everything runs out ofattachBaseContext.AnoSdk.<clinit>→System.loadLibrary("anogs")→ nativeJNI_OnLoad.AnoInfoPublisherdaemon thread starts the report drain loop;AnoSdk#ioctl("dec_tss_info:...")→AnoSDKIoctlfor symptom/config reads.- Game status channels (
AnoSdkSetGameStatus,ChkSetGameStatus{...}) run continuously.
The gshell loader boot chain (dex)h3
FFApplication.attachBaseContext(ctx) runs the whole chain before the VM touches the lifecycle:
createApplication() └─ FFApplication.attachBaseContext(ctx) ├─ com.ano.gshell.AnoApplication.attachBaseContext(ctx) │ └─ attachBaseContextShell(ctx) [0x89cde0] │ ├─ read ApplicationInfo{packageName, nativeLibraryDir, sourceDir} │ ├─ getFilesDir() │ ├─ if SDK_INT >= 21: reflectively pull "splitSourceDirs" │ │ (Field.setAccessible(true) + Field.get) │ └─ native AnoApplication.initialize(ctx, filesDir, sourceDir, │ nativeLibraryDir, splitDirs) │ └─ returns 0 → throw "initialize failed." └─ a.l(ctx) → MultiDex install0x89cde2 invoke-virtual {this}, getApplicationInfo()0x89cdea iget-object v1, v0, ApplicationInfo.packageName0x89cdee iget-object v3, v0, ApplicationInfo.nativeLibraryDir0x89cdf2 iget-object v4, v0, ApplicationInfo.sourceDir0x89cdf6 invoke-virtual {this}, getFilesDir() → v20x89cdfe sget v6, Build$VERSION.SDK_INT0x89ce02 const/16 v7, 0x15 ; 210x89ce06 if-lt v6, v7, +0x320x89ce12 const-string v7, "splitSourceDirs"0x89ce16 invoke-virtual {v6, v7}, Class.getDeclaredField(...)0x89ce20 invoke-virtual {v6, v7}, Field.setAccessible(true)0x89ce26 invoke-virtual {v6, v0}, Field.get(applicationInfo)0x89ce42 invoke-static/range {v0..v5}, AnoApplication.initialize(...)0x89ce4a if-eqz v0, throw "initialize failed."The splitSourceDirs reflective read is a fingerprint: ANO wants to inspect every apk in the split set, not just the base. A zero init status throws a hard RuntimeException; if the native fails to come up, the app refuses to start. Also note libanort.so is loaded from the loader class’s own <clinit>, before any of this: get in early, watch the clocks.
The Java shim: two call paths into nativeh3
Both paths go through AnoSdk’s JNI registered natives (no Java_* export symbols; JNI_OnLoad + RegisterNatives does it).
1. AnoSdk.ioctl(String) → getsdkantidata (command / answer). The cmd string is wrapped in an AnoIOCtlResult and the native fills .response. decAnoInfo(String) just formats dec_tss_info:%s and calls it.
2. AnoInfoPublisher → property pipes (report drain). The pipe keys are ROT-5 encoded (Caesar +5 over a-zA-Z), resolved via a.a() before going to native:
dgx_jkzi_kdkz --rot5--> ilc_open_pipedgx_mzxq_kdkz --rot5--> ilc_recv_pipedgx_xgjnz_kdkz --rot5--> ilc_close_pipeThe drain loop reads type|payload reports (0x7C delimiter), dispatch types ANO_INFO_TYPE_DETECT_RESULT / ANO_INFO_TYPE_HEARTBEAT to registered AnoInfoReceivers, Thread.sleep(1000) per tick. AnoJavaMethod.sendCmdEx prefix matches more ROT-5 verb strings (didodvgduz→initialize, ho:→mt:, hnbwjs:→msgbox:, cdyz_hnbwjs:→hide_msgbox:, kjk_rdi:→pop_win:, dia_xg:→inf_cl:), and c.a() dumps the jar banner Jar_ver:7.7.50(2020/11/10). StubFunc.func1()…func10() are empty honeypot JNI looking entry points.
TouchListenerProxy wraps every game View.OnTouchListener: ACTION_DOWN / UP / MOVE / POINTER_DOWN / POINTER_UP are mirrored to a capture routine, and the wrapper can override the return value (setOntouchRetVal) to swallow touch events outright. Input behavior is observable, so a cheat would have to fake this.
DEX pedigree: all of the above lives in classes.dex (DexGuard + ProGuard). classes2.dex is pure R8 desugaring and classes3.dex is game services — zero com.gamesafe references outside classes.dex.
Obfuscation: the antistatic coreh2
The exported functions are real, but the check logic is not arm64. It’s MVM bytecode behind opaque predicates and flattened control flow.
The VM is called MVMh3
The VM only names itself in one place, a single build path string recovered from RTTI style evidence:
libanogs.so:0x91723/Users/bkdevops/tpmobile/workspace/p-74fc7d317ebc4e7c912d0c9ffff18edf/ano/mvm/source/VM/Memory/BopMemoryOperation.cppano/mvm/..., “MVM”.BopMemoryOperation.cppis the block operation (“Bop”) memory op class: the stack machine memory subsystem.- The
/Users/bkdevops/tpmobilebuild machine matches the in house OLLVM class toolchain used by Tencent’s protected SDKs.
The VM bytecode: extracted, decrypted, module by moduleh3
The VM is not a black box in this build. It ships as five bytecode modules embedded in libanort.so, and the whole archive was decrypted and rebuilt here: exact module set, sizes, hashes, module header format, import tables, and in bytecode strings. The five modules are byte identical to the reference engine’s modules (same MD5s): ACE ships the same VM payload across titles; only the surrounding native glue differs per build.
Where it lives. The modules ride inside an a64-format archive body embedded at libanort.so:0x16c998 (the a64.dat equivalent; its 256 byte a64.sig RSA block sits at 0x19092b). The archive body is ciphertext: every byte is XORed with 0x23, then pushed through the SDK’s 256 byte substitution box at libanort.so:0x16c879:
sbox = <bytes @ libanort.so:0x16c879> # 256 byte S-boxplain = bytes(sbox[b ^ 0x23] for b in body) # per byteThe 8 byte header is big endian: magic 0x20220118, then count/size fields. Entry names are XOR protected with the same 36 character alnum key that encodes the layer-2 strings (below), and the entry bodies are packed as raw deflate ZIP streams. Central directory offsets are intentionally garbage, so the local file headers are parsed in order and each entry is decompressed with zlib.decompress(blob, -15).
Module set, decrypted (this build).
| module | size | md5 |
|---|---|---|
timeout_looper.img | 25,360 | 0467bb799c04a5ebb7085a033a66738c |
vm_hb.img | 18,352 | 984970923fc35f44b19fb690b752b53c |
vm_main.img | 265,688 | 9156c4ffc5caeab2a50f005788eeeec7 |
vm_gp7service.img | 53,685 | 6c3fce310abd5aea3b3f5b7170f13364 |
vm_gp7worker.img | 31,331 | 8a8fc16e5e446bd25ffdd55758e0ae04 |
vm_main is the core; vm_gp7service/vm_gp7worker are the gp7 service and worker hosts (the same split that surfaces as the mpmc.dat worker channel); vm_hb is the heartbeat VM; timeout_looper is the watchdog/looper.
.img module header (36 bytes, little endian u32):
[0] magic 0x20220623 [1] res = 0 [2] ver = 1 [3] build = 100[4] v36 = 0 [5..8] NA, NB, NC, NDAfter the header come two directory tables, then the bytecode stream:
- Type A rows, 8 bytes:
{u32 vm_addr, u32 host_off}maps VM address to host offset. - Type B rows:
{u32 va, u32 name_len, name[]}wherename[]is XOR encoded. This is the import/export catalog (host call outs like__ff_12,memset,memcpy). - Then the bytecode, followed by the module’s own string pool.
Import catalogs (Type B), deduplicated. The bytecode imports real host functions by these numbers; the __ff_NN entries are the native/JNI hosts resolved in the JNI section below.
| module | total refs | unique imports | notable |
|---|---|---|---|
vm_main | 1,420 | 143 | __ff_12 ×223, __ff_7 ×187, __ff_5 ×88, memset ×79, __ff_21 ×66 |
vm_gp7service | 225 | 43 | __ff_96 ×11, __ff_97 ×11 (JNI hosts) |
vm_gp7worker | 100 | 24 | __ff_96 ×11, __ff_97 ×11, __ff_170 ×7 |
timeout_looper | 34 | 14 | — |
vm_hb | 15 | 10 | — |
Across all five: 150 unique imports, of which 143 are distinct __ff_NN numbers in the range 1–200 (absent: 10, 15, 23, 24, 27, 31–60, 62, 67, 69, 73, 101–106, 129–131, 138, 142, 143, 148, 152, 164, 174, 189). The __ff_NN namespace is the native import bank of the VM; the resolved JNI vtable below has the __ff_96/__ff_97 payloads.
In bytecode strings are ciphertext too. The VM’s own strings use the layer-2 cipher, a cycling XOR over the 36 character alphabet 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ (decode[c] = c ^ key[i % 36]). Byte verified recoveries:
| stored | decoded |
|---|---|
vX\WwYWDK | FindClass |
WTFpXTED | getClass |
C^GAWPr^J | sourceDir |
WTFzZA | getInt |
WTF}UXS | getName |
WTFvLADVK | getExtras |
WTFq[ZZRYW | getBoolean |
QUVrWA_XV | addAction |
D^aGF\XP | toString |
FPVWF | vaddr |
RTUZZ | begin |
BTAFXA\x0bRJK.0 | result=error |
UIQVDA\x0c | except: |
QAYlSPBh\36. p | apk_get_dertmd5 |
The VM does not dial JNI at bytecode level. It asks the host via __ff_96/__ff_97, which is exactly what gets resolved in the JNI section.
Opaque predicates everywhereh3
AnoSDKForExport (0x1c8fbc):
001c8fc0: mov w8, #0xf4dc; movk w8, #0xf4df, lsl #16 ; w8 = 0xf4dff4dc001c8fc4: mov w9, #0x7506; movk w9, #0xd7a7, lsl #16 ; w9 = 0xd7a77506001c8fd0: cmp w9, w8 ; compare two constants, statically decidable, semantically opaque...001c9024: b #0x1c3740 ; ← branches INTO AnoSDKInit's body (instruction splicing)Against the disassembly, the decompiler still finds the real payload: a 0x31-byte context object, an init that writes a vtable anchor, three operator calls, then teardown:
void AnoSDKForExport(void) { long ctx;
ctx = malloc(0x31); // 49-byte context object if (ctx != 0) { FUN_002c95d0(ctx, 0); // ctor: vtable anchor write [0x54fc76-ish] } FUN_005b975c(); // SDK operator calls (report/operator table) FUN_005b9760(); FUN_005b97a0(); if (ctx != 0) { free(ctx); // teardown return; } return;}The b into AnoSDKInit’s neighborhood and the malloc/free sandwich are both real. The exported “function” is glue that arms the operator layer, not the check logic itself.
AnoSDKIoctl (0x1c6e40) opens the same way, a 1264 byte handler that begins with a carpet of mov/movk constant construction followed by constant vs constant cmps. AnoSDKIoctlOld (0x1c6530) is the 2320 byte variant. libanort.so:unwind_xx_ioctl (0xba8f4) is the same style.
Constant hiding via XOR pairsh3
libanogs.so:0x1d0688 decodes 32 bit immediates as pairwise XOR:
0x4e6611f3 XOR 0x6c8845f3 = 0x22ee54000x4e6611f1 XOR 0x6c8845f3 = 0x22ee5402The obfuscator never stores a real 32 bit constant in place; every immediate is recovered by an XOR pair at runtime. Same mechanism class as the string vault.
String vault decryption, running XOR decoderh3
libanogs.so:0x1d0628:
001d062c: mov w8, #0xe8 ; strb w8, [sp,#0xc] ; key = 0xe8001d063c: cmp w8, w1 ; loop while i < len001d0644: sxtw x9, w8; add x9, x0, x9; ldrb w9,[x9] ; load src[i]001d0654: eor w9, w9, w10 ; dst[i] = src[i] ^ key001d0660: add w10, w10, #1; strb w10,[sp,#0xc] ; key += 1 each byte001d066c: add x10, x2, x10; strb w9,[x10] ; store dst[i]A stream XOR with a rolling key, +1 step per byte. This is what decrypts the vault strings at runtime. strings on the .so only ever sees ciphertext.
The decompiler recovers exactly that (the cleanest function in the whole library):
void vault_decode(long src, uint len, long dst) { byte b; undefined4 i; undefined1 key;
key = 0xe8; // rolling key seed for (i = 0; i < len; i++) { b = *(byte *)(src + (int)i) ^ key; // dst[i] = src[i] ^ key key = key + 1; // key += 1 each byte *(byte *)(dst + (int)i) = b; } return;}The three recoveries below only came from a full sweep, every key value, forward and backward (see artifacts/vault decode-candidates.txt):
| rodata | key@start / step | decoded |
|---|---|---|
0x921ea | 0xb8 +1 | frida-agent-32.so (15 B) |
0x8cca6 | 0xa8 +1 | libfrida-gadget.so (18 B) |
0x90e71 | 0x9d +1 | /proc/self/mounts (17 B) |
The Frida filenames never appear as plaintext anywhere in the binary. strings proves their absence. The module scan blacklist really is stored as ciphertext and only materializes when the decoder runs a scan.
An earlier note citing the printable blob at 0xe785d (rtrhtsu||}g}xxzeb~bbecauk`) as vault ciphertext is corrected: it does not decode under the step-±1 schedule (all 256×2 keys swept). It is printable looking data, but not a vault sample. Only the three strings above are byte verified.
Decoder family census — not one decoder, dozensh3
The vault is not decrypted by a single function. A sweep for the ldrb/eor/strb rolling loop finds 435 loop sites across .text, forming a family of ~20 distinct decoder bodies. Each body embeds a different initial key with the same +1 rolling step, and some variants sit inside the flat CFG dispatcher blocks instead of a straight loop. The cleanest member (libanogs.so:0x28fd90, key 0xFD):
0x28fd90 sub sp, sp, #0x100x28fd94 mov w8, #0xfd ; strb w8,[sp,#0xc] ; key = 0xFD0x28fda0 ldr w8,[sp,#8] ; cmp w8, w1 ; b.hs end ; while i < len0x28fdb4 ldrb w9,[x0, x8] ; src[i]0x28fdb8 ldrb w10,[sp,#0xc] ; eor w9, w9, w10 ; src[i] ^ key0x28fdc0 and w9, w9, #0xff0x28fdc4 sxtb w10, w10 ; add w10, w10, #1 ; key += 10x28fdd8 strb w9,[x2, x8] ; add w8, w8, #1 ; b loopSame contract as the 0xE8 variant in every way: (src, len, dst), key seed in an immediate, +1 step. Byte verified keys observed to date include 0xE8, 0xFD, 0xB8, 0xA8, 0x9D; the three verified vault recoveries (frida frida-agent-32.so/libfrida-gadget.so, /proc/self/mounts) all decode under exactly this schedule. Any attempt at “the decoder” wants the 435-site census, not a single address.
Control flow flatteningh3
libanogs.so:0x4fc4d8 is a bitmask dispatch tree: it tests successive bits of a feature word (tbnz w0,#0x3e, tbnz w0,#7, tbnz w0,#4, …) through dozens of branches into per bit handlers. Same flattening idiom the VM dispatchers use internally.
Every exported handler is the same flattened shape. Ghidra’s decompiler chokes on all of them the identical way: "Removing unreachable block …" walls and "Type propagation algorithm not settling" walls. Each body resolves to a dispatcher over one opaque state variable:
void AnoSDKSetUserInfo(uint param_1, long param_2) { uint *puVar3; bool bVar4; ... uStack_7c = 0x856f059; // state cookie bVar4 = true;code_r0x002c4450: if (bVar4) goto code_r0x002c4454;code_r0x002c4490: do { if ((int)uStack_7c < 0x351fe58) { if ((int)uStack_7c < -0x2e00f03f) goto joined_r0x002c44a4; if (-0x8a76830 < (int)uStack_7c) goto joined_r0x002c4508; break; } ... if (uStack_7c == 0x856f059) { ... stp globals; uStack_7c = 0x4ded232a; // next state } ... FUN_002b6ccc(param_1, 0, param_2, &UNK_0018e6cc); // the one real call ... } while (true);}The uStack_7c cookie walks through pseudo random state IDs; only one real payload call (FUN_002b6ccc) survives in the noise. AnoSDKOnPause (0x1c4ba4), AnoSDKDelReportData (0x1c5a20), AnoSDKGetReportData2/3 (0x1c7864/0x1c78d0) decompile to the exact same wall. This is OLLVM style control flow flattening over the export surface: a static reader lands in a dispatcher, never the real logic.
How the encryption worksh2
People ask about the encryption in this thing. So let’s be specific about what it isn’t first. There is no AES anywhere in this build. No ChaCha. Nothing I found looks like whitebox crypto. What the stack actually does is cheaper and meaner: it scatters the secrets across obfuscated code and never lets any of them rest.
What it usesh3
Run the whole stack and this is the encryption layer, piece by piece:
- Rolling XOR stream for the string vault. One key byte per string,
+1step per byte, recovered into a register at runtime and never stored (decoderlibanogs.so:0x1d0628, verified keys0xE8/0xFD/0xB8/0xA8/0x9D). There is no single decoder. The census above finds 435 loop sites across ~20 bodies, some parked inside the flattened CFG dispatchers. Searchlibanogs.soforfrida-agent-32.soand you get nothing, because the string exists only as ciphertext. - XOR pair immediate hiding for constants. Every 32 bit literal is rebuilt at runtime (
0x4e6611f3 XOR 0x6c8845f3→0x22ee5400). No real constant sits in the file. - Opaque predicates. Branches gated on constant vs constant comparisons. Statically decidable, so a linear sweep loses the real layout.
- MVM bytecode. The check logic is not arm64. It ships as five
.imgbytecode modules decrypted above (rolling layer-1 XOR + 256 byte substitution box + layer-2 cycling 36 char key), and the decisions run as Bop style bytecode against a native__ff_NNimport bank resolved through__ff_96/__ff_97. - Instruction splicing.
AnoSDKForExportends with abintoAnoSDKInit’s body. Exports are glue, not logic. - PAC’d returns (
paciasp/autiasp) and open coded atomics (ldaddalvsldaxr/stlxrretry loops). No libc or libatomic PLT slot left to hook. - Direct syscalls.
tp_syscall_impis a rawsvc #0bridge, so PLT/GOT interposition never sees the syscall go by. - A third packed layer,
libGGP.so: 9.4 MB, zero exports, a custom entry, ~8 MB of obfuscated blob.
Why it holds uph3
The safe part is not the cipher. It’s the shape of the thing. The secret never rests. Strings and constants are recovered into registers at runtime and then gone. There’s no file that holds the blacklist and no steady state where a debugger can read it.
But there’s a second reason, and it’s the honest one. The crypto doesn’t need to be strong, because enforcement isn’t local. Decrypt the vault perfectly from inside a debugger and you’re still standing in a process being watched by opcode_scan, mincore, and TracerPid checks, and the verdict that matters belongs to the server. Breaking the encryption was never the fight. The server grades the report stream, and no amount of local decryption changes the score.
So “safe” here doesn’t mean unbreakable. It means breaking any one piece doesn’t move the needle, and the pieces have to stay consistent with each other and with the live server. That threat model is what this build is actually built around.
What it deliberately skipsh3
This is the part I find most interesting. The encryption layer itself is trivial. One byte keys, a rolling XOR, constant pairs. Any of it falls in minutes. That’s on purpose. The budget went where it pays: the VM, the predicates, the self scanning, the two process telemetry. The encryption is just the packaging. The thing being protected is the report stream.
Antidebugh2
Direct kernel syscalls via tp_syscall_imph3
libanort.so:0x146940:
00146940: mov x8, x0 ; syscall number comes in arg000146944: mov x0, x1 ; then L to R shuffle of all 6 args0014694c: mov x2, x300146954: mov x4, x50014695c: svc #0 ; ← DIRECT kernel call, no libc00146960: cmn x0, #1, lsl #12 ; Linux errno convention check00146964: cneg x0, x0, hi ; return -errno0014696c: retConsequences:
- Hooking the PLT/GOT for
ptrace/syscall/read/opendoes nothing for this path. The call never crosses a library boundary. - The only interposition vectors left are kernel level
svchooks, a GDB server before exec (blocked by TracerPid checks), or seccomp filters, which the engine itself installs and monitors. libanogs.soalso imports libcsyscallfor nonsensitive paths, so both a raw and a libc route coexist.
ptrace self attach familyh3
Both libs import ptrace, waitpid, gettid, getppid, prctl. During init the engine does a PTRACE_TRACEME self attach so a second tracer can’t attach (__NR_ptrace = 117 on aarch64). Repeated TracerPid (/proc/self/status) and /proc/self/stat state/ppid parsing catch a debugger that gets in anyway.
Seccomp filterh3
tss_sdk_init_start installs a seccomp BPF filter whitelisting only the syscalls the SDK uses. The tss_sdk_send_data_to_svr string exists in this build at libanogs.so:0x8e62e. Anything else gets EPERM. It’s aimed at unexpected shm/process_vm_writev class injection and scripted syscall usage.
Process/thread introspection importsh3
Full security relevant import set of libanogs.so: ptrace, waitpid, kill, fork, getpid, getppid, gettid, getuid, prctl, dl_iterate_phdr, dladdr, dlsym, syscall, ioctl, mincore, madvise, mprotect, mmap, munmap, dlopen, execv, getauxval, stat, fstat, fstatat, statfs, access, utimensat, inotify_init, inotify_add_watch, popen, pclose, __system_property_get/_find_nth/_read, plus the full TCP socket set and the Bionic checked variants __open_2/__memcpy_chk/__read_chk. libanort adds pread (with fstat), the on disk APK read path, and dirfd.
That import set is what backs the tracer checks, the property reads, the file watches (inotify on mpmc.dat/app data), the mprotect self protection, the mincore memory residency probes, and process enumeration.
Self hardening at instruction levelh3
- Open coded atomics with ISA fallback,
0x4fcb00(32 bit) /0x4fcb30(64 bit) atomic add:ldaddalon armv8.1 capable devices,ldaxr/stlxrretry loop otherwise, gated by the ctor computed flag[0x5693f8]. No PLT interposed libatomic path to hit. - ctor/fini code uses
paciasp/autiasp(return address signing), giving stack walking patches a visible crash site (libanogs.so:0x4fc440).
Antidebug scanner clusters (call site census)h3
All addresses are libanogs.so VAs; 0x501xxx are .plt trampolines. I didn’t find this by dumping the whole .so. A blanket disassembly pass showed nothing. It only turned up once I anchored on the real PLT call sites.
Tracer verify trio in one contiguous block 0x257f8c to 0x258c48:
0x257f30 waitpid wrapper: 0x257f8c bl #0x501120 ; libc.waitpid via PLT 0x257f98 cmn w0, #1 ; -1 → errno path 0x257fb8 bl #0x500e20 ; __errno0x2586cc fork side check (spawn guard)0x258a80 reverify loop ("try 3× then report"): 0x258a9c mov w20, #3 0x258aa0 bl #0x500cc0 ; getpid (PID gated recheck) 0x258abc bl #0x4ab1f8 ; wait/latch 0x258ac4 subs w20, w20, #1 ; b.ne loop ; 3 attempts 0x258ae4 … 0x45f858 (w4=1) ; report/log funnel0x258b10 PTRACE_ATTACH helper: 0x258b30 mov w0, #0x10 ; request = 0x10 (PTRACE_ATTACH) 0x258b40 bl #0x5010d0 ; libc.ptrace(ATTACH, pid, 0, 0)0x258c48 / 0x258cf4 second/third ptrace sites (state machines)So this build really does: waitpid errno parse → attach or detect loop with an explicit retry count of 3 → escalate to the report/log funnel. Same antidebug family as libanort ptrace+waitpid @ 0x13ea74/0x13eab4.
The reverify loop was flagged as a call site census find, but it decompiles cleanly into exactly that shape: 3 attempts, then the report funnel:
void antidebug_reverify(struct GameState *st) { int iVar1, attempts; ... FUN_003589a4(); // init/reverify entrance if (*(int *)(st + 0x870) == 0) { // per session flag [0x870] return; } attempts = 3; // mov w20,#3 do { getpid(); // our own pid iVar1 = pid_state_reader(); // internal pid/state read if (iVar1 == *(int *)(st + 0x870)) { return; // still consistent → stay quiet } wait_latch(100000); // 100 ms latch [0x4ab1f8] attempts = attempts - 1; } while (attempts != 0); *(int *)(st + 0x870) = 0; // give up; reset latch report_funnel(...); // getpid/PTRACE report path ...}The 0x870 per session latch, the 3× retry with a 100 ms gap, and the escalation funnel are all visible in the pseudocode. No disassembly gymnastics required this time.
uid gate 0x25d7e0 (getuid): getuid and an internal reader result are only ever compared/zero checked; a nonzero mismatch feeds the log funnel (0x222a34 builder) rather than a hard branch. That matches the report streaming design.
0x25d7e0 bl #0x5011b0 ; getuid0x25d7ec bl #0x25d1f8 ; internal state reader (returns a uid shaped value)0x25d7f0 cmp w0, #0 ; ccmp w21,#0 ; ccmp w0,w21 … ; getuid result vs helper value0x25d890 mov w1,w21 ; mov w2,w19 ; bl 0x25d3d8 ; state handler (uid+payload)0x25d8a4 → 0x4123 tagged log ; opaque const log tagGuarded syscall micro thunks 0x39c9d4: one global enable byte ([0x52c000+0xf28]) decides, per syscall, between a normal libc path and an OLLVM style stub. That’s how mincore (PLT site 0x39c9f4) and prctl are routed.
0x39c9e4 ldrb w8, [0x52c000+0xf28] ; one global enable byte0x39c9ec cbnz w8 → 0x3f01e0 ; obfuscated hook stub0x39c9f4 bl #0x5015e0 ; ELSE libc mincore (PLT)0x39ca04 … bl 0x500fb0 ; sibling thunk = prctlproperty read cluster 0x2b1c20 to 0x2b1d94: __system_property_get results are immediately null/length gated and numeric parsed (atoi) before use.
0x2b1d94 bl #0x5012c0 ; __system_property_get0x2b1d98 cmp w0 #0 … csel … gt ; value gated (len>0 check)0x2b1dd8 bl #0x500cb0 ; atoi on the decoded valueDetection → consequence for a debugger: flagged → report → score → delayed action; severe or combined → local fake crash path.
Hook / instrumentation detectionh2
Of everything in this engine, the hook detection is the part I’d least want to fight.
Library & thread signature scanh3
Scans /proc/self/maps + /proc/self/task/*/comm for:
frida-agent / linjector / libxposed_art / app_process64_xposed / app_process32_xposedlibzygisk_loader.so / libzygisk_injector.so / libsandhook / libwhale / libtaichigum-js-loop / gvm / pool-frida / frida-helperIn this build the signatures live in the encrypted vault (decoder libanogs.so:0x1d0628, sibling 0x257e0c), and the scanning primitives are all imported (opendir/readdir, fopen/fgets, fstat/stat, mmap, dl_iterate_phdr, popen). Hit → report, delayed model, and for Frida specifically there’s an extra live probe: a \x7fFRIDA style socket/pipe check under /data/local/tmp.
opcode_scan, inline hook trampoline hunth3
A background thread walks the RX segments of libanogs.so and the game binary, decoding instruction streams for trampoline signatures and validating prologues. The classic aarch64 stub it flags:
ldr x16, #8 ; 0x58000050 → bytes 50 00 00 58br x16 ; 0xD61F0200 → bytes 00 02 1f d6.quad target ; 8 bytes ptr16 byte window: 50 00 00 58 00 02 1f d6 ?? ?? ?? ?? ?? ?? ?? ??. A second class: b imm26 written over a function prologue, where prologues are cross checked against a protected copy.
The engine’s own hook module error strings confirm the machinery exists in this build:
libanogs.so:0x8dce4 inline_hook_opcode_dismatchlibanogs.so:0x8e507 set_inline_hook_errorlibanogs.so:0x8f406 ms_common_matchlibanogs.so:0x92315 ms_set_inlie_hooklibanogs.so:0x93e41 ms_hook_opcodeOn a hit the response is a fake crash / memory corruption event, not an error dialog.
PLT/GOT verificationh3
- Confirms GOT slots of protected functions resolve inside the library’s own PLT range.
- Checks no GOT slot was rebound to a page made RWX via
mprotectpost load (mprotectis imported in this build).
Own libunwind, stack level introspectionh3
libanort.soexportsunwind_xx_ioctl(0xba8f4, 1764 B) andunwind_xx_info_query(0xba56c, 904 B). The engine maintains its own unwind state for behavior checks and for detecting stack tampering by hooking frameworks, which rewrite return addresses.libanogs.sorodata embeds libunwind strings (libunwind: unsupported .eh_frame_hdr …,_Unwind_Resume,scan_eh_tab,itanium_demangle). The engine carries a full exception unwinder and demangler and can semantically analyze the stack, not just scan it.
The trap: functions are not where they lookh3
AnoSDKForExport(0x1c8fbc) is 108 bytes of opaque subtlety: two compared constants, then abintoAnoSDKInit(0x1c3740).AnoSDKIoctl(0x1c6e40, 1264 B) opens with an opaque predicate carpet. Hooking by VA “the ioctl handler” lands you in obfuscated glue that doesn’t contain the actual check code. The dispatch is MVM bytecode.
Why “just disable the hook check” failsh3
But notice what patching locally can’t fix: the verdict lives server side (reports stream up via AnoSDKGetReportData*), opcode_scan watches the very code you’d patch, and the 16 byte stub is exactly the fingerprint it hunts. The dual process cross report (mpmc.dat worker) also catches single address space tampering.
Root & superuser detectionh2
Detection vectorsh3
| Vector | Method | Evidence |
|---|---|---|
| su binary presence | probe well known su paths with access/stat | recon paths (targeted vault scan: 0 hits for /sbin/su, /system/bin/su, /system/xbin/su — not embedded in this build); native imports access, stat, fstatat |
| Magisk footprint | match magisk*/libzygisk_* in /proc/self/maps + /system/lib* + memory | magisk verified in vault; libzygisk_* absent |
| Zygisk | libzygisk_loader.so / libzygisk_injector.so hits in maps + process/thread names | recon; vault scan: 0 hits (not embedded in this build) |
| Mount namespace | compare /proc/self/mountinfo expected stock vs observed | /proc/self/mounts verified 0x90e71; mountinfo 0 hits |
| Root manager data dirs | scan /data/data/<rootmanager> package dirs | recon (absent from embedded vault) |
| uid checks | getuid() != 0 sanity + package uid probing | native (checked cluster 0x25d7e0) |
| Obfuscated prefix matching | signatures encrypted in this build | native (matches 0x1d0628 XOR decoder) |
The su path block, reconstructed (the real impl is VM obfuscated, paths loaded from the encrypted vault). A targeted vault scan for each listed path returned zero hits; the su path list is not statically embedded in this build (/sbin/su, /system/bin/su, /system/xbin/su all absent), so this remains recon shape only:
static const char *kSuPaths[] = { "/sbin/su", "/system/bin/su", "/system/xbin/su", "/system/sd/xbin/su", "/data/local/bin/su", "/data/local/xbin/su", "/vendor/bin/su", "/vendor/xbin/su",};static int probe_su(void) { for (int i = 0; i < (int)(sizeof(kSuPaths)/sizeof(kSuPaths[0])); i++) if (access(kSuPaths[i], F_OK) == 0) return 1; // hit → report return 0;}Byte verified vault recovery for the mount table vector: libanogs.so:0x90e71 step+1 -> "/proc/self/mounts". The mounts path itself is stored XOR encrypted in .rodata.
Magisk/Zygisk string signaturesh3
libzygisk_loader.so 6c 69 62 7a 79 67 69 73 6b 5f 6c 6f 61 64 65 72 2e 73 6flibzygisk_injector.so 6c 69 62 7a 79 67 69 73 6b 5f 69 6e 6a 65 63 74 6f 72 2e 73 6fmagisk (prefix matched in maps/mountinfo/proc listings)magisk is byte verified in the vault (libanogs.so:0x8f703, inside the wider taichi_magisk string), and /data/local/tmp/1a2t3n (0x91649) is verified too; libzygisk_loader.so/libzygisk_injector.so are not in this build’s vault (0 hits). The sharp Zygisk names are runtime/downloaded content; only the generic magisk token is embedded.
Enforcementh3
Root present: report → trust score reduction. It’s tolerated, then banned, rather than killed on the spot, so a root detect that doesn’t ban tells you nothing. Severity combination (root + hook + emulator) escalates locally toward a fake crash.
Integrityh2
APK CRC / on disk file checkh3
libanort.so imports pread and fstat, the APK CRC style streamed read path. libanogs.so imports the same streamed read primitives plus stat/fstat/fstatat. The engine family shape: keep the installed APK fd open, pread64 it in 0x10000 byte chunks, accumulate zlib crc32, compare against an encrypted/derived constant (public name inner_apk_file_crc_not_eq2).
uint32_t apk_crc = 0;while ((n = pread(fd, buf, 0x10000, off)) > 0) { // stream, don't buffer whole APK apk_crc = upd_crc32(apk_crc, buf, n); off += n;}if (apk_crc != expected_crc) sabotage(); // TERMINATE class, hard, not delayedA CRC mismatch is a hard fail, immediate.
Certificate verificationh3
- PackageManager signing cert digest (the canonical Java path) plus a native re derivation, so a Java level “spoof signature” hook must also survive the native check.
AnoSDKOnRecvSignature(libanogs.so:0x1c8884, 904 B) accepts server side signature callbacks. The server also signs and validates the session.
Memory residency probe: mincoreh3
mincore is imported (libanogs.so:0x2b13). Page state check: verify its own .text pages reside as expected; a memory dump tool that overlays or protects text shifts the residency bitmask.
unsigned char vec[NPAGES];syscall(232, text_start, text_len, vec);for (i = 0; i < NPAGES; i++) if ((vec[i] & 0x01) != expected[i]) suspicious(); // delayed report / corrupt on repeatmprotect (imported 0x2a59) supports both the CRC protection and XOM style page dancing.
TSS_SDK_SIGDATAh3
Embedded signature blob markers in both libs:
libanogs.so 0x50c100 TSS_SDK_SIGDATAlibanort.so 0x19c200 TSS_SDK_SIGDATAA versioned/checksummed data block the SDK validates on load (public RE: the sigdata digest drives integrity attestation). Location shifts per build.
Enforcement summaryh3
| Check | Class | Style |
|---|---|---|
| APK CRC / cert | TERMINATE | hard, immediate |
| mincore residency | report / fake crash | delayed or corrupt |
| JNI registered signature callbacks | server validation | continuous |
Emulator / virtual environment detectionh2
The only emulator related plaintext strings in libanogs.so rodata:
0x92f2d |emulator_name=0x8e6b3 ro.arch0x8f209 exynos9810|emulator_name=is the format the SDK prints/filters the emulator identifying property. On emulators,ro.kernel.qemu/ro.product.modelstyle props don’t exist, so a negative or missing lookup is itself a signal; on devices that do report, the value is matched against known emulator families.ro.arch+exynos9810are consumed by the ISA/SoC gating ctor. Theexynos9810reference is a real SoC behavior shim, not detection.- The fuller vault (VMOS/VirtualApp package lists etc.) is encrypted (decoder
0x1d0628); matching runs on decoded strings at runtime.
Heuristic classes:
| Class | Method |
|---|---|
| Build property | ro.kernel.qemu, ro.hardware, ro.product.model vs fingerprint consistency |
| Package driven | VMOS (com.vmos.*), VirtualApp / VXP (com.v?x.a?x), dual app/clone containers |
| Generic virtualization | vm_vap2 style discrimination between app virtualization and honest multiuser |
| Custom ROM | .img artifacts in unexpected partitions + ro.build.fingerprint deviations |
| Hardware | missing sensors, weird CPU/revision, SIM/telephony absence |
Backing imports: __system_property_get/_find_nth/_read, access/stat/statfs, opendir/readdir. Enforcement is report → delayed ban; only compound conditions escalate locally.
Blacklisted apps & targetsh2
For this build the vault of app, domain, root and hook signatures is not stored in plaintext. A static grep of the .so can’t see the blacklist at all.
The import surface proves the capability: opendir/readdir, fopen/fgets, fstat/stat, access, statfs, __system_property_*, getuid, dl_iterate_phdr, popen/pclose, getaddrinfo. The names below are the documented blacklist families for this engine. Each one is individually not extractable statically here because the vault is encrypted per build.
| Signature family | Tool class | Why flagged |
|---|---|---|
com.zhushou.cc | CC SpeedHacker / game speed tweakers | memory timing + speedhack on game clock |
net.aisence.Touchelper (& clones) | touch macro / auto fire orchestrators | player automation on shooters |
GameGuardian (guardian fragments) | memory editor / value search | classic scriptless cheat entry |
com.vmos (+ VMOS variants) | virtual environment carrying cheats | nested emulation sandbox |
VirtualApp / VXP (com.v?x.a?x) | app virtualization | used to clone/isolate cheat tooling |
is_hidden_malware_exists heuristic | apps hidden from PackageManager / root managers | presence in /data/data but invisible to PM |
Each family is matched against the PackageManager package list, /data/app entries, /data/data directories, and the process list. Hidden app detection runs a differential scan of the data directory against the PM index; force_apk_collect headers push suspicious APKs to the backend. Package component string matching happens on decoded vault entries at runtime, so a static grep never sees them.
Paths the security code touches:
| Path kind | Purpose | Evidence |
|---|---|---|
/data/app, /data/data, proc/ enlistments | black app & data dir sweep | opendir/readdir/fopen native |
/dev/random | entropy for report/seeding | string 0x94231 native |
.../files/ano_tmp/mpmc.dat | worker IPC queue (persistent) | string 0x9423d native |
| su locations | root detection | recon (0 vault hits for common su paths); access/stat confirmed native |
property space: ro.arch, ` | emulator_name=, ro.*` | arch + emulator gating |
/proc/self/... (maps, status, stat, task, mountinfo) | hook/root/debug scans | verified vault: /proc/self/maps 0x8d80f+0x90ab6, /proc/self/mounts 0x90e71, /proc/self/status 0x94464; mountinfo absent |
Telemetry & the behavioral modelh2
The report stream is where this whole design does its real work. The client reports; the server grades the stream. Most flags are delayed, cumulative, and scored.
The report queue APIh3
libanogs.so exports a versioned report queue:
| Export | VA | Size | Role |
|---|---|---|---|
AnoSDKGetReportData | 0x1c54b4 | 1388 | drain oldest report |
AnoSDKGetReportData2 | 0x1c7864 | 108 | versioned variant |
AnoSDKGetReportData3 | 0x1c78d0 | 108 | versioned variant |
AnoSDKGetReportData4 | 0x1c7f5c | 776 | versioned variant |
AnoSDKDelReportData | 0x1c5a20 | 1988 | ack/release consumed |
AnoSDKDelReportData3 | 0x1c793c | 1568 | ack variant |
AnoSDKDelReportData4 | 0x1c8264 | 1568 | ack variant |
Pattern: Get... hands back a blob, the host (game) forwards it, Del... acknowledges. They’re versioned in lockstep with protocol changes. On the dex side, AnoInfoPublisher (0x50940d34) is a Runnable pulling reports with a lazily started daemon Thread, a never sleeping drain loop. Listener registration: AnoSDKRegistInfoListener (0x1c8c0c).
The transporth3
%s/ano_tmp/mpmc.dat(0x9423d), the MPMC queue file, is the cross process channel betweenlibanogs.soand the worker.mrpcs_common_data_not_match!(0x91340) is the framing/CRC error path; frame corruption is itself reportable.- Full TCP socket imports: the engine owns its network path (telemetry + policy).
In process report channel, verifiedh3
COREREPORT is a real channel constant in this build. libanogs.so:0x9092a carries the temp file naming ...COREREPORT.%ld_%p.%*s%s.%*s.user_info->role_id_:%s.SetUserInfoEx; the corereport builder serializes into %s.COREREPORT.<ts>_<ptr>.<suffix> temp files, so the report chain (ace_create_tdm_report @ libanogs.so:0x345c54, code with TLS stack cookie prologue → bl 0x345e48; ace_submit_rule_finding path feeding it) is not just import theory. Report framing gp4, reason:%s, raw:%s, crc:%x (0x9344d) and TPM channel format get1_%d:%p, remain:%d, cs2 (0x90259) / rcv_%d,len:%d,cs:%d (0x92fc7) are byte verified in the vault.
Wire format vs the reference protocol docs, static cross checkh3
Cross checking the reference wire format docs against this build (Todo 9):
| Reference doc claim | This build | Verdict |
|---|---|---|
Sends via Java URLConnection, no libcurl/libssl | java/net/URL, URLConnection, getInputStream, setRequestMethod all present in classes.dex/classes3.dex; zero libcurl/libssl/OPENSSL_/curl_easy symbols in either .so | Confirmed |
gp7ioctl(String) JNI bridge, forwarded to native | gp7ioctl + gp6ioctl are native methods of com/gamesafe/ano/AnoJavaMethod (classes.dex:0x2a5a03); name itself lives behind layer-2 cipher in libanort (raw CRC scan of the __ff_96 tree region won’t surface it — consistent with encrypted registration) | Confirmed (class corrected to AnoJavaMethod, not the doc’s guessed com.tencent.tp) |
ace_create_tdm_report @ libanogs+0x345C54; ace_submit_rule_finding @ 0x4E24C4 | 0x345c54 = real code (tpidr_el0 stack cookie prologue, bl 0x345e48, bl 0x334da4); adjacent 0x4E24C4 in the rule interpreter cluster | Confirmed (anchor is exact) |
Packet composer family @ libanort+0x148028/0x1485E0/0x148698/0x148848, TLV parser 0x14AD10, ACE_PacketBuildAndSend 0x1479D0, ACE_NetworkSendHeartbeat | all 16 anchors (0x1479D0, 0x14AD10, 0x148028, 0x1485E0, 0x148698, 0x148848, 0x12CD6C, 0x12A308, 0x149648, 0x14917C, 0x149674, 0x14A6F4, 0x14AEDC, 0x14A948, 0x14AA50, 0x13813C) disassemble as valid code in our libanort.so | Confirmed (same build family) |
Magic numbers 0x20211111, 0x20211215, 0x010A0023; ace_shell_di.dat/ace_cache_db.dat | absent from static bytes (plaintext + both endians) — they’re layer-2 cipher constants, not readable statically | Not statically verifiable (consistent with our layer-2 finding) |
enable_gp7_exit_group kill gate, start_worker/start_service commands | absent from libanort plaintext AND rolling XOR table — ciphertext/resolved at runtime in this build | Not statically verifiable |
Bottom line for Todo 9: the architecture (JNI→Java URLConnection transport, corereport channel singleton, composer/TLV/heartbeat function family in libanort) is byte verified in this build; the constants (magics, ace_shell_di.dat, enable_gp7_exit_group) are layer-2 encrypted and genuinely unresolvable statically. Honest split: architecture verified, constants recon by construction.
Heartbeat & env fingerprinth3
The CS/GS channel layer is byte verified in this build’s vault: DoConnCSChannelImp, %s:%d (0x901e4), heartbeat counters cs_max_time_out_cnt (0x8d3ca) and disconn_cs_no_heart (0x8dba0), stream accounting send_cs:%d, send_gs:%d, remain_cnt:%d (0x8faba) and gs_toc:%d:%d (0x8fec9), checksummed frame ops get1_%d:%p, remain:%d, cs2/del1_%d:%p, remain:%d, cs2 (0x90259/0x9217e), get_1_%d:%p, node_cnt:%d, remain:%d, cs3/del_1_%d:%p, remain:%d, cs3 (0x92f9d/0x937b4), rcv_%d,len:%d,cs:%d (0x92fc7), and gp7 framing gp4, reason:%s, raw:%s, crc:%x (0x9344d). The wire envelope below is the public RE reconstruction of that layer’s report; field semantics are recon, the channel that carries it is verified.
{ "seq": 4123, // monotonic; the server tracks gaps "env": 0x00030400, // flag bitmap: root=1<<8, hook=1<<12, mem=1<<20, ... "threads": 47, // must track the game's real thread count "mem_check": "pass", // mincore/opcode result "ts": 1768012345, "jitter": 311 // scan timer jitter fingerprint}Session bind: AnoSDKSetUserInfo (0x1c4114) / AnoSDKSetUserInfoWithLicense (0x1c4518). Lifecycle ramp: AnoSDKOnResume (0x1c4fc8) / AnoSDKOnPause (0x1c4ba4) raise and lower scan frequency. Engine health from the game side arrives via the ChkSetGameStatus block (ChkSetGameStatus{ @ 0x910a9, status_info->game_status_:%d @ 0x918b0, status_info->size_:%d @ 0x932d9, close ChkSetGameStatus} @ 0x94311).
The timing chain: two boot reports, on purposeh3
Boot isn’t just “load and run” here. ace_init_core_v4_9_30_24277_oversea (libanort+0xC3A6C) spends part of init firing “I’m alive, and this is the stage I reached” reports into the same stream as the detection results:
ace_init_core_v4_9_30_24277_oversea (libanort+0xC3A6C) └─ ace_detect_elf_section_tamper (libanort+0x323F4) └─ ACE_ConditionalTimingReporter (libanort+0xEA5C4) └─ ACE_TimingReporter (libanort+0xEAB00) # fires twice, via opaque branches └─ ACE_ConditionalTimingReporter, called again straight from bootACE_ConditionalTimingReporter (libanort+0xEA5C4) is the same flattened state machine as everything else in this library. It loads three magic values (0x2AC4, 0x36B9, 0xDC18) and runs a box of arithmetic that always collapses to the same constant. Pure tautological obfuscation, the point being that the init sequence calls ACE_TimingReporter exactly twice, with the second call hidden behind an opaque branch.
ACE_TimingReporter (libanort+0xEAB00) is the actual reporter. It packs a tiny struct: {phase_id, monotonic_ns, host_pid}. Three fields, then it hands the thing to the report dispatch trunk in libanogs.so.
Two call sites, same redundant report game as the rest of the design. A single patch isn’t enough: stop one site and the other still fires, the count reads 1 instead of 2, and count mismatch is its own flag. But letting both run sends harmless boot time telemetry.
There’s a third path into ACE_TimingReporter from a vtable that I haven’t enumerated yet. The two known sites are enough to show the pattern; that one is still an open thread.
The delayed ban modelh3
- The client’s real job is to keep the stream consistent. Absent or atrophied reports score worse than flagged but present ones.
- Cheaters watching for “no immediate ban” can’t distinguish undetected from scheduled. The ~30 minute enforced ban after tampering is the canonical outcome.
- Persistent flags (
ano_r_record.datclass, host side +ano_tmp/mpmc.datpersistence) track repeat offenders across restarts.
Policy/signature round triph3
AnoSDKOnRecvData(0x1c61e4), server→client policy push.AnoSDKOnRecvSignature(0x1c8884), server signature validation of the session.
Network antibot layer (DataDome)h2
Parallel to the in process ANO engine, this build ships a DataDome integration on the network path plus WebAuthn / Play Integrity material on the auth path (dex classes from the Ghidra DB):
| Class | Behavior |
|---|---|
DataDomeSDK | client bootstrap/config of the bot detection service |
DataDomeInterceptor | HTTP/TLS interceptor, bot TLS fingerprinting + JS challenge |
DataDomeCaptchaBridgeProvider | serves the interactive captcha challenge flow |
DataDomeEvent | per request/telemetry event carrier |
It grades each HTTP client by TLS/JA3 fingerprint, HTTP header ordering, JS execution environment, mouse/metrics anomalies, and client side challenge solving. It runs at the HTTP+presentation layer before game API business logic. Attestation (AttestationConveyancePreference, AuthenticatorAttestationResponse, plus the Google Play Games / Sign In / Meta / Firebase auth stack) anchors account security partly in TEE/Hardware attested keys, again outside the RE surface of libanogs.so.
DataDome is a separate trust boundary. It lives in its own TLS/HTTP stack, independent of libanogs.so. Defeating it is a traffic impersonation problem, orthogonal to any in process game RE, and both layers have to hold at the same time for a realistic bypass.
Combined picture:
Defense takeawaysh2
Detection → response matrixh3
| Detection | Reaction | Class |
|---|---|---|
| APK CRC / certificate mismatch | immediate kill (TERMINATE) | integrity |
| Inline hook stub in own/game RX pages | fake crash / memory corruption | hook detection |
| Frida/Xposed/LSPosed/Zygisk/SandHook/Taichi footprint | report → delayed ban; severe → kill | hook detection |
| Root / su / Magisk present | report → trust score reduction, tolerated then banned | root detection |
| Emulator / VMOS / virtual app | report → delayed ban | emulator detection |
| Debugger attached / tracer pid | report + fake crash on repeat | antidebug |
| mincore/memory dump anomaly | report (delayed) / local corruption | integrity |
| Missing / atrophied telemetry | server side suspicion → delayed kill | telemetry |
Engine/worker not alive (GP6_Stat style) | server sees liveness gap | telemetry |
Designed propertiesh3
- The client is a reporter, not an enforcer. Local “success” ≠ safe. Verdicts are server side and delayed/cumulative (~30 min pattern), so a bypass can’t be validated by “did anything break?”.
- Redundancy is structural. Every check has ≥2 independent implementations; the own code
opcode_scanvalidates the checker itself. - Two processes, cross report.
libanogs.so+ worker viaano_tmp/mpmc.dat; single address space kills are exposed by the sibling process. - Obfuscation is the point. Opaque predicates, MVM bytecode, XOR hidden constants AND strings, instruction splicing, PAC’d returns. Static only analysis underestimates the engine.
- Direct syscalls. Raw
svcbridge (tp_syscall_imp) + open coded atomics. PLT/GOT interposition can’t see the path. - Network second gate. DataDome TLS/bot filtering is an independent trust boundary on the same app.
What would be needed to fully spoof the trust modelh3
To pass this engine you’d have to reproduce, every 5 to 10 s and across two processes: the exact env flag bitmap, the thread count, the memory check results, the timing jitter fingerprint, the worker’s own liveness, and the DataDome TLS/HTTP client fingerprint. All at once, while the game itself is being modified. That’s a distributed, continuously correct, server graded problem. Not a patch and play state.
JNI & export surfaceh2
The complete, verified native interface of this build (com.dts.freefireth, 1.132.1, arm64-v8a). All addresses are VAs/file offsets; sizes from libanogs-dynsym.txt / libanort-dynsym.txt.
libanogs.so defined exportsh3
| Export | VA | Size | Role |
|---|---|---|---|
JNI_OnLoad | 0x1cb85c | 580 | lazy arm: global init flag [0x513f38], fast path blr [0x54fc70], else core boot + RegisterNatives (2 banks: 0x514250×3, 0x54fc80×11) + engine init |
AnoSDKInit | 0x1c37ac | 812 | core init (license, data files) |
AnoSDKInitEx | 0x1c3ad8 | 1596 | extended init |
AnoSDKSetUserInfo | 0x1c4114 | 1028 | bind session → player identity |
AnoSDKSetUserInfoWithLicense | 0x1c4518 | 1676 | same + license token |
AnoSDKOnPause | 0x1c4ba4 | 1060 | lifecycle: lower scan freq |
AnoSDKOnResume | 0x1c4fc8 | 1260 | lifecycle: raise scan freq |
AnoSDKGetReportData | 0x1c54b4 | 1388 | drain oldest telemetry report |
AnoSDKGetReportData2 | 0x1c7864 | 108 | v2 variant |
AnoSDKGetReportData3 | 0x1c78d0 | 108 | v3 variant |
AnoSDKGetReportData4 | 0x1c7f5c | 776 | v4 variant |
AnoSDKDelReportData | 0x1c5a20 | 1988 | ack consumed report |
AnoSDKDelReportData3 | 0x1c793c | 1568 | ack v3 |
AnoSDKDelReportData4 | 0x1c8264 | 1568 | ack v4 |
AnoSDKOnRecvData | 0x1c61e4 | 844 | server→client policy push |
AnoSDKIoctlOld | 0x1c6530 | 2320 | legacy command bridge |
AnoSDKIoctl | 0x1c6e40 | 1264 | command bridge (the dec_tss_info: addressed choke point) |
AnoSDKFree | 0x1c7330 | 1332 | release session state |
AnoSDKOnRecvSignature | 0x1c8884 | 904 | server signature validation |
AnoSDKRegistInfoListener | 0x1c8c0c | 944 | register telereport listener |
AnoSDKForExport | 0x1c8fbc | 108 | export surface for loader glue (opaque, bs into AnoSDKInit) |
Section ranges: .text 0x1b12c0 to 0x500c19 (~3.4 MB), .rodata 0x8cac0 to 0x1079ac, .plt 0x500c20 to 0x501950.
libanort.so exportsh3
| Export | VA | Size | Role |
|---|---|---|---|
JNI_OnLoad | 0x2630c | 1476 | binds com/ano/gshell/AnoApplication glue |
unwind_xx_info_query | 0xba56c | 904 | unwind/info state query (stack introspection) |
unwind_xx_ioctl | 0xba8f4 | 1764 | runtime ioctl mirror (anort ↔ anogs), opaque predicate wrapped |
g_acf_array | 0x1a3110 (.data) | 424 | handler/operator table A (populated at runtime) |
g_aco_array | 0x1a4a48 (.bss) | 160 | handler/operator table B |
tp_syscall_imp | 0x146940 | (37 insn) | raw svc #0 syscall bridge, errno normalized |
Ghidra verification pass (libanort.so, base = file +0x100000)h4
Every row above was re verified in Ghidra against a fresh load of libanort.so. The same +0x100000 image base offset that governs libanogs applies here, so JNI_OnLoad’s Ghidra address 0x12630c is the 0x2630c file offset in the table, unwind_xx_info_query/unwind_xx_ioctl sit at 0x1ba56c/0x1ba8f4, and the array exports land at 0x2a3110 (.data) / 0x2a4a48 (.bss).
JNI_OnLoadregisters exactly 7 natives, not “glue” in the abstract. The decompile shows the standard shape —vm->GetEnv(&env, JNI_VERSION_1_6)at vtable+0x30, a findclass of thecom/ano/gshell/AnoApplicationpath, thenenv->RegisterNatives(clazz, table@.bss 0x2a4848, 7)— all buried under an OLLVM flattened dispatcher. The 7-entry method table lives in.bssat0x2a4848(Ghidra), i.e.0x1a4848file offset.- The table is built at runtime by
_INIT_0(Ghidra0x1268d0= file0x268d0). It writes the.bssrows at0x2a4848/0x2a4878/0x2a48a8/0x2a48d8: name and signature fields come from the decoderfunc_0x0021149c(key)(key driven string unpacker, same family aslibanogs’ XOR rotators), and the fn pointer slots point into.text/.got. This ctor is itself flattened with always true opaque predicates on stack spilled magic words — the same anti decompiler signature as the.so’s runtime exports. unwind_xx_ioctlis a real command dispatcher, not just a wall. Behind the flattened state machine it comparesparam_1against the small opcode set1..4, clears an out pointer pair, and tail dispatches throughfunc_0x00248d94/func_0x00248e18/func_0x00248e5c(the anort↔anogs mirror).unwind_xx_info_querywrapsfunc_0x00231730 → func_0x002319ccwith two opaque arithmetic predicates on spilled constants (0x3faf/0xec67,0x5db2/0x265a) that always fold to their configured branch.- Antidebug toolkit confirmed from the import table:
ptrace,waitpid,gettid,syscall,kill,nice,madvise,readlink,__open_2are all bound — matching theptrace+waitpidcensus at file0x13ea74/0x13eab4claimed in the antidebug section. tp_syscall_imp’s only in binary reference is its GOT slot (Ghidra0x265448), which is exactly what a PLT invisible raw-svctrampoline looks like from the relocation side: nothing in.textnames it directly; callers reach it through the GOT relocation, so libc level hooks onsyscall()never see the path.libanorthas 4 ctors, not 2:_INIT_0(.bsstable build),_INIT_1(Ghidra0x13b7fc, allocates a0x400-byte buffer viafunc_0x0025e5f0, arms the0x2a4948global, registers&UNK_00139b80through__cxa_atexit),_INIT_2(Ghidra0x1e56cc, flattened, resolves adlopen-family handle viafunc_0x0025e500), and_INIT_3(Ghidra0x2367d4, a singlefunc_0x00236254()call writing.bss0x2a8368).
JNI dispatch: the full 229-entry vtable, resolvedh3
The bytecode’s __ff_96/__ff_97 imports answer how the VM reaches into JNI. Both are in libanort.so, and both are fully resolved statically: no brute force, no emulation. This is the exact JNI bottleneck that the reference tree only half solved; here every entry resolves.
__ff_96 (libanort.so:0x13bce8) is a lazy, run once RB tree builder. It encodes 229 {crc32, dispatch_offset} records as inline mov/movk immediates and threads each into a red black tree via the insert helper at 0x13f4a4 (insert sites span 0x13bd0c–0x13d9c0). The dispatch_offset is JNINativeInterface slot × 8: the tree is literally a serialized JNINativeInterface vtable, slots 4 through 232.
The CRC is not a custom ACE seed. ACE_CRC32 computes the standard zlib CRC32; crc32("FindClass") = 0x26616EC1 matches the well known JNI reference value. And the leading tree key 0x4CCF1A0F is CRC32("GetVersion"), the slot-4 entry, not a key schedule seed (earlier drafts misread it as one).
Result: all 229 records resolve to real JNI function names, statically. Cross checking against the reference JNINativeInterface layout matches 32/33 of the surely named lower slots; the rest span the full mid table (ReleaseStringUTFChars, FindClass, GetMethodID, CallXXXMethod, class/ref/monitor ops, etc.). Every one of the 229 was recovered, no runtime needed.
__ff_97 (libanort.so:0x13da54) is the call through. It takes a resolved slot and calls the native function pointer, the VM’s bridge from bytecode to JNINativeInterface.
| host | address | role |
|---|---|---|
__ff_96 | libanort.so:0x13bce8 | lazy RB tree over 229 JNI records; insert helper 0x13f4a4, sites 0x13bd0c–0x13d9c0 |
__ff_97 | libanort.so:0x13da54 | call through: dispatch resolved slot → real JNI fn |
The gp7 modules import them 11× each (vm_gp7service, vm_gp7worker); that is how the VM performs FindClass/GetMethodID/reflection from inside the obfuscated core. The complete 229-row table (slot, crc32, dispatch_offset, JNI name) is dumped to artifacts/jni_dispatch_final.txt.
libGGP.so, zero exportsh3
No .dynsym exports. Only .dynstr/.dynamic resolve; there’s one ~8 MB PROGBITS blob (0x120000+). The ELF entry is nonzero (0xf600) and its bytes don’t decode as code: 90 7f 5a 87 89 ad f2 2c .... It’s a packed/encrypted self loading wrapper, the Garena layer, not a normal shared object. The printable runs inside the blob are false positives from encrypted data, not real strings.
The first time I dumped the dynsym, libGGP came back with zero exports. I nearly looked past it.
Dex → native binding pathh3
dex com.gamesafe.ano.AnoSdk#ioctl(String) (public static Java wrapper) @ 0x5094120c └─ native slot AnoSdk_getsdkantidata (RegisterNatives'd in JNI_OnLoad) └─ libanogs.so AnoSDKIoctl @ 0x1c6e40 / AnoSDKForExport @ 0x1c8fbc └─ formatted cmd (dec_tss_info:%s …) → mrpcs/worker channel (ano_tmp/mpmc.dat)
libanort’s own AnoSDKIoctl/AnoSDKIoctlOld string refs (0x16968c, 0x1696cc) match the com/ano/gshell loader path used by the collab build, AnoApplication @ 0x16195f.
The rule engine: operator cluster & expression VM, verified at byte levelh3
The rules that drive scan decisions are evaluated by an expression VM. The reference tree’s expression_engine numbers (ace_expr_add_int @ 0x395478, ace_expr_sub_int @ 0x39554c, … ge @ 0x39618c) are all present in this build at file offset libanogs.so:0x395478+, so those addresses are not a different AOSP artifact: they are this exact binary.
Operator cluster census. A prologue scan between 0x395000 and 0x39ff78 (patterns sub sp,#0x40, #0x20, #0x1c0, #0x1b0, each followed by its stp x29,x30 pair) yields 77 distinct function starts in the cluster. Each operator follows the documented shape: *self = OP(a, b); return 1, where operands are fetched through a single shared dispatcher, bl 0x39d394, called from exactly 30 instruction sites, every one inside 0x395000–0x3a0000. That function (sub sp,#0x1b0; stp x28..x30…; mrs x27, tpidr_el0) is this build’s ace_subtable_lookup counterpart: it resolves an operand/key from an expression subtable plus stack frame (str/ldr around 0x39d3bc).
Verified spot checks in this build:
| op | this build offset | shape |
|---|---|---|
| add_int | 0x395480 (0x395478 is the previous tail) | sub sp,#0x40; stp x29,x30; mov x2,#0x10; bl 0x39546c; bl 0x500c60 |
| div_int | 0x39569c | same prologue, bl 0x39546c |
| subtable dispatch | 0x39d394 | big frame 0x1b0, TLS pid, shared by 30 ops |
The operator set is homogeneous: int/floating add sub mul div, and or xor shr shl, eq ne lt le gt ge, plus the subtable lookup and identity helpers. No runtime dependent behavior in the evaluator itself; the ops are pure, and the interesting logic is in which fields they’re pointed at.
The rule state machine & interpreter loop, verifiedh3
Rule execution is a flattened jump driven state machine. The interpreter entry and loop are present here:
| claimed | this build file offset | evidence |
|---|---|---|
ace_dispatch_rule_run | 0x3ecb40 | block continues into flat layout (ldr x28,[x28]; cbnz → 0x3ecac8), dense opaque mov/movk pairs |
ace_run_scan_rules loop | 0x3ecff8 | stp x28..x30 prologue, tpidr_el0 cookie, linked list walk ldr x21,[x0]/[+0x20], jump table br x8 via adrp x26,#0x534000; add x26,#0xeb0 (0x534eb0) |
The mov/movk pairs on w8/w9/w10/w11 followed by eor/csel and br x8 are the same opaque predicate dispatch found everywhere else in this binary: rule type 4/5 leaf, 6 forward jump, 7 backward jump, 8 eval AST and submit all route through br x8 at 0x534eb0. Every branch target resolves statically; there is no indirect dispatch through memory, only the constant mangled table index.
Detection signature vault: what this build actually embedsh3
The payload signatures are stored rolled through a per string running XOR (decoded[i] = raw[off+i] ^ ((key+i)&0xff), one start key per string). The vault sits across libanogs.so:0x8d000–0x98000; a complete enumeration (all offsets × all keys, maximal runs, token filtered) recovered 997 meaningful runs. The doc-64 style “master dictionary” is mostly not in this build: of 66 representative strings from the reference dict (frida paths, root tool paths, com.tencent.* package lists, emulator fingerprints, TssIoCtl.* format strings), only comm.dat matched here. The rest ship downloaded (ob_*.zip, TSS *.dat) rather than embedded. What is embedded, verified byte for byte:
GPU profiler / graphics tools (dedicated detection + ban dialog):
| offset | string |
|---|---|
0x8d099 / 0x8dc6b | com.google.android.gapid.arm64-v8a / .armeabi-v7a (GAPID, GPU Debugger) |
0x8d0bd / 0x92b27 | org.renderdoc.renderdoccmd.arm64 / .arm32 |
0x8f70b | libVkLayer_khronos_validation.so |
0x90a8e | libVkLayer_GraphicsSpy.so |
0x8ff0b | com.powervr.carbon.payload |
0x93030 | This game may be being debugged by the GPU Profiler tool. Please uninstall the tool to continue the game. |
Hook/agent frameworks (Xposed, SandHook, Frida, fake-linker):
| offset | string |
|---|---|
0x8e7a8 | de.robv.android.xposed.installer |
0x8fb41 / 0x93844 | app_process64_xposed / app_process32_xposed |
0x8dc2a / 0x90659 | libsandhook.so / libsandhook-native.so |
0x8d799 / 0x921ea | frida-agent-64.so / frida-agent-32.so |
0x90670,0x947ca,0x933f6,0x93cca,0x8d42f,0x9301a,0x93ce0,0x90e05 | libfake-linker-{21,22,23,24,26,27,28,30}.so |
Root / environment alerts: root_alert:%s (0x8e079), unlocked device dialog at 0x92759 (“You are using an unlocked device that is compromised or modified. Please restore to the locked state or change the device to continue.”), rooted device dialog at 0x94160 (“Your device has been rooted, which poses a security risk. Please resume the game after returning to a non-Root environment.”), emu_alert:%s (0x8d4a5), check_ext_emu_feature (0x90319), x86 flags force_built_in_ip/no_built_in_ip (0x92ac0/0x8d702), /apexdata/ (0x8d7b6), /sdcard/VMOSfiletransferstation (0x8ff6b).
JDWP (debugger) & self inventory: JDWP Command Re (0x8dc45), JDWP Event Help (0x9340c); proc introspection wchan/status/stat/comm per task paths (0x8eb97–0x90e32); .text.shellcode_%s (0x8e6dd), rtld_db_dlactivity (0x8f6e8), AutoClicker found… dialog (0x8e3da), Malware found… dialog (0x944aa), Current number is: ;%d;%d (0x91e53).
Takeaway vs the reference tree: the reference catalog (docs 64/38/03/58) lists hundreds of paths/properties that this build does not statically embed; this build instead carries a small, tight, rolling XOR vault of framework GPUs, hook runtimes, and user facing coercion dialogs. Any bypass analysis must treat the downloaded rule payloads (ob_*.zip, *.dat) as the moving part and this embedded vault as the boot time constant.
Methodology & honesty notesh2
Honesty notesh3
- The engine model is report heavy and server adjudicated, with delayed bans.
- No working evasion code is provided anywhere here. The material is for understanding and defense. It also doubles as a list of what would need to be true to spoof the trust model, and why each avenue still fails structurally.
- If you want to dig deeper, the full evidence index, with the raw artifact dumps behind every claim above, lives alongside this write up as the Free Fire ANO RE dossier.
One last thing. Every conclusion here was forced by evidence: disassembly, symbol tables, byte identical string recoveries. Anything that couldn’t be verified in this build is marked recon, so you can weigh it yourself.