TCTF 2021 0bf: Emulating Obfuscated Code with Unicorn
Contents
Translation
This post is also available in Simplified Chinese.
Notice
This article was originally published on Anquanke: original article
Introduction
With tremendous help from my teammates, we won the Rising Star division of the TCTF 2021 Finals. The challenge from which I learned the most was 0bf, a ten-round cipher hidden beneath extensive code obfuscation. The official post-event solution deobfuscated it first, but during the competition we lacked time to study the obfuscation pattern and had to analyze it directly. This was also my first use of Unicorn to emulate function logic.
Program Logic
Obfuscation inflated the function enormously, so first raise IDA’s function-size limit. Open IDA installation\cfg\hexrays.cfg, set MAX_FUNCSIZE to a large value, and reanalyze the encryption function sub_11C9. After Hex-Rays finishes several minutes later, copy the result into a file and reformat it to place each assignment on its own line. I uploaded the decompiled code.
VS Code’s minimap then makes the repeated regions obvious. For example, the function begins with:
| |
The second round begins with:
| |
And the third with:
| |
Following these patterns divides the encryption function into ten rounds, with two 32-bit values passed between rounds. Round one consumes the eight bytes at a1; round two takes v103 and v105 from round one, and so forth. The tenth round’s two results form the encrypted eight-byte block.
The first round has roughly this structure:
| |
One round can therefore be summarized as follows:
Briefly transform the inputs
*a1anda1[1]intov2 = s(*a1)andv10 = t(a1[1]), then computev12 = r(v2, v10).All subsequent computation depends only on
v12. After it is computed, later uses ofv2andv10have exactly the same form asv12, namely21 * (v10 & ~v2 & 0xEE1B0FD2) + ...; the expression is simply another representation ofv12.Calling step two’s result
f(v12), the two 32-bit outputsv103andv105areg(v2, f(v12))andh(v10, f(v12)).
The exact operations performed by r, s, t, f, g, and h remain unknown.
Analysis
Without deobfuscation, treat each code segment as a black box and infer its logic by varying inputs and observing outputs.
Definitions for IDA macros such as _DWORD, LOWORD, and __CFADD__ are available in IDA installation\plugins\defs.h. To analyze s in v2 = s(*a1), convert the decompilation back into C:
| |
Testing every input from 0 through 255 plus random values reveals s(x) = x + 0x6e62d8bf. Similar experiments establish r(x, y) = g(x, y) = h(x, y) = x ^ y and t(x) = (x*0x9d267e07-((x*0x9d267e07)>>32))&0xFFFFFFFF. The remaining function f is too complex to infer directly. A round is therefore:
| |
Solution
Without deobfuscation, f is the only non-invertible component, but XOR can eliminate it. Given ciphertext x, y, choose v1'=x^y, v2'=0. Then v3'=x^y, x'=x^y^f(x^y), and y'=f(x^y). Thus the new ciphertext’s y' equals the original invocation’s f output; XORing y' with x and y recovers the original v1 and v2.
Next choose a'=0, b'=1, producing v1'=C1, v2'=C2. Together with the recovered v1 and v2, these constants reveal the original a and b. Decryption therefore requires no inverse of f.
Unicorn conveniently emulates selected code regions. The key is choosing suitable start and end points so the intervening black box depends on as little external state as possible.
The beginning of sub_11C9 is:
| |
Use 0x11F4 as the emulation start, where r12 and r13 hold the first-round inputs. The round ends at 0xF7AD, with its results again in r12 and r13. We must also control v3 and inspect v1 and v2, so choose 0x241A as an intermediate point where r12 and r13 contain those values:
| |
do_emulation executes a code region with Unicorn. First start with a=1, b=0 and run to the midpoint; the analysis predicts r12=C2, r13=C1, saved as s and t. Then begin at the midpoint with v1=x^y, v2=0 and run to the end, producing r12=x^y^f(x^y), r13=f(x^y), saved as z and w. Recover the original values with v1=w^x, v2=w^y, then compute b=v2-C2 and a=(v1*n)%0x100000001, where n is the multiplicative inverse of C1 modulo 0x100000001.
Using Unicorn
Here is the implementation of do_emulation:
| |
The challenge is PIE. To avoid relocation, map .text directly at 0x1000, allocate an empty stack, point rsp and rbp into it, load the inputs into r12 and r13, and call uc_emu_start.
Download and build Unicorn. Avoid installing self-built software into system directories with make install, where it may conflict with existing packages. Many online OpenSSL guides install directly into /usr/lib, causing system programs to load an incompatible libssl and fail.
Static linking with a local include path makes self-built third-party libraries easy to use without polluting the environment, especially for one-off programs. Link Unicorn with:
| |
-I selects the header search directory. Pass libunicorn.a directly to the linker, then add libm and libpthread to produce a standalone emulator.
The complete code follows:
| |
flag: flag{0o0..MBA_0bF_1s_S0_1ntEresT1ng!!}
Conclusion
Because I did not understand deobfuscation, this challenge required considerable effort. In the end, the XOR structure let us cleverly reuse the existing binary code. I still need to broaden my knowledge and learn proper deobfuscation techniques.