Skip to content

Taking apart a crack of our own product

This is a teardown of a crack of our own software, published for defensive and educational reasons. We name the tools the attacker used because they are public.

A free copy of our 8 Ball Pool mod showed up on Telegram, every paid feature working. Someone had stripped the licence, wrapped it in their own packer, and put it behind their own paywall.

A crack is a chain: install under a new signature, run before the target, find its decision points in a stripped library, rewrite the answer. This walks that chain in execution order, with the disassembly at each step. One sample:

Lynx-v1.0.5-8-Ball Pool-56.29.1.apk 127 MB arm64

Diff the repack against the original:

Only in theirs: META-INF/ANDROID.{RSA,SF} re-signed
Only in ours: META-INF/LYNX.{RSA,SF}
Differs: classes9.dex, classes12.dex, liblynx.so, MANIFEST.MF
Added: classes20.dex
Added: lib/arm64-v8a/libNightmare.so
Added: lib/arm64-v8a/libshadowhook_nothing.so

Four things stand out: a re-sign, one added dex and two added native libraries, two edited loader dexes, and a different liblynx.so, our own library at an older build. Take them in order.

The signer tells the first part on its own:

theirs: CN=Android, O=Android, C=US, emailAddress=android@android.com
SHA-256 a40da80a…a71bf5dc (the public AOSP test key)
ours: CN=Lynx Android Release, O=Lynx, C=TH
SHA-256 7533a648…577aedb1

They signed with the public AOSP test key instead of ours, so the app’s own signature check had to be handled separately.

An app can read its own signing certificate and refuse to run under the wrong one. To get around that, the crack carries classes20.dex, and inside it a class called arsenal.xex and a telling string:

https://github.com/L-JINBIN/ApkSignatureKillerEx

ApkSignatureKillerEx is a public toolkit. It embeds the original certificate as a blob and hooks PackageManager.getPackageInfo(..., GET_SIGNATURES), so any code that asks Android “who signed me?” gets the real answer back, even though the file on disk is signed with the test key. classes9.dex was edited to fire this at process start, before anything else runs.

By the time our code looks at its own signature, the answer is already forged.

The added library is libNightmare.so. Its section table is the tell:

.text 0x00f5c0 0x0450cc plaintext code
.main 0x05468c 0x002680 encrypted, holds the real JNI_OnLoad
.rodata 0x056d10 0x221530 2.2 MB, encrypted second stage
.ced 0x2c8d18 0x000020 control block

The entry point, JNI_OnLoad, lives inside .main, and .main is ciphertext:

55cf4: c9 24 ce 53 f1 b9 78 e3 ; not valid AArch64

The 32-byte .ced block is the map. It decodes to a struct that points straight at the encrypted region:

.ced = { vaddr 0x5468c, size 0x2680, seed } → exactly .main

So .text is a loader and the real code is encrypted until it runs. To read the crack, unpack it first.

The decrypt runs from .init_array. It is two nested ciphers:

sub_101A0(wrapped_key, &klen); // 32-byte wrapped key from NEON constants
sub_102A0(wrapped_key, 0x20, rc4_key, &n) // AES-ECB unwrap -> 16-byte RC4 key
mprotect(region, len, RWX);
sub_20000(rc4_key, n, sbox); // RC4 key schedule (custom initial S-box)
sub_20260(region, size, sbox); // RC4 decrypt .main in place
mprotect(region, len, RX);

A 32-byte constant is AES-unwrapped to a 16-byte key. That key drives an RC4 whose S-box starts from a fixed table baked into the loader, not the identity permutation. Every input sits in the file, so the payload decrypts with no device at all.

Rather than re-implement the custom AES, we mapped the library in a small arm64 harness and called its own crypto in order. The RC4 key fell out:

9ccaaa73 63303914 924ba0cd ef619791

and .main turned into code:

55cf4: ff 83 08 d1 sub sp, sp, #0x220
55cf8: 82 00 80 52 mov w2, #4
55cfc: ff 03 01 d1 sub sp, sp, #0x40

With .main decrypted, JNI_OnLoad reads plainly:

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
(*vm)->GetEnv(vm, &env, JNI_VERSION_1_4);
prctl(PR_SET_DUMPABLE, 1, 0, 0, 0);
dladdr(JNI_OnLoad, &info);
sub_546D4(info.dli_fbase); // relocate + re-hide the loader (anti-dump)
sub_559E8(cfg1); // build config A
sub_55C20(cfg2); // build config B ("libNightmare.so")
worker(cfg1, cfg2); // install the hooks
}

sub_546D4 is housekeeping. It re-opens its own mapping, rewrites its program headers, and re-encrypts the code pages it just used, so a later memory dump of the library does not hand you the plaintext. The crack proper is the worker and its two config blocks.

Everything the crack does depends on its code running before ours. It buys that twice.

First, the loader dex is patched so the attacker library loads ahead of ours:

0000: const-string v1, "Nightmare" ; inserted
0002: invoke-static System.loadLibrary
0005: const-string v1, "lynx"
0007: invoke-static System.loadLibrary

Second, the payload hooks the dynamic linker itself. Its own strings give it away:

linker: hook(invisible) soinfo::call_constructors
linker: hook(invisible) soinfo::call_destructors

By intercepting the linker’s constructor caller, the engine controls the order in which every library’s constructors run, rather than relying on the dex ordering alone. It is in position before our first line of C++ executes.

.main is only the loader. The real mod is the 2.2 MB second stage, inflated at runtime into a fresh executable mapping. Its strings name the tooling:

shadowhook version 2.0.1-rc.15
a64: %shook (with island) OK. target %lx
DobbyHook
ARM64InstructionRelocation.cc
hook_func_addr
deviceLimit devicesUsed license

The engine is ShadowHook 2.0.1 plus Dobby, a general inline-hook framework, and it carries its own deviceLimit, devicesUsed, and license vocabulary, which is the attacker’s own licensing layered on top of the crack.

Finding our functions in a stripped library

Section titled “Finding our functions in a stripped library”

Our library ships stripped. There are no symbols for the functions the crack wants, so how does it find them?

The answer is in the engine’s own memory. Several of our function prologues appear there verbatim, 12 to 16 bytes each:

our function prologue bytes in the engine?
refresh() 0x363450 ff8300d1 fd7b01a9 fd430091 ... yes
allow() 0x363430 fd7bbfa9 fd030091 801f0090 yes
sub_36C710 ff0301d1 fd7b02a9 f31b00f9 ... yes

The engine ships a table of byte-signatures, the instruction prologues of the functions it wants, and scans our .text at runtime until it finds each one, keeping the address it lands on. It is a durable way to hook a stripped library, and it breaks the day those prologues change.

We took the running process, dumped our library’s .text from memory, and compared it to the copy on disk. The difference is exactly eight four-byte changes, each one instruction overwritten with a branch:

0x36347c -> B <island>
0x363490 -> B <island>
0x365ed8 -> B <island>
0x36c72c -> B <island>
0x39ed00 -> B <island>
0x3d18a8 -> B <island>
0x3de714 -> B <island>
0x42b174 -> B <island>

Resolving the branch targets live, every one lands in a ShadowHook island, a small trampoline that runs the attacker’s handler, executes the original instruction it displaced, and jumps back. To the rest of our code the call looks normal. It just passes through their code first.

Which eight functions? The centre of the cluster is a single global with exactly one reader and one writer:

sub_363430() { // core::allow()
return gate_get(&gate_state, 2);
}
sub_363450() { // core::refresh(), hooked twice
v = decide(scan, state); // hook at 0x36347c
gate_set(&gate_state, v, 3); // hook at 0x363490
}

Our gate computes a verdict, allowed, unlicensed, or tampered, stores it, and lets the rest of the code read it back. The crack does not forge a licence and does not touch allow(). It hooks refresh() where the verdict is computed and again where it is stored, and forces the stored value to allowed. After that, allow() returns allowed on its own, through ordinary code.

The remaining hooks back this up: one in the verification path; one on the JSON writer that serializes our network documents, to read or rewrite what crosses the wire; and three larger functions in the same networking area. Between them, the eight hooks own the licence decision at every layer that matters.

The crack is not a giveaway. The engine carries deviceLimit, devicesUsed, and renewLicense, and the attacker runs it behind their own per-device licence. The licence exchange happens once at startup, with DNS resolved out of band and the TLS carrying no readable server name, but the endpoint is there:

https://[redacted]/
https://[redacted]/lynx/v1/lynx.php

The endpoint is a per-product PHP API behind Cloudflare. A bare request returns their own error envelope:

{"status":"error","error":"Syntax error"}

The /lynx/v1/ path is named after our product, so each cracked title gets its own namespace. The request carries an api_key (the buyer’s key) and an hwid, and the response returns status, error, deviceLimit, and devicesUsed. It is a complete device-locked licensing system standing on top of our binary.

Read in execution order, the crack is one chain: forge the signature, load first, unpack a two-stage native engine, find our decision functions by scanning their prologues, overwrite eight instructions with branches into hook islands, force the licence verdict at the source, and gate the whole thing behind a device-locked licence served from a copy of our own backend.

We reversed every stage, down to the RC4 key and the eight exact hook sites. A crack is a sequence of small, knowable steps, and every step leaves evidence.

  • ApkSignatureKillerEx, the signature-forgery toolkit.
  • ShadowHook and Dobby, the inline-hook engines.