Semi-Automated Reversing with Unicorn: Recovering an E-Commerce App's Sign Algorithm
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Semi-Automated Reversing with Unicorn: Recovering an E-Commerce App’s Sign Algorithm
Why Unicorn?
The title came to me near the end of writing this post. Unicorn saved an enormous amount of manual labor. You could use another emulation engine, but with Unicorn it’s just pip install unicorn, a .py file, and you’re running.
Why semi-automated? Because you still have to write the code that drives Unicorn — deciding what to execute and what to observe.
Why this sign algorithm? Because there are already writeups on calling the sign algorithm directly (e.g. via Frida RPC), and I wanted to level up my assembly-reading skills. The sign generator lives in a native library inside a major e-commerce app; my goal was to reconstruct the algorithm itself, not just call it.




Setup
Integrating the .so
For easier analysis and debugging, I integrated the sign-generating .so into my own APK and invoked it directly. Gradle version differences made most writeups I found useless, and I hit a lot of potholes; in the end the setup is simple — create src/main/jniLibs/armeabi-v7a/, drop the .so in, and configure build.gradle and CMakeLists.txt. The exact fix varies with the Gradle version.



The .so also has two trivial checks; NOP out two branch instructions and they’re gone.
The overall encryption flow
The top-level flow is:
sub_127E4 -> sub_126AC -> sub_18B8 -> sub_227C
The core encryption is in sub_126AC, which takes the plaintext string, its length, and two random values:

sub_126AC returns the encrypted bytes; sub_18B8 base64-encodes them; sub_227C runs a standard MD5.
sub_126AC — algorithm selection
sub_126AC is only the entry point. It picks the encryption scheme and the corresponding key based on the two random values:


I’ll call the three schemes version0, version1, and version2. version0 and version1 share the exact same flow; the only difference is a constant passed in — version0 uses 32, version1 uses 16. I’ll analyze version0.
Version0 encryption
Execution flow
sub_10E4C -> sub_125F0 -> sub_12580 -> sub_10EA4 -> sub_10D70
The plaintext is split into 8-byte groups, each fed to sub_10EA4. Any leftover bytes go to sub_10D70.
sub_10EA4 — bit initialization
sub_10EA4 first initializes, then loops as many times as the length of the passed-in key0:

The two tall blocks at the top are the initialization. IDA’s F5 is compact; a partial view:


Notice the eight input bytes are ANDed with 0x80 (1000 0000), 0x40 (0100 0000), 0x20 (0010 0000), 0x10 (0001 0000), 0x8 (0000 1000), 0x4 (0000 0100), 0x2 (0000 0010), and 0x1 (0000 0001), then assigned to variables. Look at what those ANDs operate on — they’re exactly the 64 bits of the 8 input bytes. Later those bits get shuffled and put back together.
sub_10EA4 — bit shuffling
The sixteen small blocks in the flow graph:

Some F5 output:


key0_i is the i-th character of key0. Inside the loop, key0_i is also ANDed against the same eight masks, and the result is the branch condition — i.e. it checks whether the corresponding bit of key0_i is 0 or 1, and branches accordingly to swap the variables from the initialization. That’s the bit shuffle.
sub_10EA4 — bit restoration
At the end, the bits are put back: four rounds, two bytes restored per round.


Recovering sub_10EA4 with Unicorn
How to reconstruct the algorithm? The process is simple but the volume is large. Do I really have to hand-transcribe F5’s output line by line? No — there’s a smarter way with Unicorn.
We’ve established that sub_10EA4 only shuffles the 64 input bits into 8 new bytes; it doesn’t change their values. So can we find the mapping of each bit before and after the shuffle? Yes.
Use Unicorn to control the 64 bits fed into sub_10EA4. If exactly one bit is set, the output has exactly one bit set. The index of the set bit before vs. after is the mapping for that position. Run it 64 times, once per bit, and you get the complete before→after mapping.
(Unicorn usage itself is out of scope here.)
from unicorn import *
from unicorn.arm_const import *
table = []
def bytes2bin(bytes):
arr = []
for v in [m for m in bytes]:
arr.append(
[(v & 128) >> 7, (v & 64) >> 6, (v & 32) >> 5, (v & 16) >> 4, (v & 8) >> 3, (v & 4) >> 2, (v & 2) >> 1,
v & 1])
return [i for j in arr for i in j]
def bin2bytes(arr):
length = len(arr) // 8
arr1 = [0 for _ in range(length)]
for j in range(length):
arr1[j] = arr[j * 8] << 7 | arr[j * 8 + 1] << 6 | arr[j * 8 + 2] << 5 | arr[j * 8 + 3] << 4 | arr[
j * 8 + 4] << 3 | arr[j * 8 + 5] << 2 | arr[j * 8 + 6] << 1 | arr[j * 8 + 7]
return bytes(arr1)
def read(name):
with open(name, 'rb') as f:
return f.read()
def hook_code(mu, address, size, user_data):
if address == BASE + 0x119cc:
arr2 = []
for byte in mu.mem_read(PLAINTEXT_ADDR, 8):
arr2.append(byte)
table.append([user_data.index(1), bytes2bin(arr2).index(1)])
if __name__ == "__main__":
key0 = b'44e715a6e322ccb7d028f7a42fa55040'
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
BASE = 0x400000
STACK_ADDR = 0x0
STACK_SIZE = 1024
PLAINTEXT_ADDR = 1024 * 2
PLAINTEXT_SIZE = 1024
KEY_ADDR = 1024 * 3
KEY_SIZE = 1024
mu.mem_map(BASE, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE)
mu.mem_map(PLAINTEXT_ADDR, PLAINTEXT_SIZE)
mu.mem_map(KEY_ADDR, KEY_SIZE)
mu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE - 1)
# mu.mem_write(BASE, read("F:\\Code\\Pycharm\\JDSign\\libjdbitmapkit.so"))
mu.mem_write(BASE, read("./libjdbitmapkit.so"))
mu.mem_write(KEY_ADDR, key0)
for i in range(64):
arr1 = [0 for j in range(64)]
arr1[i] = 1
h = mu.hook_add(UC_HOOK_CODE, hook_code, arr1)
mu.mem_write(PLAINTEXT_ADDR, bin2bytes(arr1))
mu.reg_write(UC_ARM_REG_R1, KEY_ADDR)
mu.reg_write(UC_ARM_REG_R2, 32)
mu.reg_write(UC_ARM_REG_R3, PLAINTEXT_ADDR)
mu.emu_start(BASE + 0x00010EA4 + 1, BASE + 0x000119D0)
mu.hook_del(h)
print(table)
This yields the mapping table. For example [1,4] means bit 1 ends up at position 4 after the shuffle:
[[0, 0], [1, 4], [2, 61], [3, 15], [4, 56], [5, 40], [6, 6], [7, 59], [8, 62], [9, 58], [10, 17], [11, 2], [12, 12], [13, 8], [14, 32], [15, 60], [16, 13], [17, 45], [18, 34], [19, 14], [20, 36], [21, 21], [22, 22], [23, 39], [24, 23], [25, 25], [26, 26], [27, 20], [28, 1], [29, 33], [30, 46], [31, 55], [32, 35], [33, 24], [34, 57], [35, 19], [36, 53], [37, 37], [38, 38], [39, 5], [40, 30], [41, 41], [42, 42], [43, 18], [44, 47], [45, 27], [46, 9], [47, 44], [48, 51], [49, 7], [50, 49], [51, 63], [52, 28], [53, 43], [54, 54], [55, 52], [56, 31], [57, 10], [58, 29], [59, 11], [60, 3], [61, 16], [62, 50], [63, 48]]
And sub_10EA4 is trivial to reconstruct:
def sub_10EA4(input):
table = [[0, 0], [1, 4], [2, 61], [3, 15], [4, 56], [5, 40], [6, 6], [7, 59], [8, 62], [9, 58], [10, 17], [11, 2],
[12, 12], [13, 8], [14, 32], [15, 60], [16, 13], [17, 45], [18, 34], [19, 14], [20, 36], [21, 21],
[22, 22], [23, 39], [24, 23], [25, 25], [26, 26], [27, 20], [28, 1], [29, 33], [30, 46], [31, 55],
[32, 35], [33, 24], [34, 57], [35, 19], [36, 53], [37, 37], [38, 38], [39, 5], [40, 30], [41, 41],
[42, 42], [43, 18], [44, 47], [45, 27], [46, 9], [47, 44], [48, 51], [49, 7], [50, 49], [51, 63], [52, 28],
[53, 43], [54, 54], [55, 52], [56, 31], [57, 10], [58, 29], [59, 11], [60, 3], [61, 16], [62, 50],
[63, 48]]
arr = bytes2bin(input)
arr1 = [0 for i in range(len(arr))]
for i in range(len(table)):
arr1[table[i][1]] = arr[table[i][0]]
return bin2bytes(arr1)
Analyzing sub_10D70
sub_10D70 decompiles cleanly in IDA:

As mentioned, the plaintext is split into 8-byte groups fed to sub_10EA4; sub_10D70 encrypts the leftover 1–7 bytes. These six cases (case 0 through case 6) all share one template. Take case 0 — sub_4B7C — as the example.
sub_4B7C initialization
sub_4B7C encrypts a single byte. Its flow graph:

Dense. The other five cases look the same. First thought: OLLVM? After careful analysis and dynamic debugging, I concluded it’s not OLLVM — no characteristic flood of constants from control-flow flattening, no unreachable blocks, no bogus control flow or instruction substitution. (Though I might simply have failed to recognize it.) Either way, I read through the whole thing end to end.
Initialization:

It resembles sub_10EA4, but with meaningful differences. The top half extracts the bits of the one input byte and does some extra computation, assigning to variables. The bottom half takes addresses of variables and stores them at other variables’ addresses plus offsets. It’s clearer if you look at sub_4B7C’s stack space.
Think of five consecutive variables as one array. Scrolling down, there are eight such arrays; the first four slots of each hold addresses of other arrays’ heads, and the fifth holds the computed value of an input bit. Looking at later cases, the number of such arrays equals the number of input bits.

That’s initialization. After that, a lot of loop computation.
sub_4B7C — the computation

The loop branches on each bit of each byte of key0, and there are only two kinds of computation — the two big blocks, which are two small loops. Their assembly:
.text:00005BE2 loc_5BE2 ; CODE XREF: sub_4B7C+104↑j
.text:00005BE2 CMP R1, R2
.text:00005BE4 MOV R12, R1
.text:00005BE6 BEQ loc_5C00
.text:00005BE8 MOV R6, R1
.text:00005BEA MOV R8, R2
.text:00005BEC B loc_5BF0
.text:00005BEE ; ---------------------------------------------------------------------------
.text:00005BEE
.text:00005BEE loc_5BEE ; CODE XREF: sub_4B7C+1082↓j
.text:00005BEE MOV R12, R6
.text:00005BF0
.text:00005BF0 loc_5BF0 ; CODE XREF: sub_4B7C+1070↑j
.text:00005BF0 LDRB R6, [R6,#0x10]
.text:00005BF2 STRB.W R6, [R8,#0x10]
.text:00005BF6 MOV R8, R12
.text:00005BF8 LDR.W R6, [R12,#0xC]
.text:00005BFC CMP R6, R2
.text:00005BFE BNE loc_5BEE
.text:00005C00
text:00005C00 CMP R11, R2
.text:00005C02 STRB.W R4, [R12,#0x10]
.text:00005C06 MOV R4, R11
.text:00005C08 LDRB.W R8, [SP,#0xE0+var_98]
.text:00005C0C IT NE
.text:00005C0E MOVNE R12, R2
.text:00005C10 BNE loc_5C16
.text:00005C12 B loc_5C2C
.text:00005C14 ; ---------------------------------------------------------------------------
.text:00005C14
.text:00005C14 loc_5C14 ; CODE XREF: sub_4B7C+10AE↓j
.text:00005C14 MOV R4, R6
.text:00005C16
.text:00005C16 loc_5C16 ; CODE XREF: sub_4B7C+1094↑j
.text:00005C16 LDRB R6, [R4,#0x10]
.text:00005C18 RSBS.W R6, R6, #1
.text:00005C1C IT CC
.text:00005C1E MOVCC R6, #0
.text:00005C20 STRB.W R6, [R12,#0x10]
.text:00005C24 LDR R6, [R4,#4]
.text:00005C26 MOV R12, R4
.text:00005C28 CMP R6, R2
.text:00005C2A BNE loc_5C14
What do these two loops do? Very simple: they repeatedly chase the address stored in the first four slots of the array defined above, comparing it against another variable’s address, and while comparing they keep shifting the value in the array’s fifth slot.
Recovering case0 (sub_4B7C) with Unicorn
This one looks nasty, and it is — unlike sub_10EA4’s simple bit swap, it computes further after extracting the bits.
Let’s look at the tail of sub_4B7C:

It’s nearly identical to sub_10EA4’s bit-restoration tail. So here’s a bold hypothesis: sub_4B7C also shuffles the input byte’s bits and restores them, except the bits are computed after the shuffle, and every bit follows the same rule. I.e. if input bit x is 0, then after shuffle+compute+restore, output bit y is some value m; if input bit x is 1, output bit y is n. In symbols: (x,0) -> (y,m) and (x,1) -> (y,n). Testing confirmed the hypothesis.
Again we can use Unicorn to find all mappings. This time the idea changes: control the input bits, and compare the result of “one bit set” against “all bits zero” to get every mapping.
from unicorn import *
from unicorn.arm_const import *
table = []
table1 = []
def bytes2bin(bytes):
arr = []
for v in [m for m in bytes]:
arr.append(
[(v & 128) >> 7, (v & 64) >> 6, (v & 32) >> 5, (v & 16) >> 4, (v & 8) >> 3, (v & 4) >> 2, (v & 2) >> 1,
v & 1])
return [i for j in arr for i in j]
def bin2bytes(arr):
length = len(arr) // 8
arr1 = [0 for _ in range(length)]
for j in range(length):
arr1[j] = arr[j * 8] << 7 | arr[j * 8 + 1] << 6 | arr[j * 8 + 2] << 5 | arr[j * 8 + 3] << 4 | arr[
j * 8 + 4] << 3 | arr[j * 8 + 5] << 2 | arr[j * 8 + 6] << 1 | arr[j * 8 + 7]
return bytes(arr1)
def read(name):
with open(name, 'rb') as f:
return f.read()
def hook_code(mu, address, size, user_data):
if address == BASE + 0x5284:
table.append(bytes2bin(mu.mem_read(PLAINTEXT_ADDR, 1)))
if __name__ == "__main__":
key0 = b'44e715a6e322ccb7d028f7a42fa55040'
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
BASE = 0x400000
STACK_ADDR = 0x0
STACK_SIZE = 1024
PLAINTEXT_ADDR = 1024 * 2
PLAINTEXT_SIZE = 1024
KEY_ADDR = 1024 * 3
KEY_SIZE = 1024
mu.mem_map(BASE, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE)
mu.mem_map(PLAINTEXT_ADDR, PLAINTEXT_SIZE)
mu.mem_map(KEY_ADDR, KEY_SIZE)
mu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE - 1)
# mu.mem_write(BASE, read("F:\\Code\\Pycharm\\JDSign\\libjdbitmapkit.so"))
mu.mem_write(BASE, read("./libjdbitmapkit.so"))
mu.mem_write(KEY_ADDR, key0)
for i in range(9):
arr1 = [0 for j in range(8)]
if i != 0:
arr1[i-1] = 1
h = mu.hook_add(UC_HOOK_CODE, hook_code, arr1)
mu.mem_write(PLAINTEXT_ADDR, bin2bytes(arr1))
mu.reg_write(UC_ARM_REG_R0, KEY_ADDR)
mu.reg_write(UC_ARM_REG_R1, 32)
mu.reg_write(UC_ARM_REG_R2, 1)
mu.reg_write(UC_ARM_REG_R3, PLAINTEXT_ADDR)
mu.emu_start(BASE + 0x0004B7C + 1, BASE + 0x0005288)
mu.hook_del(h)
for i in range(8):
for j in range(8):
arr3 = []
if table[0][j] != table[i+1][j]:
table1.append([i, j, table[0][j], table[i+1][j]])
print(table1)
Result:
[[0, 6, 0, 1], [1, 4, 1, 0], [2, 5, 0, 1], [3, 0, 0, 1], [4, 2, 0, 1], [5, 3, 0, 1], [6, 1, 1, 0], [7, 7, 0, 1]]
Each entry is [x, y, m, n]. For example [6, 1, 1, 0] means input bit 6 lands on output bit 1 after shuffle+compute, and if bit 6 is 0 the output is 0, if 1 the output is 1.
Then sub_4B7C is easy to reconstruct:
def sub_4B7C(input):
table = [[0, 6, 0, 1], [1, 4, 1, 0], [2, 5, 0, 1], [3, 0, 0, 1], [4, 2, 0, 1], [5, 3, 0, 1], [6, 1, 1, 0],
[7, 7, 0, 1]]
arr = bytes2bin(input)
arr1 = [0 for i in range(8)]
for i in range(8):
if arr[i] == 0:
arr1[table[i][1]] = table[i][2]
else:
arr1[table[i][1]] = table[i][3]
return bin2bytes(arr1)
All cases in one shot with Unicorn
Now that case0 is reconstructed, step back to sub_10D70: seven cases, seven functions, differing only in start/end address and byte count. Generalize — get all mappings in one run:

from unicorn import *
from unicorn.arm_const import *
table = []
table1 = []
def bytes2bin(bytes):
arr = []
for v in [m for m in bytes]:
arr.append(
[(v & 128) >> 7, (v & 64) >> 6, (v & 32) >> 5, (v & 16) >> 4, (v & 8) >> 3, (v & 4) >> 2, (v & 2) >> 1,
v & 1])
return [i for j in arr for i in j]
def bin2bytes(arr):
length = len(arr) // 8
arr1 = [0 for _ in range(length)]
for j in range(length):
arr1[j] = arr[j * 8] << 7 | arr[j * 8 + 1] << 6 | arr[j * 8 + 2] << 5 | arr[j * 8 + 3] << 4 | arr[
j * 8 + 4] << 3 | arr[j * 8 + 5] << 2 | arr[j * 8 + 6] << 1 | arr[j * 8 + 7]
return bytes(arr1)
def read(name):
with open(name, 'rb') as f:
return f.read()
def hook_code(mu, address, size, user_data):
if address == BASE + user_data[2] -4:
table.append(bytes2bin(mu.mem_read(PLAINTEXT_ADDR, m[0])))
if __name__ == "__main__":
key0 = b'44e715a6e322ccb7d028f7a42fa55040'
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
BASE = 0x400000
STACK_ADDR = 0x0
STACK_SIZE = 1024 * 10
PLAINTEXT_ADDR = 1024 * 10
PLAINTEXT_SIZE = 1024
KEY_ADDR = 1024 * 11
KEY_SIZE = 1024
mu.mem_map(BASE, 1024 * 1024)
mu.mem_map(STACK_ADDR, STACK_SIZE)
mu.mem_map(PLAINTEXT_ADDR, PLAINTEXT_SIZE)
mu.mem_map(KEY_ADDR, KEY_SIZE)
mu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE - 1)
# mu.mem_write(BASE, read("F:\\Code\\Pycharm\\JDSign\\libjdbitmapkit.so"))
mu.mem_write(BASE, read("./libjdbitmapkit.so"))
mu.mem_write(KEY_ADDR, key0)
arr = [[1, 0x0004B7C, 0x0005288], [2, 0x00061A0, 0x0006AE8], [3, 0x0007994, 0x000084A6], [4, 0x000091AC, 0x00009DF4],
[5, 0x0000ABF8, 0x0000BA8C], [6, 0x0000C8C0, 0x0000D9A0], [7, 0x0000E7FC, 0x0000FC1C]]
for m in arr:
for i in range(m[0]*8+1):
arr1 = [0 for j in range(m[0]*8)]
if i != 0:
arr1[i - 1] = 1
h = mu.hook_add(UC_HOOK_CODE, hook_code, m)
mu.mem_write(PLAINTEXT_ADDR, bin2bytes(arr1))
mu.reg_write(UC_ARM_REG_R0, KEY_ADDR)
mu.reg_write(UC_ARM_REG_R1, 32)
mu.reg_write(UC_ARM_REG_R2, 1)
mu.reg_write(UC_ARM_REG_R3, PLAINTEXT_ADDR)
mu.emu_start(BASE + m[1] + 1, BASE + m[2])
mu.hook_del(h)
for i in range(m[0]*8):
for j in range(m[0]*8):
arr3 = []
if table[0][j] != table[i + 1][j]:
table1.append([i, j, table[0][j], table[i + 1][j]])
print("case %s 映射关系:"%(m[0]-1))
print(table1)
table.clear()
table1.clear()
Output:
case 0 映射关系:
[[0, 6, 0, 1], [1, 4, 1, 0], [2, 5, 0, 1], [3, 0, 0, 1], [4, 2, 0, 1], [5, 3, 0, 1], [6, 1, 1, 0], [7, 7, 0, 1]]
case 1 映射关系:
[[0, 5, 0, 1], [1, 9, 0, 1], [2, 0, 1, 0], [3, 7, 1, 0], [4, 10, 0, 1], [5, 6, 0, 1], [6, 13, 1, 0], [7, 1, 0, 1], [8, 4, 0, 1], [9, 11, 0, 1], [10, 14, 1, 0], [11, 3, 1, 0], [12, 12, 0, 1], [13, 15, 1, 0], [14, 8, 0, 1], [15, 2, 0, 1]]
case 2 映射关系:
[[0, 17, 0, 1], [1, 7, 0, 1], [2, 5, 0, 1], [3, 19, 1, 0], [4, 18, 0, 1], [5, 15, 1, 0], [6, 22, 0, 1], [7, 21, 0, 1], [8, 16, 0, 1], [9, 4, 0, 1], [10, 12, 0, 1], [11, 2, 1, 0], [12, 10, 1, 0], [13, 13, 1, 0], [14, 20, 1, 0], [15, 8, 1, 0], [16, 9, 0, 1], [17, 23, 0, 1], [18, 11, 1, 0], [19, 6, 0, 1], [20, 1, 0, 1], [21, 3, 1, 0], [22, 0, 1, 0], [23, 14, 0, 1]]
case 3 映射关系:
[[0, 25, 1, 0], [1, 4, 0, 1], [2, 29, 0, 1], [3, 1, 0, 1], [4, 27, 1, 0], [5, 18, 1, 0], [6, 23, 1, 0], [7, 14, 1, 0], [8, 28, 1, 0], [9, 11, 0, 1], [10, 9, 1, 0], [11, 13, 0, 1], [12, 24, 1, 0], [13, 0, 1, 0], [14, 5, 0, 1], [15, 2, 1, 0], [16, 26, 0, 1], [17, 12, 0, 1], [18, 31, 1, 0], [19, 16, 1, 0], [20, 30, 0, 1], [21, 15, 0, 1], [22, 10, 0, 1], [23, 22, 1, 0], [24, 7, 1, 0], [25, 21, 0, 1], [26, 6, 1, 0], [27, 3, 1, 0], [28, 8, 1, 0], [29, 20, 0, 1], [30, 19, 1, 0], [31, 17, 0, 1]]
case 4 映射关系:
[[0, 11, 0, 1], [1, 12, 0, 1], [2, 28, 1, 0], [3, 30, 0, 1], [4, 13, 1, 0], [5, 24, 0, 1], [6, 22, 1, 0], [7, 25, 1, 0], [8, 23, 1, 0], [9, 3, 0, 1], [10, 16, 0, 1], [11, 8, 1, 0], [12, 34, 0, 1], [13, 2, 0, 1], [14, 5, 0, 1], [15, 7, 1, 0], [16, 4, 0, 1], [17, 14, 0, 1], [18, 39, 1, 0], [19, 33, 0, 1], [20, 15, 0, 1], [21, 0, 0, 1], [22, 31, 0, 1], [23, 9, 1, 0], [24, 29, 0, 1], [25, 26, 1, 0], [26, 19, 0, 1], [27, 6, 1, 0], [28, 27, 1, 0], [29, 10, 1, 0], [30, 37, 0, 1], [31, 38, 1, 0], [32, 20, 0, 1], [33, 21, 1, 0], [34, 1, 0, 1], [35, 36, 0, 1], [36, 32, 0, 1], [37, 17, 0, 1], [38, 18, 0, 1], [39, 35, 1, 0]]
case 5 映射关系:
[[0, 11, 0, 1], [1, 45, 0, 1], [2, 15, 1, 0], [3, 22, 0, 1], [4, 10, 0, 1], [5, 7, 0, 1], [6, 3, 0, 1], [7, 42, 0, 1], [8, 17, 1, 0], [9, 21, 0, 1], [10, 4, 0, 1], [11, 8, 1, 0], [12, 19, 1, 0], [13, 32, 0, 1], [14, 28, 1, 0], [15, 31, 1, 0], [16, 29, 0, 1], [17, 14, 1, 0], [18, 39, 1, 0], [19, 27, 1, 0], [20, 2, 1, 0], [21, 24, 0, 1], [22, 26, 1, 0], [23, 9, 1, 0], [24, 41, 0, 1], [25, 1, 1, 0], [26, 47, 0, 1], [27, 44, 0, 1], [28, 23, 1, 0], [29, 0, 1, 0], [30, 12, 1, 0], [31, 18, 0, 1], [32, 33, 0, 1], [33, 36, 0, 1], [34, 40, 1, 0], [35, 34, 0, 1], [36, 25, 0, 1], [37, 16, 1, 0], [38, 5, 1, 0], [39, 35, 0, 1], [40, 38, 0, 1], [41, 37, 1, 0], [42, 13, 0, 1], [43, 20, 1, 0], [44, 6, 0, 1], [45, 43, 0, 1], [46, 30, 0, 1], [47, 46, 1, 0]]
case 6 映射关系:
[[0, 7, 1, 0], [1, 9, 0, 1], [2, 53, 1, 0], [3, 19, 1, 0], [4, 15, 1, 0], [5, 8, 0, 1], [6, 3, 0, 1], [7, 24, 1, 0], [8, 18, 0, 1], [9, 51, 0, 1], [10, 42, 1, 0], [11, 39, 0, 1], [12, 20, 0, 1], [13, 12, 0, 1], [14, 28, 1, 0], [15, 27, 1, 0], [16, 23, 0, 1], [17, 49, 0, 1], [18, 10, 1, 0], [19, 55, 1, 0], [20, 52, 1, 0], [21, 17, 0, 1], [22, 48, 0, 1], [23, 14, 1, 0], [24, 33, 0, 1], [25, 25, 1, 0], [26, 4, 1, 0], [27, 11, 0, 1], [28, 47, 1, 0], [29, 0, 0, 1], [30, 21, 1, 0], [31, 44, 0, 1], [32, 16, 0, 1], [33, 41, 0, 1], [34, 29, 0, 1], [35, 1, 0, 1], [36, 46, 0, 1], [37, 5, 0, 1], [38, 30, 0, 1], [39, 45, 0, 1], [40, 31, 1, 0], [41, 43, 1, 0], [42, 36, 1, 0], [43, 26, 0, 1], [44, 34, 0, 1], [45, 2, 0, 1], [46, 6, 0, 1], [47, 50, 1, 0], [48, 13, 1, 0], [49, 37, 1, 0], [50, 32, 0, 1], [51, 40, 0, 1], [52, 35, 0, 1], [53, 38, 0, 1], [54, 54, 0, 1], [55, 22, 0, 1]]
Version2 encryption
Execution flow
sub_10DE4 -> sub_12FF0 -> sub_12ECC -> sub_130D0, with sub_12ECC as the interesting part.
sub_12ECC initialization
At the start of sub_12ECC: r1 holds the key2 address, r2 holds the constant 1, r3 holds the plaintext address, and after the stack is allocated, [SP,#0x48+arg_0] holds the plaintext length.
.text:00012ECC LDR.W R12, =(__stack_chk_guard_ptr - 0x12ED8)
.text:00012ED0 PUSH.W {R4-R11,LR}
.text:00012ED4 ADD R12, PC ; __stack_chk_guard_ptr
.text:00012ED6 LDR.W R12, [R12] ; __stack_chk_guard
.text:00012EDA SUB SP, SP, #0x24
.text:00012EDC MOV R10, R3
.text:00012EDE LDR.W R3, [R12]
.text:00012EE2 MOV R7, R1
.text:00012EE4 LDR.W R8, [SP,#0x48+arg_0]
.text:00012EE8 MOV R9, R2
.text:00012EEA STR R3, [SP,#0x48+var_2C]
.text:00012EEC CMP.W R8, #0
.text:00012EF0 BNE loc_12F02
Four instructions set up the state: plaintext address → r10, key2 address → r7, constant 1 → r9, plaintext length (from [SP,#0x48+arg_0]) → r8:
.text:00012EDC MOV R10, R3
.text:00012EE2 MOV R7, R1
.text:00012EE8 MOV R9, R2
.text:00012EE4 LDR.W R8, [SP,#0x48+arg_0]
sub_12ECC — the encryption loop
Further into sub_12ECC, the sign encryption starts at 0x12F68 — a loop:
.text:00012F66 MOVS R3, #0
.text:00012F68
.text:00012F68 loc_12F68
.text:00012F68 AND.W R2, R3, #0xF
.text:00012F6C ADD R0, SP, #0x48+var_28
.text:00012F6E ADD R2, R0
.text:00012F70 AND.W R1, R3, #7
.text:00012F74 LDRB.W R0, [R10]
.text:00012F78 ADDS R3, #1
.text:00012F7A LDRB.W R2, [R2,#-0x14]
.text:00012F7E CMP R3, R8
.text:00012F80 LDRB R4, [R7,R1]
.text:00012F82 EOR.W R0, R2, R0
.text:00012F86 EOR.W R0, R0, R4
.text:00012F8A ADD R0, R2
.text:00012F8C EOR.W R2, R2, R0
.text:00012F90 UXTB R2, R2
.text:00012F92 STRB.W R2, [R10],#1
.text:00012F96 LDRB R1, [R7,R1]
.text:00012F98 EOR.W R2, R2, R1
.text:00012F9C STRB.W R2, [R10,#-1]
.text:00012FA0 BNE loc_12F68
Register r10. The plaintext address lives in r10. Three instructions touch it. LDRB.W R0, [R10] at 00012F74 loads one plaintext byte into r0. STRB.W R2, [R10],#1 at 00012F92 first stores r2 to the address in r10 (not important yet — the final value is stored later), then post-increments r10 (a post-indexed addressing mode). Between 00012F92 and 00012F9C, a byte at r7 offset by r1 is loaded into r1, XORed with r2, and put back into r2. Finally STRB.W R2, [R10,#-1] at 00012F9C stores the result to R10-1 — since r10 was just incremented, -1 is the original address, so this is where the final value lands.
Register r3. r3 is the counter: incremented at 00012F78 ADDS R3, #1, compared against the length in r8 at 00012F7E CMP R3, R8.
Register r2. At the top, r3 is ANDed with 0xf into r2, then added to SP,#0x48+var_28, still into r2; then LDRB.W R2, [R2,#-0x14] loads the byte at SP,#0x48+var_28 plus offset (i & 0xf) - 0x14.
Register r4. AND.W R1, R3, #7 puts i & 7 into r1; LDRB R4, [R7,R1] loads key2[i & 7] into r4 (since r7 holds key2).
The computation. The actual math:
.text:00012F82 EOR.W R0, R2, R0
.text:00012F86 EOR.W R0, R0, R4
.text:00012F8A ADD R0, R2
.text:00012F8C EOR.W R2, R2, R0
.text:00012F90 UXTB R2, R2
.text:00012F96 LDRB R1, [R7,R1]
.text:00012F98 EOR.W R2, R2, R1
r0 is the plaintext byte, r2 the table value, r4 the key byte.
IDA F5 of this part:

Reconstructing sub_12ECC
We have the plaintext, key2, and the computation. The only unknown is the array at SP,#0x48+var_28 minus 0x14 — one value per loop iteration (offset i & 0xf). Static analysis can’t reveal it, so IDA dynamic debugging gave the array value [0x37, 0x92, 0x44, 0x68, 0xA5, 0x3D, 0xCC, 0x7F, 0xBB, 0xF, 0xD9, 0x88, 0xEE, 0x9A, 0xE9, 0x5A].
The Python equivalent of sub_12ECC:
def sub_12ECC(input):
arr = [0x37, 0x92, 0x44, 0x68, 0xA5, 0x3D, 0xCC, 0x7F, 0xBB, 0xF, 0xD9, 0x88, 0xEE, 0x9A, 0xE9, 0x5A]
key2 = b"80306f4370b39fd5630ad0529f77adb6"
arr1 = [0 for _ in range(len(input))]
for i in range(len(input)):
r0 = int(input[i])
r2 = arr[i & 0xf]
r4 = int(key2[i & 7])
r0 = r2 ^ r0
r0 = r0 ^ r4
r0 = r0 + r2
r2 = r2 ^ r0
r1 = int(key2[i & 7])
r2 = r2 ^ r1
arr1[i] = r2 & 0xff
return bytes(arr1)
The whole sign flow
At this point every key function is reconstructed; assembling the complete sign flow is just mechanical work. I was going to post a half-finished version, but a commenter pointed out the walkthrough is already complete enough, so I’ll leave the full reconstruction as an exercise.
What the vendor got wrong
The meta-lesson is the same one these native sign generators always teach: anything the client computes, the client can be made to reveal. This algorithm lives entirely in a .so the app ships to every device, so a determined analyst with an emulator can recover it bit-by-bit — the bit-mapping trick here is a clean, general technique for exactly that. The fix isn’t “make the shuffle harder” — obfuscation only slows this down — it’s to move integrity decisions server-side (attestation, keyed signatures, anomaly detection) and treat the client’s sign as untrusted input, not as a security control.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch