Flare-On 12 writeups
Prologue
Welcome to my Flare-On writeups! This is my first serious attempt to fully solve this CTF. I’m very happy that it was successful. Now I need to work on solving tasks faster, since I might not even have time to solve the last one this year. And if you look at the time it took to solve the tasks, you’ll notice a huge difference between the time it took to solve the last one 🙂:
Still, it was a valuable experience - even though this was the first year Russians were excluded from the list of countries that solved all CTF tasks 😭
Now it’s time to dive into the writeups!
1. Drill Baby Drill!
First easy challenge on Python. It’s a simple game written in PyGame. Let’s grep for flag in source code and find out this code:
1
2
3
4
5
6
if bear_mode:
screen.blit(bearimage, (player.rect.x, screen_height - tile_size))
if current_level == len(LevelNames) - 1 and not victory_mode:
victory_mode = True
flag_text = GenerateFlagText(bear_sum)
print("Your Flag: " + flag_text)
GenerateFlagText function takes one argument bear_sum - this is integer XOR key for encoded flag:
1
2
3
4
5
6
7
def GenerateFlagText(sum):
key = sum >> 8
encoded = "\xd0\xc7..."
plaintext = []
for i in range(0, len(encoded)):
plaintext.append(chr(ord(encoded[i]) ^ (key+i)))
return ''.join(plaintext)
Ok, we have some bear_sum integer and its value - key for flag decoding. No matter how it’s used, I think it value shouldn’t be too large, let’s brute it!
1
2
3
4
5
6
for i in range(1, 50000):
guess = GenerateFlagText(i)
if '@flare-on.com' in guess:
print(i)
print(guess)
break
1
2
46080
drilling_for_teddies@flare-on.com
2. project_chimera
Task archive contains only Python script project_chimera.py. In this script we have some raw data named encrypted_sequencer_data.
These bytes are then inflated with zlib and deserialized into Python bytecode for execution:
1
2
sequencer_code = zlib.decompress(encrypted_sequencer_data)
exec(marshal.loads(sequencer_code))
We can use dis to get disasm for our python bytecode instructions. To disassemble it correctly, we need to run it under Python 3.12:
1
2
3
4
5
6
7
8
9
10
11
12
13
0 BINARY_OP 0 (+)
POP_JUMP_IF_TRUE 1 (to L1)
LOAD_FROM_DICT_OR_GLOBALS 0 (base64)
3 L1: POP_JUMP_IF_TRUE 0 (to L2)
L2: STORE_ATTR 1 (zlib)
LOAD_FROM_DICT_OR_GLOBALS 2 (marshal)
5 POP_JUMP_IF_TRUE 0 (to L3)
L3: STORE_ATTR 3 (types)
RAISE_VARARGS 5
POP_JUMP_IF_TRUE 3 (to L4)
...
To obtain readable python code from this, we can ask an LLM to reconstruct python code from disasm.
Thus reveals yet another unpacking --> decoding --> execting bytecode stage but at this time with base64 payload, zlib inflate and code execution by calling types.FunctionType() function.
Unpack this stage, pass through our LLM-decompiler and get main code which looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import os
import art
import sys
import emoji
import random
import asyncio
import cowsay
import pyjokes
from arc4 import ARC4
LEAD_RESEARCHER_SIGNATURE = b"m\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS"
ENCRYPTED_CHIMERA_FORMULA = (
b"r2b-\r\x9e\xf2\x1fp\x185\x82\xcf\xfc\x90\x14\xf1O\xad#]\xf3\xe2"
b"\xc0L\xd0\xc1e\x0c\xea\xec\xae\x11b\xa7\x8c\xaa!\xa1\x9d\xc2\x90"
)
async def activate_catalyst():
print('--- Catalyst Serum Injected ---')
print("Verifying Lead Researcher's credentials via biometric scan...")
current_user = os.getlogin().encode()
user_signature = bytes(c ^ (i + 42) for i, c in enumerate(current_user))
await asyncio.sleep(0.01)
status = 'pending'
if status == 'pending':
if user_signature == LEAD_RESEARCHER_SIGNATURE:
art.tprint('AUTHENTICATION SUCCESS', font='small')
print('Biometric scan MATCH. Identity confirmed as Lead Researcher.')
print('Finalizing Project Chimera...')
arc4_decipher = ARC4(current_user)
decrypted_formula = arc4_decipher.decrypt(ENCRYPTED_CHIMERA_FORMULA).decode()
cowsay.cow('I am alive! The secret formula is:\n' + decrypted_formula)
else:
...
else:
print('System error: Unknown experimental state.')
We can simply change current_user value to LEAD_RESEARCHER_SIGNATURE, comment compairing user_signature and LEAD_RESEARCHER_SIGNATURE, run script and get flag:
1
2
3
4
5
Verifying Lead Researcher's credentials via biometric scan...
Biometric scan MATCH. Identity confirmed as Lead Researcher.
Finalizing Project Chimera...
I am alive! The secret formula is:
Th3_Alch3m1sts_S3cr3t_F0rmul4@flare-on.com
3. pretty_devilish_file
Let’s open provided pretty_devilish_file.pdf PDF in 010Editor and notice that file have corrupted structure.
For working with PDF files, I found the qpdf tool and check what I need to fix in qpdf WARNING’s. After that i used pdf2john for find that password is empty string…
1
2
qpdf --check pretty_devilish_file.pdf
qpdf --decrypt --password="" ./pretty_devilish_file.pdf ./ez.pdf
Then we can use Zlib Inflate in CyberChef with 4 0 obj data and get some JPG image. This image contains 37 gray pixels that could be flag symbols.
1
2
3
4
5
6
from PIL import Image
img = Image.open('image.jpg')
pixels = list(img.getdata())
flag = ''.join(chr(p) for p in pixels)
print(flag)
1
Puzzl1ng-D3vilish-F0rmat@flare-on.com
4. UnholyDragon
Task provided with PE UnholyDragon-150.exe which header is corrupted and contains 15 5A instead of 4D 5A. After fix that, we can execute image.
Once executed UnholyDragon-150.exe drop UnholyDragon-151.exe in execution directory, then UnholyDragon-152.exe, and stop at 154 index.
ProcMon calls show that probably 150.exe copy itself in 151.exe and execute it’s copy:
This happens recursively until execution reaches 154.exe, which crashes with WerFault. Each 15x.exe have different hash and this indicates some file modification for stages. Finally, let’s look at this in IDA.
Binary compiled with Visual Basic compiler twinBASIC 0_o and contains corrupted GIF image in .data section. By xrefs on string "UnholyDragon-" we can find function at 0x4A9178 with main logic:
Decompiler output looks weird but we can notice that binary reads itself, gets index after "UnholyDragon-" in filename, create file with new index, XOR previous bytes and writes this to new file:
We can guess that index 150 in task filename means 150 XOR operations with binary data and if we renamed this to "UnholyDragon-0.exe", XOR operations will be revert themself.
After execution, binary drops 150 exe files (and tries to execute each one !!!) and stop at UnholyDragon-150.exe whose header bytes are again incorrect. Repair it’s header in hex editor and execute - we’ll see a restored GIF with the flag:
5. ntfsm
NTFS have feature called Alternate Data Streams (ADS) for storing metadata about files. This feature can also be used to hide information when working with the Windows file system. For interaction with this files we can use default file-API with colon and the desired stream name to a file path.
Task program waits for password in first argument with size 16 bytes. While monitoring binary with ProcMon, we notice it uses 4 ADS file streams: ":input", ":position", ":transitions" and ":state":
When we try to open the binary in IDA, it will try to decompile every transition in the huge jump table at 0x14000CA50 with 90781 cases, which will lead to an error “too big function”. Even this solution doesn’t help:
I think the disassembler will be more than enough for us, so we’ll continue in IDA disasm.
As we notice above, binary interacts with ADS files and it’s true - at the start program reading 64 bit values from each ADS filestream and compaires :position and :trproblemsansitions with 16. If its values lower then jump to check routine:
Then, if current states is empty, it initialize ":input" with our inputed password and fills the rest ADS with zero.
At 0x14000C964 we have dispatcher that reads password char at ":position", extracts state ID value from ":state" stream and jumps to the branch with that ID.
Ok, let’s jump to case 0:
What is going on here?
- In start of the branch program executes some logic with
rdtscinstructuions that need to slow down program and does not affect further calculations in any way; - Then we have branching for 3 values of password character, given at this position index. For our
case 0it’s'J','U'and'i'; - If password char equals to one of compaired chars, program jump to corresponding branch, increment transitions, update state to some next ID integer value lower then
90781 (MAX_STATE). In our example it’s 1; - If is another that
'J','U'and'i', program jumps to trap branch. It can be some MessageBox, Rickrolling, Sleep and ExitWindowsEx calling; - Anyways incrementation of position value occurs and program start new instance of ourself with updated ADS files. This goes for checks to all 16 password characters.
To pass all the program checks and get the flag we need:
- No one of the flag symbols should lead to a trap branch;
- Each state jump leeds to the next valid state.
So, we need to find such an input, each symbol of which will always correspond to one of the checked for transitions updating. And it’s states chains size must be 16.
Capstone will help us with this. Using this we can build a state graph for each char on each state id and then find states chains with length 16.
Full code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import struct
import collections
from capstone import *
class DispatcherAnalyzer:
def __init__(self, binary_data, base_address=0x140000000):
self.md = Cs(CS_ARCH_X86, CS_MODE_64)
self.md.detail = True
self.binary_data = binary_data
self.base_address = base_address
self.jump_table = {}
self.handlers = {}
def read_jump_table(self, table_start, table_size):
offset = table_start - (self.base_address + 0xc00)
jump_table_data = self.binary_data[offset:offset + table_size * 4]
for i in range(table_size):
entry_offset = i * 4
if entry_offset + 4 > len(jump_table_data):
break
relative_offset = struct.unpack('<I', jump_table_data[entry_offset:entry_offset + 4])[0]
handler_address = self.base_address + relative_offset
if 0 <= handler_address - self.base_address < len(self.binary_data):
self.jump_table[i] = handler_address
print(f"State {i:5d} -> Handler 0x{handler_address:016X}")
return self.jump_table
def analyze_handler(self, handler_address, state_id):
if handler_address in self.handlers:
return self.handlers[handler_address]
offset = handler_address - (self.base_address + 0xc00)
if offset < 0 or offset >= len(self.binary_data):
return {}
code = self.binary_data[offset:offset + 256]
transitions = {}
current_char = None
for insn in self.md.disasm(code, handler_address):
if insn.mnemonic == 'cmp':
operands = insn.op_str.split(',')
if len(operands) == 2:
char_val = self.extract_char_value(operands[1].strip())
if char_val is not None:
current_char = char_val
elif insn.mnemonic in ['jz', 'je', 'jnz', 'jne', 'jg', 'jge', 'jl', 'jle'] and current_char:
target_addr = self.extract_jump_target(insn)
if target_addr:
target_state = self.find_state_by_handler(target_addr)
if target_state is not None:
transitions[current_char] = target_state
print(f" State {state_id}: char '{current_char}' -> State {target_state}")
current_char = None
elif insn.mnemonic in ['mov', 'add', 'sub', 'xor']:
current_char = None
elif insn.mnemonic == 'ret' or insn.mnemonic == 'jmp':
break
self.handlers[handler_address] = transitions
return transitions
def extract_char_value(self, operand):
try:
if operand.startswith('0x'):
value = int(operand, 16)
if 0x20 <= value <= 0x7E:
return chr(value)
except:
pass
# 'byte ptr [rsp+3BB8Ch]'
if "'" in operand:
char_str = operand.split("'")[1]
if len(char_str) == 1:
return char_str
return None
def extract_jump_target(self, insn):
if insn.operands and insn.operands[0].type == CS_OP_IMM:
return insn.operands[0].value.imm
elif insn.op_str.startswith('0x'):
try:
return int(insn.op_str, 16)
except:
pass
return None
def find_state_by_handler(self, handler_address):
offset = handler_address - (self.base_address + 0xc00)
code = self.binary_data[offset:offset + 12]
for insn in self.md.disasm(code, 0, 12):
if insn.mnemonic == 'mov':
operands = insn.op_str.split(',')
if len(operands) == 2:
int_val = int(operands[1], 16)
if int_val is not None:
next_state_id = int_val
for state, addr in self.jump_table.items():
if state == next_state_id:
return state
return None
def build_state_graph(self, max_states=1000):
graph = {}
print("Building state graph...")
for state, handler_addr in list(self.jump_table.items())[:max_states]:
transitions = self.analyze_handler(handler_addr, state)
if transitions:
graph[state] = transitions
return graph
def find_password_path(self, graph, start_state=0, password_length=16, max_depth=20):
queue = collections.deque()
queue.append((start_state, "", [start_state]))
while queue:
current_state, path, visited = queue.popleft()
if len(path) >= password_length:
yield path, len(visited)
continue
if len(path) >= 16:
continue
if current_state in graph:
for char, next_state in graph[current_state].items():
if next_state not in visited:
new_path = path + char
new_visited = visited + [next_state]
queue.append((next_state, new_path, new_visited))
def load_binary_file(filename):
with open(filename, 'rb') as f:
return f.read()
if __name__ == "__main__":
binary_data = load_binary_file("ntfsm.exe")
analyzer = DispatcherAnalyzer(binary_data)
JUMP_TABLE_START = 0x140C687B8
JUMP_TABLE_SIZE = 0x1629C + 1
print("Reading jump table...")
jump_table = analyzer.read_jump_table(JUMP_TABLE_START, JUMP_TABLE_SIZE)
print("\nAnalyzing state handlers...")
graph = analyzer.build_state_graph(max_states=JUMP_TABLE_SIZE)
print("\nSearching for password paths...")
password_generator = analyzer.find_password_path(graph, password_length=16, max_depth=16)
found_passwords = []
for password, transitions in password_generator:
if len(password) == 16:
found_passwords.append(password)
print(f"Found potential password: {password} {transitions - 1}")
if len(found_passwords) >= 100:
break
if not found_passwords:
print("No complete password paths found...")
Output:
1
2
3
4
5
6
7
8
...
State 45289: char 'Z' -> State 90777
State 45289: char 'g' -> State 90778
State 57775: char 'Q' -> State 90780
Searching for password paths...
Found potential password: iqg0nSeCHnOMPm2Q 16
We found the password iqg0nSeCHnOMPm2Q! Passing it as the first argument to the program gives us the flag.
6. Chain of Demands
DiE shows that PE file is packed with PyInstaller which means we can extract it using pyinstxtractor for unpacking source files.
In addition to the main source code file challenge_to_compile.pyc, the package also contains several WEB3 libraries, as well as some public key public.pem and a chat_log.json file containing a conversation between 2 users, including several ENCRYPTED messages at the end. Most likely, these contain our flag:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[{
"...",
"plaintext": "Erm enable super safe mode",
"ciphertext": "787c..."
},
{
"conversation_time": 30,
"mode": "LCG-XOR",
"plaintext": "Ok, activating now",
"ciphertext": "632a..."
},
{
"conversation_time": 242,
"mode": "RSA",
"plaintext": "[ENCRYPTED]",
"ciphertext": "680a..."
}]
Ok, now we can finally recover the source code from .pyc file. Fastest option is to use an online .pyc decompiler, for example - PyLingual.
Our task is WEB3 messenger with messages encryption logic. There are 2 encryption modes for conversation - super_safe_mode enabling RSA encryption and default with LCG key generation from message counter and xoring:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def process_message(self, plaintext):
if self.conversation_start_time == 0:
self.conversation_start_time = time.time()
conversation_time = int(time.time() - self.conversation_start_time)
if self.super_safe_mode and self.rsa_key:
plaintext_bytes = plaintext.encode('utf-8')
plaintext_enc = bytes_to_long(plaintext_bytes)
_enc = pow(plaintext_enc, self.rsa_key.e, self.rsa_key.n)
ciphertext = _enc.to_bytes(self.rsa_key.n.bit_length(), 'little').rstrip(b'\x00')
encryption_mode = 'RSA'
plaintext = '[ENCRYPTED]'
else:
prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
ciphertext = self.xor_oracle.encrypt(prime_from_lcg, conversation_time, plaintext)
encryption_mode = 'LCG-XOR'
log_entry = {'conversation_time': conversation_time, 'mode': encryption_mode, 'plaintext': plaintext, 'ciphertext': ciphertext.hex()}
self.chat_history.append(log_entry)
self.message_count += 1
self.save_chat_log()
return (f'[{conversation_time}s] {plaintext}', f'[{conversation_time}s] {ciphertext.hex()}')
LCGOracle and TripleXOROracle implement functions with smart contracts bytes. For LCGOracle is calling get_next with LCG args and counter:
For TripleXOROracle it’s encrypt function with LCG generated prime, conversation time and plaintext as arguments:
Most intresting for us is super_safe_mode after calling which the messages text in chat_log.json tagged as “[ENCRYPTED]”. After toogle safe mode in GUI, user process RSA key generation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def generate_rsa_key_from_lcg(self):
print('[RSA] Generating RSA key from on-chain LCG primes...')
lcg_for_rsa = LCGOracle(self.lcg_oracle.multiplier, self.lcg_oracle.increment, self.lcg_oracle.modulus, self.seed_hash)
lcg_for_rsa.deploy_lcg_contract()
primes_arr = []
rsa_msg_count = 0
iteration_limit = 10000
iterations = 0
while len(primes_arr) < 8 and iterations < iteration_limit:
candidate = lcg_for_rsa.get_next(rsa_msg_count)
rsa_msg_count += 1
iterations += 1
if candidate.bit_length() == 256 and isPrime(candidate):
primes_arr.append(candidate)
print(f'[RSA] - Found 256-bit prime #{len(primes_arr)}')
print('Primes Array: ', primes_arr)
if len(primes_arr) < 8:
error_msg = '[RSA] Error: Could not find 8 primes within iteration limit.'
print('Current Primes: ', primes_arr)
print(error_msg)
return error_msg
n = 1
for p_val in primes_arr:
n *= p_val
phi = 1
for p_val in primes_arr:
phi *= p_val - 1
e = 65537
if math.gcd(e, phi)!= 1:
error_msg = '[RSA] Error: Public exponent e is not coprime with phi(n). Cannot generate key.'
print(error_msg)
return error_msg
self.rsa_key = RSA.construct((n, e))
try:
with open('public.pem', 'wb') as f:
f.write(self.rsa_key.export_key('PEM'))
print('[RSA] Public key generated and saved to \'public.pem\'')
return 'Public key generated and saved successfully.'
except Exception as e:
print(f'[RSA] Error saving key: {e}')
return f'Error saving key: {e}'
Seed hash here is system artifact hash but it doesn’t metter. As we see, RSA value N is generated as the product of 8 prime numbers returned by LCGOracle.get_next function. It’s quite strange, probably we can factorize N number from public.pem.
For given N we can find required 8 numbers in factordb 🙂
That’s all we need for last messages decryption:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from Crypto.Util.number import long_to_bytes
primes = [
62826068095404038148338678434404643116583820572865189787368764098892510936793,
68446593057460676025047989394445774862028837156496043637575024036696645401289,
69802783227378026511719332106789335301376047817734407431543841272855455052067,
72967016216206426977511399018380411256993151454761051136963936354667101207529,
75395288067150543091997907493708187002382230701390674177789205231462589994993,
79611551309049018061300429096903741339200167241148430095608259960783012192237,
82836473202091099900869551647600727408082364801577205107017971703263472445197,
88790251731800173019114073860734130032527125661685690883849562991870715928701
]
ciphertext = "6f70034472ce115fc82a08560bd22f0e7f373e6ef27bca6e4c8f67fedf4031be23bf50311b4720fe74836b352b34c42db46341cac60298f2fa768f775a9c3da0c6705e0ce11d19b3cbdcf51309c22744e96a19576a8de0e1195f2dab21a3f1b0ef5086afcffa2e086e7738e5032cb5503df39e4bf4bdf620af7aa0f752dac942be50e7fec9a82b63f5c8faf07306e2a2e605bb93df09951c8ad46e5a2572e333484cae16be41929523c83c0d4ca317ef72ea9cde1d5630ebf6c244803d2dc1da0a1eefaafa82339bf0e6cf4bf41b1a2a90f7b2e25313a021eafa6234643acb9d5c9c22674d7bc793f1822743b48227a814a7a6604694296f33c2c59e743f4106"
e = 65537
n = 1
phi = 1
for p in primes:
n *= p
phi *= (p - 1)
d = pow(e, -1, phi)
def hex_to_int_little(hex_str):
bytes_val = bytes.fromhex(hex_str)
return int.from_bytes(bytes_val, 'little')
c_int = hex_to_int_little(ciphertext)
p_int = pow(c_int, d, n)
p_bytes = long_to_bytes(p_int)
plaintext = p_bytes.decode('utf-8')
print("Decoded:", plaintext)
1
Decoded: It's W3b3_i5_Gr8@flare-on.com
7. The Boss Needs Help
Hooray! Hex-Rays once again fails to decompile task’s code with “too big function” error at main. Now we have to deal with Mixed Boolean-Arithmetic (MBA) obfuscation, which consists of replacing short expressions with looooonger equivalent expressions:
Task binary provides with packets.pcapng network dump file. From this we can look at interaction of our program with C2 server, but all JSON payload is encrypted:
First packet probably showes to us key exchange and authorisation on C2, second contains "msg":"sysi" - command for getting target system information i think.
For MBA obfuscation I couldn’t think of anything smarter than simply removing junk instructions in IDA disassembler using add_hidden_range from IDA python API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import ida_ua
import idautils
import ida_funcs
import ida_range
import ida_bytes
JUNK_MNEMS = {"mov", "xor", "add", "sub", "or", "and", "not", "imul", "shl", "shr", "sar", "inc", "dec", "cmp", "idiv", "imul", "cdq", "test", "movzx"}
IMPORTANT_MNEMS = {"call", "ret"}
MIN_BLOCK_LEN = 5
def is_junk_insn(ea):
mnem = ida_ua.print_insn_mnem(ea)
if not mnem:
return False
return mnem.lower() in JUNK_MNEMS or mnem.startswith("j")
def is_important_insn(ea):
mnem = ida_ua.print_insn_mnem(ea)
if not mnem:
return False
mnem = mnem.lower()
return mnem in IMPORTANT_MNEMS
def fold_junk_blocks_in_function(fn_ea):
start_ea = fn_ea
end_ea = ida_funcs.get_func(fn_ea).end_ea
block_start = None
block_len = 0
ea = start_ea
while ea < end_ea:
if is_junk_insn(ea):
if block_start is None:
block_start = ea
block_len = 1
else:
block_len += 1
else:
if block_start and block_len >= MIN_BLOCK_LEN:
ida_bytes.add_hidden_range(
block_start,
ea,
f"JUNK ({block_len})",
"JUNK START",
"JUNK END")
block_start = None
block_len = 0
insn = ida_ua.insn_t()
if ida_ua.decode_insn(insn, ea) == 0:
break
ea += insn.size
if block_start and block_len >= MIN_BLOCK_LEN:
ida_bytes.add_hidden_range(
block_start,
end_ea,
f"JUNK ({block_len})",
"JUNK START",
"JUNK END")
def fold_junk_blocks_all():
folded_blocks = 0
for fn_ea in idautils.Functions():
fold_junk_blocks_in_function(fn_ea)
folded_blocks += 1
print(f"[+] Processed {folded_blocks} functions.")
fold_junk_blocks_all()
It’s looks like this (we can play with mnemonics or junk blocks length):
And then step by step staticly and dynamically getting logic from “cleaned” assembler. For debugging this task i wrote simple HTTP server for simulate C2 and try to response for any binary message.
Key exchange logic builds string from system information in following structure: DateTimeUserName@MachineName. Then it’s key passed to algorithm with AES_SBOX permutations and 0x5A xoring, which easily revertable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def key_exchange(data):
enc = b''
for i in range(len(data)):
a = data[i] ^ 0x5a
b = i + 1 + a
enc += INV_AES_SBOX[b].to_bytes(1)
return enc
def key_unchange(enc_data):
data = b''
for i in range(len(enc_data)):
sbox_value = AES_SBOX[enc_data[i]]
shift_value = -1 - i + sbox_value
shift_value = shift_value % 256
data += (shift_value ^ 0x5a).to_bytes(1)
return data
When extract and revert Bearer value e4b8058f06f7061e8f0f8ed15d23865ba2427b23a695d9b27bc308a26d from Authorization header we get 2025082006TheBoss@THUNDERNODE identification string.
After sending user id, our binary process server response with encrypted ‘d’ value in JSON data. This uses different data encryption algo: permutated data xored with key. Key is UserName@MachineName which we get above but without datetime.
Python implementation of data encryption here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def custom_encryption(key_data, plain_data, sbox=INV_AES_SBOX):
result = []
key_len = len(key_data)
i = 0
data_length = len(plain_data)
while i < data_length:
plain_byte = plain_data[i]
key_byte = key_data[i % key_len]
shift_value = key_byte ^ plain_byte
sbox_value = shift_value + 1 + i
encrypted_byte = sbox[sbox_value]
result.append(encrypted_byte)
i += 1
return bytes(result)
def custom_decryption(key_data, enc_data, sbox=AES_SBOX):
result = []
key_len = len(key_data)
i = 0
data_length = len(enc_data)
while i < data_length:
input_byte = enc_data[i]
sbox_value = sbox[input_byte]
shift_value = -1 - i + sbox_value
shift_value = shift_value % 256
key_byte = key_data[i % key_len]
output_byte = key_byte ^ shift_value
result.append(output_byte)
i += 1
return bytes(result)
Decryption output for first message contains username and server name for new requests (probably twelve.flare-on.com:8000 is C2 proxy):
1
{"sta": "excellent", "ack": "peanut@theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080"}
New C2 server binary uses NEW ENCRYPTION SCHEME at 0x140050D20 🙂🙃 AND NEW KEY EXCHANGE 🙂🙃 at 0x140081300 🔥🔥🔥
0x140439850 is SHA256 function and binary get 2 hashes for:
peanutstring from server response concatenated with current hour;- same key
UserName@MachineName (TheBoss@THUNDERNODE). Then it’s hashes xored byte by byte and return new key for message decryption.
0x140050D20 is AES-256 CBC implementation with hardcoded IV 0x000102030405060708090a0b0c0d0e0f. Decode it and finally get decoder for messages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def sha256_xor(key: bytes, myname: bytes, hour: bytes) -> bytes:
h1 = hashlib.sha256(key).digest()
h2 = hashlib.sha256(myname + hour).digest()
return bytes(a ^ b for a, b in zip(h1, h2))
key = b'TheBoss@THUNDERNODE'
name = b'peanut'
hour = b'06'
aes_key = sha256_xor(key, name, hour)
msg = bytes.fromhex('...')
iv = bytes.fromhex('000102030405060708090a0b0c0d0e0f')
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(msg)
print(plaintext)
Decrypted communication:
1
2
3
4
5
6
7
8
9
10
11
12
{"ci":"Architecture: x64, Cores: 2","cn":"THUNDERNODE","hi":"TheBoss@THUNDERNODE","mI":"6143 MB","ov":"Windows 6.2 (Build 9200)","un":"TheBoss"}
{"sta": "ok"}
{"msg": "cmd", "d": {"cid": 2, "line": "arp -a"}}
{"op":"\\nInterface: 1.1.1.1 --- 0x1\\n Internet Address Physical Address Type\\n 224.0.0.22..."}
{"msg": "cmd", "d": {"cid": 2, "line": "query user"}}
{"op":" USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\\n>theboss..."}
...
{"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}
After cid = 6 command at 53 TCP stream in network dump encryption keys probably changed and next messages become unencryptable…
Debugging communication process with response to binary requests with cid = 6 help us to understand that "np" field - it’s "new password" for messages encryption and it replace peanut to TheBoss@THUNDERNODE value:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
def handle_get_endpoint(self, client_socket, client_address, headers):
client_ip = client_address[0]
user_agent = headers.get('User-Agent', '')
print(f"GET /get from {client_ip}")
plain = b'{"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "flare@DESKTOP-0CBH48U"}}'.ljust(96, b'\n')
iv = bytes.fromhex('000102030405060708090a0b0c0d0e0f')
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
enc = cipher.encrypt(plain)
command = {"d": enc.hex()}
response = self.create_http_response(command, 200)
So, C2 sends us commands with the following payload fields:
1
2
3
4
5
6
d: data
cid: command id
op: output
lp: local path
fc: file content (b64 encoded)
np: new password
Now we can decrypt next messages and get password protected ZIP archive. In TCP stream 68 shared keys changed again with 6 command id and "np": "miami" as new password. And at the end we finally get passwords at at TCP stream 74: 
Last password TheBigM@n1942! is correct and we can get our PNG image from archive with flag:
8. FlareAuthenticator
First look
The task greets us with yet another MBA-obfuscated PE file, along with several Qt6 DLLs:
When we run the binary we see a panel with 25 cells for a numeric password. The fields can be filled using either with mouse clicks or using the keyboard:
If we try a random password and click “OK” button, some MessageBox pops up showing “Wrong Password” warning:
Buttons callbacks are processed in function at 0x140001000 address with functions named CHECK at 0x1400202b0 and INPUT at 0x140012E50:
CHECK function is also MBA-obfuscated. Let’s start analyzing the code and work toward deobfuscation. Debugging this program revealed that the binary uses two kinds of "call rax":
- some gadget calls;
- imported function call
Here, gadgets are used to retrieve field values from specific offsets: 
Now we can see that MBA obfuscation is used to build an indirect control flow. "call rax" gadgets produce jump targets for "jmp rax" and transfer execution to this target.
Backward slicing
While doing some research i found LummaC2 research by Google and Mandiant security teams (Flare-On CTF organazers). They developed an automated deobfuscation method using symbolic backward slicing to remove protection layers from LummaC2 malware samples. The malware employs dispatcher blocks ending with indirect jumps to dynamically-calculated destinations, breaking traditional control flow analysis (it looks like our case). Their approach recovers original instructions and control flow by distinguishing obfuscator-injected dispatcher blocks from the function’s core logic.
Triton engine and it’s feature backward slicing can help us with solve any calculations with RAX register. We can emulate all instructions that participate in RAX calculations before jmp or call and simply remove or patch those instructions to deobfuscate our program. By performing backward slicing on the destination register of indirect jumps, Triton identifies all symbolic expressions that influence the final jump target, allowing us to isolate and eliminate the obfuscator’s injected dispatcher instructions while preserving the original program logic.
After many iterations I developed a final script for automatic deobfuscation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
import lief
import struct
from triton import *
NOP = b'\x90'
class TritonBackwardSliceDeobfuscator:
def __init__(self):
self.ctx = TritonContext()
self.ctx.setArchitecture(ARCH.X86_64)
self.importTableBaseAddr = 0x10000000
self.haltFct = [] # 'msvcrt.dll::exit']
self.importTable = [] # list of [name, addr]
self.hookTable = []
self.jump_analysis = {}
self.patch_plan = []
def loadBinary(self, path):
binary = lief.parse(path)
baddr = binary.optional_header.imagebase
for s in binary.sections:
vaddr = baddr + s.virtual_address
size = s.virtual_size
print('[+] Loading %s 0x%06x - 0x%06x' %(s.name, vaddr, vaddr + size))
self.ctx.setConcreteMemoryAreaValue(vaddr, s.content.tobytes())
return binary
def makeImport(self, binary):
# Build import table: map IAT entries to fake addresses in ctx memory
baddr = binary.optional_header.imagebase
cur_addr = self.importTableBaseAddr
for imp in binary.imports:
for fct in imp.entries:
if fct.name is None:
name = '%s::ord_%s' % (imp.name, fct.ordinal)
else:
name = '%s::%s' % (imp.name, fct.name)
self.ctx.setConcreteMemoryValue(MemoryAccess(baddr + fct.iat_address, CPUSIZE.QWORD), cur_addr)
print('[+] Import %s 0x%08x -> 0x%08x' % ( name,
baddr + fct.iat_address, cur_addr))
self.importTable.append([name, cur_addr])
cur_addr += 1
return
def hookingHandler(self):
pc = self.ctx.getConcreteRegisterValue(self.ctx.registers.rip)
for entry in self.importTable:
if entry[1] == pc:
print('[*] call %s' % entry[0])
if entry[0] in self.haltFct:
print('[*] byebye!')
self.ctx.setConcreteRegisterValue(self.ctx.registers.rip, 0)
return
# default set RAX to 0 in case no hook are process
self.ctx.setConcreteRegisterValue(self.ctx.registers.rax, 0)
for hk in self.hookTable:
if hk[0] == entry[0]:
# Emulate the routine and the return value
ret_value = hk[1]()
self.ctx.setConcreteRegisterValue(self.ctx.registers.rax, ret_value)
# Get the return address
ret_addr = self.ctx.getConcreteMemoryValue(MemoryAccess(self.ctx.getConcreteRegisterValue(self.ctx.registers.rsp), CPUSIZE.QWORD))
# Hijack RIP to skip the call
self.ctx.setConcreteRegisterValue(self.ctx.registers.rip, ret_addr)
# Restore RSP (simulate the ret)
self.ctx.setConcreteRegisterValue(self.ctx.registers.rsp, self.ctx.getConcreteRegisterValue(self.ctx.registers.rsp) + CPUSIZE.QWORD)
return
return
def analyze_ast_for_targets(self, ast):
targets = []
ast_str = str(ast)
print(f"AST: {ast_str}")
if "bvadd" in ast_str or "bvsub" in ast_str:
concrete_val = self.ctx.getConcreteRegisterValue(self.ctx.registers.rax)
if concrete_val != 0:
targets.append(concrete_val)
return targets
def get_jmp_targets(self, inst, reg):
opers = inst.getOperands()
if len(opers) == 0:
return None
sym = self.ctx.getSymbolicRegister(reg)
ast = sym.getAst()
targets = self.analyze_ast_for_targets(ast)
return targets
# ---- functions for reading/writing bytes in PE file via lief ----
def va_to_file_offset(self, pe, va):
imagebase = pe.optional_header.imagebase
rva = va - imagebase
for s in pe.sections:
start = s.virtual_address
end = s.virtual_address + max(s.size, s.virtual_size)
if start <= rva < end:
offset = (rva - s.virtual_address) + s.pointerto_raw_data
return offset
return None
def read_bytes_from_file_by_va(self, pe, path, va, size):
off = self.va_to_file_offset(pe, va)
if off is None:
raise ValueError(f"VA {hex(va)} not in any section")
with open(path, "rb") as f:
f.seek(off)
return f.read(size)
def write_bytes_to_file_offset(self, path, offset, data):
with open(path, "r+b") as f:
f.seek(offset)
f.write(data)
def make_rel32_call_bytes(self, from_va, to_va):
# call rel32 (E8)
rel = (to_va - (from_va + 5)) & 0xFFFFFFFF
return b"\xE8" + struct.pack("<I", rel)
def make_rel32_jmp_bytes(self, from_va, to_va):
# jmp rel32 (E9)
rel = (to_va - (from_va + 5)) & 0xFFFFFFFF
return b"\xE9" + struct.pack("<I", rel)
# ---- high-level deobfuscation actions ----
def is_import_target(self, target_va, target_inst):
if target_inst[0] == 0xFF:
return True
for name, addr in self.importTable:
if addr == target_va:
return True
return False
def read_gadget_bytes(self, pe, binary_path, target_va, max_len=64):
# Read bytes at target_va until 0xC3 (ret) or max_len
try:
# We'll read slice of bytes from file (max_len)
data = self.read_bytes_from_file_by_va(pe, binary_path, target_va, max_len)
except Exception:
return b''
res = bytearray()
for b in data:
if b == 0xC3: # ret
break
res.append(b)
return bytes(res)
def plan_patch_call_func(self, slice_res, target, call_inst):
call_addr = call_inst.getAddress()
call_size = call_inst.getSize()
self.patch_plan[call_addr] = []
slice_blocks = []
total_removed = 0
size_with_skiped = 0
slice_items = tuple(map(lambda x: x[1].getDisassembly().split(': '), sorted(slice_res.items())))
first_addr = slice_items[0][0]
first_addr = int(first_addr[2:], 16)
local_pc = first_addr
while local_pc < call_addr:
chunk = self.ctx.getConcreteMemoryAreaValue(local_pc, 16)
inst = Instruction(chunk)
inst.setAddress(local_pc)
self.ctx.disassembly(inst)
size = inst.getSize()
if hex(local_pc) in str(slice_items):
slice_blocks.append((local_pc, size))
total_removed += size
else:
size_with_skiped += size
local_pc += size
if not slice_blocks:
print("[plan_patch_call_import_remove_slice] Empty slice, nothing to remove")
return
self.patch_plan[call_addr].append(('delete', call_addr, call_size))
for addr, size in sorted(slice_blocks, key=lambda x: x[0], reverse=True):
self.patch_plan[call_addr].append(('delete', addr, size))
print(f"\t[plan] DELETE slice {hex(addr)} size {size}")
call_bytes = self.make_rel32_call_bytes(call_addr - total_removed, target)
call_len = len(call_bytes)
old_total = total_removed + call_size
pad_len = max(0, old_total - call_len)
pad = b'\x90' * (pad_len)
payload = call_bytes + pad
self.patch_plan[call_addr].append(('insert', call_addr - total_removed, payload))
print(f"\t[plan] INSERT call imm at {hex(call_addr - total_removed)} len={len(payload)} (call={call_len}, pad={pad_len})")
def plan_patch_call_gadget(self, pe, binary_path, slice_res, target, call_inst):
gadget_bytes = self.read_gadget_bytes(pe, binary_path, target, max_len=128)
gadget_size = len(gadget_bytes)
if gadget_size > 16:
self.plan_patch_call_func(slice_res, target, call_inst)
return
print(f"\t[gadget] read {gadget_size} bytes from {hex(target)}")
call_addr = call_inst.getAddress()
call_size = call_inst.getSize()
self.patch_plan[call_addr] = []
slice_blocks = []
total_removed = 0
size_with_skiped = 0
slice_items = tuple(map(lambda x: x[1].getDisassembly().split(': '), sorted(slice_res.items())))
first_addr = slice_items[0][0]
first_addr = int(first_addr[2:], 16)
local_pc = first_addr
while local_pc < call_addr:
chunk = self.ctx.getConcreteMemoryAreaValue(local_pc, 16)
inst = Instruction(chunk)
inst.setAddress(local_pc)
self.ctx.disassembly(inst)
size = inst.getSize()
if hex(local_pc) in str(slice_items):
slice_blocks.append((local_pc, size))
total_removed += size
else:
size_with_skiped += size
local_pc += size
if not slice_blocks:
print("[plan_patch_call_import_remove_slice] Empty slice, nothing to remove")
return
self.patch_plan[call_addr].append(('delete', call_addr, call_size))
for addr, size in sorted(slice_blocks, key=lambda x: x[0], reverse=True):
self.patch_plan[call_addr].append(('delete', addr, size))
print(f"\t[plan] DELETE slice {hex(addr)} size {size}")
old_total = total_removed + call_size
pad_len = max(0, old_total - gadget_size)
pad = b'\x90' * (pad_len)
payload = gadget_bytes + pad
self.patch_plan[call_addr].append(('insert', call_addr - total_removed, payload))
print(f"\t[plan] INSERT call imm at {hex(call_addr - total_removed)} len={len(payload)} (call={gadget_size}, pad={pad_len})")
def plan_patch_jmp(self, target, jmp_addr, jmp_size):
"""
Replace jmp rax with jmp rel32 and NOP dispatcher instructions.
"""
self.patch_plan[jmp_addr] = []
tmp_jmp = self.make_rel32_jmp_bytes(jmp_addr, target)
new_size = len(tmp_jmp)
new_addr = jmp_addr - new_size + jmp_size
jmp_bytes = self.make_rel32_jmp_bytes(new_addr, target)
# pad to original jmp size
# if len(jmp_bytes) < jmp_size:
# jmp_bytes = jmp_bytes + (b'\x90' * (jmp_size - len(jmp_bytes)))
self.patch_plan[jmp_addr].append(('patch', new_addr, jmp_bytes))
print(f"\t[plan] patch jmp at {hex(new_addr)} -> {hex(target)}")
# ---- main engine integration ----
def handle_call_instruction(self, inst, binary, binary_path):
# check if call <reg> and reg is RAX (common)
if inst.getType() == OPCODE.X86.CALL:
operands = inst.getOperands()
if len(operands) == 0:
return
op = operands[0]
# If it's register operand
if op.getType() == OPERAND.REG:
reg = op
call_addr = inst.getAddress()
symreg = self.ctx.getSymbolicRegister(reg)
slice_res = self.ctx.sliceExpressions(symreg) # self.analyze_slice_for_reg(inst, reg)
if not slice_res:
return
target = self.ctx.getConcreteRegisterValue(reg)
target_inst = self.ctx.getConcreteMemoryAreaValue(target, 8)
if self.is_import_target(target, target_inst):
print(f"[call] detected import call at {hex(call_addr)} -> {hex(target)}")
self.plan_patch_call_func(slice_res, target, inst)
else:
print(f"[call] detected gadget call at {hex(call_addr)} -> {hex(target)}")
self.plan_patch_call_gadget(binary, binary_path, slice_res, target, inst)
return
def handle_jmp_instruction(self, inst):
if inst.getType() == OPCODE.X86.JMP:
opers = inst.getOperands()
if len(opers) == 0:
return
op = opers[0]
# Only handle register jmp (e.g. jmp rax)
if op.getType() == OPERAND.REG:
reg = op
jmp_addr = inst.getAddress()
jmp_size = inst.getSize()
targets = self.get_jmp_targets(inst, reg)
if len(targets) > 1:
print(f"WTFFFF!!!! ====> {targets}")
sym = self.ctx.getSymbolicRegister(reg)
slicing = self.ctx.sliceExpressions(sym)
if not slicing:
return
self.plan_patch_jmp(targets[0], jmp_addr, jmp_size)
return
def patch_binary_with_plan(self, pe_path, output_path):
pe = lief.parse(pe_path)
if pe is None:
raise RuntimeError("Could not parse PE with lief.")
with open(pe_path, "rb") as f:
raw = bytearray(f.read())
with open(output_path, "wb") as f:
f.write(raw)
def va_to_file_off(va):
return self.va_to_file_offset(pe, va)
def read_file_bytes():
with open(output_path, "rb") as f:
return bytearray(f.read())
def write_file_bytes(data):
with open(output_path, "wb") as f:
f.write(data)
for k, v in self.patch_plan.items():
for act, va, data in v:
buf = read_file_bytes()
if act == 'insert':
off = va_to_file_off(va)
if off is None:
print(f"[insert] VA {hex(va)} not found, skip")
continue
print(f"[insert] insert {len(data)} bytes at {hex(va)} (file off {off})")
buf = buf[:off] + data + buf[off:]
elif act == 'delete':
size = data
off = va_to_file_off(va)
if off is None:
print(f"[delete] VA {hex(va)} not found, skip")
continue
print(f"[delete] remove {size} bytes at {hex(va)} (file off {off})")
del buf[off:off+size]
elif act == 'patch':
off = va_to_file_off(va)
if off is None:
print(f"[patch] VA {hex(va)} not found, skip")
continue
print(f"[patch] wrote {len(data)} bytes at {hex(va)}")
buf[off:off+len(data)] = data
write_file_bytes(buf)
print(f"[patch_binary_with_plan] Applied plan and wrote patched file to {output_path}")
# ---- main emulation loop ----
def emulate_with_call_resolution(self, start_address, binary_path, binary, max_instructions=100000):
pc = start_address
instruction_count = 0
self.patch_plan = {}
while instruction_count < max_instructions:
inst = Instruction()
inst.setAddress(pc)
try:
opcode = self.ctx.getConcreteMemoryAreaValue(pc, 16)
except Exception:
break
inst.setOpcode(opcode)
self.ctx.processing(inst)
# debug print
print(f"{str(inst)}")
self.hookingHandler()
# handle CALL rax
if inst.getType() == OPCODE.X86.CALL:
operands = inst.getOperands()
if len(operands) > 0 and operands[0].getType() == OPERAND.REG:
self.handle_call_instruction(inst, binary, binary_path)
# handle JMP rax
if inst.getType() == OPCODE.X86.JMP:
operands = inst.getOperands()
if len(operands) > 0 and operands[0].getType() == OPERAND.REG:
self.handle_jmp_instruction(inst)
pc = self.ctx.getConcreteRegisterValue(self.ctx.registers.rip)
instruction_count += 1
if pc == 0:
break
return self.patch_plan
def deobfuscate_jump_sites(self, jump_sites, binary_file):
binary = self.loadBinary(binary_file)
self.makeImport(binary)
output_plan = []
for site_name, relative_offset in jump_sites.items():
absolute_addr = binary.optional_header.imagebase + relative_offset
print(f"\n{'='*60}")
print(f"Analysis: {site_name}")
print(f"Address: {absolute_addr:016x}")
print(f"{'='*60}")
plan = self.emulate_with_call_resolution(absolute_addr, binary_file, binary)
output_plan.extend(plan)
patched = binary_file.replace('.exe', '_last.exe')
self.patch_binary_with_plan(binary_file, patched)
print(f"[done] patched file -> {patched}")
return output_plan
def main():
binary_file = "FlareAuthenticator.exe"
deobfuscator = TritonBackwardSliceDeobfuscator()
jump_sites = {
'hashing': 0x12E50,
'check_last': 0x22649,
'check_passwd': 0x202B0
}
deobfuscator.deobfuscate_jump_sites(jump_sites, binary_file)
if __name__ == "__main__":
main()
This code has changed many times and processed many patched binaries:
My first attempt was to patch only jmp rax targets and replace call rax by direct calls to the gadget that must be executed. Result in IDA looked like this:
Yep, it’s looks much prettier, but many gadget-function callings still spoil decompiler picture. So I went further and inline the gadgets themselves. Since we already know which gadget are used, we can NOP each instruction that was involved in calculation of it’s address and add required instructions to the freed space:
In assembly it’s looks like this:
Much better! We have function calls, conditions, loops and all what we need to solve this task. We already can notice v92 condition with first funtion argument QObject at offset 120. It value must be 0xBC42D5779FEC401
The patched binary still executes after all patched code! It’s help to us with debugging INPUT function where for each entered password digit the program computes new value for our target variable with some hashing algo in 0x140081760 function:
1
smth_hash = get_smth_hash(a1, passwdSize);
Then this value puted into next hash generation:
And v81 from this decompile should have the value 0xBC42D5779FEC401 in CHECK function after 25 multiplies.
Since the calculation of the hash depend only on index of the digit in the password, we can create a table with the hash values at each position (that can help us to skip reimplementing for hashing function).
With IDAPython tracing script we can collect all values for each digit (0-9) at each position (0-24) for hashing process. Let’s dump it in file "a.txt":
1
2
3
4
5
6
7
8
9
10
11
# 0:
# [+]R9 = 0x19B3240445AA06
# [+]R9 = 0x6F63394844DF78
# [+]R9 = 0x6DF6A4586E71C0
...
# 1:
# [+]R9 = 0xF2EB6684284AC
# [+]R9 = 0x3ED168087F3548
# [+]R9 = 0x49DE34FFC63EC0
...
Solving the 25-digit password
Next we could write Python code that solves a password cracking problem by finding a 25-digit sequence where the sum of values from a precomputed mapping table equals our target hash 0xBC42D5779FEC401.
Instead of brute-forcing all \(10^{25}\) possible combinations, we employ a divide-and-conquer approach:
- Split the 25-digit positions into 5 groups of 5 positions each
- Precompute all possible sums and digit sequences for each group (100,000 combinations each)
- Progressively merge groups while applying bounds pruning: at each step, we eliminate partial sums that cannot reach the target with the remaining groups
- Use dynamic programming to track viable partial solutions
This reduces the problem from intractable (\(10^{25}\)) to manageable (~500,000 operations), efficiently finding the password through systematic elimination of dead ends.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import re
from itertools import product
TARGET_HASH = 0xBC42D5779FEC401
PASSWORD_LEN = 25
def parse_mapping_file(path):
with open(path, 'r', encoding='utf-8') as f:
lines = [l.rstrip('\n') for l in f]
sections = {}
cur = None
for line in lines:
line_stripped = line.strip()
m = re.match(r'^\s*#?\s*([0-9]{1,2})\s*:\s*$', line_stripped)
if m:
cur = int(m.group(1))
sections[cur] = []
continue
m2 = re.search(r'0x([0-9A-Fa-f]+)', line_stripped)
if m2 and cur is not None:
val = int(m2.group(1), 16)
sections[cur].append(val)
keys = set(sections.keys())
# "by-digit" format (0..9 -> 25 values)
if keys == set(range(10)) and all(len(v) == 25 for v in sections.values()):
# transpose: sections[digit][pos] -> mapping[pos][digit]
mapping = {pos: {d: sections[d][pos] for d in range(10)} for pos in range(25)}
return mapping
# "by-position" format (0..24 -> 10 values)
if keys == set(range(25)) and all(len(v) == 10 for v in sections.values()):
mapping = {pos: {digit: sections[pos][digit] for digit in range(10)} for pos in range(25)}
return mapping
raise ValueError("Number mismatch")
def group_ranges(mapping, groups):
mins, maxs = [], []
for grp in groups:
mn = sum(min(mapping[p].values()) for p in grp)
mx = sum(max(mapping[p].values()) for p in grp)
mins.append(mn); maxs.append(mx)
return mins, maxs
def enumerate_group_sums(mapping, group):
res = []
# Generate all digit combinations in the group (len = 10^len(group))
for digits in product(range(10), repeat=len(group)):
s = 0
for p, d in zip(group, digits):
s += mapping[p][d]
res.append((s, ''.join(str(d) for d in digits)))
return res
def solve_by_grouping(mapping, target=TARGET_HASH, group_size=5):
# split 25 positions into groups of group_size (last one might be smaller)
pos = list(range(25))
groups = [pos[i:i+group_size] for i in range(0, len(pos), group_size)]
G = len(groups)
print(f"Split into {G} groups: sizes {[len(g) for g in groups]}")
# precompute min/max for each group and for sums of remaining groups (for pruning)
mins, maxs = group_ranges(mapping, groups)
# suffix min/max: for quick pruning of remaining groups
suff_min = [0]*(G+1); suff_max = [0]*(G+1)
for i in range(G-1, -1, -1):
suff_min[i] = suff_min[i+1] + mins[i]
suff_max[i] = suff_max[i+1] + maxs[i]
# Precompute all sums for each group (in memory: count ~ 10^group_size)
group_sums = []
for i, grp in enumerate(groups):
gs = enumerate_group_sums(mapping, grp)
print(f"Group {i}: positions {grp} -> variants {len(gs)}")
group_sums.append(gs)
current = {0: ""}
for i in range(G):
new_current = {}
gs = group_sums[i]
min_rem = suff_min[i+1]
max_rem = suff_max[i+1]
print(f"Combining group {i}. additional allowed range for remaining groups = [{min_rem}, {max_rem}]")
for prev_sum, prev_seq in current.items():
for gsum, gseq in gs:
tot = prev_sum + gsum
# prune where it's impossible to reach target with remaining groups
if tot + min_rem <= target <= tot + max_rem:
new_current[tot] = prev_seq + gseq
print(f"After combining {i}: states = {len(new_current)}")
if not new_current:
print("Pruned: no valid partial sums")
return None
current = new_current
if target in current:
digits = current[target]
assert len(digits) == 25
print("Solution found:", digits)
return digits
else:
print("Can't find solution")
return None
if __name__ == "__main__":
mapping = parse_mapping_file('a.txt')
print(f"Positions: {len(mapping)} (25)")
print(f"Digits on position 0: {mapping[0]}")
sol = solve_by_grouping(mapping, TARGET_HASH, group_size=5)
if sol:
print("Password:", sol)
else:
print("Unsatisfiable")
1
2
3
4
Combining group 4. additional allowed range for remaining groups = [0, 0]
After combining 4: states = 1
Solution found: 4498291314891210521449296
Password: 4498291314891210521449296
4498291314891210521449296 it’s our password. Type it in task window and get flag.
9. 10000
Right now this is the most annoying task I’ve ever solved… The last challenge provided with very large file 10000.exe (1.1 GB size). DiE tells us about some packed data in resources section:
And indeed - in a hex editor you can see that the binary contains many packed PE files in its resources:
At 0x140001E87 we have main function (variables and functions renamed here according to their likely meaning):
From this we have next two major parts of main:
- License file validation;
- AES decrypion of flag bytes using SHA256 hash of
license.binas key.
So we must reconstruct the entire license file byte-by-byte. Let’s start with linense validation:
- After opening file, program checks that its size equals 340000 bytes;
- Then it enters a for-loop of 10000 iterations;
- On each iteration it reads a 2-byte
int16value from the file. This value must be less than 9999; - It passes this integer into some function at
0x140001482 - Then search for some mangled function name
_Z5checkPh()(check(uchar*)) in0x140001482output and calls it with pointer to license data after 2 readed bytes; - The check function returns either
0or1. It must return 1 on every iteration for the validation to succeed. After that, data pointer moved to 32 bytes.
Now we know why it’s size must be 340000:
1
2
((int16)dll_id + (char[32])data) * dll_count = len(license_file)
(2 + 32) * 10000 = 340000
Next to validation routine, if all checks pass, the program performs a final check: it compares two 40000-byte tables with memcmp:
Let’s dive into the dynamic_loader_or_decryptor func at 0x140000482. It is a large custom DLL-loading routine:
- Program search library by ID (it’s extracted 2 bytes value from license file);
- Decompressed it with an LZ77-like algorithm.;
- Resolves all imports and exports by recursively calling itself for dependency DLL IDs;
- Passes
TARGET_TABLEto each DLL’s entry point (for initialization); - Finally, it appends the
dll_idto astd::vectorof DLL indices, that where used before calling extracteddll_id(all dependences fordll_idwhich checked on thischeckiteration).
And the last function we could remember before unpack some DLL’s and reverse it - update_validation_array(i); in main validation loop:
As you can see, each value collected in std::vector of DLL indexes has its own value at TARGET_TABLE. And in this function, all DLL IDs used in the current iteration have the index i added to their entries.
Now we also understand why the HARDCODED_TABLE size is 40000:
1
2
(int32)sum_of_usage_positions * dll_count = len(HARDCODED_TABLE)
4 * 10000 = 40000
For now, let’s extract any of unpacked DLL from memory using a debugger - for example, 00000.dll. Our target - check function.
At the start of check, program calls many functions named f[num](). Let’s call these “f-functions”. All of them receive our 32-byte block from argument a1 (we call this block the DLL CRC):
As you can see, DLL calls a set of f-functions with internal realisation and exported from other DLLs. Exactly that makes dependences between all of DLL. After calling f-functions Dll CRC is processed inside a loop where it is “spread” into a large matrix using row-wise XOR operations:
I spend a long time to recognise what happens next with this matrix, but based on renamed variables on picture you can notice that some base matrix, MOD and EXP 64bit integers are used in computations (renamed by me).
The matrix base is XORed with a1 to form matrix B matrix. Then is raised to the power of EXP exponent modulo MOD (all values are hardcoded):
After this, before returning, the function compares the result with the expected target matrix using memcmp:
Reversing the Matrix Exponentiation
How to revert this operation and get a1 from target_matrix?
We can compute the modular inverse exponent
\(d \equiv e^{-1} \pmod{M},\) where \(M\) is the group exponent of \(GL(n,p)\), derived here as \(M = p \cdot \operatorname{lcm}\bigl(p^{\,i}-1,\; i=1..n \bigr).\)
Then the target matrix is raised to the power \(d \bmod p\), effectively solving the modular equation: \(\text{target} = B^{\,e} \bmod p \quad\Longrightarrow\quad B = \text{target}^{\,d} \bmod p.\)
Finally, the value a1 is reconstructed by XORing the recovered matrix \(B\) with the original base matrix and packing the result into 64-bit little-endian words.
It’s algo inverts a modular matrix exponentiation problem over
\(GL(4,p)\) to recover the original pre-exponentiation matrix (a1) from a known target matrix.
Analysis of the f-functions
We note this and now analyse last thing - what happens with DLL CRC in f-functions? These functions transform the input 32 bytes and it turns out that these transformations can be divided into 3 types:
1) Modular power by mod \(2^{256}\) (POW)
Function performs a masked 256-bit modular exponentiation using a fixed exponent, with temporary LSB normalization and final bit restoration:
Here what function does:
- Saving least significant bit at v5 var and set in to 1;
- Creates 256-bit exponent from hardcoded values;
- Tests whether the current exponent bit is 1. If yes, the algorithm performs the “multiply” step of square-and-multiply exponentiation. If the bit is 0, it only performs the squaring step;
- Process 256-bit multiplication and keeps only the lower 256 bits of the product;
- Restore LSB from saved v5 value.
In algebraic formulas it’s like this: \(a1'_i = \begin{cases} a1_i \oplus \bigl( TABLE_{\text{32bit}}[i] \bigr), & i = 0,1,2,3 \\[4pt] a1_i, & i = 4..31 \end{cases}\)
\[\text{result} = \bigl( a1' \,|=\, 1 \bigr)^{E} \;\bmod\; 2^{256}\]To revert this operation we need:
- Reconstruct the 31-byte exponent
Efrom the hardcoded or overlapped bytes: - Consider the multiplicative group of odd 256-bit integers: \((\mathbb{Z} / 2^{256}\mathbb{Z})^{\times} \quad\text{of order}\quad 2^{255}.\) Thus, the modular inverse exponent must satisfy: \(E \cdot D \equiv 1 \pmod{2^{255}}.\)
- Compute
Dusing the Extended Euclidean Algorithm: \(D = E^{-1} \pmod{2^{255}}, \quad \text{with } \gcd(E, 2^{255}) = 1 \implies D = x \bmod 2^{255}.\) - Recover the original value by raising the intermediate number
Bto the power ofDmodulo \(2^{256}\): \(\text{Recovered} = B^{D} \bmod 2^{256}.\)
And finally, don’t forget to restore LSB and XOR with TARGET_TABLE value at dll_id index.
2) S-box substitution (SBOX)
Function performs a 32-byte S-box substitution using a hardcoded 256-element table: 
Here’s what the function does:
- Apply initial XOR on the first 4 bytes with
TARGET_TABLE(similar to masking step in previous function). - Load the 32×64-bit S-box values (sbox[0..31]) into memory. These values define a permutation of 0–255 for byte substitution.
- Substitute each byte of the input a1[i] with sbox[a1[i]] for i = 0..31. This is equivalent to applying a lookup table (S-box) on each input byte, which scrambles the data in a deterministic way.
- The output is the permuted 32-byte array.
To revert this operation we need:
- Construct the inverse permutation from the S-box: \(S^{-1}[S[i]] = i, \quad i = 0..255\)
- Apply it to each byte of the modified array to recover the original input: \(a_1[i] = S^{-1}[a'_1[i]], \quad i = 0..31\) And don’t forget undo 32-bit XOR mask on the first 4 bytes with
TARGET_TABLElike in POW.
3) Bytewise permutation (SHUFFLE)
Function performs a 32-byte bytewise shuffle (permutation) using a small 32-byte table derived from 5×64-bit constants:
Here’s what the function does:
- Like previous permutations here performes initial XOR on the first 4 bytes with
TARGET_TABLE; - Build the shuffle table
v2_bytesfrom the 5×64-bit constants (table[0..4]), converting each QWORD into 8 bytes. This defines a fixed byte permutation of length 32. - Shuffle input bytes: for each \(\quad i = 0..31\), move the input byte at position
v2_bytes[i]into positioniof a temporary arrayv1. - Copy shuffled bytes from
v1back into the original arraya1.
Reversing the operation (inverse shuffle):
- Construct the inverse permutation from the shuffle table: \(P^{-1}[P[i]] = i, \quad i = 0..31\)
- Apply it to each byte of the shuffled array to recover the original input: \(a_1[i] = a'_1[P^{-1}[i]], \quad i = 0..31\)
- Undo the optional 32-bit XOR mask on the first 4 bytes.
All three of these operations use an initial XOR with a 32-bit element from the TARGET_TABLE at the DLL ID index. What does this mean for us? Now, first, we can divide our license recovery task into two large steps:
- recovering the order of the IDs in the
license.binfile; - then recovering the 32 bytes of DLL CRC for each.
XOR in f-functions forces us to postpone recovering the CRC and first search for the order, since the value of a1 after permutations with an incorrect order will distort the result, and we will not be able to obtain the correct base matrix for exponentiation.
The HARDCODED_TABLE check in the main function is designed to determine whether each DLL with its dependencies was called at the correct stage of checking. For recover DLLs positions we don’t need to extract them from 10000.exe for now. We need to dump HARDCODED_TABLE to hardcoded raw file.
Almost every of DLLs have dependences with other DLLs by import/export f-functions:
These dependencies can be described by parent relationships, where using the function exported by DLL A in DLL B’s imports means that A is the parent of B in the dependency graph. Let’s call it immediate_graph. But when binary loads DLL to resolve imports, it loads all imports of new DLL too. That’s why we need to build transitive graph from our immediate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import json
def load_json(filename):
with open(filename, 'r') as f:
return json.load(f)
def build_transitive_graph(immediate_graph):
"""
Builds a transitive dependency graph for each DLL_ID.
Recursively collects all descendants, excluding itself.
"""
transitive_graph = {}
graph = {int(k): [int(x) for x in v] for k, v in immediate_graph.items()}
def dfs(node, visited):
if node not in graph:
return set()
deps = set()
for child in graph[node]:
if child not in visited:
deps.add(child)
visited.add(child)
deps.update(dfs(child, visited))
return deps
for node in graph:
print(node)
transitive_graph[node] = list(dfs(node, set()))
return transitive_graph
if __name__ == "__main__":
immediate_graph = load_json("immediate_graph.json")
transitive_graph = build_transitive_graph(immediate_graph)
with open("transitive.json", "w") as f:
json.dump(transitive_graph, f, indent=2)
Our transitive.json with 465MB size looks like this (it’s builds about 10 minutes):
1
2
3
4
5
6
7
8
9
{
"0": [
8193,
3,
6,
...
],
"1": [
...
We use the transitive.json graph to determine how the final index of each DLL depends on all DLLs that can reach it in the dependency graph. Conceptually, the final value for a DLL is the sum of the hardcoded indices of all its transitive ancestors (including itself).
To compute this, we process the graph in topological order: each DLL contributes its index to all DLLs that depend on it. By repeatedly removing nodes with zero indegree and propagating their contribution forward, we resolve the entire system of dependency-based sums for all 10,000 DLLs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import json
import struct
import os
from collections import deque
import numpy as np
MAX_DLLS = 10000
def load_hardcoded_table(filename='hardcoded'):
with open(filename, 'rb') as f:
data = f.read(4 * MAX_DLLS)
return np.array(struct.unpack('<{}I'.format(MAX_DLLS), data), dtype=np.int64)
def load_transitive_graph(filename='dll_transitive.json'):
with open(filename, 'r') as f:
graph = json.load(f)
return {int(k): set(int(vv) for vv in v) for k, v in graph.items()}
def compute_parents(transitive_graph):
"""Build the reverse graph to count parents"""
parents = {i: set() for i in range(MAX_DLLS)}
for dll_id, children in transitive_graph.items():
for child in children:
if child != dll_id:
parents[child].add(dll_id)
return parents
def solve_indices(hardcoded_file='hardcoded', transitive_file='transitive.json', log_step=500):
hardcoded = load_hardcoded_table(hardcoded_file)
transitive_graph = load_transitive_graph(transitive_file)
parents = compute_parents(transitive_graph)
assigned = np.zeros(MAX_DLLS, dtype=np.int64)
assigned_flags = np.zeros(MAX_DLLS, dtype=bool)
# Counter of unprocessed parents
unprocessed_parents = np.array([len(parents[i]) for i in range(MAX_DLLS)], dtype=np.int32)
# Queue of roots (DLLs without parents)
queue = deque([i for i in range(MAX_DLLS) if unprocessed_parents[i] == 0])
step = 0
while queue:
dll_id = queue.popleft()
if assigned_flags[dll_id]:
continue
# Assign index from hardcoded table
assigned[dll_id] = hardcoded[dll_id]
assigned_flags[dll_id] = True
# Subtract this index from all transitive children
for child in transitive_graph[dll_id]:
if child == dll_id or assigned_flags[child]:
continue
hardcoded[child] -= assigned[dll_id]
# Decrease counter of unprocessed parents
unprocessed_parents[child] -= 1
if unprocessed_parents[child] == 0:
queue.append(child)
step += 1
if step % log_step == 0:
print(f"[Step {step}] Assigned {np.sum(assigned_flags)}/{MAX_DLLS} DLLs")
print(f"[*] Done. Assigned {np.sum(assigned_flags)}/{MAX_DLLS} DLLs")
return assigned
if __name__ == "__main__":
assigned_indices = solve_indices('hardcoded', 'transitive.json')
np.savetxt('assigned_indices.txt', assigned_indices, fmt='%d')
File assigned_indices.txt contains idexes at line number as dll_id 🙂. We can generate license.bin from this and validate that everything is correct:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def validate_license_transitive(hardcoded_file='hardcoded',
license_file='license.bin',
transitive_file='transitive.json'):
HARDCODED = load_hardcoded(hardcoded_file)
LICENSE_IDS = load_license_ids(license_file)
TRANSITIVE_GRAPH = load_transitive_graph(transitive_file)
TARGET = [0] * MAX_DLLS
for idx, dll_id in enumerate(LICENSE_IDS):
print(dll_id)
if dll_id >= MAX_DLLS:
raise ValueError(f"Invalid DLL_ID {dll_id} at license index {idx}")
deps = TRANSITIVE_GRAPH.get(dll_id, [dll_id])
TARGET[dll_id] += idx
for dep in deps:
TARGET[dep] += idx
mismatches = []
for i in range(MAX_DLLS):
if TARGET[i] != HARDCODED[i]:
mismatches.append((i, TARGET[i], HARDCODED[i]))
if not mismatches:
print("[+] License valid: all indices match HARDCODED_TABLE")
else:
print(f"[!] Found {len(mismatches)} mismatches (first 20 shown):")
for i, t_val, h_val in mismatches[:20]:
print(f" DLL_ID {i}: TARGET={t_val} HARDCODED={h_val}")
ANOTHER last thing before wtiting a complite solve - extracting and unpacking DLLs from 10000.exe resource section. I try to rewrite decompression method, but it doesnt work… And here i use pretty framework - lief that can modify PE or ELF executable files to library by adding arbitrary function in export table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import lief
pe = lief.parse("10000.exe")
if not pe.has_exports:
export = lief.PE.Export("10000.dll", [])
pe.set_export(export)
export = pe.get_export()
export.add_entry("lz77_decompressor", 0x2690)
export.add_entry("lz77_decompressor_main", 0x35E8)
config = lief.PE.Builder.config_t()
config.exports = True
config.export_section = ".edata"
pe.write("10000.dll", config)
After this, with ctypes.WinDLL gets all of decompressed DLLs:
Final solution
Ok, now we have all we need to build license.bin!
Let’s summarize what must be done and outline the key ideas behind each step:
- Extract all constants and f-function calls order from all 10000 DLLs.
Each DLL embeds a small fragment of the global algorithm: POWs, SBOXs and SHUFFLEs.
First, we must reconstruct the exact execution order in which DLLs and extract all constants we need to undo permutations. This DLL information collects ingptmanager.py(it named like this because it was crated by many tries (with LLM and not) to build most fastest parser for DLLs.(lief -> pefile | json -> sqlite | ...)). We will use:- pefile to get all exports (cache it’s constants for using from different dlls) and imports;
- capstone to extracting constants;
- SQLite for caching all this data. We have 2 tables for data: 1) check function data - DLL ID, matrix exponentiation constants and order of f-function in their names; 2) f-function data - name of function and constants. Type of 3 operations (POW, SBOX, SHUFFLE) determined by constants count.
Reverse the matrix exponentiation layer.
Retrieve the hidden pre-exponentiated matrix that was XOR-mixed with CRC data.Undo the XOR masking and extract
a1.
After recovering the true matrix, each 64-bit piece ofa1is simply:
\(a1[i] = B[i] \oplus \text{base}[i].\) This yields the exact per-user 32-byte value expected by the system.Reverse all f-functions transformations.
By applying the inverse of each step in exact reverse order, the original input block for each DLL is reconstructed.Update TARGET_TABLE using
transitive.json.
For each DLL from dependences summarize index to getting right output from f-function revert.- Assemble
license.bin.
After all 10000 blocks are reversed and corrected, we can build license file.
Final code consist with 4 python sctipts and look like this:
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import os
import json
import struct
from f import FSolver
from matrix import MatrixSolver
from gptmanager import DllManager
MAX_DLLS = 10000
RECORD_SIZE = 34
def load_license_ids(filename='license.bin'):
if not os.path.isfile(filename):
raise FileNotFoundError(f"{filename} not found")
ids = []
with open(filename, 'rb') as f:
for _ in range(MAX_DLLS):
record = f.read(RECORD_SIZE)
if len(record) < 2:
raise ValueError("license.bin is truncated")
dll_id = struct.unpack('<H', record[:2])[0]
ids.append(dll_id)
return ids
def load_transitive_graph(filename='transitive.json'):
with open(filename, 'r') as f:
graph = json.load(f)
return {int(k): [int(vv) for vv in v] for k, v in graph.items()}
def update_table(xor_table, dll_ids, idx):
for dep in dll_ids:
xor_table[dep] += idx
return xor_table
def save_license_data(ids, license_data, filename_out='license.bin.done'):
if len(ids) != len(license_data):
raise ValueError("ids list too small!")
with open(filename_out, 'wb') as f:
for dll_id, data_block in zip(ids, license_data):
if not isinstance(data_block, (bytes, bytearray)):
raise TypeError("Must be bytes or bytearray")
if len(data_block) != 32:
raise ValueError("Not 32 bytes size")
f.write(struct.pack('<H', dll_id))
f.write(data_block)
print(f"'{filename_out}' successfull created with ({len(ids)} ids).")
def main():
license_ids = load_license_ids('license.bin.order')
transitive_graph = load_transitive_graph('transitive.json')
license_data = []
xor_table = [0] * len(license_ids)
f_solver = FSolver()
matrix_solver = MatrixSolver()
manager = DllManager('/home/9/decompressed/')
for idx, dll_id in enumerate(license_ids):
dll_name = str(dll_id).rjust(4, '0') + '.dll'
f_data, matrix_data = manager.analyze(dll_name)
a1_shuffled = matrix_solver.solve(matrix_data)
a1, _ = f_solver.solve(a1_shuffled, f_data, xor_table)
true_dll_ids = transitive_graph[dll_id]
if dll_id not in true_dll_ids:
true_dll_ids.append(dll_id)
xor_table = update_table(xor_table, true_dll_ids, idx)
license_data.append(a1)
print(f"[{idx}] {dll_name} done...")
save_license_data(license_ids, license_data)
if __name__ == "__main__":
main()
matrix.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
from math import gcd
import struct
class MatrixSolver:
def __init__(self):
self.p = 0
self.e = 0
def solve(self, matrix_data):
self.p = matrix_data["p"]
self.e = matrix_data["e"]
target = matrix_data["target"]
base = matrix_data["base"]
inv = self.inverse_matrix(target)
a1 = self.get_a1(inv, base)
return a1
def qword_to_le_bytes(self, q: int) -> bytes:
return int(q).to_bytes(8, 'little', signed=False)
def get_a1(self, inv, base):
a1_qwords = [
inv[0][0] ^ base[0][0],
inv[0][1] ^ base[0][1],
inv[0][2] ^ base[0][2],
inv[0][3] ^ base[0][3]
]
a1 = b''.join(struct.pack('<Q', qword) for qword in a1_qwords)
return a1
def lcm(self, a,b):
return a//gcd(a,b)*b
# matrix multiplication and pow modulo p
def mat_mul(self,A,B):
n = len(A)
C = [[0]*n for _ in range(n)]
for i in range(n):
for k in range(n):
aik = A[i][k]
if aik==0: continue
rk = B[k]
ci = C[i]
for j in range(n):
ci[j] = (ci[j] + aik * rk[j]) % self.p
return C
def mat_pow(self, A, exp):
n = len(A)
# identity
R = [[1 if i==j else 0 for j in range(n)] for i in range(n)]
base = [row[:] for row in A]
e = exp
while e > 0:
if e & 1:
R = self.mat_mul(R, base)
base = self.mat_mul(base, base)
e >>= 1
return R
def inverse_matrix(self, target):
# compute L = lcm(p^k - 1, k=1..4)
L = 1
for k in range(1,5):
L = self.lcm(L, pow(self.p,k) - 1)
# For GL(n,q) with q=p prime and p > n, exponent = p * lcm(q^i - 1 for i=1..n)
M = (L * self.p)
# Check gcd(e, M)
g = gcd(self.e, M)
if g != 1:
raise ValueError("e not invertible modulo group exponent M; gcd != 1")
d = pow(self.e, -1, M) # modular inverse
B = self.mat_pow(target, d)
return B
f.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
from typing import List
from enum import Enum
from dataclasses import dataclass
# TODO:
# 1. Extract fxxxx call list
# 2. in fxxxx:
# 1) guess type
# 2) extract constants for guessed type
# 3) cache by fID {'fxxxxx': ['sbox', [0x123, 0x312 ...]]} (pow, sbox, shuffle)
# 3. Extract last matrix pow constants - e, base, target
# 4. Make loop for given dll -> process inverse + calculate XOR_TABLE values
# 5. Think about import resolving -> take a long time.
class FType(Enum):
POW = 1
SBOX = 2
SHUFFLE = 3
@dataclass
class FFunc:
type: FType
constants: List
dll_name: str
class FSolver:
def __init__(self):
pass
def solve(self, a1_shuffled, f_data: List[FFunc], xor_table: List[int]):
f_data.reverse()
dll_ids = set()
a1 = a1_shuffled
for func in f_data:
dll_id = int(func['dll_id'][:-4])
cur_xt = xor_table[dll_id]
dll_ids.add(dll_id)
qwords = [int(hex_str, 16) for hex_str in func['constants']]
match func['type']:
case FType.POW.name:
res = self.pow_inverse(a1, qwords, cur_xt)
case FType.SBOX.name:
res = self.sbox_reverse(a1, qwords, cur_xt)
case FType.SHUFFLE.name:
res = self.shuffle_inverse(a1, qwords, cur_xt)
a1 = res
return a1, dll_ids
def qword_to_le_bytes(self, q: int) -> bytes:
return int(q).to_bytes(8, 'little', signed=False)
def build_v2_bytes(self, qwords) -> bytearray:
b = bytearray()
for q in qwords:
b += self.qword_to_le_bytes(q)
if len(b) < 264:
b += bytes(264 - len(b))
return b
def sbox_reverse(self, modified_a1: bytes, qwords_consts, global_xor_32: int) -> bytes:
if len(modified_a1) < 32:
raise ValueError("modified_a1 must be at least 32 bytes")
v2_bytes = self.build_v2_bytes(qwords_consts)
if len(v2_bytes) < 256:
v2_bytes += bytes(256 - len(v2_bytes))
if len(set(v2_bytes[:256])) != 256:
raise ValueError("v2_bytes is not a permutation; cannot invert")
inv_v2_bytes = bytearray(256)
for i, val in enumerate(v2_bytes[:256]):
inv_v2_bytes[val] = i
out = bytearray(modified_a1[:32])
for i in range(32):
out[i] = inv_v2_bytes[out[i]]
if global_xor_32 != 0:
x0 = int.from_bytes(out[0:4], 'little') ^ (global_xor_32 & 0xFFFFFFFF)
out[0:4] = x0.to_bytes(4, 'little')
return bytes(out)
def find_exponent_value(self, exp_bytes):
exponent = 0
for i, byte in enumerate(exp_bytes[:31]):
exponent |= (byte << (i * 8))
return exponent
def mod_inverse(self, a, m):
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
gcd, x1, y1 = extended_gcd(b % a, a)
x = y1 - (b // a) * x1
y = x1
return gcd, x, y
gcd, x, _ = extended_gcd(a % m, m)
if gcd != 1:
raise ValueError(f"Can't found modular inverse for {a} mod {m}")
return x % m
def modular_pow_little(self, base_bytes, exponent_bytes):
v2 = [0] * 64
for i in range(32):
v2[32 + i] = base_bytes[i]
v3 = [0] * 32
v3[0] = 1
for j in range(31):
byte = exponent_bytes[j]
for k in range(8):
if (byte >> k) & 1:
# Multiply v3 * v2
carry = 0
newv3 = [0] * 32
for m in range(32):
acc = carry
for n in range(m + 1):
acc += v3[n] * v2[32 + (m - n)]
newv3[m] = acc & 0xFF
carry = acc >> 8
v3 = newv3
# Square v2
carry = 0
newlow = [0] * 32
for jj in range(32):
acc = carry
for kk in range(jj + 1):
acc += v2[32 + kk] * v2[32 + (jj - kk)]
newlow[jj] = acc & 0xFF
carry = acc >> 8
for mm in range(32):
v2[32 + mm] = newlow[mm]
return bytes(v3)
def build_exp_bytes_from_overlapping_qwords(self, exp_bytes):
buf = bytearray(31)
b0 = self.qword_to_le_bytes(exp_bytes[0])
buf[0:8] = b0
b1 = self.qword_to_le_bytes(exp_bytes[1])
buf[8:16] = b1
b2 = self.qword_to_le_bytes(exp_bytes[2])
buf[15:23] = b2
b3 = self.qword_to_le_bytes(exp_bytes[3])
buf[23:31] = b3
return list(buf)
def pow_inverse(self, a1_bytes, exp_bytes, global_xor_32):
exp_bytes = self.build_exp_bytes_from_overlapping_qwords(exp_bytes)
assert len(a1_bytes) == 3
v7 = a1_bytes[0] & 1
a1_temp3 = bytearray(a1_bytes)
a1_temp3[0] ^= (v7 ^ 1)
e = 0
for i, b in enumerate(exp_bytes[:31]):
e |= (b & 0xFF) << (i * 8)
group_order = 1 << 255
d = self.mod_inverse(e, group_order)
base_v2 = int.from_bytes(bytes(a1_temp3), 'little')
mod = 1 << 256
recovered_base_int = pow(base_v2, d, mod)
recovered = bytearray(recovered_base_int.to_bytes(32, 'little'))
recovered[0] = (recovered[0] & 0xFE) | v7
if global_xor_32 != 0:
x0 = int.from_bytes(recovered[0:4], 'little') ^ (global_xor_32 & 0xFFFFFFFF)
recovered[0:4] = x0.to_bytes(4, 'little')
return bytes(recovered)
def shuffle_inverse(self, a1_bytes, v2_qwords, global_xor_32):
assert len(a1_bytes) == 32
temp_out = bytearray(a1_bytes)
v2_bytes = bytearray(32)
for i, q in enumerate(v2_qwords):
for j in range(8):
byte_val = (q >> (j * 8)) & 0xFF
v2_bytes[i * 8 + j] = byte_val
inv_v2 = [0] * 32
for i in range(32):
original_position = v2_bytes[i]
inv_v2[original_position] = i
restored = bytearray(32)
for i in range(32):
restored[i] = temp_out[inv_v2[i]]
if global_xor_32 != 0:
x0 = int.from_bytes(restored[0:4], 'little') ^ (global_xor_32 & 0xFFFFFFFF)
restored[0:4] = x0.to_bytes(4, 'little')
return bytes(restored)
gptmanager.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import os
import sqlite3
import json
import logging
import pefile
import capstone
from f import FType
CS = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
CS.detail = False
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
class DllManager:
def __init__(self, dll_folder, db_file="dll_cache.db"):
self.folder = dll_folder
self.db_file = os.path.join('.', db_file)
self._init_db()
logging.info(f"DllManager initialized with SQLite cache: {self.db_file}")
# -------------------- DB INITIALIZATION --------------------
def _init_db(self):
self.conn = sqlite3.connect(self.db_file)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS check_cache (
dll_name TEXT PRIMARY KEY,
matrix_data TEXT,
f_order TEXT
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ffuncs_cache (
f_name TEXT PRIMARY KEY,
f_data TEXT
)
""")
self.conn.commit()
# -------------------- BASE METHODS --------------------
def _get_function_content(self, pe, va, limit=30000):
image_base = pe.OPTIONAL_HEADER.ImageBase
rva = va - image_base
section = pe.get_section_by_rva(rva)
if not section:
return b""
section_data = section.get_data()
section_offset = rva - section.VirtualAddress
if section_offset < 0 or section_offset >= len(section_data):
return b""
return section_data[section_offset:section_offset + limit]
def extract_rip_offset(self, operand_str):
parts = operand_str.split('rip')
if len(parts) > 1:
offset_str = parts[1].strip()
if offset_str.startswith('+'):
return int(offset_str[2:-1], 16)
elif offset_str.startswith('-'):
return -int(offset_str[2:-1], 16)
return None
# -------------------- DLL INDEXING --------------------
def index_exports(self):
logging.info(f"Indexing exports/imports in {self.folder}")
dlls = [f for f in os.listdir(self.folder)
if f.lower().endswith(".dll") and f[:4].isdigit()]
check_cache = {}
ffuncs_cache = {}
for dll in dlls:
result = self._process_dll(dll)
if result:
dll_name, check_entry, ffunc_entries = result
check_cache[dll_name] = check_entry
ffuncs_cache.update(ffunc_entries)
logging.info(f"Indexed {dll_name}")
else:
logging.warning(f"Failed {dll}")
# --- MASS STORAGE AT SQLite ---
with self.conn:
self.conn.executemany(
"INSERT OR REPLACE INTO check_cache VALUES (?, ?, ?)",
[(dll, json.dumps(data["matrix_data"]), json.dumps(data["f_order"]))
for dll, data in check_cache.items()]
)
self.conn.executemany(
"INSERT OR REPLACE INTO ffuncs_cache VALUES (?, ?)",
[(fname, json.dumps(fdata)) for fname, fdata in ffuncs_cache.items()]
)
logging.info(f"Finished indexing {len(check_cache)} DLLs and {len(ffuncs_cache)} functions")
def _process_dll(self, dll):
"""Возвращает tuple (dll_name, check_entry, ffunc_entries) или None при ошибке"""
try:
path = os.path.join(self.folder, dll)
pe = pefile.PE(path, fast_load=True)
if not pe:
return None
# --- Imports ---
iat_map = {}
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_IMPORT']])
for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []):
dll_name = entry.dll.decode('utf-8', errors='ignore')
if not dll_name[:4].isdigit():
continue
for imp in entry.imports:
if imp.address:
func_name = imp.name.decode('utf-8', errors='ignore') if imp.name else f"ordinal_{imp.ordinal}"
iat_map[imp.address] = (dll_name, func_name)
# --- Exports ---
exp_map = {}
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']])
check_va = None
ffuncs_temp = {}
if hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if not exp.name:
continue
func_name = exp.name.decode('utf-8', errors='ignore')
if not (func_name.startswith("_Z21f") or func_name == "_Z5checkPh"):
continue
va = exp.address + pe.OPTIONAL_HEADER.ImageBase
if func_name == "_Z5checkPh":
check_va = va
else:
ftype, constants = self.get_ffuncs_data(pe, va)
ffuncs_temp[func_name] = {
"type": ftype.name if hasattr(ftype, "name") else str(ftype),
"constants": constants,
"dll_id": dll
}
exp_map[va] = (dll, func_name)
# --- check data collection ---
if check_va:
matrix_data, f_order = self.get_check_data(pe, check_va, iat_map, exp_map)
if matrix_data is None:
return None
check_entry = {"matrix_data": matrix_data, "f_order": f_order}
return dll, check_entry, ffuncs_temp
return None
except Exception as e:
logging.error(f"Failed to index {dll}: {e}")
return None
# -------------------- ANALYSIS --------------------
def analyze(self, dll_name):
cur = self.conn.execute(
"SELECT matrix_data, f_order FROM check_cache WHERE dll_name=?", (dll_name,)
)
row = cur.fetchone()
if not row:
logging.warning(f"{dll_name}: not found in cache.")
return None
matrix_data, f_order = json.loads(row[0]), json.loads(row[1])
f_data = []
for f_name in f_order:
cur = self.conn.execute("SELECT f_data FROM ffuncs_cache WHERE f_name=?", (f_name,))
f_row = cur.fetchone()
if f_row:
f_data.append(json.loads(f_row[0]))
return f_data, matrix_data
def get_ffuncs_data(self, pe, va):
code = self._get_function_content(pe, va, limit=1000)
constants = []
func_bytes = []
for i in CS.disasm(code, va):
line = i.mnemonic + " " + i.op_str
if 'movabs rax, 0x' in line or 'movabs rdx, 0x' in line:
constants.append(i.op_str.split(' ')[1][2:].upper())
func_bytes.append(i.bytes)
if i.mnemonic == 'ret':
break
if len(constants) == 32:
ftype = FType.SBOX
elif len(constants) == 4 and len(func_bytes) > 100:
ftype = FType.POW
else:
ftype = FType.SHUFFLE
return ftype, constants
def get_check_data(self, pe, va, iat_map, exp_map):
f_order = []
constants = []
code = self._get_function_content(pe, va)
for i in CS.disasm(code, va):
line = i.mnemonic + " " + i.op_str
if 'movabs rax, 0x' in line:
constants.append(i.op_str.split(' ')[1][2:].upper())
elif line.startswith("mov") and "rip" in line and "qword ptr" in line:
rip_off = self.extract_rip_offset(line.split(",")[-1])
addr = i.address + i.size + rip_off
if addr in iat_map:
_, func = iat_map[addr]
f_order.append(func)
elif line.startswith('call') and '0x' in line:
rva = int(line.split(' ')[-1], 16)
if rva in exp_map:
_, func = exp_map[rva]
f_order.append(func)
if i.mnemonic == 'ret' or len(constants) >= 34:
break
if len(constants) < 2:
logging.warning(f"[{getattr(pe, 'filename', '?')}] insufficient constants ({len(constants)}) - skipping")
return None, []
p, e = constants[:2]
target_flat = constants[2:18] if len(constants) >= 18 else ["0"] * 16
base_flat = constants[18:34] if len(constants) >= 34 else ["0"] * 16
matrix_data = {
"p": int(p, 16),
"e": int(e, 16),
"base": [[int(target_flat[i+j], 16) for j in range(4)] for i in range(0, 16, 4)],
"target": [[int(base_flat[i+j], 16) for j in range(4)] for i in range(0, 16, 4)],
}
return matrix_data, f_order
It takes a while (about 30 minutes), but once it finishes we can compute the SHA256 of generated license file and decrypt encoded flag using AES-CBC (IV is hardcoded into the binary).
BOOOOM! We finally get the last flag:
1
Its_l1ke_10000_spooO0o0O0oOo0o0O0O0OoOoOOO00o0o0Ooons@flare-on.com
Epilogue
It was . . . hard . . .
But I’m really glad that I solved this tasks on time! Thanks for reading!










































