Post

Flare-On 12 writeups

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?

  1. In start of the branch program executes some logic with rdtsc instructuions that need to slow down program and does not affect further calculations in any way;
  2. Then we have branching for 3 values of password character, given at this position index. For our case 0 it’s 'J', 'U' and 'i';
  3. 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;
  4. If is another that 'J', 'U' and 'i', program jumps to trap branch. It can be some MessageBox, Rickrolling, Sleep and ExitWindowsEx calling;
  5. 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):

junks example

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:

  • peanut string 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:

7 task done!

8. FlareAuthenticator

First look

The task greets us with yet another MBA-obfuscated PE file, along with several Qt6 DLLs:

mba

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:

Flare Authenticator

If we try a random password and click “OK” button, some MessageBox pops up showing “Wrong Password” warning:

Wrong Password

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:

it's packed!

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.bin as key.

So we must reconstruct the entire license file byte-by-byte. Let’s start with linense validation:

  1. After opening file, program checks that its size equals 340000 bytes;
  2. Then it enters a for-loop of 10000 iterations;
  3. On each iteration it reads a 2-byte int16 value from the file. This value must be less than 9999;
  4. It passes this integer into some function at 0x140001482
  5. Then search for some mangled function name _Z5checkPh() (check(uchar*)) in 0x140001482 output and calls it with pointer to license data after 2 readed bytes;
  6. The check function returns either 0 or 1. 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_TABLE to each DLL’s entry point (for initialization);
  • Finally, it appends the dll_id to a std::vector of DLL indices, that where used before calling extracted dll_id (all dependences for dll_id which checked on this check iteration).

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):

\[target\_matrix = \text{B}^{\text{EXP}} \bmod \text{MOD}\]

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:

  1. Saving least significant bit at v5 var and set in to 1;
  2. Creates 256-bit exponent from hardcoded values;
  3. 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;
  4. Process 256-bit multiplication and keeps only the lower 256 bits of the product;
  5. 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 E from 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 D using 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 B to the power of D modulo \(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_TABLE like 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_bytes from 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 position i of a temporary array v1.
  • Copy shuffled bytes from v1 back into the original array a1.

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:

  1. recovering the order of the IDs in the license.bin file;
  2. 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 in gptmanager.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 of a1 is 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!

This post is licensed under CC BY 4.0 by the author.

Trending Tags