Hyper Packer was a reverse challenge from FCSC 2022, of medium difficulty, and for which I got first blood.
We were asked to connect to a remote server that sends us multiple binaries, to unpack these and send back the secret they display.
Preliminary analysis
First, we connect to the remote server once and solve the proof-of-work using hashcash, in order to retrieve a sample binary. Surprise, it's a Windows executable.
╭─face@0xff ~/ctf/fcsc/reverse/hyperpacker
╰─$ file sample.exe
sample.exe: PE32+ executable (console) x86-64, for MS Windows
If we try to execute it, nothing really seems to happen. It just runs indefinitely.
Let's open up IDA. Although the executable is ~300KB, there are very few functions:
The main function looks like this:
It first calls sub_140097203, and if it succeeded, calls sub_1400973F0 next. We notice these two sub-functions have an address as argument: 0x14004E000, which is the start of the .data section. If we look at what's in there, all we can see is lots of high entropy data, which accounts for the vast majority of the file's size.
Let's check out the second function first (sub_1400973F0) because it will be easier to understand.
The functions CheckMZ and CheckPE simply check that the data buffer starts with MZ and also contains a PE header. Therefore, these are checks for a valid executable file. We understand another executable shall be unpacked, or decrypted, to this buffer.
We also guess the next functions take care of loading this new executable and running it, hence we will rename sub_1400973F0 as CheckAndRun.
Let's now dive into the sub_140097203 function, which we suppose is what unpacks the executable.
Reversing the unpacking algorithm
Let's go through the Unpack function.
First, a 16-byte buffer (v6) is set to all null bytes. This could very be a key for a block cipher like AES. We will rename it key for the future.
It calls sub_140097132, which is the same function that is called in Unpack right after this the call to this function, although with different arguments: 0x48A8 instead of 1 here.
We notice 0x48A8 is 0x48A80 divided by 16, in other words 0x48A8 is the number of 16-byte blocks in the data buffer, which supports the idea of AES-like symmetrical encryption.
Then, it checks whether outbuf starts with "MZ". This surely means sub_140097132 decrypts a first block of data, and a basic heuristic is used to determine whether the decryption was successful. We will rename this function to GoodFirstBlock, and the sub_140097132 function to DecryptData.
If it passes the check for the first block, then it proceeds to decrypt the entire data buffer. If it does not, then something happens with the key:
while (sub_1400973B3(key)) {if (!sub_140097358(key,0x140096A80,16))break;}
The first function, sub_1400973B3, is easy to understand:
It increments the key buffer, viewed as a 16-byte integer in little endian. We will rename it to Increment.
The second function, sub_140097358, takes the key and an address (0x140096A80) as input:
.data:0000000140096A80 unk_140096A80 db 0 ; DATA XREF: DecryptData+54↓o
.data:0000000140096A81 db 98h ; ˜
.data:0000000140096A82 db 0
.data:0000000140096A83 db 0
.data:0000000140096A84 db 0C8h ; È
.data:0000000140096A85 db 0
.data:0000000140096A86 db 0
.data:0000000140096A87 db 0
.data:0000000140096A88 db 0
.data:0000000140096A89 db 0
.data:0000000140096A8A db 0
.data:0000000140096A8B db 0
.data:0000000140096A8C db 0
.data:0000000140096A8D db 0
.data:0000000140096A8E db 0
.data:0000000140096A8F db 0
What it does is ensure that all the bits set to 1 in the key lie at positions defined by a certain mask (unk_140096A80). For instance (with a reduced number of bits), with the mask 01100010, the key 01000010 passes the check, but the key 01100011 does not. We will rename this function to CheckBitMask.
Therefore, the following loop increments the key until it finds a key that satisfies the mask.
while (Increment(key)) {if (!CheckBitMask(key, mask,16))break;}
We understand now why the executable never really manages to finish unpacking itself: this loop is extremely unefficient. It comes down to brute-forcing up to 2128 keys worst-case scenario until finding one that satisifes the mask.
It is much easier to brute-force all the possible subsets of bit indexes given by the mask, since the mask only contains a few one bits.
The InitRoundKeys and DecryptAESBlock functions were easily recognizable because of the heavy use of XMM registers and AES-related instructions specific to Intel. For instance, the following DecryptAESBlock function leverages the aesdec instruction with the XMM registers xmm5 to xmm15 already filled with the round keys.
As we can see, a XOR is performed at the end of each block, hinting at CBC mode decryption. Under this hypothesis, we notice the mask is also used as the initialization vector for the decryption. One can verify the hypothesis of AES-128-CBC by debugging the executable.
All there is left for us to do is to implement the unpacking algorithm for generalized executables.
Solution implementation
If we ask for a few more executables, we notice the only parts that change through these are the encrypted data and the mask value.
We can then easily fetch an executable, extract the relevant parts (the packed data and the mask) and run our "optimized brute-force" to find the key. Once the binary is unpacked, we get a new executable that runs:
Fortunately for us, there is no obfuscation in this unpacked binary: the secret string can be found inside it in cleartext.
╭─face@0xff ~/ctf/fcsc/reverse/hyperpacker
╰─$ python3.9 solve_pow.py
[+] Opening connection to challenges.france-cybersecurity-challenge.fr on port 2202: Done
[*] Solving PoW
[+] Solved PoW: 1:26:220507:603e6556f2108faf::kDm1OewN30GkkNXF:6wHth
b'Here is your binary:\n'
[+] Binary written at /tmp/hyperpacker.bin
b'Please input the secret within 60 seconds:\n'
00000000000000190010000000000000
b'MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00'
00000000000000190000000000000000
b'BLPEN3FICK5OJEXG80YQS83HRIDGAS1M'
b'Well done!\n'
[...]
b'Here is your binary:\n'
[+] Binary written at /tmp/hyperpacker.bin
b'Please input the secret within 60 seconds:\n'
000000ac7488349c0000000000000000
b'MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00'
0000002c300034840000000000000000
b'G46PZ0L9PIMUZAUA6J7SK4USE451ZIQK'
b'Well done!\n'
b'Congratulations! Here is your flag: FCSC{2b60aef3d241d3c37c30373a9a4446017a6d5b6761ae5492e3f824e
280cb8ceb}\n'
Solve script
import subprocessfrom pwn import*from Crypto.Cipher import AESfrom itertools import chain, combinationsdefpowerset(iterable): xs =list(iterable)return chain.from_iterable(combinations(xs,n) for n inrange(len(xs)+1))defdecrypt(data,key,IV): cipher = AES.new(key, AES.MODE_CBC, IV=IV)return cipher.decrypt(data)HOST = args.HOST or"challenges.france-cybersecurity-challenge.fr"PORT = args.PORT or2202defmain(): r =remote(HOST, PORT) _ = r.recvlines(5) cmdline = r.recvline().strip().decode("utf-8").split(" ")assert cmdline[0]=="hashcash"assert cmdline[1]=="-mb26"assert cmdline[2].isalnum() log.info(f"Solving PoW") solution = subprocess.check_output([cmdline[0], cmdline[1], cmdline[2]]) log.success(f"Solved PoW: {solution.decode()}") r.send(solution)whileTrue: _ = r.recvline()print(_) encoded = r.recvline().strip() binary =b64d(encoded)withopen("/tmp/hyperpacker.bin", "wb")as fp: fp.write(binary) log.success("Binary written at /tmp/hyperpacker.bin")print(r.recvline()) magic_data = binary[0x400:0x400+0x48a80] IV = binary[0x48e80:0x48e80+0x10]print(IV.hex()) bin_iv =f"{int.from_bytes(IV, byteorder='big'):0128b}" ones = [i for i inrange(128)if bin_iv[i]=="1"] found =Falsefor subset inpowerset(ones): int_key =0for bit_index in subset: int_key ^=1<< (127- bit_index) key = int_key.to_bytes(length=16, byteorder="big") first_block =decrypt(magic_data[:16], key, IV)if first_block[:2]==b"MZ":ifb"PE"indecrypt(magic_data[:256], key, IV): found =Truebreakifnot found:print("not found :(")exit(1)print(first_block)print(key.hex()) decrypted =decrypt(magic_data, key, IV) secret = decrypted.split(b"SECRET is : ")[1].split(b"\x00")[0]print(secret) r.sendline(secret)print(r.recvline())if__name__=="__main__":main()"""FCSC{2b60aef3d241d3c37c30373a9a4446017a6d5b6761ae5492e3f824e280cb8ceb}"""