reverse-engineeringandroidgame-hackingpreservationwriteup

Reverse-engineering Sick Bricks

A technical writeup on restoring an always-online Android game to offline play via a native login patch, reverse-engineering its encrypted save format (custom XTEA + LZ77 + FNV-1), and analyzing why the unreleased worlds present in its package data cannot be enabled.

/7 min read

Sick Bricks (com.spinmaster.sickbricks) is an always-online Android title. Every launch authenticates against a remote server before reaching gameplay; the servers are now offline, so the client hangs on login and times out against 192.67.64.156:443. This writeup covers three things: patching the client to run offline, the structure of its encrypted save format, and an analysis of the unreleased worlds present in the package data and why they cannot be enabled without the source.

All native work was done against libkand.so (the engine) and libpack.so (compression) with Ghidra. The runtime environment was a rooted LDPlayer 9 instance (Android 9, ARM-translated x86), driven over adb. The game is 32-bit armeabi-v7a only.

Offline patch

Gameplay is gated entirely on the login request. The engine builds an HTTP request to the auth server, checks the status code, and branches on success. Tracing the request builder in libkand.so down to its status check, two instruction edits defeat it:

0xbee2:  ldrb r2,[r6,r3]   ->  movs r2,#0xc8     ; force status = 200
0xbef2:  bne  <fail>       ->  b    <success>    ; force the success branch

The first replaces the load of the real status byte with a constant 0xC8 (200); the second turns the conditional failure branch into an unconditional jump to the success path. With both applied, any credentials "succeed" against the dead host and the client proceeds to a local profile.

Packaging for distribution required folding the assets in. The game keeps a 175 MB OBB expansion file separately, which is fragile to distribute. The OBB was embedded into the APK under assets/, marked non-compressed in apktool.yml, and a smali bootstrap was injected into KandApplication.onCreate to copy it to getObbDir() on first launch if absent. The manifest targetSdk was raised so current Android versions do not block the install. The result is a single self-contained APK.

One residual quirk: the first interactive login still freezes once after the intro video (the dead-server handoff stalling). The token and profile are persisted regardless, so a force-stop and relaunch loads directly into gameplay on every subsequent run.

Save format

SIBSAVE.BIN is encrypted on disk. Following the load path in libkand.so, the format is three nested layers:

SIBSAVE.BIN = XTEA_encrypt( "EZ10" header (16 B) + LZMP_compress( 179024 B raw state ) )

Layer 1: XTEA variant. The outer cipher is a 32-round XTEA derivative with per-block key feedback: after each 8-byte block, the key is mutated as a function of the plaintext block, so blocks cannot be decrypted independently; the stream must be processed in order with an evolving key.

nk0 = k0 ^ v0
nk1 = k1 ^ v1
key = (nk0, nk1, k2 ^ ((nk1 + nk0) & M), k3 ^ ((nk0 - nk1) & M))

The base key (8f48d5a4 35ddb650 b1792b3e 2a55c221) is stored in libkand.so at offset 0x11aac8; DELTA is the standard 0x9E3779B9. Reimplementing the round function and feedback rule in Python round-tripped exactly.

Layer 2: "EZ10" header. A 16-byte header: magic 0x30315A45, then the uncompressed size (179024), a length delta, and the total encrypted file length (which must be a multiple of 8 for the cipher).

Layer 3: LZMP compression. Beneath the header is a custom LZ77-style codec located in libpack.so (LZMP_Unpack @ 0x208c, LZMP_Compress @ 0x23c8, ARM). It uses a single interleaved bitstream with prefix-coded match lengths and distances, no magic number. The unpacker was reverse-engineered to produce the exact 179024-byte body; an all-literal packer was written for the reverse direction.

Integrity. A 32-bit FNV-1 checksum is stored at raw offset 0x20, computed over raw[0x24:0x26f10] (init 0x811c9dc5, prime 0x01000193). A mismatch is rejected on load. Recomputation matched the original byte-for-byte, and a no-op repack (decrypt, decompress, recompress, re-encrypt with no changes) loaded cleanly, confirming the full pipeline.

One sibling file warranted a check. During editing, modified saves were sometimes rejected on load and replaced with a fresh save, which suggested an integrity cross-check somewhere outside SIBSAVE.BIN. The obvious candidate was SIBSAVE2.BIN, an "OCOR"-tagged structure that looked like an anti-tamper mirror. Decoding a fresh save and a fully-progressed save and diffing the two showed they are byte-identical, so it carries no progression data and cannot be the source of the rejections. The actual cause was an incorrect FNV-1 checksum on the edited file; once that was computed correctly, edits persisted.

Player state fields

With the pipeline solved, the decompressed state is directly editable. Currency is three consecutive 32-bit integers:

0x15a44  coins
0x15a48  gold bricks
0x15a4c  stars

Any such field can be located by searching the decompressed state for the exact value the UI displays. Editing a field, recomputing the FNV-1 checksum, repacking, and pushing the file back is accepted by the game.

Unlock analysis

The package contains complete, playable level data for several worlds that the live game never exposes, flagged as unreleased and unreachable past the second boss gate. The question was whether that gate is a flag that can be flipped or unfinished release wiring. Four approaches were tested.

1. Save edit

The map advances through worlds, so the first attempt was to mark the gated worlds cleared in the save. On load this produced two currency-migration popups followed by a SIGSEGV in the game loop. The crashing instruction computes base + level*0x58 + 0x3e; the constant fault address decodes to a level index of 0xFFFF (a "no level" sentinel) against a base (world level-table pointer) of null. The world beyond the gate has no data resident in memory; forcing the save sends the engine to dereference a table that was never loaded.

2. Native crash guard

A code cave was assembled in ARM and patched into libkand.so, redirecting the crashing function's entry through a bounds check on the level index and bailing out cleanly; the patched library was hot-swapped into the installed app with root. The original crash was eliminated and immediately replaced by a different null-dereference deeper in the same path. A single guard does not produce the missing data; the failure is the absence of an entire load step, not one check.

3. World-definition table (worldsdef_db)

The engine loads a resource named worldsdef_db, looked up by FNV-1 of the uppercased name (WORLDSDEF_DB to 0x18c564d8), which indexes the OBB. The OBB format is a 16-byte header followed by 16-byte index records ([offset][size][hash][hash2]) with data beginning at 0x40770; entries are LZMA ("LZMA" magic plus a standard alone header). The decompressed resource is a 342-byte columnar DataBase table of fourteen worlds, each carrying a status string, a type, and a star requirement. The statuses partition as 6 LOCKED, 6 HIDDEN, 2 UNLOCKED, with the hidden entries also carrying a sentinel 10000-star requirement.

The hidden statuses were edited (first to UNLOCKED, then to LOCKED), recompressed to LZMA, and patched into the OBB entry in place, preserving the entry's byte length so the first-launch extractor would not detect a size change and overwrite it. On-device verification confirmed the statuses changed. The hidden worlds still did not become reachable. Setting them UNLOCKED (asserting reachability with no routable path) caused the map camera to clamp early and oscillate; setting them LOCKED (visible, not asserted reachable) was stable but the camera still would not scroll to them.

4. Camera scroll bound

The controlling limit is the map camera's scroll bound, which tracks the furthest defeated boss. This was confirmed empirically: maxing currency, playing legitimately, and defeating the second boss gate advanced the reachable frontier exactly one step, then clamped again at the next unbuilt segment. The camera functions are exposed to the scripting layer, but the clamp itself is inside the map renderer, in large obfuscated functions (10 to 46 KB decompiled) of indirect calls and inlined geometry, and was not isolated to a patchable instruction.

Findings

The unreleased worlds are gated by unfinished release wiring, not a single flag. The level data ships in the package, but the steps that would load it, register it in the world table, draw its map nodes, and extend the camera bound were never completed. This is consistent across all four approaches: the save dereferences unloaded data, a native guard exposes the next missing step, the status table changes visibility without producing routing, and legitimate progression clamps at the same boundary. Editing the save, the package data, or the code cannot synthesize a load-and-route path that does not exist in the build.

Scope that did complete: the client runs offline from a single APK, the save format is fully reverse-engineered and documented, and player state is editable with a verified write pipeline.