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 += 1As 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:

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:

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

Note also that small and medium-sized functions are not obfuscated:
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:
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:

The response is mapped using RSBOX similarly, except it does not include the xor 0x5A step:
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:

The second C2-side algorithm uses AES:

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:


First hash is the hash of the string formed by concatenating the ACK response from the first stream with the time from the ID:
Second hash is the hash of TheBoss@THUNDERNODE:

Using this Key and IV, we decrypt subsequent streams:
After stream 53, the response changes the communication encryption key via the np field and provides a sleep time dt:

The new key is reformatted using data from np plus the old time, following the same algorithm, to decrypt the following streams:
At stream 64, a ZIP file is exfiltrated:

The ZIP is password-protected, so we need to find the password:
The communication key is changed again at stream 68:
At stream 73, another file is exfiltrated — this file contains the password needed to open the ZIP:

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

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