Inside BitAIM+: Reverse Engineering a Commercial Carrom Pool Cheat

Valentin Lobstein /
Inside BitAIM+: Reverse Engineering a Commercial Carrom Pool Cheat
Table of Contents

TL;DR

BitAIM+ is a paid cheat for Carrom Pool, Miniclip’s online board game. It draws the winning shot on screen while you play against a real opponent. It is sold as “AI aim assistance”. I decompiled it.

There is no AI. There is no machine learning runtime anywhere in the package, and the native library does not import a single mathematical function - no sqrt, no cos, no atan2. The “engine” is a lookup table with linear interpolation. I decrypted the table: 444 rows of 42 floats, shipped inside the APK behind a key I recovered from the binary, matching the 168-byte struct exactly.

There is real computer vision, though it is 1990s computer vision. A 21.7 MB encrypted asset decrypts to the eight Carrom Pool board skins, which the app uses as reference templates: identify which skin the player has, then subtract it from the screen capture and whatever differs is a coin. Template matching and background subtraction with a fixed RGB tolerance. No learning of any kind.

What is genuinely impressive is everything around it: the app runs the real Play Store build of Carrom Pool inside a rebranded VirtualApp container, hooks it with YAHFA, and defeats PairIP, Google’s VM-based application protection, by reimplementing the virtualized method bodies by hand in Java.

And then it protects its own paywall with a single boolean and an integrity check whose result is overwritten four instructions after it is computed.

FieldValue
Samplebitaim_v102_.apk
Packageapp.ai.lab.bitaimplus (versionCode 102, versionName 3.7.02)
SHA-2562198db872518d6b322ee1a66dbab92703c75b248b905c0f9b3c716d3186036a7
Size / ABI31 MB, arm64-v8a only
SDKmin 23, target 34
SignerCN=Siddique Shabir, OU=builderX, O=builderX, L=Mumbai, C=IND
Targetcom.miniclip.carrom (Carrom Pool)

Why I Looked At This At All

Android game cheats have interested me for a long time, as an object of study. They are one of the few places where you find real anti-tamper work, real memory manipulation and real protection bypasses, all in a package anyone can download.

But that is not why I opened this one.

I also just play games on my phone. Not everything I install becomes a research project, and I do not decompile every application I touch, whatever my browser history might suggest. Sometimes I am bored and I want to flick a disc across a board for ten minutes.

Carrom Pool is one of those. I played it properly, as a player.

And it became unplayable. Not gradually, and not because I got worse. At some point almost every opponent I was matched against turned out to be perfect. Not good, perfect. They pot everything, from any position, on the first attempt, and in carrom a player who never misses simply never hands the turn over. You sit and watch. Some games I did not get a single shot.

That is the part I want to be clear about, because it is easy to shrug at cheating in a free mobile game. Cheating in a shooter means losing a match faster. Cheating in carrom means the other person does not get to play. The game structurally denies you a turn until your opponent fails, and their aim assistant makes sure they do not.

Miniclip games have carried a cheat economy for as long as I can remember, and 8 Ball Pool has been the classic example for over a decade. But this one struck me as worse, because of that turn structure. It does not tilt the match. It removes the other player from it.

So I wanted to know what was actually doing this. That question turned out to have a longer answer than I expected, and it started with an error message that made no sense.


The Error That Made No Sense

I pulled Carrom Pool from a mirror site, installed it on an API 30 emulator, and watched it launch, hang for a second, and tell me it needed at least twenty megabytes of free space. The emulator had four gigabytes.

The logs told a different story:

MiniclipAndroidActivity: Library Load Failed
java.lang.UnsatisfiedLinkError: dlopen failed:
  library "libgame-CARROM-GooglePlay-Gold-Release-Module-1477.so" not found

The APK I had contained zero .so files. Not a single lib/ directory. It was the base module of an App Bundle: Miniclip ships the actual game engine as a Play Feature Delivery module, downloaded separately at install time. The disk space message was just the app’s generic error dialog, firing because the native library failed to load.

That is a packaging detail, and on its own it is only mildly annoying. But it sharpened the question I had arrived with: if the game cannot even start without Play delivering its feature module, and if it is wrapped in Google’s anti-tamper protection on top of that, how do the cheat apps that everyone in the Carrom Pool community talks about actually work?

So I went and got one.


First Look

You can tell a lot about an Android app before decompiling a single class, and this one gives itself away twice in the first five minutes.

It ships four native libraries, all arm64-v8a:

483120  lib/arm64-v8a/libmmkv.so       Tencent MMKV key-value store
  5968  lib/arm64-v8a/libdlfunc.so     dlopen/dlsym namespace bypass
1116432 lib/arm64-v8a/libcpp_code.so   the actual engine
 14216  lib/arm64-v8a/libyahfa.so      YAHFA ART inline hooking

libyahfa.so is the first tell.

What YAHFA is. YAHFA stands for “Yet Another Hook Framework for ART”. It rewrites Java method dispatch from inside a running process, with no root and no modified system image, which is what makes it the natural choice inside a virtual container.

Every Java method in ART is backed by an ArtMethod struct holding a pointer to its compiled code. YAHFA locates the target with findMethodNative(Class, name, signature), then backupAndHookNative(target, hook, backup) swaps that entry point so calls land in your static hook method, while preserving a backup method that still reaches the original body.

That is why every hook class in BitAim looks the same:

public static String className  = "com.miniclip.carrom.CarromActivity";
public static String methodName = "onCreate";
public static String methodSig  = "(Landroid/os/Bundle;)V";

public static void hook(Object thisObj, Bundle bundle) { /* your code */ }
public static void backup(Object thiz, Bundle bundle) { /* patched to call the original */ }

A HookInfo.hookItemNames array lists which of those classes to apply, and init(Build.VERSION.SDK_INT) lets the native side adapt to each ART release. It is a clean design, and BitAim uses it exactly as intended.

The manifest is the second tell. It declares 143 permissions, including a long tail of Samsung and Huawei vendor permissions that no aim assistant could possibly need:

<uses-permission android:name="com.samsung.svoice.sync.READ_DATABASE" />
<uses-permission android:name="com.sec.android.permission.VOIP_INTERFACE" />
<uses-permission android:name="com.huawei.authentication.HW_ACCESS_AUTH_SERVICE" />
<uses-permission android:name="android.permission.UPDATE_APP_OPS_STATS" />

That is the signature of a virtual container. The host has to declare every permission any hosted app might request, because the guest cannot request permissions itself.

And sure enough, the component list confirms it:

com.vbox.client.stub.StubActivity$C0 .. $C49
com.vbox.client.stub.StubDialog$C0 .. $C20
com.vbox.client.stub.StubPendingActivity
com.vbox.client.stub.StubPendingService
com.vbox.server.BinderProvider
com.vbox.client.core.VirtualCore

Fifty pre-declared stub activities, a Binder provider, a mirror.* reflection framework. This is VirtualApp, rebranded to com.vbox.


The Architecture

Android keeps applications apart. Your banking app cannot read your messaging app, and neither can read a game. That isolation is the whole foundation of the platform’s security model, and it is the wall any cheat has to get past.

BitAIM+ does not get past it. It steps around it, by not being a separate application at all.

Carrom Pool never runs on your Android. It is installed and executed inside BitAIM+, as a guest process under the host’s UID. From the system’s point of view there is one app running, and the game is something happening inside it.

flowchart TB
    subgraph phone["Your phone sees ONE app"]
        subgraph host["BitAIM+ (app.ai.lab.bitaimplus)"]
            UI["Overlay window<br/>draws the aim line"]
            Cap["Screen capture<br/>VirtualDisplay 'AiAim'"]
            Core["VirtualCore<br/>rebranded VirtualApp"]
        end
        subgraph guest["Guest process: com.miniclip.carrom"]
            Game["Carrom Pool<br/>the real Play build"]
            Engine["libgame-CARROM-...so<br/>physics engine"]
            Hooks["YAHFA hooks<br/>5 plugin DEX"]
        end
    end

    Cloud[("Their servers<br/>licence flag, memory offsets,<br/>kill switch")]

    Core ==>|"launches"| Game
    Hooks -->|"rewrites methods"| Game
    Cap -->|"pixels"| UI
    Engine -.->|"ptrace + process_vm_readv<br/>same UID, no root needed"| UI
    Cloud -->|"config"| host

Because host and guest share a UID, process_vm_readv on the game works with no root and no special permission. That single design decision is what makes the whole thing possible.

How the game gets in there

BitAIM never distributes Carrom Pool. It requires you to install it from the Play Store yourself, then clones the copy already on your device. The picker is VirtualApp’s standard one: enumerate installed packages, take each app’s APK path, offer it for cloning.

List<PackageInfo> installedPackages = context.getPackageManager().getInstalledPackages(128);
for (PackageInfo packageInfo : installedPackages) {
    ApplicationInfo applicationInfo = packageInfo.applicationInfo;
    String parent = applicationInfo.publicSourceDir;
    if (parent == null) parent = applicationInfo.sourceDir;
    if (applicationInfo.splitPublicSourceDirs != null
     || applicationInfo.splitSourceDirs != null) {
        parent = new File(parent).getParent();     // take the whole split set
    }
    ...
}

That getParent() branch is the important one, and it is the answer to the problem I opened this post with. Carrom Pool is an App Bundle, so its base APK alone has no engine. By taking the parent directory when splits are present, the clone picks up the base, the ABI config splits and the feature module together, exactly as Play delivered them. VirtualCore.get().installPackage(path, flags) then installs that set into the container.

The rest is version bookkeeping, and it is strict:

boolean onDevice   = isInstalledOnDevice(activity, "com.miniclip.carrom");
boolean inSandbox  = VirtualCore.get().isAppInstalled("com.miniclip.carrom");
int deviceVersion  = deviceVersionCode(activity);

if (deviceVersion != sandboxVersionCode()) {
    VirtualCore.get().uninstallPackage("com.miniclip.carrom");   // force a re-clone
}
if (onDevice && deviceVersion < 1) {
    Toast.makeText(activity,
        "Update! Carrom pool required 5.4.2 version and above", 1).show();
    openUrl("https://play.google.com/store/apps/details?id=com.miniclip.carrom", activity);
}

If the device copy and the sandbox copy disagree on version, the sandbox one is wiped and recloned. If the game is missing or too old, you are sent to the Play Store. Which makes sense: their memory offsets are keyed to a specific engine build, so a version mismatch would make the cheat read garbage.

So the user journey is: install Carrom Pool from Play, install BitAIM from their website, let BitAIM clone the game, and from then on play through BitAIM’s copy rather than the real one.

The IPC between the overlay and the guest goes through MMKV with deliberately short keys: gss (golden shot), ngs (new game start), rss (reset), bff (buffering), sts (the authorized PID), cr, cm.


The Payloads Are Hidden, But Barely

Knowing they use YAHFA, I went looking for the hooks. They are not there. Every class name in the app is obfuscated to something like O0000000000000000000, which is tedious but survivable, and after an hour of following references I still could not find a single line of code that touches Carrom Pool.

That is because none of the hooking logic is in the DEX. VM_Work_ini.initialize() calls a native method that returns a base64 string, writes it to files/temp, loads it with a DexClassLoader and applies it through YAHFA:

public static void initialize(Context context, ClassLoader classLoader) {
    if (isInit) return;
    isInit = true;
    if (isHookEnable) {
        applyHookPlugin(getApkHookFile(context), null, classLoader);
    }
    new Thread(new O0000000000000000000(context)).start();
}

private static String getApkHookFile(Context context) throws IOException {
    byte[] bArrDecode = isChromeLogin(context)
        ? Base64.decode(NatLib.O000O0O000OO0O000000(1).getBytes(), 0)
        : Build.VERSION.SDK_INT >= 35
            ? Base64.decode(NatLib.O000O0O000OO0O000000(2).getBytes(), 0)
            : Base64.decode(NatLib.O000O0O000OO0O000000(0).getBytes(), 0);
    // ... write to files/temp, chmod read-only, return path
}

NatLib.O000O0O000OO0O000000(int) is a JNI method in libcpp_code.so. Indices 0, 1, 2 select the main hook plugin variants, -1 is a classloader hook and -2 a crash-fix hook.

The whole point of putting them in the native library is to make them hard to find. Except they are stored as plaintext base64, so the entire protection collapses to one command:

strings -n 200 libcpp_code.so | awk '/^UEsDBA/{n++; print > ("blob" n ".b64")}'
for f in blob*.b64; do base64 -d "$f" > "${f%.b64}.zip"; done

UEsDBA is PK\x03\x04. Five ZIP files, each a tiny signed APK containing a YAHFA plugin DEX. All of the app’s name obfuscation - and it is thorough, every class is named something like O0000000000000000000 - protects nothing once you have these.

A note on false leads: I initially thought I had found a sixth, encrypted 17.5 KB blob at offset 0xd8cd1 with 7.28 bits/byte of entropy. It was not. rabin2 chunks long strings every 4096 bytes, and I had decoded a base64 substring starting at a non-aligned offset. Checking the static initializer showed exactly five global string constants. Worth mentioning because high entropy on a misaligned base64 slice looks exactly like ciphertext.


Defeating PairIP

This is where the actual engineering is, and it is the reason I decided this deserved a write-up.

Carrom Pool is protected by PairIP, Google’s application protection that replaces the body of sensitive methods with a call into a bytecode interpreter:

public class com.pairip.VMRunner {
    public static Object executeVM(byte[] vmCode, Object[] args);
}

The original code is gone. What remains is opaque bytecode fed to a native VM in libpairipcore.so. Running that inside VirtualApp breaks in several ways at once, and BitAIM+ addresses each of them.

flowchart TB
    subgraph s1["1. Miniclip writes the method"]
        A["CarromActivity.onCreate()<br/>five ordinary calls"]
    end

    subgraph s2["2. Google's PairIP pass deletes it"]
        B["CarromActivity.onCreate()"]
        C["VMRunner.executeVM(sealed bytecode)"]
        D[["libpairipcore.so<br/>interpreter"]]
        B --> C --> D
    end

    subgraph s3["3. BitAIM+ puts it back"]
        E["YAHFA hook takes over onCreate()"]
        F["hand-written Java<br/>reproducing the same five calls"]
        E --> F
    end

    A ==>|"original instructions removed"| B
    D ==>|"watch the interpreter,<br/>infer what it must be doing"| F

Making pairipcore load at all

Under VirtualApp the classloader hierarchy is faked, so System.loadLibrary resolves the calling class incorrectly and pairipcore refuses to initialize. Their fix walks the stack to find the real caller and calls the hidden Runtime.loadLibrary0 directly:

public static void hook(String libName) throws ClassNotFoundException {
    Class<?> realCallerClass = null;
    StackTraceElement[] stack = new Throwable().getStackTrace();
    String hookClassName = System_loadLibrary_hook.class.getName();
    for (StackTraceElement e : stack) {
        String clsName = e.getClassName();
        if (!clsName.equals(hookClassName)) {
            try {
                realCallerClass = Class.forName(clsName, false,
                    System_loadLibrary_hook.class.getClassLoader());
            } catch (ClassNotFoundException e2) { }
            if (realCallerClass != null) break;
        }
    }
    if (realCallerClass != null) {
        Runtime runtime = Runtime.getRuntime();
        Method m = Runtime.class.getDeclaredMethod("loadLibrary0",
                       Class.class, String.class);
        m.setAccessible(true);
        m.invoke(runtime, realCallerClass, libName);
        return;
    }
    backup(libName);
}

Using the VM as a trigger

They hook executeVM itself and use a specific bytecode blob as an anchor point. When a payload of exactly 111 bytes goes through, they link their own engine:

public static String vlen = "111";
public static String className = "com.pairip.VMRunner";
public static String methodName = "executeVM";
public static String methodSig = "([B[Ljava/lang/Object;)Ljava/lang/Object;";

public static Object hook(byte[] vmCode, Object[] args) {
    ObfFieldDumper.initOnce();
    if (vmCode != null && vmCode.length == Integer.parseInt(vlen)) {
        Class<?> natLibClass = findClassAny("com.vbox.NatLib",
                                            AppCLHolder.getAllSnapshot());
        Method m = natLibClass.getDeclaredMethod("linkNative");
        m.setAccessible(true);
        boolean ok = (Boolean) m.invoke(null);
        Log.d(TAG, "NatLib.linkNative() => " + ok);
    }
    return null;
}

Rewriting the virtualized methods by hand

This is the part that took someone real effort. CarromActivity.onCreate is one of the methods PairIP virtualized. BitAIM+ replaces it with a hand-written reimplementation that replays the original sequence through reflection:

public static String className = "com.miniclip.carrom.CarromActivity";
public static String methodName = "onCreate";

public static void hook(Object thisObj, Bundle bundle) {
    Class<?> buildConfigClass = Class.forName("com.miniclip.carrom.BuildConfig");
    Field nativeLibField = buildConfigClass.getField("NATIVE_LIBRARY_NAME");
    String nativeLibName = (String) nativeLibField.get(null);
    Method setNativeLibMethod = findMethodInHierarchy(thisObj.getClass(),
                                    "setNativeLibraryName", String.class);
    setNativeLibMethod.invoke(thisObj, nativeLibName);

    NucleusActivity_onCreate_hook.backup(thisObj, bundle);   // super.onCreate

    findMethodInHierarchy(thisObj.getClass(), "setupAudioChangedListener")
        .invoke(thisObj);

    Class.forName("com.miniclip.clipboard.Clipboard")
        .getDeclaredMethod("init", Activity.class).invoke(null, thisObj);

    Class.forName("com.miniclip.platform.MCApplication")
        .getDeclaredMethod("disableRequestPermissionRationale").invoke(null);
}

Somebody sat down, traced what the PairIP VM was doing for that method, and transcribed it back into Java. setNativeLibraryName, then super.onCreate, then the audio listener, then clipboard init, then a permission rationale toggle. That is manual devirtualization, done by reading the interpreter’s behavior.

Tracking Miniclip’s obfuscation

The last piece handles the fact that Miniclip renames fields between releases. A JSON template lists classes and static fields, dumpOnce() reads their current values out of the running game and writes obf_fields_snapshot.json, and initOnce() writes values back in:

private static final String OUTPUT_FILE_NAME = "obf_fields_snapshot.json";

public static void initOnce() {
    JSONObject root = new JSONObject(ObfTemplate.JSON_TEMPLATE);
    Iterator<String> classKeys = root.keys();
    while (classKeys.hasNext()) {
        String className = classKeys.next();
        Class<?> clazz = tryLoadClass(className);
        // ... setAccessible + f.set(null, valueToSet) per field
    }
}

It is a maintenance tool. It lets them re-target a new game build without rebuilding the plugin.


How It Reads the Board

Once PairIP is out of the way, the actual cheat is straightforward.

linkNative parses /proc/self/maps, lowercases entries, matches on the game’s engine module and caches its base address in an unordered_map:

libgame-CARROM-GooglePlay-Gold-Release-Module-1323.so
Module base = %p
Found base by signature = 0x%lx

Note the fallback: byte-pattern scanning, so they are not entirely dependent on a hardcoded module name. The 1323 in the string is stale, the current game ships 1477.

The native library imports exactly the primitives you would expect:

process_vm_readv   ptrace   mprotect   dl_iterate_phdr   dladdr

and exposes:

peekModuleAddressHex (basePtr) call
pokeModuleAddressHex: wrote %zu bytes to %p
pokeModuleAddressHex: mprotect RWX failed addr=%p len=%zu errno=%d
installHookModule failed for %s at offset %s

So they read the physics state, and they also write to the game’s code segment after flipping the page to RWX. That distinction matters legally, and I will come back to it.

The offsets are versioned. The Java side stores the game’s versionCode under the key cv and looks up a matching entry under o6, which the native side unpacks:

decode_hex48_to_offsets6(const char*, const char*, char**)
encode_offsets6_to_hex48(const char**, const char*, char*)
free_offsets6(char**)

Six offsets packed into 48 hex characters. o6 is a plain string field on a Firestore document (drsd/y26m06d19), so shipping support for a new Carrom Pool release is a database edit, not an app update.

ptrace, not just a read

The reads themselves are not subtle. MemRead::MemSearch_4 decompiles to the textbook sequence:

ptrace(PTRACE_ATTACH, pid, 0, 0);       // "error:Attach" on failure
waitpid(pid, &status, 0);
process_vm_readv(pid, local_iov, 1, remote_iov, 1, 0);
ptrace(PTRACE_DETACH, pid, 0, 0);

Attaching to another process normally requires being its parent or having CAP_SYS_PTRACE. Inside the container, BitAIM is the parent of the game process and shares its UID, so the kernel allows it with no privilege at all. This is the payoff of the whole VirtualApp design, in four lines.

A query family that gives the game away

The MemRead class exposes a graded set of scanners, and their signatures are the interesting part:

MemVSearchINT   (int, int, long, long);
MemVSuperQuery  (int, long, long, int*, int*, int);                                  // 1 set
MemVMegaQuery   (int, long, long, int*, int*, int, int*, int*, int, int*, int*, int); // 3 sets
MemVGigaQueryV2 (int, long, long, int*, int*, int, /* ... */);                        // 4 sets

Super, Mega, Giga. Each takes one more (int* xs, int* ys, int count) triple than the last. These are not “read address X” calls, they are searches for a set of coordinate pairs that must all be present.

Which raises the obvious question: where do the coordinates come from?


Locating the Physics Engine

The memory path does not read a fixed address. It finds one, using a technique any game-trainer author will recognise, and the details are worth spelling out because they are the most competent part of the whole product.

The scan is page by page:

for (off = 0; off < range; off += 0x1000) {
    process_vm_readv(pid, page, 0x1000, base + off);   // one 4 KB page
    for (i = 0; i < 0x1000; i += 4) {
        if (page[i] == 0xC0787970) {                   // anchor
            ...verify the signature...
        }
    }
}
flowchart TB
    A["Parse /proc/self/maps<br/>locate the engine module"] --> B["ptrace attach + process_vm_readv"]
    B --> C["Read the next 4 KB page"]
    C --> D{"Anchor float present?<br/>-3.88 / -3.59 / -174.15"}
    D -->|no| C
    D -->|yes| E{"Signature matches around it?<br/>0.12, 8.2, 2.55 at fixed offsets"}
    E -->|no| C
    E -->|yes| F["Physics structure found<br/>read exact disc positions"]

Those anchor constants are floats. Decoded:

ConstantAs float
0xC0787970-3.8824120
0xC065C4F1-3.5901453
0xC32E278E-174.1545105
0xBF7FFFFF-0.9999999

Fixed geometry values that live in the engine and never move. When one is found, the scanner checks a signature around it:

addr = pageBase + off + (long)offsets[k] * 4;      // word offset from the anchor
process_vm_readv(pid, &tmp, 4, addr);
ok &= (read == 4 && tmp == expected[k]);

The signature itself is hardcoded in the Java as a list of (word offset, expected 32-bit value) pairs:

arrayList.add(new Pair(  0, 1069463633));
arrayList.add(new Pair( -1, -343597384));
arrayList.add(new Pair( -2, 1075865190));
arrayList.add(new Pair( -3, 1717986918));
arrayList.add(new Pair(-24, 1074030182));
arrayList.add(new Pair(-25, 1717986918));

Which looks like noise until you notice the values pair up into little-endian doubles:

Word offsetsDouble
-1, 00.12
-3, -28.2
-25, -242.55

Round, human-chosen numbers. They are the physics constants of a carrom board sitting in the engine’s parameter block, and BitAIM finds the structure by looking for them. That is why the MemRead class comes in grades - MemVSuperQuery, MemVMegaQuery, MemVGigaQueryV2 - each taking one more signature set than the last. More constraints, fewer false positives.

Once the block is located, the guest process reads the live board out of it, serialises it to JSON and drops it in MMKV for the overlay to pick up:

{"crl": {"s": 9, "x0": ..., "y0": ...}, "vtr": {"x": ..., "y": ..., "s": ..., "d": 2}, "a": 1}

A correction, and how I got it wrong. I first read this as the vision pipeline seeding the memory scan: measure the coins on screen, then search memory for those coordinates. It is a tidier story and it is wrong. The values passed into the scanner are hardcoded engine constants, not measurements, and the vision output never leaves Java - it goes to the overlay and to the on-image geometry, and nothing else.

The two pipelines are independent. Pixels for legacyAI and NewAI, signature scanning for DirectAim. I am leaving the mistake documented rather than quietly deleting it, because “these two subsystems must feed each other” is exactly the kind of assumption that feels obvious and costs you an afternoon.

The “AI” That Isn’t

The app is named after it. The package is app.ai.lab.bitaimplus. The store listing calls it “AI Aim assistance”. So let us be precise about what is actually there.

No inference runtime, anywhere. The four native libraries link against exactly liblog, libm, libdl and libc. No libneuralnetworks.so, so no NNAPI. On the Java side, zero hits for TensorFlow, TFLite, Interpreter, ML Kit, ONNX or OpenCV. No model file in the assets.

The AI package is a naming coincidence. app.ai.lab.bitaimplus.AI.model contains four classes: Fwqr, Kqra, Nhka, Zsnf. They are @Keep-annotated POJOs for Firebase serialization. “Model” as in data model. The vendor’s own name is “ai.lab”.

The native library imports no mathematics at all. Here is the complete set of math-adjacent imports: atof, strtod, strtof, strtold. That is parsing. There is no sqrt, no sin, no cos, no atan2, no pow, no hypot. You cannot compute a cushion reflection angle without at least a square root. There is no physics simulation in this binary.

So what does the engine do? FunctionSlope is a 168-byte struct, 42 floats, the first being a key. Three functions operate on an array of them:

// getMin_near(table, x, n) -> nearest row with key below x
// getMax_near(table, x, n) -> nearest row with key above x

void getInterpolationFunctionSlope(float *out, float x, float *a, float *b) {
    out[0] = x;
    float w1 = (x - a[0]) / (b[0] - a[0]);
    float w0 = (x - b[0]) / (a[0] - b[0]);
    // out[i] = a[i] * w0 + b[i] * w1  for the remaining 41 floats
}

That is a lookup table with linear interpolation. generateFunctionSlope fuses the two neighbor searches and calls the lerp.

Where do the tables come from? A family of parsers reconstructs rows from strings, and getFS uses atof:

FROM_STRING_S_S    FROM_STRING_S_E    FROM_STRING_S_ES
FROM_STRING_S_EE   FROM_STRING_S_CS   FROM_STRING_S_CE
FROM_STRING_D      FunctionSlopeToString

The suffixes look like start/end/cushion combinations, one calibration table per shot family. I did not have to guess where those tables live: they ship inside the APK, and I decrypted them. That is the next section.

The vision that does exist is not learned. There is a MediaProjection pipeline creating a VirtualDisplay named AiAim, feeding an ImageReader, and it does feed a real detector - I get to that in the next section. But the two native pixel routines are pure plumbing:

  • updateWholePixels - RGBA to ARGB byte swizzling, row by row with stride handling
  • updateCachePixels - compares against the previous frame, counts changed pixels, returns the count

Format conversion and a dirty-region diff. The actual detection lives in Java, and it is template matching plus background subtraction against reference images, which I will show in a moment. Classic image processing, not machine learning.

The 444 rows had to be produced somehow, and they may well have been fitted offline with something statistical. That is unverifiable from the binary, and it changes nothing about the shipped product. A table of logarithms is not a mathematician.


Breaking the Assets

The APK carries two opaque files, assets/bin at 21.7 MB and assets/bin2 at 257 KB, both ASCII, both high entropy. 70% of the package by size. I assumed at first that the big one was a bundled copy of the game. I was wrong on both counts, and the truth is better.

assets/bin: the vision reference set

The key was sitting in the Java, hardcoded:

public static String OO000000000000000000(String str) {
    SecretKeySpec secretKeySpec = new SecretKeySpec("Bar12345Bar12345".getBytes(), "AES");
    Cipher cipher2 = Cipher.getInstance("AES");   // AES/ECB/PKCS5Padding
    cipher2.init(2, secretKeySpec);
    return new String(cipher2.doFinal(
        Base64.decode(str.substring(str.indexOf(",") + 1), 0)), "UTF8");
}

Bar12345Bar12345 is a key that appears in a thousand StackOverflow answers about Cipher.getInstance("AES"). Decrypting gives 16 MB of JSON with keys "0" through "7", each holding a base64 PNG:

ct = base64.b64decode(payload)
pt = AES(b"Bar12345Bar12345", ECB).decrypt(ct)   # strip PKCS5 padding
data = json.loads(pt)                             # keys "0".."7"

Eight 1000x1000 RGBA images, and they are the Carrom Pool board skins. The plain wooden board, several with engraved rosettes and mandalas in the centre circle, a teal one, a dark walnut, a red-trimmed variant and a pink one.

The eight Carrom Pool board skins recovered from assets/bin
The eight board textures hidden in assets/bin, recovered with the hardcoded AES key. Each is a 1000x1000 RGBA PNG, keyed "0" to "7" in the decrypted JSON. The app decrypts all sixteen megabytes at every launch and never looks at them again.

They are loaded on a background thread into a static list:

for (int i = 0; i < 8; i++) {
    String string2 = jSONObject.getString(i + "");
    OO00O0OOOOO000000000.f3328OO000000000000000000.add(
        O00000O00OO000000000.O0000000000000000000(string2));   // -> Bitmap
}

A trap worth naming. My first pass concluded this list was never read. ArrayList<Bitmap> f3328 appeared exactly twice in the decompiled source: its declaration and that single add(). I nearly published that the app decrypts sixteen megabytes at every launch for nothing.

It was a decompiler artifact. The class that consumes the list contains a 1877-instruction method that jadx could not lift, and it silently replaced the entire body with throw new UnsupportedOperationException("Method not decompiled"). My grep was searching a method that had been erased.

The scale of it: across the application code, 201 files carried that exception. Re-running the whole APK with --show-bad-code brought that down to 4. So 197 method bodies were missing from my first pass, one of which was load-bearing for a conclusion I was about to publish. If a decompiled class contains that exception, treat every “this is never used” statement about it as void, and re-run before you write anything down.

Here is what actually happens. The detector walks the eight reference boards, asks a matcher whether the current capture corresponds to board i8, and on a hit keeps that bitmap as the reference:

for (int i8 = 0; i8 < 8; i8++) {
    result = matcher.O0O00000000000000000(capturedFrame, boardMetadata.get(i8));
    if (result.f2399O0000000000000000000) {          // matched
        f6123 = new int[board(i8).getHeight() * board(i8).getWidth()];
        board(i8).getPixels(f6123, 0, board(i8).getWidth(), 0, 0, ...);
        result.f2400OO000000000000000000 = board(i8);  // reference bitmap
        break;
    }
}
// none of the eight matched -> fall back to the downloaded pack

Then two million integers of comparison:

public int[] f3066O0000000000000000000 = new int[1000000];   // captured frame
public int[] OO000000000000000000   = new int[1000000];      // reference board

capturedBitmap.getPixels(this.f3066O0000000000000000000, ...);
referenceBitmap.getPixels(this.OO000000000000000000, ...);

public final boolean OO000000000000000000(int i, int i2) {
    int i3 = (i2 * 1000) + i;
    int a = this.f3066O0000000000000000000[i3];
    int b = this.OO000000000000000000[i3];
    return Color.red(a)   > Color.red(b)   - 15 && Color.red(a)   < Color.red(b)   + 15
        && Color.green(a) > Color.green(b) - 15 && Color.green(a) < Color.green(b) + 15
        && Color.blue(a)  > Color.blue(b)  - 15 && Color.blue(a)  < Color.blue(b)  + 15;
}

1000 by 1000, which is exactly the resolution of the board PNGs. Identify the skin, subtract the empty board from the live capture, and every pixel outside a fifteen-level RGB tolerance is something that should not be there: a coin, the striker, the queen.

flowchart LR
    A["Screen capture"] --> B["Which of the 8 boards?<br/>match the frame colour range"]
    B --> C["Align and rescale<br/>to 1000 x 1000"]
    C --> D["Subtract the empty board<br/>tolerance +/- 15 per RGB channel"]
    D --> E["Keep blobs that are<br/>54-66 px tall, 20-35 px wide"]
    E --> F["Disc positions"]

The matching step is not correlation either. Each board skin ships with metadata (the rbgl field) that is just twelve integers, three min/max pairs for red, green and blue. The matcher averages a horizontal run of pixels down the centre column and walks for the frame colour:

// average a 200/1080-wide run centred on x, at row y
int avg = Color.argb(255, red/n, green/n, blue/n);

// does it fall inside this skin's colour bounds?
return r >= b[6] && r <= b[7] && g >= b[8] && g <= b[9]
    && bl >= b[10] && bl <= b[11];

for (int y = (int)(h*0.2f); y < (int)(h*0.4f); y++)   // find top edge
    if (inBounds(sample(cx, y))) top = y;
for (int y = (int)(h*0.8f); y > (int)(h*0.6f); y--)   // find bottom edge
    if (inBounds(sample(cx, y))) bottom = y;
if (bottom - top >= (int)(width * 0.8f)) { /* plausible board */ }

Colour-range edge detection, with a sanity check that the box is roughly square. The first skin whose bounds produce a valid rectangle wins, and its PNG becomes the reference.

Then the coin finder, which is a blob detector with hardcoded size gates:

for (int y = 0; y < 1000; y++) {
    // build runs of consecutive pixels that differ from the reference
    ...
    // for each run: measure vertical extent at its centre
    while (matchesReference(cx, y2) == false) height++;
    if (height > 54 && height < 66) {          // a coin is this tall
        while (matchesReference(x2, cy) == false) width++;
        if (width > 20 && width < 35) {        // and this wide
            // dedupe against coins already found, +/- 15 px
            coins.add(new Coin(cx, cy));
        }
    }
}

A coin on a board normalised to 1000x1000 is between 54 and 66 pixels tall and 20 to 35 wide. Those numbers are not derived from anything at runtime. Somebody measured them once and typed them in.

The trajectory work on this path is equally direct. The “slope” the whole engine is named after is literally:

float slope(Point a, Point b) {
    float dx = a.x - b.x;
    if (dx == 0f) return 0f;
    float dy = a.y - b.y;
    return dy == 0f ? 1000f : dx / dy;
}

and collisions are found by marching along the line and sampling the pixel buffer, marking tested pixels blue as it goes.

The routine that does this is a single 750-line method, and its shape is worth reporting because it settles the question for good. It contains eighteen call sites of that pixel probe and not one call to Math.sqrt, Math.atan2, Math.sin or Math.cos. Nothing trigonometric at all, which is exactly what the native library’s empty import table predicted.

The probes expand outward from a point until the test fails:

float f4 = (-i3) / 2;
if (probe(p.x + f4, p.y)) {
    if (probe(p.x + f4, p.y + 1.0f)) { ... }
}
...
float f9 = i4 - (i4 / 2);
if (!probe(f6 + f9, f5)) { right = f6 + f9; foundRight = true; }
float f10 = (-i4) / 2;
if (!probe(f6 + f10, f5)) { left = f6 + f10; foundLeft = true; }

Widening left and right until the colour test stops matching, which is how it measures a disc’s extent and how it detects an obstruction along a candidate path. The recurring constants are screen and board references (1080, 1000.0f, 852.0f, 832.0f) and one sentinel, 98989.0f, standing in for “no solution”.

An entire aim engine built from integer stepping, a slope, and a colour comparison.

That is genuine computer vision. It is also the kind you would have written in 1995. There is no model, no classifier, no learned feature. A template library, three colour ranges, two size gates and a threshold.

The fallback path is worth noting too. If none of the eight bundled boards matches, the code tries a second set loaded from a downloaded file, in the same encrypted JSON format but with extra keys (jsid, rbgl, size, imar). That is how they ship support for a board skin Miniclip added after this APK was built, without an app update.

So the eight images are not dead weight. They are the app’s eyes. And the app has two independent ways to see the board: this one, and reading the physics engine’s memory directly. Which fits the three engines the entitlement system exposes - legacyAI, NewAI and DirectAim - with a boolean deciding, at thread startup, whether the board state comes from the pixels or from process_vm_readv.

The “AI” name now makes a little more historical sense. There was never learning, but there was, and still is, image recognition.

assets/bin2: the actual engine data

The small file goes straight to native, NatLib.O000000000OO0O00OO00(new String(bArr)), which lands in FN::iniDec. Decompiling that gives the key as a literal, and dec_d gives the scheme:

size_t n = strlen(payload);
uchar *buf = base64_decode_(payload, n, &len);
AES_init_ctx_iv(ctx, key, iv);
AES_CTR_xcrypt_buffer(ctx, buf, len);

AES-128-CTR, key D3C72B6E80176080, and an IV read from .rodata at offset 0xe6464:

f0 f1 f2 f3 f4 f5 f6 f7 f8 f9 fa fb fc fd fe ff

That is the initial counter block straight out of the tiny-AES-c CTR test vector, shipped unchanged. Note also that the binary contains three separate base64 tables, each followed by its own IV, and the three decryption paths use different pairs. Picking the wrong one costs you an afternoon.

Decrypted, bin2 is plain text:

<-10000.0,-10000.0,-10000.0, ... >$<1.6127899,1.6127899,1.6133652,1.6147282, ... >$ ...

444 blocks between angle brackets. And every single block contains exactly 42 floats.

That is the proof. FunctionSlope is a 168-byte struct, and 168 / 4 = 42. The data file and the struct layout I recovered from the disassembler agree exactly, which settles what the engine is without any interpretation on my part.

The $ separator splits them into four tables:

TableRowsKey range
0101sentinel rows (-10000.0)
11410.0000 to 2.6836
21110.0000 to 1.3425
3910.0000 to 1.0837

Four calibration families, keyed by what look like angles in radians, with smooth monotonic values inside each row. Exactly what getMin_near / getMax_near / getInterpolationFunctionSlope consume.

The file ends with one more string, unencrypted after the last table:

https://bitaim-plus-83752-default-rtdb.asia-southeast1.firebasedatabase.app/ServerTimeInfo/GMT.json

So the entire “AI aim engine” is 444 rows of precomputed numbers and a clock check, hidden behind a demo IV and a key any search engine will hand you.


The Paywall, Decompiled

The entire business model fits in one method. Two hardcoded constructor calls:

public OO0OOOO0OOO000000000 O0000000000000000000() {
    if (this.f2044O0000000000000000000) {          // forced free
        return new OO0OOOO0OOO000000000(
            true, true, false, false, false, false, false, false, 1, 0, false, false);
    }
    if (this.OO000000000000000000) {               // forced full
        return new OO0OOOO0OOO000000000(
            true, true, true,  true,  true,  true,  true,  true,  6, 5, true,  true);
    }
    return /* server-supplied custom tier */;
}

Twelve parameters. Ten booleans and two integers. Free gets two booleans set. Premium gets all ten.

The server sends the same object as JSON, which recovers the original field names:

new OO0OOOO0OOO000000000(
    json.getBoolean("sc"),    json.getBoolean("udpc"),
    json.getBoolean("updcc"), json.getBoolean("scc"),
    json.getBoolean("ss"),    json.getBoolean("upds"),
    json.getBoolean("updsc"), json.getBoolean("ssc"),
    json.getInt("c"),         json.getInt("b"),
    json.getBoolean("bv1"),   json.getBoolean("bv2"));

The abbreviations follow a regular scheme: an s or upd prefix, a c or s, sometimes a trailing c. My reading, offered as interpretation rather than established fact, is coin / striker / cushion. Free gets “show coin path” and “compute coin path” only. Everything striker-related and everything cushion-related is off.

The two integers I can confirm by use rather than by name. In the solver:

int i = entitlement.c;
if (i == 1) {
    depth = i - 1;              // 0
    bounceBudget = entitlement.b;   // 0
} else {
    depth = 2;
    bounceBudget = 1;
}
if (entitlement.c < depth)        depth = entitlement.c;
if (entitlement.b < bounceBudget) bounceBudget = entitlement.b;

c is the trajectory search depth, b the cushion bounce budget. The settings panel names them directly:

Switch  "Bounce shot"    -> bounce_on
Switch  "Brush shot"     -> brush_on
Switch  "Lucky shot"     -> lucky_on
Picker  "Multi - Coins"  -> multi_coins    (c: 1 free, 6 premium)
Picker  "Multi - Bounce" -> multi_bounce   (b: 0 free, 5 premium)

Six is almost certainly the “6 Line Aim Hack” from their own referral copy. The bounce switch is guarded by a literal entitlement(DirectAim).b > 0 in three places.

The booleans gate whole branches of the solver. There is a resolution mode enum { c, m_1, m_2, m_3, m_4 } and each multi-collision case is behind a flag. On the free tier those branches never execute, so those shots do not merely display worse - they are never searched.

Two more paywalls sit outside the solver:

"App auto-closed in 5 min, Upgrade to premium to increase time limit."
"Upgrade to premium to unlock Lucky Shot"

The five minute timer kills the overlay and flips its icon red. “Lucky shot” is gated three ways: premium, a 64-bit device (“For 32bit coming soon…”), and Carrom Pool above 6.1.1, checked as versionCode > 820.

Internally Lucky shot is the gss / golden shot flag. Enabling it does not change the existing overlay, it adds a second window: a square full-width view offset by -11.8% of screen width, redrawn every 20 ms, drawing glowing line segments and circles in a palette named after IPL cricket franchises (mi, csk, kkr, rcb, srh, rr, punjab, gujrat, lucknow, delhi).

It is fed by a different native entry point than the main solver, one that returns the complete board state:

int n = json.getInt("s");
for (i = 0; i < n; i++)
    coins.add(new Point(json.getDouble("x" + i), json.getDouble("y" + i)));

striker = new Striker(new Point(x, y), size,
                      d == 1 ? D1 : d == 2 ? D2 : d == 3 ? D3 : D4);

Every coin coordinate plus the striker with its shooting side. Whether it enumerates all pottable coins, picks a best shot, or predicts the resulting layout, I could not determine from the code alone. That logic lives in the native routine and the geometry converter, and “lucky” is a marketing label, not a technical one. I am not going to invent a definition for it.

Finally, the tiering is not simply free versus paid. There are three engines, each with its own entitlement set:

public enum { legacyAI, NewAI, DirectAim }

They are not three names for one thing. Each is selected from a different place in the code, and the two acquisition paths map onto them:

EngineSelected fromBoard state comes from
legacyAIinside the MediaProjection capture servicepixels
NewAIa thread the capture service starts, flag truepixels
DirectAimVM_Work_ini.setIniController, in the hooked game process, flag falseprocess_vm_readv

The switch is a single boolean carried into the worker thread:

if (visionMode) {
    showOverlay(R.layout.oo00000000oo0o00oo0o);
    entitlement = manager.get(Engine.NewAI);
} else {
    showOverlay(R.layout.o000000000oo0o00oo0o);   // the Lucky shot surface
    entitlement = manager.get(Engine.DirectAim);
    startFiveMinuteTimer(context);
}

legacyAI is referenced only from within the capture service itself, alongside the template matcher. The naming tells the product history: an original vision engine, a second-generation vision engine, and the memory reader that came last. Each is priced separately, which is why the entitlement object is fetched per engine rather than once.

On top of that sit three profiles selected by two booleans, and the middle one comes from the server, so arbitrary intermediate tiers can be sold without touching the app.

What actually enforces payment

All of the above is local. The tiers are compiled into the APK, the calibration tables are in the APK, the engine is in the APK. So the obvious question is what stops a customer from simply flipping the switch themselves.

flowchart LR
    A["You pay<br/>UPI / GPay / Paytm"] --> B[("Firebase<br/>UsrLg/your-google-id<br/>xe: true")]
    B --> C["The app reads<br/>one boolean"]
    C --> D["Picks the paid<br/>entitlement profile"]
    D --> E["6 chain shots<br/>5 bank shots<br/>no 5-minute timer"]

    F["Self-integrity check<br/>hash of its own code"] -.->|"result overwritten<br/>4 instructions later"| G(["never used"])

The answer starts well. When you sign in with Google, the app reads one record from Firebase and takes a single field from it:

FirebaseDatabase.getInstance().getReference("UsrLg")
    .child(sanitise(googleAccountId))
    .addListenerForSingleValueEvent(...);

// in the callback
Kqra record = dataSnapshot.getValue(Kqra.class);
callback.onResult(record.isXe(), record);      // xe == "has paid"

That boolean becomes the premium flag, the premium flag picks the entitlement profile, and the profile decides everything you can do. One field in a database, everything else client-side.

Which is fine, provided the client cannot be modified. And they thought of that. The app contains a genuine self-integrity check: it resolves the path of its own APK, reads its DEX and hashes it. Better still, it calls that check from inside FN::iniDec, the routine that decrypts the engine’s calibration tables. Tying an integrity check to the code path that unlocks your product is exactly where you want it.

Then it throws the answer away.

5ff44:  sub  x8, x29, #0x100          ; return slot for the hash
5ff48:  mov  x0, x19                  ; JNIEnv
5ff4c:  bl   GetHashKeyString         ; hash of our own DEX -> [x29-0x100]

5ff78:  adrp x1, 0xe6000
5ff7c:  add  x1, x1, #0xba5           ; "D3C72B6E80176080"
5ff80:  sub  x0, x29, #0x100          ; the slot holding the hash
5ff84:  mov  w2, #0x10
5ff88:  bl   std::string::assign      ; overwrite it with the AES key

The string that receives the hash is reused as scratch space for the decryption key, four instructions later. The value is never read, never compared, never sent anywhere, and the function has no global side effects to compensate. The integrity check is dead code.

So nothing detects a patched client. Set that one boolean and every tier unlocks, permanently, offline, with no way for the vendor to know.

They still hold a remote kill switch, the NSV check that must receive the literal body 200. But it gates the board-state accessor rather than the entitlement, and it validates a server response, not the application. It lets them turn the product off. It does not let them see that a copy has been modified.

Which makes the whole commercial model rest on a single assumption: that people who play carrom do not open APKs. On the evidence of their customer base, that is probably a safe bet. It is still an odd place to land after spending real effort defeating PairIP.


What Leaves the Device

I went looking for exfiltration, because a container app with 143 permissions holding your game account’s Facebook session deserves the question.

The verdict is licence telemetry, not data theft. But the telemetry is not nothing.

The user identifier is ANDROID_ID, used everywhere. Alongside it they resolve your public IP by querying five separate services (ipify, icanhazip, checkip.amazonaws.com, ipinfo.io, wtfismyip.com, plus the Cloudflare trace endpoint). If you signed in, they also have your Google account identifier. All of it goes to Firebase:

FirebaseDatabase.getInstance().getReference("Usrinfo")
  .child(hash(googleAccountId != null ? googleAccountId : androidId))
  .setValue(new Nhka(ServerValue.TIMESTAMP, publicIP(), androidId, false));

with a di sub-node accumulating {id, ts, ip} tuples. That is device-sharing detection for the paid licence.

The REST API lives on a domain that is not the vendor’s public one: https://function.cloudsw3.com/bt-app-api/, with sixteen endpoints, all product features (referral, tournament, global chat, analytics).

There is also one endpoint that appears only in the native library:

https://us-central1-bitaim-plus-83752.cloudfunctions.net/analytic?extra=

Decompiling RS::iniRest shows what extra carries: a timestamp reduced to six digits and reversed, ten digits of rand() % 9, wrapped in braces and encrypted. No identifier, no device data. It is a nonce, for a licence check whose response is compared with strcmp.

The crypto is recoverable in full. encrypt_str decompiles to:

AES_init_ctx_iv(ctx, key, iv);
AES_CTR_xcrypt_buffer(ctx, buf, strlen(buf));
BinaryToHexString_(buf, strlen(buf));

AES-128-CTR, key passed as the second argument, and the IV sitting in .rodata immediately after the base64 alphabet:

...0123456789+/  AAAAAAAZBBBBBBBZ
                 ^--- 16-byte IV

The key for that endpoint is the literal CCCXBBBYAXRXYZAC.

The kill switch

There is a second network check, and this one is load-bearing. NSV(url, JNIEnv) fetches a URL through the Java HTTP helper and validates two things:

n = strlen("https://bitaim-plus");
if (strlen(url) < n)               return 0x65;
if (memcmp("https://bitaim-plus", url, n) != 0) return 0x65;
if (strcmp(response, "200") != 0)  return 0x65;   /* else the good value */

The URL must begin with https://bitaim-plus, and the response body must be the literal string 200. The result is cached and read back by FN::getNS().

Then the accessor that hands the overlay the current board state is gated on it:

if (FN::getNS() == 0x441) {
    /* call back into Java, parse {"crl": coins, "vtr": striker, "a": n} */
    return boardState;
}
return 0;

0x441 is 1089. If the check fails, the accessor returns null and the aim lines simply stop appearing, with no error and nothing to debug. It is a clean remote off switch for a licence they cannot otherwise enforce.

Worth noting the shape of that accessor, too. It is a native function whose entire job is to call two Java static methods and check a gate. The board state is not read in native code at all: the guest process writes it into MMKV as JSON, and this trampoline reads it back. Routing the flow through JNI means patching the Java alone does not get you past the gate.

What does not leave: the screen capture stays local. The MediaRecorder path writes an MP4 to external files and copies it to DCIM/bitAIM, behind a user toggle, with no upload path. No contacts, no SMS, no call logs, no arbitrary file reads. getInstalledApplications is used only for membership tests, including one that looks for com.codex.appinspector - an anti-analysis check.

The Facebook flow is the one that deserves a caveat. Meta blocks embedded WebView logins (PLATFORM__LOGIN_DISABLED_FROM_WEBVIEW), which breaks the game’s sign-in inside the container. Their workaround opens the OAuth flow in their own activity, waits for fbconnect://success, stores the URL in a local JSON preferences file, and lets the guest process read it back and delete it. The WebView sets setSavePassword(false) and setSaveFormData(false), and nothing in the code ships that URL anywhere. But the access token for your Facebook account does transit through their application and touch their disk.

One more detail worth quoting, from their own UI strings:

We detect Aim carrom is installed in your device which is chinese application, it can cause your data and personal information leak !

A cheat vendor warning you that a competing cheat vendor might leak your data.


Left In By Accident

Everything so far is what the app does on purpose. This section is what it does by mistake. All of it comes from the package itself. I did not touch their infrastructure.

An exported activity that loads any URL

The activity that hosts the Facebook OAuth WebView is exported with no permission guard:

<activity android:name="app.ai.lab.bitaimplus.UI.OOOO00O000000000000O"
          android:exported="true">
    <intent-filter>
        <action android:name="app.ai.lab.bitaimplus.OPEN_WEBVIEW" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</activity>

and it does this with the intent it receives:

settings.setJavaScriptEnabled(true);
String stringExtra = getIntent().getStringExtra("url");
if (stringExtra == null) webView.loadUrl("https://www.google.com");
else                     webView.loadUrl(stringExtra);

No scheme check, no host allowlist, nothing. Any application on the device, holding zero permissions, can make BitAim render an attacker-controlled page with JavaScript enabled, inside BitAim’s own window. This is the activity users are told to expect a Facebook login in. A malicious app can therefore render a fake Facebook login inside BitAIM’s own window, at the exact moment the user is waiting for a real one.

The exported WebView has no addJavascriptInterface on it, which limits the damage. The app’s two AndroidBridge bridges live on other WebViews loaded over HTTPS from their own domain.

The Facebook token goes to logcat

In that same activity, the logging is unconditional in the release build, and it runs before the check:

public void onPageStarted(WebView webView, String str, Bitmap bitmap) {
    Log.d("MainApp", "Webview loading URL: " + str);
public boolean shouldOverrideUrlLoading(WebView webView, final String str) {
    Log.d("MainApp", "Loaded URL: " + str);
    if (!str.startsWith("fbconnect://success")) { ... }

So the final redirect, the one carrying the access token for the game’s Facebook account, is written out in clear text. The hook plugins repeat it on their side with Log.d("ClientApp", "shouldOverrideUrlLoading invoked with: " + url).

Payments get the same treatment:

Log.d("Premium Plan", "handlePurchaseSuccess: fulljson" + str4);

That dumps the entire transaction record: order id, plan, amount, transaction id, email, status.

Since Android 11 an app can only read its own logcat, so collecting this needs ADB, a rooted device, a forensic extraction or a privileged application. It is not remotely exploitable. It is also a Facebook credential and a full payment record written to disk for no reason, in a shipping build, by developers who were competent enough to defeat PairIP.

They shipped debug builds of the YAHFA demo

This is my favourite. The five hook plugins are development builds of the upstream demo project, never renamed:

public final class BuildConfig {
    public static final String APPLICATION_ID = "lab.galaxy.yahfa.demoPlugin";
    public static final String BUILD_TYPE = "debug";
    public static final boolean DEBUG = Boolean.parseBoolean("true");
}

And the upstream sample hooks are still in there, in production, aimed at classes that obviously do not exist in Carrom Pool:

public class Hook_ClassWithStaticMethod_tac {
    public static String className = "lab.galaxy.yahfa.demoApp.ClassWithStaticMethod";
    public static String methodName = "tac";
    public static String hook(String a, String b, String c, String d) {
        Log.w(HookInfo.TAG, "in ClassWithStaticMethod.tac(): " + a + ", " + b + ", " + c + ", " + d);
        return "test" + a;
    }
}

Somebody opened the YAHFA demo project, added their PairIP work next to the samples, built it in debug and shipped it. Alongside it sits app.builderx.ogfa.hooktest.Native, their own leftover test hook, which is how I confirmed the old package name before the search engines did.

Tutorial-grade key material

Neither key I broke is merely hardcoded. Both are example values.

Bar12345Bar12345 is the key from the StackOverflow answers about Cipher.getInstance("AES"), used here in ECB mode. And the IV f0 f1 f2 ... ff is the counter block from the tiny-AES-c test vector, copied unchanged.

Cleartext transport feeding the licence check

The manifest sets usesCleartextTraffic="true", and it is used: the public IP lookups really do go out over plain HTTP to checkip.amazonaws.com, icanhazip.com and wtfismyip.com.

That IP is the input to their device-sharing detection, so anyone on the network path can decide what BitAim believes the device’s public address to be. The chat WebView also passes the user’s email in the query string, which puts it in every intermediary’s logs.

What they got right

Not everything here is broken. allowBackup is false, the OAuth WebView disables password and form-data saving, the exported activity carries no JavaScript bridge, and purchaseSuccess does not grant entitlement locally. It refetches state from the server, so the JS bridge is not a free premium unlock. The tournamentDev/* endpoints do ship in the release, but behind a caller-supplied boolean, so that one is a deliberate switch rather than an oversight.


Who Builds This

After a day inside somebody’s code you start to wonder who they are. In this case you do not have to wonder for long, because every Android app is signed, and signatures carry names. This one is not anonymized at all:

Signer #1 certificate DN:
  CN=Siddique Shabir, OU=builderX, O=builderX,
  L=Mumbai, ST=Maharashtra, C=IND
SHA-256: d1c5df0ea0d82e3382d324a4ac630952ec89ee71e9bf9d263e674d63ec2e42fe

Their own site names Siddique Mohd Shabir as publisher. Store mirrors list the developer as “App BuilderX co.” under the package app.builderx.ogfa.bitaim.

That last string closes a loop. One of the extracted hook plugins contains a leftover test class:

public class Native_nativeCall_hook {
    public static String className = "app.builderx.ogfa.hooktest.Native";
    public static String methodName = "nativeCall";
}

Their old namespace, forgotten in a debug plugin that still ships in the release.

The product history is visible too. The original bitAIM, version 1.1.18 from May 2021, was 19.6 MB and its description said it used image recognition to analyze the screen. The current build is 31 MB, renamed to app.ai.lab.bitaimplus, and does none of that. It reads memory and devirtualizes PairIP. The package rename between the two is the usual move after enforcement.

Payment runs on GPay, PhonePe, Paytm and UPI through a WebView on their own site, never Play Billing. Which is also why nobody can cut off the revenue at the store level.


The Business Around It

One application does not explain itself. The economy it sits in does.

Carrom Pool has a currency. Coins buy entry into matches, and the bigger the table the more a win pays. That gives coins a resale value, and there is an open market for them. One Indian shop lists, in rupees:

ItemPrice
5,000,000 coins₹125
20,000,000 coins₹380
100,000,000 coins₹1,600
44,000 gems₹600
Cosmetic stickers₹199 each

Roughly seventeen euros for a hundred million coins. That is the engine. A tool that reliably wins matches is not a toy, it is a coin printer feeding a market that pays cash. It pays for itself, which is why anyone bothers building something this elaborate for a board game.

How BitAIM+ itself is sold

Directly, and entirely in-house. You buy from inside the application, which opens their own payment page and takes GPay, PhonePe, Paytm or UPI. Never Google’s billing, which is also why nobody can switch off their revenue at the store level.

A small detail worth noting. Their purchase page carries a plan parameter, and the first plan is called p1. In the configuration the app downloads, the three entitlement profiles are named p1, p2 and p3, with p2 being the one whose contents the server supplies freely. The naming lines up, which fits an operator who can define an arbitrary middle tier and sell it under its own name.

There is some third-party resale. One shop offers a month of BitAIM access alongside its coin packages, and asks only for your email address to activate it.

That request tells you something the marketing does not. There is no key in BitAIM. No serial, no activation code, no field to type anything into. The only thing the application verifies is whether your Google account carries xe: true in the vendor’s database. So a reseller cannot hand you a key, because none exists. They can only pass your account to the vendor and have the flag set, which is exactly why the shop asks for an email and nothing else.

Which explains the surveillance

Recall what the app accumulates against every account: a growing list of {device identifier, timestamp, public IP}.

Licences here are attached to a person’s Google account, not to a device and not to a code. There is nothing preventing that account being handed to five friends, and nothing on the client that would notice. The device and address list is how the vendor sees it from the server side. It is not profiling. It is licence enforcement of the only kind their design leaves available.

The rest of the app reads the same way once you look for it. The five minute timeout on free users is a conversion funnel. The referral system, with its codes, rewards and share text written in Hinglish, is the growth loop. The remote kill switch is how you turn a customer off. None of it is security. All of it is commercial plumbing, implemented inside a cheat.

A market, not a product

BitAIM+ is not alone, and the neighbours matter, because it is easy to attribute one product’s behaviour to another. The competing family is called Aim Carrom, and one of its variants, Aim Carrom King, advertises something BitAIM+ does not have: autoplay, the machine taking the shot for you. I checked BitAIM+ for that specifically and it has none of the machinery, no accessibility service, no event injection, nothing.

Those competitors are sold differently too. Their retail runs through Telegram channels on a shared-account model, with pitches like “first you have to make payment then I will give you login details”, Facebook logins advertised as more reliable than Google ones, one device per purchase, no refund after activation. That is a different product with a different distribution channel, and I nearly wrote it up as BitAIM’s own before checking.

Which gives the warning message inside BitAIM+ a second reading:

We detect Aim carrom is installed in your device which is chinese application, it can cause your data and personal information leak!

Not a public service announcement. Competitor removal, aimed at the rival whose autoplay is the more attractive product.

The one I have not opened yet

I want to be careful here, because I have analysed one application and not the other. Everything above about Aim Carrom comes from its own marketing and its own sales channels, not from its code.

That said, on what is visible from the outside it looks like the more serious of the two, on both counts.

On features, it goes further than anything in BitAIM+. Autoplay means the software takes the shot itself, which removes the last thing a cheat user still has to do. Their listings also advertise brush shots, double bounce shots, kiss shots and something called an unlimited connection shot. BitAIM+ draws a line and leaves you to flick. This one plays the game.

On distribution, it is more opaque. BitAIM+ sells directly, from its own site, with a named company and a signing certificate carrying a real address in Mumbai. Aim Carrom’s premium access is sold through Telegram accounts handing out login credentials for shared accounts, payment first, no refund after activation, with the seller reachable only by direct message. There is no company name attached to any of it that I could find.

And a competitor accusing it of leaking user data is worth nothing as evidence, but it is worth noting that BitAIM+ collects a device identifier, a public IP and a Google account identifier, and considered its rival worth warning users about anyway.

So I intend to take that one apart as well. If the autoplay works the way the marketing describes, the interesting question is no longer how it sees the board, since we now know two ways of doing that. It is what it touches, and how it decides when. That is a different piece of engineering, with a different set of things it could be doing to the device it runs on.

That will be a separate post.

And the other side

Miniclip’s position is public and unambiguous: accounts using third-party tools are permanently banned, and they say they monitor continuously. Community reports suggest the anti-cheat has been revised repeatedly since 2023, and PairIP is part of that answer.

So the customer buys a tool that violates the rules, to farm a currency they hope to resell, on a platform that removes the account if it notices, from a vendor who takes payment up front. Every technical decision in this application makes sense once you see the market it serves.


I am not a lawyer. What follows is the exposure as I read it.

“Cheating in a game” is the weakest of the issues here, and it distracts from the rest. Stripping that away, the app still:

  • circumvents a technical protection measure, both PairIP and the Play licence check via a LicenseClientV3 hook, which is a separate offence from infringement under Article 6 of Directive 2001/29/EC, DMCA §1201, and India’s Copyright Act §65A
  • modifies the program’s code, not just its data

That last point matters more than it looks. In October 2024 the CJEU decided Sony v Datel (C-159/23), holding that the Software Directive protects the program’s expression, and that software which only changes the value of variables in RAM, without reproducing or altering the code, does not infringe copyright in that program. It is the strongest defence a cheat can have.

BitAIM+ does not qualify. pokeModuleAddressHex flips pages to RWX and writes bytes into the game’s code segment, and installExternalFixups places inline hooks at fixed offsets. And the ruling says nothing about defeating a technical protection measure, which is governed by an entirely separate provision.

One thing they are clear of: they do not redistribute the game. The user installs Carrom Pool from the Play Store and BitAIM clones that copy. The vendor never reproduces Miniclip’s package, which removes the simplest claim against them.

Worth adding: the US treats trafficking in circumvention tools as its own violation, distinct from using one. Selling is the offence.


Tools Used

Nothing exotic, and everything free.

ToolUse
jadxDEX to Java for the host app and the five plugins
radare2cross-references, string xrefs, function mapping in libcpp_code.so
Ghidra (headless)decompiling the crypto, the licence check and the aim engine
apkanalyzer, apksignermanifest and certificate
llvm-objdump, nm, rabin2imports, symbols, static initializers
Android emulator (API 30)lab for the game itself

Three practical notes for anyone doing the same.

Always run jadx with --show-bad-code. Without it, 201 application-code files in this APK carried a method body silently replaced by throw new UnsupportedOperationException("Method not decompiled"). With it, 4. Everything interesting in the vision pipeline was inside those missing bodies.

Ghidra 11 refuses Python post-scripts unless it was started through PyGhidra, so write the decompilation driver in Java and run it with analyzeHeadless -postScript.

And objdump on a typical Linux distribution has no AArch64 backend. Use llvm-objdump.


Conclusion

The engineering that went into BitAIM+ is real, and it is almost entirely in the wrong place. Manually devirtualizing PairIP methods by reading a bytecode interpreter’s behavior is genuinely difficult work. Running a Play-delivered App Bundle inside a virtual container, with feature modules and licence checks, is not trivial either.

One part of the design is clever. Instead of chasing a moving address, the memory scanner sweeps for the physics engine’s own constants, 0.12, 8.2 and 2.55, sitting in the arrangement only that engine produces. Find the numbers, you have found the structure. That technique is old and it is good.

All of that effort ends in a lookup table that draws a line.

The marketing sells an AI. There is no AI, and there never was: no runtime, no model, and a native library that does not import a single trigonometric function. What it has is 1990s image processing, 444 rows of precomputed numbers, and ptrace. The marketing also sells practice tooling, while the entire architecture exists to run against the live online game. And the app warns you that a competitor might leak your data, while shipping your ANDROID_ID, your public IP and your Google account identifier to a Firebase instance so it can tell whether you shared your licence.

The part I find most instructive is how thin the protection layers turn out to be on both sides. Google’s PairIP was beaten by a small team in Mumbai, by hand, method by method. And that team’s own anti-analysis - obfuscated class names on every symbol, hook logic exiled into a native library, assets encrypted twice - fell to strings, base64 -d, and two keys that were sitting in the binary as plain literals. One of them is a StackOverflow example. The other is a demo test vector.

The same asymmetry runs through their own product. They wrote a real self-integrity check and called it from exactly the right place, the routine that decrypts the engine, then reused the string holding its result as scratch space for an AES key four instructions later. Everything they sell rests on one boolean in a database that nothing verifies.

Obfuscation is not a boundary. It is a speed bump, on both ends.

The one thing that did nearly stop me was not their work at all. It was my own decompiler quietly deleting 197 method bodies and replacing them with an exception, and me not checking.

As for the game, I know exactly what is happening now, and it has not made me want to go back. The people on the other side of those matches are not better than me. They bought a table of 444 numbers and a screen reader, and between the two of them they make sure I never take a shot. The lookup table is unimpressive. What it does to the person on the other end is not.