Flareon 12 Writeup Part 2

Published By: BLUE TEAM

Published On: 03/11/2025

Published in:

6 – Chain Of Demands

DiE detects this is an ELF file, which is packed by PyInstaller, the Go information might be misleading.

To get the bytecode, we utilize the great tools pyinstxtractor, among bunch of extracted file, these two file are suspicious

Challenge pyc file (challenge_to_compile.pyc) can be decompiled back to python code, not 100% but enough for us to understand the logic of this chat app.

Too long to go detail each class and each function, generally, this chat app has two mode to secure data

  • LCG-XOR: save both plaintext and ciphertext in chat_log.json, encrypt function utilize web3 contract triple-xor with two key includes conversation_time and prime_from_lcg. The contract_bytes of the contract is hardcoded, it is a nested contract, the final decompiled solidity code for this encrypt is actual perform triple xor.

  • RSA: implement RSA encrypt, with primes number generated by A Linear Congruential Generator (LCG) - a function also utilizes web3 contract, but this is not a standard LCG, it has additional condition check varg4 which is the counter value in python code.

There is no hint in the challenge description, so the only suspicious thing we might need to discovery is the redacted message of the two encryted message in chat_log.json extracted before. Decrypt RSA can be done if we know the seed and other constant.

Notetably, XOR encrypt and RSA encrypt use the same LCG constant, which might give us a hope to find out the constants. From chat log, we can restore the lcg_prime used in every triple xor encryption.

import json 

chatlog = json.load(open('chat_log.json', 'r')) 
f = open('lcg_primes.txt', 'w') 
i = 0 

for entry in chatlog: 
    if entry['mode'] == 'LCG-XOR': 
        ciphertext = int(entry['ciphertext'], 16) 
        plaintext = int.from_bytes(entry['plaintext'].encode().ljust(32, b'\x00'), "big")
       conversation_time = entry['conversation_time'] 
        prime_xor = ciphertext ^ plaintext ^ conversation_time 
       f.write(f"Counter: 0x{i:x} -> lcg_prime: 0x{prime_xor:x}\n") 
        i += 1

As mention before, the LCG in the web contract is not the standard implementation, the condition counter == 0 which will ultimately lead to result is varg3 which is the state value, and the first state is assigned is the seed_hash which used to generate prime number for RSA encryption. So with the first get_next() in XOR encryption, it actually returns the seed_hash.

You might think we freaking solve this, because other constant of RSA is derived from this seed_hash, me too :) but somehow, apply exactly the function in decompiled code, we get the wrong RSA constants

After for a while, my cryptography CTF friend told me that the other constants can be archived by apply LCG attack which is perfectly explained here and code for that crack here. Finally, we recover the constants and the remain task is trivial.

import hashlib 
from Crypto.Util.number import bytes_to_long, long_to_bytes, isPrime 
import math 
from Crypto.PublicKey import RSA 

def rebuild_rsa_key(multiplier, increment, modulus, initial_seed): 
    primes_arr = [] 
    seed = initial_seed 
    iterations = 0 
   
    while len(primes_arr) < 8 and iterations < 10000: 
        candidate = get_next(multiplier, increment, modulus, seed, iterations) 
        seed = candidate
        iterations += 1 
       
        if candidate.bit_length() == 256 and isPrime(candidate): 
           primes_arr.append(candidate) 
   
    if len(primes_arr) < 8: 
        raise Exception("Could not regenerate primes") 
   
    n = 1 
    for p in primes_arr:
        n *= p 
   
    phi = 1 
    for p in primes_arr:
        phi *= (p - 1) 
   
    e = 65537 
    d = pow(e, -1, phi) # modular inverse 
    return n, e, d 

def decrypt(ciphertext_bytes, n, d): 
    cipher_int = int.from_bytes(ciphertext_bytes, "little") # matches your encoding 
    plain_int = pow(cipher_int, d, n) 
    return long_to_bytes(plain_int).decode("utf-8", errors="ignore") 

seed_hash = 0xa151de1d76f12318fe16e8cd1c1678fd3b0a752eca163a7261a7e2510184bbe9
m = 98931271253110664660254761255117471820360598758511684442313187065390755933409 
a = 11352347617227399966276728996677942514782456048827240690093985172111341259890 
c = 61077733451871028544335625522563534065222147972493076369037987394712960199707 

n, e, d = rebuild_rsa_key(a, c, m, seed_hash) 
cipher = bytes.fromhex('6f70034472ce115fc82a08560bd22f0e7f373e6ef27bca6e4c8f67fedf4031be23bf50311b4720fe74836b352b34c42db46341cac60298f2fa768f775a9c3da0c6705e0ce11d19b3cbdcf51309c22744e96a19576a8de0e1195f2dab21a3f1b0ef5086afcffa2e086e7738e5032cb5503df39e4bf4bdf620af7aa0f752dac942be50e7fec9a82b63f5c8faf07306e2a2e605bb93df09951c8ad46e5a2572e333484cae16be41929523c83c0d4ca317ef72ea9cde1d5630ebf6c244803d2dc1da0a1eefaafa82339bf0e6cf4bf41b1a2a90f7b2e25313a021eafa6234643acb9d5c9c22674d7bc793f1822743b48227a814a7a6604694296f33c2c59e743f4106')
plaintext = decrypt(cipher, n, d) 
print(plaintext)

Flag: W3b3_i5_Gr8@flare-on.com

 

7 - The Boss Needs Help

The challenge provides a malware binary and a pcap file. It’s clearly a common C2-traffic decryption task.

The program is obfuscated with many convoluted arithmetic operations applied to numerous junk variables:

Ảnh có chứa văn bản, ảnh chụp màn hình

Nội dung do AI tạo ra có thể không chính xác.

After analyzing instruction patterns, i notice that the main processing blocks are wrapped by two instructions: mov reg32, global_var and mov global_var, reg32:

Ảnh có chứa văn bản, ảnh chụp màn hình, thực đơn

Nội dung do AI tạo ra có thể không chính xác.

After some synthesis, I identified few global variables that act as wrappers:

Ảnh có chứa văn bản, ảnh chụp màn hình, Phông chữ, hàng

Nội dung do AI tạo ra có thể không chính xác.

Note also that small and medium-sized functions are not obfuscated:Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, Phần mềm đa phương tiện

Nội dung do AI tạo ra có thể không chính xác.

From here the basic idea to deobfuscate is:

-          Find large-sized functions → locate mov global_var, reg32 sequences → do not NOP out those regions until you encounter the matching mov reg32, global_var. There are a few extra small fixes. I have an IDA script deobfuscate as follows:

 import idautils, ida_funcs, idc
import re
from capstone import *
regs = ["eax","ebx","ecx","edx","esi","edi","ebp","esp",
         *["r{}d".format(i) for i in range(8,16)]]

EXCLUDE = ["cdq", "nop"]

def IsNotDirty(insList):
    for ins in insList:
        if ins.mnemonic in EXCLUDE or ins.mnemonic.startswith("j"):
            continue
        if any(reg in ins.op_str for reg in regs):
            continue
        else:
            return True
    return False

def FixDirt(insList, byteseq):
    currlen = len(byteseq)
    for ins in insList:
        currlen -= len(ins.bytes)
    byteseq = byteseq[:currlen]
    for ins in insList:
        byteseq += b"\x90" * len(ins.bytes)
    return byteseq

TURNOFF = [0x14047A3B4, 0x14047A3AC, 0x14047A3B0]
TURNON = [0x14047A3B4, 0x14047A3AC]
DisEngine = Cs(CS_ARCH_X86, CS_MODE_64)
DisEngine.detail = True

for func_ea in idautils.Functions():
    func = ida_funcs.get_func(func_ea)
    if not func:
        continue
    start = func.start_ea
    end = func.end_ea
    size = end - start
    name = idc.get_func_name(start)
    Funccode = idc.get_bytes(start, size)
    dis = DisEngine.disasm(Funccode, start)

    byteSequence = b""
    START_CAPTURE = False
    CHECKDIRT = []
    saved_globval = 0

    if size > 0x1400:
        dis = DisEngine.disasm(Funccode, start)
        HEAD_FUNC_CAPTURE = True
        saved_reg = ""
        for i in dis:
            line = f"{i.mnemonic} {i.op_str}"

            if i.mnemonic == "mov":
                op = i.op_str
                op1 = op.split(",")[0]
                op2 = op.split(",")[1]
                if op1 in regs:
                    if "dword ptr [rip +" in op2:
                        if not HEAD_FUNC_CAPTURE:
                             START_CAPTURE = False
                         HEAD_FUNC_CAPTURE = False
                        # saved_reg = op1
                         saved_globval = int(op2.split(" ")[5][:-1], 16) + len(i.bytes) + i.address
                        if saved_globval in TURNOFF:
                            if not IsNotDirty(CHECKDIRT):
                                 byteSequence = FixDirt(CHECKDIRT, byteSequence)
                             CHECKDIRT = []
                             print(f"[+] ENDED CAPTURE {i.address:08X}: {line}")
                             byteSequence += b"\x90" * len(i.bytes)
                             continue

                if "dword ptr [rip +" in op1:
                    curr_globval = int(op1.split(" ")[4][:-1], 16) + len(i.bytes) + i.address
                    if curr_globval in TURNON:
                        # if curr_globval == saved_globval:
                            # if op2.replace(" ", "") == saved_reg:
                                 START_CAPTURE = True
                               
                                 print(f"[+] START CAPTURE {i.address:08X}: {line}")
                                byteSequence += b"\x90" * len(i.bytes)
                                 continue
            if HEAD_FUNC_CAPTURE:
                byteSequence += i.bytes
                continue

            if START_CAPTURE:
                byteSequence += i.bytes
                 CHECKDIRT.append(i)

            else:
                byteSequence += b"\x90" * len(i.bytes)
        for p in range(size):
            idc.patch_byte(start + p, byteSequence[p])
print("DONE")

The program can now be decompiled; although not particularly pretty, the control flow is followable:Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm

Nội dung do AI tạo ra có thể không chính xác.

Following the provided PCAP, the first TCP stream issues a request to the C2 server twelve.flare-on.com:8000, with Authorization field that is a unique ID per victim. This ID is generated from information such as the username and datetime using AES RSBOX:Ảnh có chứa văn bản, ảnh chụp màn hình, Phông chữ, hàng

Nội dung do AI tạo ra có thể không chính xác.

Ảnh có chứa ảnh chụp màn hình, văn bản, phần mềm, Phần mềm đa phương tiện

Nội dung do AI tạo ra có thể không chính xác.

The response is mapped using RSBOX similarly, except it does not include the xor 0x5A step:Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, Phần mềm đa phương tiện

Nội dung do AI tạo ra có thể không chính xác.

This ACK field contains a key part and a secondary C2 address for subsequent communication. After obtaining the second C2, the program switches to the next communication flow with a different algorithm from the first stream:

A screen shot of a computer program

AI-generated content may be incorrect.

The second C2-side algorithm uses AES:

Ảnh có chứa văn bản, ảnh chụp màn hình, màn hình, phần mềm

Nội dung do AI tạo ra có thể không chính xác.

With a fixed IV of \x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f.

The AES key is computed by XOR-ing two SHA256 hash values:

Ảnh có chứa văn bản, ảnh chụp màn hình

Nội dung do AI tạo ra có thể không chính xác.

Ảnh có chứa văn bản, ảnh chụp màn hình, Phông chữ, phần mềm

Nội dung do AI tạo ra có thể không chính xác.

First hash is the hash of the string formed by concatenating the ACK response from the first stream with the time from the ID:Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, màn hình

Nội dung do AI tạo ra có thể không chính xác.

Second hash is the hash of TheBoss@THUNDERNODE:

Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, Trang web

Nội dung do AI tạo ra có thể không chính xác.

Using this Key and IV, we decrypt subsequent streams:Ảnh có chứa văn bản, ảnh chụp màn hình, Phông chữ, thực đơn

Nội dung do AI tạo ra có thể không chính xác.

After stream 53, the response changes the communication encryption key via the np field and provides a sleep time dt:Ảnh có chứa văn bản, Phông chữ, ảnh chụp màn hình, hàng

Nội dung do AI tạo ra có thể không chính xác.

Ảnh có chứa văn bản, ảnh chụp màn hình, Phông chữ

Nội dung do AI tạo ra có thể không chính xác.

The new key is reformatted using data from np plus the old time, following the same algorithm, to decrypt the following streams:Ảnh có chứa văn bản, ảnh chụp màn hình, hàng, Phông chữ

Nội dung do AI tạo ra có thể không chính xác.

At stream 64, a ZIP file is exfiltrated:

Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, hàng

Nội dung do AI tạo ra có thể không chính xác.

The ZIP is password-protected, so we need to find the password:Ảnh có chứa văn bản, phần mềm, Biểu tượng máy tính, Phần mềm đa phương tiện

Nội dung do AI tạo ra có thể không chính xác.

The communication key is changed again at stream 68:Ảnh có chứa văn bản, ảnh chụp màn hình, hàng, Phông chữ

Nội dung do AI tạo ra có thể không chính xác.

At stream 73, another file is exfiltrated — this file contains the password needed to open the ZIP:Ảnh có chứa văn bản, ảnh chụp màn hình, hàng, Phông chữ

Nội dung do AI tạo ra có thể không chính xác.

Ảnh có chứa văn bản, ảnh chụp màn hình, phần mềm, hàng

Nội dung do AI tạo ra có thể không chính xác.

Password = TheBigM@n1942! — and we have the flag:

Ảnh có chứa nhạc cụ, nhạc cụ dùng dây, văn bản, ảnh chụp màn hình

Nội dung do AI tạo ra có thể không chính xác.

Flag: C4N7_ST4R7_A_FL4R3_W1THOUT_A_SP4RK@FLARE-ON.COM

Read more:

Part 1:  Flareon 12 Writeup Part 1

Part 3:  Flareon 12 Writeup Part 3

3 lượt xem