Analyzing the ttEncrypt Algorithm with Unidbg
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.
Analyzing the ttEncrypt Algorithm with Unidbg
Why I did this
The honest answer: I wanted to kill some time one evening. But the real reason is that everyone around me is either already done analyzing it or in the middle of it. If I don’t keep up, I’ll get left behind. Since I’m just starting out, I picked something simple.
This is a small sample I looked at out of boredom one evening, so feel free to skip it.
Summary
I spent a few hours on a quick pass, so I didn’t fully trace the entire flow — just used some fast-analysis tricks to locate and verify the important parts. Toward the end I found the whole thing basically looks the same, like the JNI_OnLoad method at the very start. Given time I’d like to dig into the instruction execution too, to see how it passes args in/out and runs methods.
What you’ll get from this article:
- Simple debugging with unidbg
- Quick fixes for a
.so’s methods - The AES key initialization flow
From there you can judge whether it’s worth reading on.
Target selection
Since I’m analyzing with unidbg, I can’t avoid the author’s small demo, which is:
- unidbg-android/src/test/java/com/bytedance/frameworks/core/encrypt/TTEncrypt.java
When I first started out, I learned unidbg’s various hook scripts by playing with these demos. So consider this a long-overdue homework submission. Target confirmed — let’s start.
Environment
- Dev environment: Unidbg — that’s all you need
- Host: Windows 11
- App version: 26.1.0 (sent to me, so no download link)
Emulated execution
For this “homework,” the first step is to copy the author’s demo and tweak it, then point the apk at our target version.
Since the version under analysis is 64-bit and loaded from the apk, the first step is to change for32Bit() to for64Bit(). Then pass the apk’s current path to createDalvikVM, change the loadLibrary argument to ttEncrypt, and set the second argument to true so it runs the .so’s init method.
Some of his hook demos and the IDA Pro debugging code aren’t needed right now, so delete them first.
After the changes it looks like this:

- Then run it.

- And it immediately blew up. Following the error, let’s check whether this dependency exists in the apk.

- There’s no library named
ttEncrypt, so let’s look at the phone. - Since the library is missing, and from the author’s code we know it calls
ttEncrypt([BI)[B, let’s check whether the phone exports that symbol and try our luck. 
- Lucky guess — no need to analyze the apk at all. Today’s luck is good, keep going.
- So we change the library name from
ttEncrypttoEncryptorand try again. 
- Better this time: it can’t find the
ttEncrypt([BI)[Bmethod. Since we can locate the.soby symbol, the method must exist. - Why can’t it find it? Looking at the first three lines above, the class name has changed to
com/bytedance/frameworks/encryptor/EncryptorUtil, no longercom/bytedance/frameworks/core/encrypt/TTEncryptUtils. - Fix it and try again.
- First arg is 0x10 bytes; second arg is the byte[] length.

- It runs!
- And there’s a result.
- Time to call it a night.
Of course it doesn’t end here — we still need to port the algorithm out.
Porting the algorithm
The entry point
The first step of porting is obvious: fire up IDA Pro.

- Then drag the
.soin for analysis. - As usual, let’s look at the exports first.

- Only two exported functions:
JNI_OnLoadandstart. - We all know
JNI_OnLoadis where dynamic registration happens, andstartis the entry for directly launching the.so. Let’s see what’s inJNI_OnLoad. 
- Nothing. Let’s step into
sub_da0. 
- Can’t read it. So let’s just glance at
sub_da0’s control-flow graph. 
- A dense wall of basic blocks — enough to make anyone with a block-density phobia close the tab immediately.
- Since I don’t want to find the dynamic-registration site by analyzing
JNI_OnLoad, I’ll get the real entry from unidbg’s log instead:
Find native function Java_com_bytedance_frameworks_encryptor_EncryptorUtil_ttEncrypt => RX@0x40007a38[libEncryptor.so]0x7a38
- This is a reminder that when you’re stuck, look at the logs — there’s usually a clue.
- So the code entry point is
0x7a38.
Algorithm analysis
Fixing the .so methods

- Opened, and it’s all red — and F5 won’t work. What now?
- Relax, we just do a simple fix.

- Starting at
0x7a38, select a region downward, pick a bit, then pressp(this force-creates a function from the selected code). 
- There are still some red blocks below. Don’t panic — scroll down until you find the last instruction of the red block right before the
RETinstruction, i.e. at0x7B58, and pressethere. 
- Now it’s normal.

- But scrolling up,
0x7B04also has a red block. 
- Do the same thing — press
eon this line. After fixing it looks like this. 
- Now we can happily press F5.
- Some might say this is overkill — just press
pdirectly at0x7a38and be done. True, this sample only needs apat0x7a38. But it’s worth learning the select-block fix anyway, because on later samplespalone won’t cut it; selecting a block works better.
Fixing the JNI method
- Here I’ll just do it directly — for importing the structures, see other authors’ articles.

- After fixing and reading IDA’s hints, we know the core method is
sub_2AC4, and its arguments are, in order:bytes(input byte array),length(byte array size),output(our result),size(result size). - Let’s analyze
sub_2ac4. 
- It puts our arguments into an array (a struct). Where:

- There’s a long blob in
.rodatawe can’t read, so we’ll leave it for now. - Analyzing
2ac4, we see it further calls2c18. Its first argument is0xb090, the second is our passed args plus return value, the third looks like an index, the fourth is also unreadable (probably similar to the first, leave it), and the fifth looks like a callback method (this is important below). 
- We notice it’s very similar to
JNI_OnLoad’ssub_da0method. - This kind of code is unreadable without optimization, and I can’t write an optimizer. Since this path is blocked, let’s try another approach.
traceWrite
Since direct code analysis failed, all that’s left is tracing. This time I won’t trace code directly, but trace writes.
Why not trace code and reconstruct from the results? Because this is obfuscated code and the trace log would be enormous — so we need to simplify.

- That trace log has over a hundred thousand lines — porting from it would be brutal. So I chose trace-write. Some might ask why not trace-read? We’re results-driven, so I trace the written output.
- Since we’re tracing writes, we pick the address and size.
- Back to
sub_783a. 
- The
mallocallocateslength + 118bytes, so we now know the size islength + 118, leaving just the address. 
- Testing shows the address after
0x7AA8executes is fixed! So we can pin the address at0x40358000— a constant. (What if it’s not fixed? Just add abackend.hook_add_newto grab the actual value.time,rand,srandcan all be fixed too — check the source or write a hook.) - Now we can happily write the trace-write:
// 0x10 is the passed-in byte[] size, known
emulator.traceWrite(0x40358000, 0x40358000 + 0x10 + 118);


- Screen too small, had to screenshot twice.
- From the trace we can see the data is composed of four parts and three methods:
0x2be0,0x3c2c(PC is in libc.so, so look at LR to see where it returns to), and0x2a58(0x2a58and0x2a88are probably in the same method).
74 63 05 10 ==>0x10056374
00 00 ==> 0x2be0
// 0x1c184
A0 BB 10 FA EC 38 50 03 EA 42 C6 94 15 ED CD 30(0x35038ecfa10bba0、0x30cded1594c642ea)
B6 5F F5 E8 25 43 A3 66 D4 B8 E7 FB D7 F3 89 77 (0x66a34325e8f55fb6、0x7789f3d7fbe7b8d4)
// 0x2a58
[01:48:41 773] Memory WRITE at 0x40358026, data size = 8, data value = 0xeb12c44a6b02c09, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x4035802e, data size = 8, data value = 0x632b08e36313439e, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x40358036, data size = 8, data value = 0x241e2a664df9f031, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x4035803e, data size = 8, data value = 0x2bee5c58693bd0fc, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x40358046, data size = 8, data value = 0x468e9bebbf9629eb, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x4035804e, data size = 8, data value = 0x2a698ed9942a3431, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x40358056, data size = 8, data value = 0x713bbf110c38b4, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x4035805e, data size = 8, data value = 0x217a8beac056e0c6, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x40358066, data size = 8, data value = 0x1e3380f1680f33dc, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x4035806e, data size = 8, data value = 0xe121d61a37fcf7dc, PC=RX@0x40002a58[libEncryptor.so]0x2a58, LR=RX@0x40002a4c[libEncryptor.so]0x2a4c
[01:48:41 773] Memory WRITE at 0x40358076, data size = 8, data value = 0x77f78e1f1dd196d1, PC=RX@0x40002a88[libEncryptor.so]0x2a88, LR=RX@0x40002a84[libEncryptor.so]0x2a84
[01:48:41 773] Memory WRITE at 0x4035807e, data size = 8, data value = 0x2e7d43264681b810, PC=RX@0x40002a88[libEncryptor.so]0x2a88, LR=RX@0x40002a84[libEncryptor.so]0x2a84
09 2C B0 A6 44 2C B1 0E 9E 43 .....w.,..D,...C
0030: 13 63 E3 08 2B 63 31 F0 F9 4D 66 2A 1E 24 FC D0 .c..+c1..Mf*.$..
0040: 3B 69 58 5C EE 2B EB 29 96 BF EB 9B 8E 46 31 34 ;iX\.+.).....F14
0050: 2A 94 D9 8E 69 2A B4 38 0C 11 BF 3B 71 00 C6 E0 *...i*.8...;q...
0060: 56 C0 EA 8B 7A 21 1E 33 80 F1 68 0F 33 DC DC F7 V...z!.3..h.3...
0070: FC 37 1A D6 21 E1 D1 96 D1 1D 1F 8E F7 77 10 B8 .7..!........w..
0080: 81 46 26 43 7D 2E .F&C}.
- So we focus on these three methods to complete the protocol port:
0x2be0,0x3c2c(PC in libc.so, so look at LR), and0x2a58(0x2a58and0x2a88should be in the same method).
0x2be0
- First look at
0x2be0. 
- Looks a bit familiar.
- Convert 268788596 to hex?

- Isn’t that the first segment’s
0x10056374and0x0? - A third of the way there already. Luck is on our side.
0x3c2c

- It returns to
sub_2C18and callsblr x20, and we don’t know whatx20holds, so let’s set a breakpoint and look. - Set a breakpoint at
0x3C28and checkx20:
debugger.addBreakPoint(module, 0x3C28);

- It calls
sub_2c0c. - What does
sub_2c0cdo? 
- A trampoline: it passes
x0andx1in, then callsx0insidesub_2c0cwith argument 1. - What are the values of
x0andx1? - It calls
sub_2b40with: 
- We don’t know what it is; let’s look at what
sub_2b40does. 
- Looks like dynamic memory allocation? That’s inconsistent with our earlier analysis. Could this execution not be the one we’re after?
- Let’s run several times and see which methods get called.

- It ran
sub_2b5c. 
- Looks like time and random numbers.

- It ran again? We don’t know how many times it runs before reaching our method, so let’s handle it with code.
backend.hook_add_new(new CodeHook() {
@Override
public void hook(Backend backend, long address, int size, Object user) {
if (address == module.base + 0x3C28){
Number x0 = backend.reg_read(Arm64Const.UC_ARM64_REG_X0);
Number x20 = backend.reg_read(Arm64Const.UC_ARM64_REG_X20);
long m0 = x20.longValue()-module.base;
long m1 = x0.longValue()-module.base;
System.out.println("direct call -->" + Long.toHexString(m0));
System.out.println("indirect call -->" + Long.toHexString(m1));
}
}
@Override
public void onAttach(UnHook unHook) {
}
@Override
public void detach() {
}
}, module.base, module.base + module.size, null);
- The output:
direct call -->2c0c
indirect call -->2b40
direct call -->2c0c
indirect call -->2b5c
direct call -->2c0c
indirect call -->2b40
direct call -->2c0c
indirect call -->2b40
direct call -->2c0c
indirect call -->2b94
direct call -->2c0c
indirect call -->2b40
direct call -->2c0c
indirect call -->2ba8
direct call -->2c0c
indirect call -->2bb8
direct call -->2c0c
indirect call -->2bb8
direct call -->2c0c
indirect call -->2bc8
direct call -->2c0c
indirect call -->2be8
direct call -->2c0c
indirect call -->2c04
direct call -->2c0c
indirect call -->2c04
direct call -->2c0c
indirect call -->2c04
- Let’s briefly analyze what these indirectly-called methods do, and print them all (I’ll only analyze the two core methods; the rest are left as an exercise).
- After analyzing we can rewrite the code like this and check the output:
Map<Integer, String> map = new HashMap<>();
map.put(0x2c0c, "trampoline, x0 = function to call, x1 = arg (pointer)");
map.put(0x2b40, "malloc");
map.put(0x2b5c, "time/srand/rand RNG functions");
map.put(0x2b94, "jumps to another large function (same as the entry 0x2C18)");
map.put(0x2ba8, "hash-family algorithm (6A58)");
map.put(0x2bb8, "memcpy1");
map.put(0x2bc8, "memcpy2");
map.put(0x2be8, "aes-related function");
map.put(0x2c04, "free");
Backend backend = emulator.getBackend();
backend.hook_add_new(new CodeHook() {
@Override
public void hook(Backend backend, long address, int size, Object user) {
if (address == module.base + 0x3C28){
Number x0 = backend.reg_read(Arm64Const.UC_ARM64_REG_X0);
Number x20 = backend.reg_read(Arm64Const.UC_ARM64_REG_X20);
long m0 = x20.longValue()-module.base;
long m1 = x0.longValue()-module.base;
System.out.println("direct call -->" + Long.toHexString(m0)+ "\t" + map.get((int)m0));
System.out.println("indirect call -->" + Long.toHexString(m1)+ "\t" + map.get((int)m1));
}
}
@Override
public void onAttach(UnHook unHook) {
}
@Override
public void detach() {
}
}, module.base, module.base + module.size, null);
- Then the output:
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b40 malloc
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b5c time/srand/rand RNG functions
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b40 malloc
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b40 malloc
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b94 jumps to another large function (same as the entry 0x2C18)
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2b40 malloc
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2ba8 hash-family algorithm (6A58)
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2bb8 memcpy1
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2bb8 memcpy1
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2bc8 memcpy2
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2be8 aes-related function
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2c04 free
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2c04 free
direct call -->2c0c trampoline, x0 = function to call, x1 = arg (pointer)
indirect call -->2c04 free
- From this we discover another
sub_2b94that callssub_45C8, similar toJNI_OnLoad’ssub_DA0andttEncrypt’ssub_2C18. 


- So we apply the same method to
sub_45C8’s0x55D8(likesub_2C18, it has only one call site, so it’s quick to resolve), producing this full code (JNI_OnLoaduntouched, since we haven’t seen anything related to it yet):
Map<Integer, String> map = new HashMap<>();
map.put(0x2c0c, "trampoline, x0 = function to call, x1 = arg (pointer)");
map.put(0x2b40, "malloc");
map.put(0x2b5c, "time/srand/rand RNG functions");
map.put(0x2b94, "jumps to another large function (same as the entry 0x2C18)");
map.put(0x2ba8, "hash-family algorithm (6A58)");
map.put(0x2bb8, "memcpy1");
map.put(0x2bc8, "memcpy2");
map.put(0x2be8, "aes-related function");
map.put(0x2c04, "free");
map.put(0x45bc, "trampoline, x0 = function to call, x1 = arg (pointer)");
map.put(0x44c4, "malloc");
map.put(0x44e0, "hash-family algorithm (6A58)");
map.put(0x44f0, "memcpy");
map.put(0x4500, "table lookup");
map.put(0x45B4, "free");
Backend backend = emulator.getBackend();
backend.hook_add_new(new CodeHook() {
@Override
public void hook(Backend backend, long address, int size, Object user) {
if (address == module.base + 0x3C28 || address == module.base + 0x55D8){
Number x0 = backend.reg_read(Arm64Const.UC_ARM64_REG_X0);
Number x20 = backend.reg_read(Arm64Const.UC_ARM64_REG_X20);
long m0 = x20.longValue()-module.base;
long m1 = x0.longValue()-module.base;
System.out.println("direct call -->" + Long.toHexString(m0)+ "\t" + map.get((int)m0));
System.out.println("indirect call -->" + Long.toHexString(m1)+ "\t" + map.get((int)m1));
}
}
@Override
public void onAttach(UnHook unHook) {
}
@Override
public void detach() {
}
}, module.base, module.base + module.size, null);

- From the code above we can see the pattern and the methods to focus on:
2be8, (2ba8and44E0both call6A58), and2b5c. The rest are allocation/free and memcpy primitives (can be mapped).
2ba8, 44e0 (6A58)

- Stepping in, it calls
6a58. 
- Inside
6a58we see a length-4 array, the constant0x5BE0CD19137E2179LL, and128. Let’s check what0x5BE0CD19137E2179LLis first. 
- Looks like sha512. Let’s check whether the array matches sha512’s constants.

- They all match. Let’s debug.

- The second argument
0x20looks like the input length; let’s check its content. 
- From this output we can guess: first arg is the input byte array, second is the length, third is the output.
- Time to write a hook.
// Dobby throws here — don't ask how I know.
IHookZz hookZz = HookZz.getInstance(emulator); // load HookZz, supports inline hook; docs at https://github.com/jmpews/HookZz
hookZz.wrap(module.base + 0x6A58, new WrapCallback<HookZzArm64RegisterContext>() { // inline wrap the exported function
@Override
public void preCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x0 = ctx.getPointerArg(0);
int x1 = ctx.getIntArg(1);
UnidbgPointer x2 = ctx.getPointerArg(2);
byte[] input = x0.getByteArray(0, x1);
Inspector.inspect(input, "input(sha512)");
ctx.push(x2);
emulator.getUnwinder().unwind();
}
@Override
public void postCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x2 = ctx.pop();
int length = 0x40;
Inspector.inspect(x2.getByteArray(0, length), "final result");
}
});

- Now we have input and output, so we can verify whether our analysis is right.
>-----------------------------------------------------------------------------<
[02:55:46 216]input(sha512), md5=7e6de8344f55d460143710510155b657, hex=2ad949a94f9867924645bdfc952302dea3193a95cbe9913e3bbea28a659eaa90
size: 32
0000: 2A D9 49 A9 4F 98 67 92 46 45 BD FC 95 23 02 DE *.I.O.g.FE...#..
0010: A3 19 3A 95 CB E9 91 3E 3B BE A2 8A 65 9E AA 90 ..:....>;...e...
>-----------------------------------------------------------------------------<
[02:55:46 234]final result, md5=bed9304b171af26ae94b5fcaccd1fd6b, hex=17b4b1b1094010ca669a8c2116ad14a25b40302b693684f23686cad7817f5295064c06ab34f5f55a8dfceee08bd2ae3a8cb12d52fbc7981e0b4ed5f995b921eb
size: 64
0000: 17 B4 B1 B1 09 40 10 CA 66 9A 8C 21 16 AD 14 A2 .....@..f..!....
0010: 5B 40 30 2B 69 36 84 F2 36 86 CA D7 81 7F 52 95 [@0+i6..6.....R.
0020: 06 4C 06 AB 34 F5 F5 5A 8D FC EE E0 8B D2 AE 3A .L..4..Z.......:
0030: 8C B1 2D 52 FB C7 98 1E 0B 4E D5 F9 95 B9 21 EB ..-R.....N....!.
^-----------------------------------------------------------------------------^

- They match. Let’s analyze one more.
2b5c

- Too easy — straight to code.
hookZz.wrap(module.base + 0x2B5C, new WrapCallback<HookZzArm64RegisterContext>() { // inline wrap the exported function
@Override
public void preCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x0 = ctx.getPointerArg(0);
ctx.push(x0);
}
@Override
public void postCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x0 = ctx.pop();
Inspector.inspect(x0.getPointer(0).getByteArray(0, 0x100), "test");
}
});

- There’s a reference elsewhere; let’s see where it’s used. We only have one sha512 — could it be it?

- Indeed it is. Not wasted.
2be8

sub_2be8callssub_2948.
- Seeing the 16-round loop below, still not obvious — click the first function
sub_5DF4. 
- Experienced eyes will already know what this is.
- If not, see all the different arrays and step in?

- Look at this one.

- An sbox! So it’s AES.
- Then we can guess
sub_5DF4isinitKey— it’s the only thing that runs that early. 
sub_6380seems useless, skip it.
- This looks like XOR with the ctx’s sk.

- An old friend again.
- Now to confirm key & value — still needs debugging.
debugger.addBreakPoint(module, 0x2948);

- As usual, check their actual values.
- x0

- Another reference — where from?

- It’s from the sha512 result.
- x1 –> 0x10
- x2

- Right after x0, also from the sha512 result.
- Guess: x0 is key, x2 is iv.
- x3

- x4 –> 0x50
- By common C++ conventions, address comes first, size second. So guess: x3 is the plaintext, x4 is the text size.
- x5

- The blank one looks like the output.
- x6

- Looks like the output length.
- x7

- All the necessary args seem present, no need to dig further.
- Straight to the hook code.
hookZz.wrap(module.base + 0x2948, new WrapCallback<HookZzArm64RegisterContext>() {
@Override
public void preCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x0 = ctx.getPointerArg(0);
int x1 = ctx.getIntArg(1);
UnidbgPointer x2 = ctx.getPointerArg(2);
UnidbgPointer x3 = ctx.getPointerArg(3);
int x4 = ctx.getIntArg(4);
byte[] key = x0.getByteArray(0, x1);
byte[] iv = x2.getByteArray(0, x1);
byte[] input = x3.getByteArray(0, x4);
Inspector.inspect(key, "key");
Inspector.inspect(iv, "iv");
Inspector.inspect(input, "input(aes)");
UnidbgPointer x5 = ctx.getPointerArg(5);
int x6 = ctx.getPointerArg(6).getInt(0);
ctx.push(x5, x6);
}
@Override
public void postCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx, HookEntryInfo info) {
UnidbgPointer x5 = ctx.pop();
int x6 = ctx.pop();
Inspector.inspect(x5.getByteArray(0, x6), "aes result");
}
});

- We have output! Let’s compare to see if we can decrypt — to confirm it’s AES.
- Since it has both key and iv, it looks like CBC mode, so let’s take a guess and see if we get lucky.

- Guessed it on the first try?
Putting it together
- At this point it’s a bit dizzying — so many methods, three of which look important. How to proceed? Answer: stay results-driven and work backwards.
- We’ve analyzed three methods, so let’s check which of them are actually used.
The last segment

- Our result’s last segment looks like the AES-encrypted part.
- Going back, what is AES’s input?
- AES’s input:

- It’s passed in here.

- Looks like the concatenation of
sha512(bytes) + bytes.
The middle segment

- Our middle segment is also referenced elsewhere.

- It’s randomly generated right here.
The last open question: how are AES’s key/iv generated?

- It has a reference too! Where?

- It’s a sha512 value.
- What’s sha512’s input?

- Another reference — what is it?

5C A5 E7 F7 3C 2B 01 CE CA BB DE C0 5C 8E A2 B9looks familiar — wasn’t it the random value from before?- And the other half of the generated value?

0040: 4D D4 C2 E6 B8 31 62 09 0E 52 B3 C7 A6 73 3B A4 M....1b..R...s;.
0050: 1C B2 46 2B 82 9A B5 8A 19 6B 39 DB 57 17 75 24 ..F+.....k9.W.u$
0060: F4 9B AF 7F 08 E8 D6 8D 26 A7 2E 37 C1 A9 5A 2F ........&..7..Z/
0070: 1F 05 A5 18 92 AE F2 94 97 32 B6 2A 38 AA DD 58 .........2.*8..X
- After many tests, it turns out to be a fixed value.
Final summary
- The whole flow is basically analyzed at this point.
- I didn’t write the final logic code, and I can’t — I have no business need for it. If you need it, just code it up; the whole flow is clear. Analysis took four to five hours, and writing it up took another four. It’s 4:01 AM as I type this.
- If anything’s wrong, corrections are welcome.
2023-12-20 10:34 update
- Per expert feedback, that value is not fixed — I missed something. It looked fixed only because our input was fixed. The logs show we skipped one more method: the one labeled “table lookup”.
- Let’s catch this escapee and see what it is.
debugger.addBreakPoint(module, 0x4500);

- Also looks like a pointer — let’s look at what it points to.

- There’s a reference; where is it used?

- The sha512 result!
- What’s sha512’s input text?

- Another reference.

- Still the random value. So we know its input is the sha512 of the random value.
- Let’s briefly analyze this method: it takes one
resultargument, then extracts three values from it:
0-->80 60 35 40
1-->00 00 00 00
2-->40 00 00 00
v1 = result[2];
v2 = *result;
v3 = result[1];
so v1=0x40, v2=0x40356080, v3=0

- Looking at it this way, the first
if ( v1 )is true, and the secondif ( v1 < 0x20 )is false. - Scrolling down, only here does it write to the address — let’s debug it.

- The corresponding assembly:

- Set a breakpoint and look.
b0x4560
>>> q0=0xa43b73a6c7b3520e096231b8e6c2d44d(1.8056244479338115E-263, -3.7768713601031674E-134) q1=0x24751757db396b198ab59a822b46b21c(-4.496232995265719E-257, 4.642831181428702E-133)
- What are these
q0andq1? Aren’t they the “fixed value” we mentioned above? - Run once more and see:

q0=0x2f5aa9c1372ea7268dd6e8087faf9bf4(-5.367597597728704E-242, 1.4054386263309782E-80) q1=0x58ddaa382ab6329794f2ae9218a5051f(-9.09210480120694E-208, 1.1969099196948154E120)
- Run to the end and combine with the “fixed value” from above:
0040: 4D D4 C2 E6 B8 31 62 09 0E 52 B3 C7 A6 73 3B A4 M....1b..R...s;.
0050: 1C B2 46 2B 82 9A B5 8A 19 6B 39 DB 57 17 75 24 ..F+.....k9.W.u$
0060: F4 9B AF 7F 08 E8 D6 8D 26 A7 2E 37 C1 A9 5A 2F ........&..7..Z/
0070: 1F 05 A5 18 92 AE F2 94 97 32 B6 2A 38 AA DD 58 .........2.*8..X
- It matches perfectly. So that’s where it comes from — the escapee is caught.
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
