Reversing WebAssembly with pure guesswork - Ximalaya xm encryption
Prologue
In my previous article, I left a lingering question regarding the decryption methodology inside wasm. Today, let's dive deep into the world of reverse engineering again and uncover the mysteries that lie beneath the code.
Now, some of you might wonder why this post is titled pure guesswork. Well, that's because I tackled this 'reversing' challenge without actually doing any acutally reversing.
Thinking Forward
If there exists a decryption algorithm, there must be a corresponding encryption mechanism. In this case, the exported function h
in the webassembly is the encryption method I want.
Similar to its decryption counterpart, it requires two parameters: encrypted data and a trackId.
1 | function f_h(a:{ a:byte, b:byte }, b:int, c:long_ptr, d:int, e:int) { |
Let's explore how this encryption works.
Guessing the encryption
First, we need a script to test how different parameters influence the encryption outcome:
1 | from Crypto.Cipher import AES |
From the results, we can observe that after track_id
reaches a length of 0x18 bytes, the outcome remains the same. This implies that the length of track_id
beyond 0x18 doesn't impact the results.
result
1 | 0x16 NrVlG9gtu3MmpUlXK8gIxHD0Kh07iORGc6Dz5tLaLSUBSffF0/FU1vB8OmX921rP |
0x18 is an intriguing value, precisely 192 bits. This instantly reminded me of aes-192 encryption. In fact, if you search "192 bit encryption" on Google, the first result typically points towards AES.
But how can we ascertain that this is indeed AES encryption? While we could manually verify it, we also need to identify the encryption mode and its IV (Initialization Vector).
I began by trying out CBC mode, primarily because it's commonly employed. Also, due to the nature of CBC mode (as detailed on Wikipedia), it's straightforward to verify it by using the initial 16 bytes as the IV.
uhhhhhh, wtf, it indeed was the CBC mode (Coooooooool). Now, the task was to identify the IV.
Finding Parameters
Since the encryption function doesn't explicitly demand an IV, there are two possibilities. The IV could either be generated based on trackId
(since the data would eventually be encrypted) or it could be randomly generated and then appended to the returned value.
The latter can be quickly ruled out, given the length of the return value doesn't seem to have room for an appended IV. This points to the former - the IV might be derived from the trackId
. But how?
To solve this, I started with the simplest assumption: that the first 16 bytes of the trackId
were being used as the IV.
LOOOOOOOL, it is same as the key. wtf is your encryption bro.
But then another challenge arose. The xm_encryptor
can also process a trackId
with a length less than 24 bytes. Knowing that AES-192 cannot work with a key length other than 24 bytes, I assert that the algorithm must somehow pad the trackId
with some additional characters.
Our task now was to identify the padding characters and its padding method. Since the encryption needed to support variable trackId
lengths and since the padding was done based on our provided trackId
, the most straightforward solution would be to pad with some constant characters.
There are also two types of simplest padding methods, one is to pad behind the trackId
, and the other is to pad in front.
So at this time, there is one simple but effective method - Bruteforce. One byte by one byte. We only need to run 256*24=6144 iterations. Not even hitting 10k.
1 | xm_encryptor = Instance(Module( |
lets start with the first one. padding after the trackId
, ummmmmmmmmmmmmmmmmm. not this time.
1 | [aynakeya @ ThinkStation]:~/workspace/ximalaya |
But the second approach, padding before the trackId
, worked.
1 | [aynakeya @ ThinkStation]:~/workspace/ximalaya |
Verify Parameter with Memdump
To validate the padding method is accuracy, another technique I can employ is the memory dump. While debugging would work too, I'm just too lazy to debugging WebAssembly.
To achieve this, I added a few lines to the encrypt
function:
1 | result_data = bytearray(memory_i.buffer)[result_pointer:result_pointer+result_length].decode() |
By examining the output, we can clearly identify the padding:
1 | bytearray(b'}\x11\x00@\x00\x00\x00@\x00\x00\x00\xe8\x01\x11\x00X}\x11\x00@\x00\x00\x00@\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00X}\x11\x00@\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00AAAA@\x00\x00\x00\x10\x00\x00\x000\xa4\x0e\x000\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\xa8\x00\x11\x00p\x08\x10\x00123456781AAAAAAAAAAAAAAA123456781AAAAAAA123456781AAAAAAAAAAAAAAA123456781AAAAAAA\xe8\x01\x11\x000\x00\x00\x000\x00\x00\x00AAAA\x08\x00\x11\x00 \x00\x00\x00 ') |
The presence of sequences like 123456781AAAAAAAAAAAAAAA
in the dumped data suggests that our assumption regarding the padding is indeed accurate.
Put it together
With all the pieces of the puzzle now in place, it's clear that the wasm encryption follows these steps:
- Decode the input with Base64.
- Use AES-192-CBC encryption. The key is derived from
trackId
. IftrackId
has fewer than 24 bytes, it's prepended with123456781234567812345678
to reach the required length. And the IV is the first 16 byte of the key. - Encode the result using Base64.
Walking Backwards
Having understood the correct encryption method, I took a moment to ponder whether there might be a more straightforward reverse engineering approach.
I wasn't keen on trawling through WebAssembly. Just then, one user from some git issue mentions that the older version of the Ximalaya client used a DLL to implement the encryption, and more importantly, the encryption mechanism was identical.
Hey, I don't need to deal with the stupid WebAssembly anymore. I could now dive straight into dissecting the algorithm using the my good old assembly code. Well, at least that's what I initially thought.
The Golang DLL
I did take some time dived into reverse engineering - mainly because the dll was crafted using Golang . While the world of assembly offers its intricate dance of registers and memory locations, it became exponentially challenging with Go's binaries, thanks to its distinctive calling convention.
For those unfamiliar, a calling convention defines the runtime call stack's operation, dictating how functions receive parameters from their callers and return their results. Typically, languages like C or C++ adhere to conventions that is fairly easy to use and understand. Enter Golang - a language that not only dances but also brings its own dance floor. Unlike its peers, Go uses a unique, stack-based calling convention, throwing a wrench into the familiar rhythm of traditional reverse engineering tools.
What does this mean for reverse engineers? Pure Pain.
Standard tools like IDA Pro, Ghidra, or Radare2 can get pretty confused by Go's conventions. Recognizing function boundaries, arguments, and return values becomes an uphill battle. Local variables and function arguments are frequently intermixed, and because Go binaries are statically compiled, there's a whole lot of code to sift through.
But every dark cloud has a silver lining.
Instead of wrangling with Go's intricacies head-on, an alternative approach dawned upon me: why not write a similar AES decryption function in Go, compile it with debugging symbols, and then use that as a basis for comparison with the target binary? By doing this, I'd have a "known" reference, a roadmap of sorts, to guide my analysis of the original DLL.
The process was fairly straightforward. I wrote an AES decryption method in Go, ensuring it closely mirrored the suspected functionality of the target DLL. Upon compiling this with debugging symbols, I was armed with a binary rich in annotations that helped demystify the corresponding sections of the DLL.
1 | package main |
Using my custom-compiled Go binary as a reference, I could now align and correlate patterns, function calls, and data structures more effectively with the DLL. This comparative approach illuminated the obscured parts of the DLL, allowing me to make headway much faster than if I had been grappling with the unfamiliar Go assembly on its own.
Here's the key padding:
Debugging
Another approach is to create a C program that calls the binary. This way, I can engage in debugging to uncover the inner workings of the fill_key
function and understand its behavior more effectively.
1 |
|
After all, the results of the proper reverse engineering were very consistent with my guesswork solution.
Some Thoughts
Sometimes, when confronted with a perplexing problem, it's beneficial to shift gears. Using an informed approach to make educated guesses can be far more productive than blindly grappling in the dark. Instead of hitting the wall repeatedly, stepping back and reassessing can often open doors to solutions previously unseen.
The age-old wisdom of "Don't put all your eggs in one basket" rings true here. Diversifying your strategies can save you from potential pitfalls and lead you to your eureka moment faster than you might think.
All in all, the joy of conquering a formidable challenge is incomparable. Those fleeting moments of doubt now serve as milestones marking how far I've journeyed, filling me with a profound sense of achievement.
Code
1 | from mutagen.easyid3 import ID3 |