Flareon 12 Writeup Part 1

Published By: BLUE TEAM

Published On: 03/11/2025

Published in:

1 - Drill Baby Drill!

The challenge is a Python game.

The game builds a boulder map based on the level’s name.

You just need to add three extra lines of code to show where the bear is on the screen.

After that, enjoy—seriously, have fun with it! :3

Flag: drilling_for_teddies@flare-on.com

2 - project_chimera

This challenge is delivered as a single Python script leveraging both zlib and marshal. The script contains precompiled Python bytecode embedded in a compressed blob, which is loaded and executed from memory.

Attempting to run the script directly yields a SyntaxError, due to reliance on Python 3.12+

Rather than setting up a new Python environment, I commented out the incompatible line, dumped the decompressed marshal code to disk, and attached a .pyc header.

I then used PyLingual to decompile the .pyc. This revealed an additional marshal stage. 

Repeating the above dump/decompile process exposes the final code stage. 

At this step, the core logic is a straightforward XOR-inverse algorithm used for username generation. Running the following in Python retrieves the original value: 

LEAD_RESEARCHER_SIGNATURE = b’m\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS’ 
username = bytes(c ^ (i + 42) for i, c in enumerate(LEAD_RESEARCHER_SIGNATURE)) 
print(username.decode()) 

Patching this username into the relevant variable produces the flag:

Flag: Th3_Alch3m1sts_S3cr3t_F0rmul4@flare-on.com

3 - pretty_devilish_file

We are given a PDF named pretty_devilish_file.pdf. If opened in Chrome, it just displays “Flare-On!”, but it crashes in other viewers. 

Suspecting the PDF is packed/encrypted, we use qpdf

qpdf.exe --decrypt pretty_devilish_file.pdf pretty_devilish_file_decrypted.pdf 

With AI we dump the FlateDecode stream from object 4 in the decrypted PDF. This stream contains our hidden image. 

import zlib 
import re 
with open(’pretty_devilish_file_decrypted.pdf’, ‘rb’) as f: 
   pdf_data = f.read() 
obj4_match = re.search(rb’4\s+0\s+obj.*?stream\s*(.*?)\s*endstream’, pdf_data, re.DOTALL) 
if obj4_match: 
   decompressed = zlib.decompress(obj4_match.group(1)) 
   print(f”Decompressed {len(decompressed)} bytes”) 
   print(decompressed.decode(’latin1’, errors=’ignore’)) 
   
   bi_match = re.search(rb’BI\s+.*?ID\s+(.*?)\s+EI’, decompressed, re.DOTALL) 
   if bi_match: 
          img_data = bi_match.group(1) 
          print(f”Inline image found: {len(img_data)} bytes”) 
          print(f”Image data (hex): {img_data[:100].hex()}”) 
   else: 
          # Look for JPEG data 
          jpeg_start = decompressed.find(b’\xff\xd8’) 
          if jpeg_start != -1: 
          jpeg_end = decompressed.find(b’\xff\xd9’, jpeg_start) 
          jpeg_data = decompressed[jpeg_start:jpeg_end+2] if jpeg_end != -1 
else decompressed[jpeg_start:] 
          print(f”JPEG found: {len(jpeg_data)} bytes”) 
                    print(f”JPEG data (hex): {jpeg_data[:100].hex()}”) 
          else:
          print(”No image found”) 

We get an image sized 37x1 pixels.

Each pixel’s column intensity in the image seems to map to an ASCII character, essentially forming the flag.

from PIL import Image 
import numpy as np 

img = Image.open("secret_image.jpg").convert("L") 
arr = np.array(img) 

col_means = arr.mean(axis=0) 
rounded = np.rint(col_means).astype(int).tolist() 
print("Rounded column means:", rounded) 

chars = [] 
for v in rounded: 
    if 32 <= v <= 126: 
        chars.append(chr(v)) 
    else: 
        chars.append('.') 
print("Mapped characters:", ''.join(chars))

Flag: Puzzl1ng-D3vilish-F0rmat@flare-on.com

4 - UnholyDragon

This challenge gives us an executable named UnholyDragon-150.exe

We are unable to run this executable because the first byte of the file doesn't match the DOS Header magic.

image

If we replace the first byte with 0x4D, then run the file, we can see it spawns another process UnholyDragon-151.exe. The newly created process continues to spawn UnholyDragon-152.exe, and so on until it reaches number 154.

image

So why did the author give us the 150th file? We can conclude that the flag may be stored in the file with index 0 or 1.

Let's start analyzing the file in Binary Ninja.

image

Based on the file icon and some strings inside it, we determine that this file is compiled with twinBasic, which makes it really difficult to reverse the entire logic.

Therefore, I decided to create a simple example program with twinBasic to compare. As a result, I can identify the main function at address 0x4a8f30.

image

The main function first extracts the number from the file name.

image

It then creates a copy of the file with an incremented number in the name.

image

The extracted file number is XORed with the constant 0x6746 before being used as the seed for the function at 0x4a5e4c

image

The random number returned from mw_rand_transform (0x4a86a3) is used as the offset to read one byte from the file.

image

That byte is then XORed with another random number from mw_rand_transform and written back to the file.

image

At this point we know the difference between two consecutive files is exactly one byte at a random offset that gets XORed with a random number. All these random values are generated based on the file number(used as the seed).

We can reimplement the random generation logic to compute the offset and XORed value, and then create back the file for index 0 or 1.

But as we know that these number are constant based on the file number, we can easily solve the challenge: rename the file to UnholyDragon-0.exe and run it.

Doing so causes the program to create another UnholyDragon-150.exe with the wrong DOS Header magic. Do you find it familiar? Yes, this is the same error as the one in the given file from the start.

image

Fix this byte and running the file yields the flag.

image

Flag : dr4g0n_d3n1al_of_s3rv1ce@flare-on.com

5 - ntfsm

Given an executable, "ntfsm.exe" suggests that it might related to NTFS stub. Run it a few times

A password checker requires 16-bytes length password. As usual, we go with IDA magic, but this time, we don't get lucky, IDA decompiler takes a lot time to fully decompile the binary, and even crash.

Giving up with auto analysis, uncheck auto-analysis when open binary with IDA, go to main and we manual make code with P shortcut.

The logic is quite simple, it first checks two ntfs values of current running file - position and transitions. When position value is 0x10, it will come and check the value transitions, if it is also 0x10, "correct!" and the flag decrypted by derived input value from ntfs will be printed out, else "wrong!" will be. If position is not equal 0x10, it then continue go to main logic where need to dissect.

It check the ntfs state value, if it is -1, it check the arguments passing through command line

  • '-r' option: restore ntfs value of the file where position, transitions, input is 0 and state is -1
  • password provide: valid the condition 16-byte length password then write initial ntfs value to file where input is the provided password, position, transitions and state are assigned to 0

     

It then goes to the big jump table where it makes IDA crash earlier. ntfs value 'state' is used as the table index to calculate where the destination will be. jump table contains 0x1629D entry, which make the flow immersive big that lead IDA to crash. Examine some function of the jump table, they all contain the same logic.

It check input at current position with some hardcode characters, if it matches one of them, the next state value will be feed and transitions value will increase one, else it will display some message, or terminate - these nonsense things.

At the end of these functions, it go all the way to update_ntfs_value. This is where position value will be increase, and the all new ntfs values will be write to file. Finally, it create a sub process that run the file with updated ntfs values

To solve it, we start by dumping all constants that it compares input with and find the trace where it go through that increase the transitions 16 times. As mentions before, the the logic of check functions are the same, so we craps a idapython script to dump all of them

import idc 
import idaapi 
import idautils 
import ida_ua 
import json 
idaapi.msg_clear()
def get_jump_address(index): 
    return 0x140000000 + get_wide_dword(index * 4 + 0x140C687B8) 
d = open('dump.txt', 'w') 
data = {} 
for i in range(0x1629c + 1): 
    data[i] = {} 
insn = ida_ua.insn_t() 
for i in range(0x1629C + 1): 
    handle = get_jump_address(i) 
    head = handle 
    # print(f"Handle for state 0x{i:x}") 
    ida_ua.create_insn(head, insn)
    # make code first 
    while idc.get_wide_byte(head) != 0xE9: 
        head += ida_ua.create_insn(head, insn) 
    ida_ua.create_insn(head, insn) 
    head = handle 
    while idc.get_wide_byte(head) != 0xE9: # jump to update check 
        if idc.print_insn_mnem(head) == 'cmp' and idc.get_operand_type(head, 0) != 1: 
            c = idc.get_operand_value(head, 1) 
            jz_des = idc.get_operand_value(next_head(head), 0) 
            new_state = idc.get_operand_value(jz_des, 1) 
            data[i][chr(c)] = new_state 
            # print(f"{c} -> 0x{new_state:x}") 
        head = next_head(head) 
plain = json.dumps(data, indent=4) 
d.write(plain)
d.close()

Our task now turns into programing challenge finding the longest path of this big tree

import json 

def find_longest_path(tree): 
    # Recursive DFS to find longest path and its corresponding labels 
    def dfs(node): 
        if node not in tree or not tree[node]: 
            return [], [] # (path of nodes, list of edge labels) 
       
        longest_nodes = [] 
        longest_labels = [] 
       
        for label, child in tree[node].items():
            child_nodes, child_labels = dfs(str(child)) 
           
            if len(child_labels) + 1 > len(longest_labels): 
                longest_labels = [label] + child_labels 
                longest_nodes = [node] + child_nodes 
       
        return longest_nodes, longest_labels 

    # Start from root "0" 
    nodes, labels = dfs("0") 
    return "".join(labels) 

# Example tree 
tree = json.load(open('dump.txt', 'r', encoding='utf-8')) 
# Run 
print(find_longest_path(tree))

 

 

 

 

Flag: f1n1t3_st4t3_m4ch1n3s_4r3_fun@flare-on.com

Read more:

Part 2: Flareon 12 Writeup Part 2

Part 3: Flareon 12 Writeup Part 3

3 lượt xem