Taking apart a crack of our own product
Disclaimer
Section titled “Disclaimer”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 arm64Recon: what moved
Section titled “Recon: what moved”Diff the repack against the original:
Only in theirs: META-INF/ANDROID.{RSA,SF} re-signedOnly in ours: META-INF/LYNX.{RSA,SF}Differs: classes9.dex, classes12.dex, liblynx.so, MANIFEST.MFAdded: classes20.dexAdded: lib/arm64-v8a/libNightmare.soAdded: lib/arm64-v8a/libshadowhook_nothing.soFour 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…577aedb1They signed with the public AOSP test key instead of ours, so the app’s own signature check had to be handled separately.
Forging the signature check
Section titled “Forging the signature check”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/ApkSignatureKillerExApkSignatureKillerEx 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 payload is packed
Section titled “The payload is packed”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 blockThe entry point, JNI_OnLoad, lives inside .main, and .main is ciphertext:
55cf4: c9 24 ce 53 f1 b9 78 e3 ; not valid AArch64The 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 .mainSo .text is a loader and the real code is encrypted until it runs. To read the
crack, unpack it first.
Breaking the packer offline
Section titled “Breaking the packer offline”The decrypt runs from .init_array. It is two nested ciphers:
sub_101A0(wrapped_key, &klen); // 32-byte wrapped key from NEON constantssub_102A0(wrapped_key, 0x20, rc4_key, &n) // AES-ECB unwrap -> 16-byte RC4 keymprotect(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 placemprotect(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 ef619791and .main turned into code:
55cf4: ff 83 08 d1 sub sp, sp, #0x22055cf8: 82 00 80 52 mov w2, #455cfc: ff 03 01 d1 sub sp, sp, #0x40With .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.
Running before our code
Section titled “Running before our code”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" ; inserted0002: invoke-static System.loadLibrary0005: const-string v1, "lynx"0007: invoke-static System.loadLibrarySecond, the payload hooks the dynamic linker itself. Its own strings give it away:
linker: hook(invisible) soinfo::call_constructorslinker: hook(invisible) soinfo::call_destructorsBy 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.
The second stage
Section titled “The second stage”.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.15a64: %shook (with island) OK. target %lxDobbyHookARM64InstructionRelocation.cchook_func_addrdeviceLimit devicesUsed licenseThe 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 ... yesallow() 0x363430 fd7bbfa9 fd030091 801f0090 yessub_36C710 ff0301d1 fd7b02a9 f31b00f9 ... yesThe 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.
The eight hooks
Section titled “The eight hooks”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.
What the hooks change
Section titled “What the hooks change”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.
Their backend
Section titled “Their backend”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.phpThe 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.
Summary
Section titled “Summary”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.
Reading
Section titled “Reading”- ApkSignatureKillerEx, the signature-forgery toolkit.
- ShadowHook and Dobby, the inline-hook engines.