47 mins
9368 words
Loading views
Free Fire ANO Anticheat Reverse Engineering

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

LayerComponentEvidence
Java shimcom.gamesafe.ano.AnoSdk (loads anogs), AnoInfoPublisher, AnoJavaMethod, TouchListenerProxy, MainThreadDispatcher2, gshell loader com.ano.gshell.AnoApplicationdex
Native corelibanogs.so 5.5 MB, ANO SDK, VM, checks, telemetrynative
Native runtimelibanort.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 layerlibGGP.so 9.4 MB, packed/encrypted loader (0 exports, custom entry), ~8 MB obfuscated blobnative
Antibot (net)DataDome SDK + WebAuthn / Play Integrity attestationdex

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.

The whole stack in one flow: device-side libraries feed the report stream, which crosses the DataDome TLS gate to server-side scoring and the delayed ban.

Headline findingsh2

All eleven are verified in this build.

  1. Raw syscall bridge. libanort.so:tp_syscall_imp @ 0x146940 is a generic svc #0 trampoline (syscall number in x8, args reshuffled, Linux errno normalized). It’s a direct kernel call, so libc PLT/GOT interposition can’t touch it.
  2. Opaque obfuscated exports. AnoSDKIoctl (0x1c6e40, 1264 B) and AnoSDKForExport (0x1c8fbc, 108 B) are real handlers wrapped in opaque predicates and constant hiding XOR pairs. AnoSDKForExport ends with a b into AnoSDKInit’s region, cross function instruction splicing.
  3. String vault is encrypted. No plaintext su/frida/zygisk/blacklist strings in rodata. A running XOR decoder at libanogs.so:0x1d0628 decrypts them at runtime. Byte verified recoveries: frida-agent-32.so, libfrida-gadget.so, /proc/self/mounts. Stored as ciphertext, absent as plaintext.
  4. Mrpcs/worker channel is real. %s/ano_tmp/mpmc.dat (0x9423d) plus mrpcs_common_data_not_match! (0x91340) confirm the on disk MPMC queue used for worker IPC in this engine family.
  5. The VM core. Build machine RTTI path names the VM “MVM”. .text is ~3.4 MB and the sensitive logic runs as VM bytecode, not arm64.
  6. ISA/SoC adaptive runtime. The first .init_array ctors read ro.arch and getauxval(AT_HWCAP), then pick atomic implementations (ldaddal vs ldaxr/stlxr) and SoC specific behavior (exynos9810).
  7. Command tables built by constructors. 0x1cbd00 and siblings resolve ~40 opaque IDs through an ID→fn bank into global handler arrays, the SDK’s dispatch tables, the same family as g_acf_array/g_aco_array in libanort.so.
  8. JNI lazy arm. JNI_OnLoad (0x1cb85c) checks a global init flag ([0x513f38]); if it’s already set it takes an indirect blr fast path, otherwise it boots the core, registers natives, then runs engine init.
  9. Antidebug scanner clusters verified. waitpid @ 0x257f8c, PTRACE_ATTACH @ 0x258b40, and a 3× retry verify then report loop at 0x258a80. getuid/geteuid parity @ 0x25d7e0. Guarded syscall micro thunks around mincore. __system_property_get @ 0x2b1d94.
  10. 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 zero const string references in this build’s code. They’re native consumed or R8 dead. Dex presence here is not Java side usage.
  11. JNI dispatch, resolved 229/229. libanort.so:0x13bce8 lazy builds an RB tree of {CRC32(name), JNINativeInterface slot×8} from inline mov/movk constants; ACE_CRC32 is standard zlib CRC32 (crc32("FindClass")=0x26616EC1) and 0x4CCF1A0F is CRC32("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:

libanogs.so, ctor[0] (ghidra base; feature byte @ file 0x5693f8)
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:

libanogs.so, ctor[1] (decompiled)
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:

libanogs.so, ctor 0x1cbd00 (shape)
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:

libanogs.so, JNI_OnLoad fast/slow path
001cb878: bl 0x2c5ca4 ; engine core boot
001cb884: 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 slot
001cb8c0: bl 0x222798 ; slow path: build natives table
001cb8cc: mov w0,#0xc2a3; bl 0x4daf48 ; resolve table fn by ID
001cb8e0: bl 0x222a34 ; RegisterNatives class call

Loading 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:

libanogs.so, JNI_OnLoad @ 0x1cb85c (decompiled, ghidra base +0x100000)
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 natives
armed:
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

  1. com.dts.freefireth.FFApplication subclasses com.ano.gshell.AnoApplication (the loader; its superclass is android.app.Application). There is no com.gamesafe.ano.AnoApplication class in this build. Everything runs out of attachBaseContext.
  2. AnoSdk.<clinit> → System.loadLibrary("anogs") → native JNI_OnLoad.
  3. AnoInfoPublisher daemon thread starts the report drain loop; AnoSdk#ioctl("dec_tss_info:...") → AnoSDKIoctl for symptom/config reads.
  4. 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 install
classes.dex, attachBaseContextShell bytecode
0x89cde2 invoke-virtual {this}, getApplicationInfo()
0x89cdea iget-object v1, v0, ApplicationInfo.packageName
0x89cdee iget-object v3, v0, ApplicationInfo.nativeLibraryDir
0x89cdf2 iget-object v4, v0, ApplicationInfo.sourceDir
0x89cdf6 invoke-virtual {this}, getFilesDir() → v2
0x89cdfe sget v6, Build$VERSION.SDK_INT
0x89ce02 const/16 v7, 0x15 ; 21
0x89ce06 if-lt v6, v7, +0x32
0x89ce12 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_pipe
dgx_mzxq_kdkz --rot5--> ilc_recv_pipe
dgx_xgjnz_kdkz --rot5--> ilc_close_pipe

The 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 obfuscation stack bottom-up: ELF layout, control-flow flattening, opaque predicates, decoder diversity, the string vault, and anti-debug hardening.

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.cpp
  • ano/mvm/..., “MVM”. BopMemoryOperation.cpp is the block operation (“Bop”) memory op class: the stack machine memory subsystem.
  • The /Users/bkdevops/tpmobile build 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.

libanort internals: the embedded VM modules under the word cipher, and the JNI dispatch tree that reaches them from bytecode.

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:

a64 body decryption, first layer
sbox = <bytes @ libanort.so:0x16c879> # 256 byte S-box
plain = bytes(sbox[b ^ 0x23] for b in body) # per byte

The 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).

modulesizemd5
timeout_looper.img25,3600467bb799c04a5ebb7085a033a66738c
vm_hb.img18,352984970923fc35f44b19fb690b752b53c
vm_main.img265,6889156c4ffc5caeab2a50f005788eeeec7
vm_gp7service.img53,6856c3fce310abd5aea3b3f5b7170f13364
vm_gp7worker.img31,3318a8fc16e5e446bd25ffdd55758e0ae04

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, ND

After 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[]} where name[] 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.

moduletotal refsunique importsnotable
vm_main1,420143__ff_12 ×223, __ff_7 ×187, __ff_5 ×88, memset ×79, __ff_21 ×66
vm_gp7service22543__ff_96 ×11, __ff_97 ×11 (JNI hosts)
vm_gp7worker10024__ff_96 ×11, __ff_97 ×11, __ff_170 ×7
timeout_looper3414—
vm_hb1510—

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:

storeddecoded
vX\WwYWDKFindClass
WTFpXTEDgetClass
C^GAWPr^JsourceDir
WTFzZAgetInt
WTF}UXSgetName
WTFvLADVKgetExtras
WTFq[ZZRYWgetBoolean
QUVrWA_XVaddAction
D^aGF\XPtoString
FPVWFvaddr
RTUZZbegin
BTAFXA\x0bRJK.0result=error
UIQVDA\x0cexcept:
QAYlSPBh\36. papk_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):

libanogs.so, AnoSDKForExport opaque predicate
001c8fc0: mov w8, #0xf4dc; movk w8, #0xf4df, lsl #16 ; w8 = 0xf4dff4dc
001c8fc4: mov w9, #0x7506; movk w9, #0xd7a7, lsl #16 ; w9 = 0xd7a77506
001c8fd0: 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:

libanogs.so, AnoSDKForExport @ 0x1c8fbc (decompiled)
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 = 0x22ee5400
0x4e6611f1 XOR 0x6c8845f3 = 0x22ee5402

The 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:

libanogs.so, rolling XOR string decoder
001d062c: mov w8, #0xe8 ; strb w8, [sp,#0xc] ; key = 0xe8
001d063c: cmp w8, w1 ; loop while i < len
001d0644: sxtw x9, w8; add x9, x0, x9; ldrb w9,[x9] ; load src[i]
001d0654: eor w9, w9, w10 ; dst[i] = src[i] ^ key
001d0660: add w10, w10, #1; strb w10,[sp,#0xc] ; key += 1 each byte
001d066c: 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):

libanogs.so, FUN_002d0628 @ 0x1d0628 — rolling XOR string decoder
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):

rodatakey@start / stepdecoded
0x921ea0xb8 +1frida-agent-32.so (15 B)
0x8cca60xa8 +1libfrida-gadget.so (18 B)
0x90e710x9d +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.

CAUTION

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):

libanogs.so, decoder family member @ 0x28fd90
0x28fd90 sub sp, sp, #0x10
0x28fd94 mov w8, #0xfd ; strb w8,[sp,#0xc] ; key = 0xFD
0x28fda0 ldr w8,[sp,#8] ; cmp w8, w1 ; b.hs end ; while i < len
0x28fdb4 ldrb w9,[x0, x8] ; src[i]
0x28fdb8 ldrb w10,[sp,#0xc] ; eor w9, w9, w10 ; src[i] ^ key
0x28fdc0 and w9, w9, #0xff
0x28fdc4 sxtb w10, w10 ; add w10, w10, #1 ; key += 1
0x28fdd8 strb w9,[x2, x8] ; add w8, w8, #1 ; b loop

Same 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:

libanogs.so, AnoSDKSetUserInfo @ 0x1c4114 — flattened state machine (abridged)
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, +1 step per byte, recovered into a register at runtime and never stored (decoder libanogs.so:0x1d0628, verified keys 0xE8/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. Search libanogs.so for frida-agent-32.so and 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 .img bytecode 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_NN import bank resolved through __ff_96/__ff_97.
  • Instruction splicing. AnoSDKForExport ends with a b into AnoSDKInit’s body. Exports are glue, not logic.
  • PAC’d returns (paciasp/autiasp) and open coded atomics (ldaddal vs ldaxr/stlxr retry loops). No libc or libatomic PLT slot left to hook.
  • Direct syscalls. tp_syscall_imp is a raw svc #0 bridge, 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:

libanort.so, tp_syscall_imp raw syscall bridge
00146940: mov x8, x0 ; syscall number comes in arg0
00146944: mov x0, x1 ; then L to R shuffle of all 6 args
0014694c: mov x2, x3
00146954: mov x4, x5
0014695c: svc #0 ; ← DIRECT kernel call, no libc
00146960: cmn x0, #1, lsl #12 ; Linux errno convention check
00146964: cneg x0, x0, hi ; return -errno
0014696c: ret

Consequences:

  • Hooking the PLT/GOT for ptrace/syscall/read/open does nothing for this path. The call never crosses a library boundary.
  • The only interposition vectors left are kernel level svc hooks, a GDB server before exec (blocked by TracerPid checks), or seccomp filters, which the engine itself installs and monitors.
  • libanogs.so also imports libc syscall for 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: ldaddal on armv8.1 capable devices, ldaxr/stlxr retry 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 ; __errno
0x2586cc 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 funnel
0x258b10 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:

libanogs.so, reverify loop @ 0x258a80 (decompiled, ghidra base)
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 ; getuid
0x25d7ec bl #0x25d1f8 ; internal state reader (returns a uid shaped value)
0x25d7f0 cmp w0, #0 ; ccmp w21,#0 ; ccmp w0,w21 … ; getuid result vs helper value
0x25d890 mov w1,w21 ; mov w2,w19 ; bl 0x25d3d8 ; state handler (uid+payload)
0x25d8a4 → 0x4123 tagged log ; opaque const log tag

Guarded 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 byte
0x39c9ec cbnz w8 → 0x3f01e0 ; obfuscated hook stub
0x39c9f4 bl #0x5015e0 ; ELSE libc mincore (PLT)
0x39ca04 … bl 0x500fb0 ; sibling thunk = prctl

property 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_get
0x2b1d98 cmp w0 #0 … csel … gt ; value gated (len>0 check)
0x2b1dd8 bl #0x500cb0 ; atoi on the decoded value

Detection → 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_xposed
libzygisk_loader.so / libzygisk_injector.so / libsandhook / libwhale / libtaichi
gum-js-loop / gvm / pool-frida / frida-helper

In 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:

inline hook stub signature
ldr x16, #8 ; 0x58000050 → bytes 50 00 00 58
br x16 ; 0xD61F0200 → bytes 00 02 1f d6
.quad target ; 8 bytes ptr

16 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_dismatch
libanogs.so:0x8e507 set_inline_hook_error
libanogs.so:0x8f406 ms_common_match
libanogs.so:0x92315 ms_set_inlie_hook
libanogs.so:0x93e41 ms_hook_opcode

On 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 mprotect post load (mprotect is imported in this build).

Own libunwind, stack level introspectionh3

  • libanort.so exports unwind_xx_ioctl (0xba8f4, 1764 B) and unwind_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.so rodata 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 a b into AnoSDKInit (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

VectorMethodEvidence
su binary presenceprobe well known su paths with access/statrecon 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 footprintmatch magisk*/libzygisk_* in /proc/self/maps + /system/lib* + memorymagisk verified in vault; libzygisk_* absent
Zygisklibzygisk_loader.so / libzygisk_injector.so hits in maps + process/thread namesrecon; vault scan: 0 hits (not embedded in this build)
Mount namespacecompare /proc/self/mountinfo expected stock vs observed/proc/self/mounts verified 0x90e71; mountinfo 0 hits
Root manager data dirsscan /data/data/<rootmanager> package dirsrecon (absent from embedded vault)
uid checksgetuid() != 0 sanity + package uid probingnative (checked cluster 0x25d7e0)
Obfuscated prefix matchingsignatures encrypted in this buildnative (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:

su path probe (recon shape; not embedded in this build — 0 vault hits)
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 6f
libzygisk_injector.so 6c 69 62 7a 79 67 69 73 6b 5f 69 6e 6a 65 63 74 6f 72 2e 73 6f
magisk (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).

APK CRC (recon representative)
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 delayed

A 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.

mincore residency (recon shape; __NR_mincore = 232 on aarch64)
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 repeat

mprotect (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_SIGDATA
libanort.so 0x19c200 TSS_SDK_SIGDATA

A versioned/checksummed data block the SDK validates on load (public RE: the sigdata digest drives integrity attestation). Location shifts per build.

Enforcement summaryh3

CheckClassStyle
APK CRC / certTERMINATEhard, immediate
mincore residencyreport / fake crashdelayed or corrupt
JNI registered signature callbacksserver validationcontinuous

Emulator / virtual environment detectionh2

The only emulator related plaintext strings in libanogs.so rodata:

0x92f2d |emulator_name=
0x8e6b3 ro.arch
0x8f209 exynos9810
  • |emulator_name= is the format the SDK prints/filters the emulator identifying property. On emulators, ro.kernel.qemu/ro.product.model style 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 + exynos9810 are consumed by the ISA/SoC gating ctor. The exynos9810 reference 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:

ClassMethod
Build propertyro.kernel.qemu, ro.hardware, ro.product.model vs fingerprint consistency
Package drivenVMOS (com.vmos.*), VirtualApp / VXP (com.v?x.a?x), dual app/clone containers
Generic virtualizationvm_vap2 style discrimination between app virtualization and honest multiuser
Custom ROM.img artifacts in unexpected partitions + ro.build.fingerprint deviations
Hardwaremissing 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 familyTool classWhy flagged
com.zhushou.ccCC SpeedHacker / game speed tweakersmemory timing + speedhack on game clock
net.aisence.Touchelper (& clones)touch macro / auto fire orchestratorsplayer automation on shooters
GameGuardian (guardian fragments)memory editor / value searchclassic scriptless cheat entry
com.vmos (+ VMOS variants)virtual environment carrying cheatsnested emulation sandbox
VirtualApp / VXP (com.v?x.a?x)app virtualizationused to clone/isolate cheat tooling
is_hidden_malware_exists heuristicapps hidden from PackageManager / root managerspresence 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 kindPurposeEvidence
/data/app, /data/data, proc/ enlistmentsblack app & data dir sweepopendir/readdir/fopen native
/dev/randomentropy for report/seedingstring 0x94231 native
.../files/ano_tmp/mpmc.datworker IPC queue (persistent)string 0x9423d native
su locationsroot detectionrecon (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 scansverified 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 detection pipeline this build actually runs: scan clusters feed findings into the heartbeat/report path, then out via corereport and onto the server.

The report queue APIh3

libanogs.so exports a versioned report queue:

ExportVASizeRole
AnoSDKGetReportData0x1c54b41388drain oldest report
AnoSDKGetReportData20x1c7864108versioned variant
AnoSDKGetReportData30x1c78d0108versioned variant
AnoSDKGetReportData40x1c7f5c776versioned variant
AnoSDKDelReportData0x1c5a201988ack/release consumed
AnoSDKDelReportData30x1c793c1568ack variant
AnoSDKDelReportData40x1c82641568ack 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 between libanogs.so and 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).
Wire format and packet compose: TLV parser, composer variants, heartbeat/reserved types, and chunking over the socket path.

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 claimThis buildVerdict
Sends via Java URLConnection, no libcurl/libssljava/net/URL, URLConnection, getInputStream, setRequestMethod all present in classes.dex/classes3.dex; zero libcurl/libssl/OPENSSL_/curl_easy symbols in either .soConfirmed
gp7ioctl(String) JNI bridge, forwarded to nativegp7ioctl + 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 @ 0x4E24C40x345c54 = real code (tpidr_el0 stack cookie prologue, bl 0x345e48, bl 0x334da4); adjacent 0x4E24C4 in the rule interpreter clusterConfirmed (anchor is exact)
Packet composer family @ libanort+0x148028/0x1485E0/0x148698/0x148848, TLV parser 0x14AD10, ACE_PacketBuildAndSend 0x1479D0, ACE_NetworkSendHeartbeatall 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.soConfirmed (same build family)
Magic numbers 0x20211111, 0x20211215, 0x010A0023; ace_shell_di.dat/ace_cache_db.databsent from static bytes (plaintext + both endians) — they’re layer-2 cipher constants, not readable staticallyNot statically verifiable (consistent with our layer-2 finding)
enable_gp7_exit_group kill gate, start_worker/start_service commandsabsent from libanort plaintext AND rolling XOR table — ciphertext/resolved at runtime in this buildNot 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.

heartbeat envelope (recon shape; carrier channel byte verified above)
{
"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 boot

ACE_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

Kill paths: report submit, string-decode integrity, gp7ioctl stop, APK CRC, all resolving to process death and the delayed ban.
  • 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.dat class, host side + ano_tmp/mpmc.dat persistence) 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):

ClassBehavior
DataDomeSDKclient bootstrap/config of the bot detection service
DataDomeInterceptorHTTP/TLS interceptor, bot TLS fingerprinting + JS challenge
DataDomeCaptchaBridgeProviderserves the interactive captcha challenge flow
DataDomeEventper 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:

The report flow that has to hold all the way through: device process feeds the report stream, which crosses the DataDome TLS/bot gate and lands in server-side score accumulation and the delayed ban.

Defense takeawaysh2

Detection → response matrixh3

DetectionReactionClass
APK CRC / certificate mismatchimmediate kill (TERMINATE)integrity
Inline hook stub in own/game RX pagesfake crash / memory corruptionhook detection
Frida/Xposed/LSPosed/Zygisk/SandHook/Taichi footprintreport → delayed ban; severe → killhook detection
Root / su / Magisk presentreport → trust score reduction, tolerated then bannedroot detection
Emulator / VMOS / virtual appreport → delayed banemulator detection
Debugger attached / tracer pidreport + fake crash on repeatantidebug
mincore/memory dump anomalyreport (delayed) / local corruptionintegrity
Missing / atrophied telemetryserver side suspicion → delayed killtelemetry
Engine/worker not alive (GP6_Stat style)server sees liveness gaptelemetry

Designed propertiesh3

  1. 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?”.
  2. Redundancy is structural. Every check has ≥2 independent implementations; the own code opcode_scan validates the checker itself.
  3. Two processes, cross report. libanogs.so + worker via ano_tmp/mpmc.dat; single address space kills are exposed by the sibling process.
  4. Obfuscation is the point. Opaque predicates, MVM bytecode, XOR hidden constants AND strings, instruction splicing, PAC’d returns. Static only analysis underestimates the engine.
  5. Direct syscalls. Raw svc bridge (tp_syscall_imp) + open coded atomics. PLT/GOT interposition can’t see the path.
  6. 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

ExportVASizeRole
JNI_OnLoad0x1cb85c580lazy arm: global init flag [0x513f38], fast path blr [0x54fc70], else core boot + RegisterNatives (2 banks: 0x514250×3, 0x54fc80×11) + engine init
AnoSDKInit0x1c37ac812core init (license, data files)
AnoSDKInitEx0x1c3ad81596extended init
AnoSDKSetUserInfo0x1c41141028bind session → player identity
AnoSDKSetUserInfoWithLicense0x1c45181676same + license token
AnoSDKOnPause0x1c4ba41060lifecycle: lower scan freq
AnoSDKOnResume0x1c4fc81260lifecycle: raise scan freq
AnoSDKGetReportData0x1c54b41388drain oldest telemetry report
AnoSDKGetReportData20x1c7864108v2 variant
AnoSDKGetReportData30x1c78d0108v3 variant
AnoSDKGetReportData40x1c7f5c776v4 variant
AnoSDKDelReportData0x1c5a201988ack consumed report
AnoSDKDelReportData30x1c793c1568ack v3
AnoSDKDelReportData40x1c82641568ack v4
AnoSDKOnRecvData0x1c61e4844server→client policy push
AnoSDKIoctlOld0x1c65302320legacy command bridge
AnoSDKIoctl0x1c6e401264command bridge (the dec_tss_info: addressed choke point)
AnoSDKFree0x1c73301332release session state
AnoSDKOnRecvSignature0x1c8884904server signature validation
AnoSDKRegistInfoListener0x1c8c0c944register telereport listener
AnoSDKForExport0x1c8fbc108export 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

ExportVASizeRole
JNI_OnLoad0x2630c1476binds com/ano/gshell/AnoApplication glue
unwind_xx_info_query0xba56c904unwind/info state query (stack introspection)
unwind_xx_ioctl0xba8f41764runtime ioctl mirror (anort ↔ anogs), opaque predicate wrapped
g_acf_array0x1a3110 (.data)424handler/operator table A (populated at runtime)
g_aco_array0x1a4a48 (.bss)160handler/operator table B
tp_syscall_imp0x146940(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_OnLoad registers 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 the com/ano/gshell/AnoApplication path, then env->RegisterNatives(clazz, table@.bss 0x2a4848, 7) — all buried under an OLLVM flattened dispatcher. The 7-entry method table lives in .bss at 0x2a4848 (Ghidra), i.e. 0x1a4848 file offset.
  • The table is built at runtime by _INIT_0 (Ghidra 0x1268d0 = file 0x268d0). It writes the .bss rows at 0x2a4848 / 0x2a4878 / 0x2a48a8 / 0x2a48d8: name and signature fields come from the decoder func_0x0021149c(key) (key driven string unpacker, same family as libanogs’ 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_ioctl is a real command dispatcher, not just a wall. Behind the flattened state machine it compares param_1 against the small opcode set 1..4, clears an out pointer pair, and tail dispatches through func_0x00248d94/func_0x00248e18/func_0x00248e5c (the anort↔anogs mirror). unwind_xx_info_query wraps func_0x00231730 → func_0x002319cc with 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_2 are all bound — matching the ptrace+waitpid census at file 0x13ea74/0x13eab4 claimed in the antidebug section.
  • tp_syscall_imp’s only in binary reference is its GOT slot (Ghidra 0x265448), which is exactly what a PLT invisible raw-svc trampoline looks like from the relocation side: nothing in .text names it directly; callers reach it through the GOT relocation, so libc level hooks on syscall() never see the path.
  • libanort has 4 ctors, not 2: _INIT_0 (.bss table build), _INIT_1 (Ghidra 0x13b7fc, allocates a 0x400-byte buffer via func_0x0025e5f0, arms the 0x2a4948 global, registers &UNK_00139b80 through __cxa_atexit), _INIT_2 (Ghidra 0x1e56cc, flattened, resolves a dlopen-family handle via func_0x0025e500), and _INIT_3 (Ghidra 0x2367d4, a single func_0x00236254() call writing .bss 0x2a8368).

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.

hostaddressrole
__ff_96libanort.so:0x13bce8lazy RB tree over 229 JNI records; insert helper 0x13f4a4, sites 0x13bd0c–0x13d9c0
__ff_97libanort.so:0x13da54call 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)
JNI and dex binding: the Java-side native methods (gp7ioctl/gp6ioctl) wired into the libanogs SDK entry points.

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.

Rule engine and expression VM: dispatch, scan loop, jump table, and the shared op-cluster with its subtable lookup.

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:

opthis build offsetshape
add_int0x395480 (0x395478 is the previous tail)sub sp,#0x40; stp x29,x30; mov x2,#0x10; bl 0x39546c; bl 0x500c60
div_int0x39569csame prologue, bl 0x39546c
subtable dispatch0x39d394big 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:

claimedthis build file offsetevidence
ace_dispatch_rule_run0x3ecb40block continues into flat layout (ldr x28,[x28]; cbnz → 0x3ecac8), dense opaque mov/movk pairs
ace_run_scan_rules loop0x3ecff8stp 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

Detection signature vault layout: GPU profilers, hook frameworks, root/environment alerts, and proc introspection, all under rolling-XOR.

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):

offsetstring
0x8d099 / 0x8dc6bcom.google.android.gapid.arm64-v8a / .armeabi-v7a (GAPID, GPU Debugger)
0x8d0bd / 0x92b27org.renderdoc.renderdoccmd.arm64 / .arm32
0x8f70blibVkLayer_khronos_validation.so
0x90a8elibVkLayer_GraphicsSpy.so
0x8ff0bcom.powervr.carbon.payload
0x93030This 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):

offsetstring
0x8e7a8de.robv.android.xposed.installer
0x8fb41 / 0x93844app_process64_xposed / app_process32_xposed
0x8dc2a / 0x90659libsandhook.so / libsandhook-native.so
0x8d799 / 0x921eafrida-agent-64.so / frida-agent-32.so
0x90670,0x947ca,0x933f6,0x93cca,0x8d42f,0x9301a,0x93ce0,0x90e05libfake-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

The user-facing flow this file documents, and where each claim above was verified end to end.

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.