ACS 2025: A Busan Victory with KMA.LightBlue

#ctf#writeup#reverse-engineering#acs

The new season of ASEAN Cyber Shield (ACS) has arrived, and once again, I was selected to represent our university at ACS 2025 in Busan, Korea. This time, I competed with team KMA.LightBlue.

As the final installment of the three-year ACS program, our team entered with a singular goal: to improve on last year’s silver and secure 1st place.

We competed in the General Division, which brought even tougher competition from across Southeast Asia. The format remained consistent: a Qualifier followed by a Final for the top-performing teams.

Qualifiers

The Qualifiers proceeded smoothly. Our team maintained a strong, consistent performance across all categories, allowing us to finish the round in 1st place in the General Division.

Qualifiers Scoreboard

The Finals

The Finals in Busan were incredibly intense. We started strong but faced some technical difficulties and delays during the Attack & Defense (Atk/Def) portion, which caused us to lose some early momentum.

However, the team rallied in the later hours. We focused on solving additional Jeopardy-style challenges while stabilizing our defenses. The final hour was particularly stressful as the scoreboard froze; even with a lead over roeb1ono (2nd place) - Indonesia, the atmosphere was tense—one mistake could have cost us the championship.

In the end, our late-game recovery was enough. We secured 1st place overall, winning the $20,000 USD grand prize. Standing on the stage in Busan and receiving the first-place trophy after such a hard-fought match was an unforgettable experience.

Finals Scoreboard

Other Experiences

Beyond the competition, Busan provided a stunning backdrop for the event. It was another great opportunity to reconnect with peers from across the region and exchange ideas. Sharing this victory with my teammates and friends in Korea made the journey even more rewarding.


Write-up

My primary focus this season remained on Reverse Engineering (RE); additionally, I supported the team in completing multiple challenges across other categories.

[Qual] rev/Easy_korea_lottery

This challenge is a Windows crackme that simulates a Korean lottery checker. The input format is X-Y-Z-A-B-C (6 ascending numbers, each in the range 1-45).

The validation routine sub_140001190 contains the main logic:

  • Most user-facing strings are obfuscated with XOR 0xAA.
  • The main password bytes are stored in encrypted form and decoded with bitwise NOT (~).
  • One flag uses a custom formula: ((32 * (byte - 31)) | ((byte - 31) >> 3)) ^ 0xF3

The password is stored encrypted in Source at 0x140005100:

image

We can easily recover the password as follows:

  • Encrypted bytes: c8 d2 cc cf d2 cc c7 d2 cc c6 d2 cb cf d2 cb cc
  • Apply bitwise NOT to each byte.
  • Recovered password: 7-30-38-39-40-43
def decrypt_password():
    """Decrypt the password from the Source variable"""
    # Encrypted password from address 0x140005100
    encrypted = bytes([0xc8, 0xd2, 0xcc, 0xcf, 0xd2, 0xcc, 0xc7, 0xd2, 
                       0xcc, 0xc6, 0xd2, 0xcb, 0xcf, 0xd2, 0xcb, 0xcc])
    
    # Decrypt using bitwise NOT
    decrypted = bytes((~b) & 0xFF for b in encrypted)
    
    # Find null terminator
    try:
        null_idx = decrypted.index(0)
        password = decrypted[:null_idx].decode('utf-8')
    except ValueError:
        password = decrypted.decode('utf-8', errors='ignore')
    
    return password

def main():  
    password = decrypt_password()
    
    print(f"\n[+] Extracted Password: {password}")

if __name__ == "__main__":
    main()

With this input, the program returns the first flag:

  • ACS{KOREA_LOTTERY_IS_SIMPLE}

There is also an alternative valid sequence (6-26-30-38-39-40) that leads to:

  • ACS{FAKER_IS_GOD}

image

[Qual] rev/deep dive

This binary validates a 69-character flag through a long chain of checker functions. Each position follows the same pipeline with position-specific parameters:

transformed = reverse_bits(char[i])
transformed = rotate_right(transformed, rotation[i])
transformed = transformed ^ rotation[i]
compare transformed with expected[i]

Key observations included:

  • main() enforces an input length of 69.

image

  • init_expected_values() initializes 69 target bytes at 0x55555555A086.

image

  • Each per-index checker uses its own rotation value; the rotation and XOR values are identical for a given index.

image

image

  • Rotation values are effectively modulo 8 in byte-rotation operations.

image

The solving strategy was straightforward once the transformation was identified:

  1. Extract all expected bytes from 0x55555555A086.
  2. For each position, reverse the pipeline:
    • Undo the XOR with rotation[i].
    • Rotate left by rotation[i] (the inverse of rotate right).
    • Reverse the bits again (a self-inverse operation).
  3. Reconstruct all 69 characters and verify each via forward simulation.
def reverse_bits(byte_val):
    """Reverse all 8 bits in a byte using bit manipulation."""
    byte_val = byte_val & 0xFF
    temp = ((byte_val >> 4) | (byte_val << 4)) & 0xFF  # Swap nibbles
    temp = ((temp >> 2) & 0x33) | ((temp << 2) & 0xCC)  # Swap pairs
    result = ((temp >> 1) & 0x55) | ((temp << 1) & 0xAA)  # Swap bits
    return result & 0xFF

def rotate_byte_right(byte_val, n):
    """Rotate byte RIGHT by n positions."""
    byte_val = byte_val & 0xFF
    n = n % 8
    result = ((byte_val >> n) | (byte_val << (8 - n))) & 0xFF
    return result

def rotate_byte_left(byte_val, n):
    """Rotate byte LEFT by n positions (inverse of rotate_right)."""
    byte_val = byte_val & 0xFF
    n = n % 8
    result = ((byte_val << n) | (byte_val >> (8 - n))) & 0xFF
    return result

# Expected values extracted from IDA at address 0x55555555A086
expected_values = [
    0x11, 0x28, 0xc2, 0x6e, 0x67, 0x31, 0x9b, 0x92, 0x34, 0x68,
    0xc8, 0x27, 0x3e, 0x60, 0x66, 0xcb, 0xb7, 0x8b, 0x9e, 0x31,
    0x8e, 0xb5, 0x5e, 0x33, 0xb6, 0xc7, 0xab, 0x42, 0xc8, 0xc5,
    0x29, 0x47, 0x62, 0xb6, 0x66, 0x32, 0x37, 0x1f, 0x4f, 0x33,
    0x93, 0x4a, 0xc7, 0xcf, 0xce, 0xa3, 0x4f, 0x5a, 0x9f, 0x6f,
    0x67, 0x39, 0x62, 0xcc, 0x8e, 0xb5, 0x34, 0x66, 0xb6, 0x31,
    0xc6, 0x35, 0x04, 0x90, 0x84, 0x07, 0x3e, 0x68, 0xfc
]

rotation_pattern = [
    5, 4, 8, 1, 1, 10, 2, 3, 5, 4, 4, 1, 7, 5, 5, 7, 6, 2, 3, 2, 8, 6, 7, 5, 6, 3, 10, 1, 4, 4, 2, 9, 4, 6, 5, 1, 1, 7, 9, 5, 2, 9, 3, 3, 8, 2, 1, 9, 6, 9, 1, 2, 4, 4, 3, 6, 6, 4, 6, 2, 4, 6, 8, 3, 8, 1, 7, 4, 6
]

def solve_character(expected, rotation_amount):
    """
    Solve for original character given the expected value and rotation amount.
    
    Forward: reverse_bits(char) -> rotate_right(n) -> XOR n -> expected
    Reverse: expected -> XOR n -> rotate_left(n) -> reverse_bits -> char
    """
    # Step 1: Reverse the XOR
    after_xor = expected ^ rotation_amount
    
    # Step 2: Reverse the rotation (rotate left to undo rotate right)
    after_rotation = rotate_byte_left(after_xor, rotation_amount)
    
    # Step 3: Reverse the bit reversal
    original_char = reverse_bits(after_rotation)
    
    return original_char

def verify_character(char, expected, rotation_amount):
    """Verify that a character produces the expected result."""
    reversed = reverse_bits(char)
    rotated = rotate_byte_right(reversed, rotation_amount)
    xored = rotated ^ rotation_amount
    return xored == expected

if __name__ == "__main__":
   # Solve for all 69 characters
   flag = []

   for i in range(69):
       rotation = rotation_pattern[i]
       expected = expected_values[i]
       
       # Solve for this character
       char = solve_character(expected, rotation)
       
       # Verify it's correct
       is_valid = verify_character(char, expected, rotation)
       
       flag.append(char)
       
   # Convert to string
   flag_string = ''.join(chr(c) for c in flag)

   print(flag_string)

Recovered flag:

ACS{Y0u_f0unD_7h3_bu994_1n_4ll_7h3_n015y_d474_bu7_c4n_y0u_f1nd_7h3_fl49}

[Qual] rev/SmartCity’s Vault

Description: There is a Vault that manages all the information of the Smart City. Please find out the password for the Vault

This was a classic password-checker RE challenge with some anti-debug noise. The app has two login options (User and Admin), and the User credentials are directly recoverable from the code:

  • User ID: Finder
  • Password: Fin

image

The Admin path is where the real challenge lies. At first glance, the check appeared complex: long routines, multiple stages, and crypto-like transforms. Tracing from start into AdminLoginDialog_WndProc shows the exact constraints:

  • Input must be ASCII and follow the ACS{...} format.
  • Total length must be exactly 85 bytes, ending with }.
  • The program extracts 80 bytes from inside the braces, then validates them in chunks.
  • Chunk 1 is transformed with EncryptionRound1.
  • The remaining chunks are processed through EncryptionRound2 plus extra byte mixing.

image

image

image

I started with a probe input:

ACS{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}

While debugging, I noticed a weak point: the program not only transforms our input but also transforms embedded data in the same way and compares the results. This means we do not need to fully reverse the custom “encryption”; we can break at the comparison path and recover the plaintext expected blocks.

First recovered block:

i7K9mQ2Zp4xYt6Vw

image

Then I re-tested with the first block fixed:

ACS{i7K9mQ2Zp4xYt6Vw????????????????????????????????????????????????????????????????}

When the program hit the next breakpoint, the entire flag was recovered.

image

The final admin password:

ACS{i7K9mQ2Zp4xYt6VwRj8Nf1Lcl0veDgXs9Tu4Pq7Wz2Ey6Kv1Mr8ivy5Bn0Gh3Cl98d9f9e9g939d0019}

image

[Qual] rev/Virtual

This challenge presented a custom Virtual Machine (VM) for reverse engineering. The goal was to understand the VM’s instruction set and workflow to analyze and break the program logic.

image

To approach this, I first spent some time studying the VM’s opcodes and instruction format. Once I had mapped out how each opcode worked, I wrote a script to disassemble the VM bytecode and make the analysis more systematic:

import struct
import sys

# Instruction names
OPCODES = {
    0x00: "MOV",
    0x01: "LOAD",
    0x02: "ADD",
    0x03: "SUB",
    0x04: "MUL",
    0x05: "DIV",
    0x06: "MOD",
    0x07: "AND",
    0x08: "OR",
    0x09: "XOR",
    0x0A: "ROL",
    0x0B: "ROR",
    0x0C: "RET",
    0x0D: "SHR",
    0x0E: "SHL",
    0x0F: "CMP",
    0x10: "JMP",
    0x11: "JL",
    0x12: "JLE",
    0x13: "JE",
    0x14: "JGE",
    0x15: "JG",
}

def format_operand(value, is_register=False):
    """Format operand value for display"""
    if value >= 0x80000000:
        # Indirect register reference (negative value)
        reg_idx = value - 0x80000000
        return f"*reg[{reg_idx}]"
    elif is_register or (value >= 256 and value < 51200):
        # Register index (0x100 = 256 is the first register)
        return f"reg[{value}]"
    elif value < 0:
        # Negative immediate
        return str(value)
    else:
        # Positive immediate
        return f"0x{value:x}" if value > 9 else str(value)

def disassemble_instruction(data, pc):
    """Disassemble a single instruction"""
    if pc >= len(data):
        return None, 0
    
    opcode = data[pc]
    pc += 1
    
    if opcode not in OPCODES:
        return f"UNKNOWN_OPCODE(0x{opcode:02x})", 1
    
    mnemonic = OPCODES[opcode]
    
    # RET only takes 1 operand
    if opcode == 0x0C:
        if pc + 4 > len(data):
            return f"{mnemonic} <incomplete>", 1
        operand = struct.unpack('<I', data[pc:pc+4])[0]
        return f"{mnemonic} {format_operand(operand)}", 5
    
    # JMP and conditional jumps take 1 operand (offset)
    if opcode >= 0x10:
        if pc + 4 > len(data):
            return f"{mnemonic} <incomplete>", 1
        offset = struct.unpack('<i', data[pc:pc+4])[0]
        return f"{mnemonic} {offset:+d}", 5
    
    # All other instructions take 2 operands
    if pc + 8 > len(data):
        return f"{mnemonic} <incomplete>", 1
    
    op1 = struct.unpack('<I', data[pc:pc+4])[0]
    op2 = struct.unpack('<I', data[pc+4:pc+8])[0]
    
    # Check if operands are negative (indirect addressing)
    op1_signed = struct.unpack('<i', data[pc:pc+4])[0]
    op2_signed = struct.unpack('<i', data[pc+4:pc+8])[0]
    
    # Special handling for LOAD (opcode 1) - second operand is always indirect
    if opcode == 0x01:
        op1_reg = op1_signed if op1_signed < 0 else op1
        op2_reg = op2_signed if op2_signed < 0 else op2
        if op1_signed < 0:
            op1_reg = op1_signed + 0x80000000
        if op2_signed < 0:
            op2_reg = op2_signed + 0x80000000
        return f"{mnemonic} {format_operand(op1, True)}, {format_operand(op2, True)}", 9
    
    # For MOV, first operand is destination register, second is source
    if opcode == 0x00:
        return f"{mnemonic} {format_operand(op1, True)}, {format_operand(op2, False)}", 9
    
    # For arithmetic/logic ops, first is destination register, second is source (can be reg or immediate)
    return f"{mnemonic} {format_operand(op1, True)}, {format_operand(op2, False)}", 9

def disassemble_program(filename):
    """Disassemble the entire VM program"""
    with open(filename, 'rb') as f:
        data = f.read()
    
    print(f"Program size: {len(data)} bytes")
    print(f"{'Address':<10} {'Instruction':<50}")
    print("-" * 60)
    
    pc = 0
    instruction_count = 0
    
    while pc < len(data):
        addr = pc
        instr, size = disassemble_instruction(data, pc)
        
        if instr is None:
            break
        
        print(f"0x{addr:08x}  {instr}")
        
        pc += size
        instruction_count += 1
    
    if pc < len(data):
        print(f"\n... ({len(data) - pc} bytes remaining)")

if __name__ == "__main__":
    filename = "program.txt"
    
    disassemble_program(filename)

Examining the disassembly, it became clear that the VM reads the input in blocks of 3 bytes. For each block, it applies a series of transformations, converting the 3-byte input into 4 output characters in a manner similar to Base64 encoding. The VM then checks these encoded values against a predefined reference string.

Input processing — 3 bytes loaded

; Byte 0: Shift left 16 bits
SHL *reg[512], 0x10          ; input[i] << 16
LOAD reg[515], reg[512]      ; Load input[i]
ADD reg[514], *reg[515]      ; accumulator += input[i] << 16
ADD reg[512], 1              ; i++  ← Increment 1

; Byte 1: Shift left 8 bits
SHL *reg[512], 8             ; input[i+1] << 8
LOAD reg[515], reg[512]      ; Load input[i+1]
ADD reg[514], *reg[515]      ; accumulator += input[i+1] << 8
ADD reg[512], 1              ; i++  ← Increment 2

; Byte 2: No shift
SHL *reg[512], 0             ; input[i+2] (no shift)
LOAD reg[515], reg[512]      ; Load input[i+2]
ADD reg[514], *reg[515]      ; accumulator += input[i+2]
ADD reg[512], 1              ; i++  ← Increment 3

Output generation — 4 bytes produced

; Extract chunk 1 (bits 18-23)
MOV *reg[513], *reg[514]     ; Copy 24-bit value
SHR *reg[513], 0x12          ; >> 18 bits
AND *reg[513], 0x3f          ; & 0x3f (6 bits)
ADD *reg[513], reg[768]      ; Lookup table 1
LOAD reg[515], reg[513]      ; Double indirect
LOAD reg[515], reg[515]
MOV *reg[513], *reg[515]     ; output[0] = table1[chunk1]
ADD reg[513], 1              ; output_ptr++  ← Output 1

; Extract chunk 2 (bits 12-17)
MOV *reg[513], *reg[514]     ; Copy 24-bit value again
SHR *reg[513], 0xc           ; >> 12 bits
AND *reg[513], 0x3f
ADD *reg[513], reg[832]      ; Lookup table 2
LOAD reg[515], reg[513]
LOAD reg[515], reg[515]
MOV *reg[513], *reg[515]     ; output[1] = table2[chunk2]
ADD reg[513], 1              ; output_ptr++  ← Output 2

; Extract chunk 3 (bits 6-11)
MOV *reg[513], *reg[514]     ; Copy 24-bit value again
SHR *reg[513], 6             ; >> 6 bits
AND *reg[513], 0x3f
ADD *reg[513], reg[896]      ; Lookup table 3
LOAD reg[515], reg[513]
LOAD reg[515], reg[515]
MOV *reg[513], *reg[515]     ; output[2] = table3[chunk3]
ADD reg[513], 1              ; output_ptr++  ← Output 3

; Extract chunk 4 (bits 0-5)
MOV *reg[513], *reg[514]     ; Copy 24-bit value again
SHR *reg[513], 0             ; >> 0 bits (no shift)
AND *reg[513], 0x3f
ADD *reg[513], reg[960]      ; Lookup table 4
LOAD reg[515], reg[513]
LOAD reg[515], reg[515]
MOV *reg[513], *reg[515]     ; output[3] = table4[chunk4]
ADD reg[513], 1              ; output_ptr++  ← Output 4

Final comparison:

1Nc1Ics+IhUjq5xHy7eCFCDQhTJUDvUkFrltS1c0op8v9P3m1Dh5IjJgngtjTNUp6btNwrERlBK7bVbpWQ0xNzq2

Initially, I attempted to reproduce the VM logic directly in Python but didn’t quite manage to get a working implementation.

However, since any specific group of 3 input bytes consistently maps to the same group of 4 output characters, it is possible to brute-force all possible 3-byte combinations and record the corresponding outputs as a lookup.

image

I wanted to set a breakpoint right after the Run function is called, capturing the memory region where the output string is generated.

Since the input length is 64 bytes, I needed to brute-force 21 * 21 * 21 = 9,261 inputs.

To automate the process, I wrote a Python script that controls GDB and collects the output mappings.

import gdb
import struct
import tempfile
import os
import itertools

# ===== config =====
BINARY    = "./prob"

# break right after call Run
BP_ADDR   = 0x000055555555533E

# memory region I want to dump
MEM_ADDR  = 0x000055555558B460
N_WORDS   = 88          # 88 * 4 bytes = 352

# Output file for results
OUTPUT_FILE = "dump_results.txt"

# Generate all combinations: 3 chars (0-9a-f) repeated 21 times + 'a'
def generate_inputs():
    hex_chars = '0123456789abcdef'
    inputs = []
    for combo in itertools.product(hex_chars, repeat=3):
        base = ''.join(combo)
        input_str = base * 21 + 'a'
        inputs.append(input_str)
    return inputs

INPUTS = generate_inputs()

# ==================

class AfterRunBreakpoint(gdb.Breakpoint):
    def __init__(self, output_file):
        super().__init__("*0x%x" % BP_ADDR, internal=False)
        self.output_file = output_file
        self.current_input = None
        self.got_dump = False

    def stop(self):
        """Called when breakpoint is hit."""
        inferior = gdb.selected_inferior()
        # read 88 * 4 bytes
        mem = inferior.read_memory(MEM_ADDR, N_WORDS * 4)

        # interpret as 32-bit little-endian words
        words = struct.unpack("<%dI" % N_WORDS, mem)

        print("[+] Dump at 0x%x:" % MEM_ADDR)
        for i, w in enumerate(words):
            if i % 8 == 0:
                print()
            print("0x%08x" % w, end=" ")
        print("\n")

        # Convert dump to ASCII string
        dump_string = ''.join(chr(w) if 32 <= w <= 126 else '?' for w in words)
        
        # Write to output file
        with open(self.output_file, 'a') as f:
            f.write(f"Input: {self.current_input}\n")
            f.write(f"Dump (hex): {' '.join('0x%08x' % w for w in words)}\n")
            f.write(f"Dump (ASCII): {dump_string}\n")
            f.write("-" * 80 + "\n")

        # Set flag that we got the dump
        self.got_dump = True
        
        # return False => don't stop, just continue execution automatically
        return False

def main():
    # no interactive questions like "Kill the program?"
    gdb.execute("set confirm off", to_string=True)
    # disable pagination to avoid "Type <RET> for more" prompts
    gdb.execute("set pagination off", to_string=True)
    gdb.execute("file %s" % BINARY, to_string=True)

    # Clear output file at start
    with open(OUTPUT_FILE, 'w') as f:
        f.write("=" * 80 + "\n")
        f.write("Auto Dump Results\n")
        f.write(f"Total inputs to test: {len(INPUTS)}\n")
        f.write("=" * 80 + "\n\n")

    # set breakpoint class
    bp = AfterRunBreakpoint(OUTPUT_FILE)

    for idx, s in enumerate(INPUTS):
        print("\n==============================")
        print(f"[*] Run #{idx+1}/{len(INPUTS)} with input: {s!r}")
        
        # Set the current input for the breakpoint
        bp.current_input = s
        bp.got_dump = False

        # write input to a temp file
        with tempfile.NamedTemporaryFile(delete=False) as f:
            f.write((s + "\n").encode())
            fname = f.name

        try:
            # run program with stdin redirected from the temp file
            # this is equivalent to: run < /tmp/whatever
            gdb.execute(f"run < {fname}", to_string=True)
            
            # If we got the dump, kill the process if it's still running
            if bp.got_dump:
                try:
                    inferior = gdb.selected_inferior()
                    if inferior.is_valid() and inferior.pid != 0:
                        gdb.execute("kill", to_string=True)
                except:
                    pass  # Process already exited
        except gdb.error as e:
            print(f"[!] Error running program: {e}")
        finally:
            os.unlink(fname)

    print(f"[*] All runs done. Results written to {OUTPUT_FILE}")
    print("[*] Quitting.")
    gdb.execute("quit")

main()

Result looks like:

Input: 000000000000000000000000000000000000000000000000000000000000000a
Dump (hex): 0x00000033 0x0000004c 0x00000073 0x00000052 0x00000030 0x00000031 0x00000032 0x00000072 0x00000062 0x0000006d 0x00000047 0x00000056 0x00000064 0x0000004a 0x00000032 0x00000054 0x0000007a 0x00000036 0x00000031 0x0000007a 0x0000006c 0x00000049 0x00000051 0x0000006c 0x00000044 0x00000041 0x00000065 0x0000006d 0x00000038 0x00000053 0x00000079 0x0000006a 0x00000064 0x00000032 0x00000057 0x00000054 0x00000053 0x0000004b 0x0000004c 0x00000059 0x00000056 0x0000007a 0x00000073 0x00000037 0x00000066 0x0000004e 0x0000004b 0x00000032 0x00000079 0x00000068 0x00000077 0x00000053 0x0000004c 0x00000074 0x00000068 0x00000049 0x00000056 0x0000002f 0x00000070 0x00000073 0x0000004c 0x00000059 0x00000063 0x00000031 0x00000046 0x00000032 0x00000045 0x00000076 0x00000050 0x00000043 0x0000004f 0x00000035 0x0000006a 0x0000004d 0x00000065 0x00000031 0x00000038 0x0000007a 0x00000072 0x0000004d 0x00000059 0x00000046 0x00000049 0x00000061 0x00000045 0x00000076 0x00000071 0x00000032
Dump (ASCII): 3LsR012rbmGVdJ2Tz61zlIQlDAem8Syjd2WTSKLYVzs7fNK2yhwSLthIV/psLYc1F2EvPCO5jMe18zrMYFIaEvq2
--------------------------------------------------------------------------------
Input: 001001001001001001001001001001001001001001001001001001001001001a
Dump (hex): 0x00000033 0x0000004c 0x00000073 0x00000069 0x00000030 0x00000031 0x00000032 0x00000059 0x00000062 0x0000006d 0x00000051 0x00000050 0x00000064 0x0000004a 0x00000052 0x0000006f 0x0000007a 0x00000036 0x00000031 0x00000069 0x0000006c 0x00000049 0x00000051 0x00000059 0x00000044 0x00000041 0x00000065 0x00000070 0x00000038 0x00000053 0x00000079 0x00000033 0x00000064 0x00000032 0x00000057 0x0000004a 0x00000053 0x0000004b 0x0000004c 0x00000035 0x00000056 0x0000007a 0x00000035 0x00000076 0x00000066 0x0000004e 0x0000004b 0x00000042 0x00000079 0x00000068 0x00000041 0x00000064 0x0000004c 0x00000074 0x00000056 0x00000042 0x00000056 0x0000002f 0x00000063 0x00000070 0x0000004c 0x00000059 0x00000050 0x00000031 0x00000046 0x00000032 0x00000045 0x00000072 0x00000050 0x00000043 0x00000077 0x00000056 0x0000006a 0x0000004d 0x00000031 0x00000068 0x00000038 0x0000007a 0x00000072 0x00000068 0x00000059 0x00000046 0x00000072 0x0000006e 0x00000045 0x00000076 0x00000071 0x00000032
Dump (ASCII): 3Lsi012YbmQPdJRoz61ilIQYDAep8Sy3d2WJSKL5Vz5vfNKByhAdLtVBV/cpLYP1F2ErPCwVjM1h8zrhYFrnEvq2
--------------------------------------------------------------------------------
Input: 002002002002002002002002002002002002002002002002002002002002002a
Dump (hex): 0x00000033 0x0000004c 0x00000035 0x0000004e 0x00000030 0x00000031 0x00000034 0x00000047 0x00000062 0x0000006d 0x00000051 0x00000074 0x00000064 0x0000004a 0x00000052 0x00000039 0x0000007a 0x00000036 0x0000006e 0x00000071 0x0000006c 0x00000049 0x0000004e 0x00000076 0x00000044 0x00000041 0x0000006e 0x00000042 0x00000038 0x00000053 0x00000063 0x00000064 0x00000064 0x00000032 0x0000004b 0x00000071 0x00000053 0x0000004b 0x0000004c 0x00000067 0x00000056 0x0000007a 0x00000030 0x00000076 0x00000066 0x0000004e 0x00000078 0x00000035 0x00000079 0x00000068 0x0000004f 0x00000053 0x0000004c 0x00000074 0x0000002b 0x00000049 0x00000056 0x0000002f 0x00000050 0x00000052 0x0000004c 0x00000059 0x00000063 0x00000054 0x00000046 0x00000032 0x00000056 0x00000078 0x00000050 0x00000043 0x00000069 0x00000078 0x0000006a 0x0000004d 0x0000004a 0x0000004c 0x00000038 0x0000007a 0x0000006a 0x00000062 0x00000059 0x00000046 0x00000066 0x00000042 0x00000045 0x00000076 0x00000071 0x00000032
Dump (ASCII): 3L5N014GbmQtdJR9z6nqlINvDAnB8Scdd2KqSKLgVz0vfNx5yhOSLt+IV/PRLYcTF2VxPCixjMJL8zjbYFfBEvq2

Once the dump was obtained, I could build a lookup table to map each expected output to its corresponding input as follows:


import re

# Target string to match
TARGET = "1Nc1Ics+IhUjq5xHy7eCFCDQhTJUDvUkFrltS1c0op8v9P3m1Dh5IjJgngtjTNUp6btNwrERlBK7bVbpWQ0xNzq2"

# Split target into groups of 4
def split_into_groups(s, n):
    return [s[i:i+n] for i in range(0, len(s), n)]

target_groups = split_into_groups(TARGET, 4)
print(f"[*] Target split into {len(target_groups)} groups of 4:")
for i, g in enumerate(target_groups):
    print(f"    Group {i}: {g}")
print()

# Parse dump_results.txt to build lookup table
# Map: (position, 4-char output) -> 3-char input
# Position-aware lookup since the encoding is stateful
lookup = {}  # {position: {output_pattern: input_pattern}}

print("[*] Parsing dump_results.txt...")
with open('dump_results.txt', 'r') as f:
    content = f.read()

# Split by separator
entries = content.split('-' * 80)

for entry in entries:
    if 'Input:' not in entry or 'Dump (ASCII):' not in entry:
        continue
    
    # Extract input
    input_match = re.search(r'Input: (\w+)', entry)
    if not input_match:
        continue
    input_str = input_match.group(1)
    
    # Extract the 3-char pattern (first 3 chars, since it repeats 21 times)
    if len(input_str) < 3:
        continue
    input_pattern = input_str[:3]
    
    # Extract dump ASCII
    ascii_match = re.search(r'Dump \(ASCII\): (.+)', entry)
    if not ascii_match:
        continue
    dump_ascii = ascii_match.group(1).strip()
    
    # Split dump into groups of 4 (first 21 groups = 84 chars)
    # Each input of 3 chars repeated 21 times produces 21 groups of 4 chars
    if len(dump_ascii) >= 84:
        dump_groups = split_into_groups(dump_ascii[:84], 4)
        
        # Store each position separately since encoding is position-dependent
        for pos, output_pattern in enumerate(dump_groups[:21]):
            if pos not in lookup:
                lookup[pos] = {}
            if output_pattern not in lookup[pos]:
                lookup[pos][output_pattern] = input_pattern

total_mappings = sum(len(lookup[pos]) for pos in lookup)
print(f"[*] Built position-aware lookup table with {len(lookup)} positions")
print(f"[*] Total mappings: {total_mappings}")
print()

# Now find the input
print("[*] Looking up each target group:")
result_parts = []
for i, target_group in enumerate(target_groups[:21]):  # Only need first 21 groups
    if i in lookup and target_group in lookup[i]:
        input_part = lookup[i][target_group]
        result_parts.append(input_part)
        print(f"    Group {i:2d}: {target_group} -> {input_part}")
    else:
        print(f"    Group {i:2d}: {target_group} -> NOT FOUND")
        result_parts.append("???")

print()
print("=" * 80)
print("[+] RESULT:")
result = ''.join(result_parts) + 'a'  # Add 'a' at the end
print(f"    {result}")
print("=" * 80)

# Write to file
with open('found_input.txt', 'w') as f:
    f.write(f"Target: {TARGET}\n")
    f.write(f"Input:  {result}\n")
    f.write("\n")
    f.write("Breakdown:\n")
    for i, (tg, ip) in enumerate(zip(target_groups[:21], result_parts)):
        f.write(f"  {i:2d}: {tg} <- {ip}\n")

print(f"[*] Result written to found_input.txt")

The script successfully produced found_input.txt:

Target: 1Nc1Ics+IhUjq5xHy7eCFCDQhTJUDvUkFrltS1c0op8v9P3m1Dh5IjJgngtjTNUp6btNwrERlBK7bVbpWQ0xNzq2
Input:  5bce98f73f3ed0c837f2729ed9509b38ea66a156db7f653356cb6fe37b366e8a

Breakdown:
   0: 1Nc1 <- 5bc
   1: Ics+ <- e98
   2: IhUj <- f73
   3: q5xH <- f3e
   4: y7eC <- d0c
   5: FCDQ <- 837
   6: hTJU <- f27
   7: DvUk <- 29e
   8: Frlt <- d95
   9: S1c0 <- 09b
  10: op8v <- 38e
  11: 9P3m <- a66
  12: 1Dh5 <- a15
  13: IjJg <- 6db
  14: ngtj <- 7f6
  15: TNUp <- 533
  16: 6btN <- 56c
  17: wrER <- b6f
  18: lBK7 <- e37
  19: bVbp <- b36
  20: WQ0x <- 6e8

Result: 5bce98f73f3ed0c837f2729ed9509b38ea66a156db7f653356cb6fe37b366e8a

At this point, I had constructed 63 characters (21 groups of 3), leaving just the final character to complete the required input. Since there was only one byte left to determine, I could simply brute-force this last value.

The complete solution is:

5bce98f73f3ed0c837f2729ed9509b38ea66a156db7f653356cb6fe37b366e85

image

[Final] scenario/Step2_Malicious

Description: Let’s analyze the malicious code and find out the address of the C2 server. In the process of finding the address of the C2 server, you can see something strange. “FLAG : ACS{…}”

The program emulated malware that decrypts a flag and a C2 server address.

image

The main decryption logic is in malware_main_decrypt_flag_and_c2 (0x401890).

image

It calls:

  • sub_4029E0(&unk_4CE0E0, 56, v109);
  • sub_4029E0(&unk_4CE0D8, 5, v101);
  • sub_4029E0(&unk_4CE0A0, 56, v102);

unk_4CE0A0, unk_4CE0D8, and unk_4CE0E0 are encrypted global blobs.

image

malware_main_decrypt_flag_and_c2 then:

  • Uses v101 as a small key to derive a seed.

  • Runs a PRNG over v109 to build a keystream v111, then XORs it with v109 to obtain v103.

  • v103 decrypts to the following ASCII string: ACS{7AekmeIgxZupZWG64VSWtJhL7FNHnQZmaxsmQUmGJyAcEC2suM} (which matches the ACS{...} format mentioned in the challenge description).

  • Then it takes v102 (from unk_4CE0E0) and XORs it byte-wise with v103 (as a repeating key) to get the final config string, which is the C2 address: 10.100.0.11.

Reproduction script:

def decrypt_sub_4029e0(buf: bytes) -> bytes:
    v6 = 123
    v7 = 1097289259
    out = []
    for idx, b in enumerate(buf):
        v9 = b
        v8 = idx
        # same v10 expression as decompiled
        v10 = v8 + 3 - ((v8 + 3) // 5
                       + (((0xCCCCCCCCCCCCCCCD * (v8 + 3)) >> 64) & 0xFFFFFFFC)) + 1
        # core mixing
        v12 = (v7 ^ v6) ^ (v9 - 45 - (v7 & 0x1F) - 9 * v8)
        r = v12 & 0xFF
        v10 &= 7  # rotate over 8-bit value
        r = ((r >> v10) | (r << (8 - v10))) & 0xFF
        v12 = (r - (v8 + v6)) & 0xFF
        v6 = v9 ^ v10
        out.append(v12)
        v7 = (-2048144789 * (v7 ^ (v9 + 40503))) & 0xFFFFFFFF
    if out:
        out[-1] = 0
    return bytes(out)


def strnlen(b: bytes, n: int) -> int:
    l = 0
    for i in range(min(len(b), n)):
        if b[i] == 0:
            break
        l += 1
    return l


def main():
    # encrypted blobs as dumped from IDA memory
    c_4CE0A0 = bytes([
        0x40, 0x31, 0xbe, 0x1f, 0x90, 0x38, 0x70, 0xf7,
        0xba, 0xe2, 0xf2, 0x2e, 0x74, 0xcd, 0x9e, 0xb2,
        0xf7, 0x44, 0x50, 0x3f, 0x25, 0x20, 0xc9, 0xdc,
        0x31, 0x88, 0x5b, 0x1c, 0x92, 0x6c, 0x19, 0x50,
        0x6f, 0x19, 0x09, 0xa8, 0x8c, 0xdb, 0x07, 0x5c,
        0x87, 0x90, 0xd0, 0x48, 0xaa, 0x5e, 0xd0, 0x97,
        0x63, 0x0b, 0x9c, 0xc5, 0x44, 0xbb, 0x81, 0x01,
    ])
    c_4CE0D8_first5 = bytes([0xd2, 0x6d, 0x98, 0xf4, 0x02])
    c_4CE0E0_first12 = bytes([
        0x26, 0xea, 0x72, 0xe8, 0xe6, 0x8a, 0xac, 0x0b,
        0x34, 0x72, 0xd5, 0xba,
    ])

    v109 = decrypt_sub_4029e0(c_4CE0A0)
    v101 = decrypt_sub_4029e0(c_4CE0D8_first5)
    v102 = decrypt_sub_4029e0(c_4CE0E0_first12)

    # derive v103 (flag) from v109 and v101, as in sub_401890
    v107 = bytearray(b"0000\x00")

    if v101[0] != 0:
        v12 = bytearray(v101)
    else:
        v12 = v107

    v14 = strnlen(v12, 16)
    if v14 == 0:
        v14 = 4
        v12 = v107

    v17 = (-1622266605) & 0xFFFFFFFF
    for idx in range(v14):
        ch = v12[idx]
        v18 = v17 ^ ch
        v19 = (-1514973411 + idx) & 0xFFFFFFFF
        v18 &= 0xFFFFFFFF
        v17 = (((v18 << 5) | (v18 >> (32 - 5))) + v19) & 0xFFFFFFFF

    v11 = strnlen(v109, 56)
    if v11 > 0x3F:
        v11 = 0x3F

    v111 = bytearray(v11)
    v21 = 0
    for i in range(v11):
        tmp = v17 ^ (v17 >> 13)
        v111[i] = ((16807 * tmp) >> 5) & 0xFF
        v17 = (16807 * tmp + v21) & 0xFFFFFFFF
        v21 += 127

    v103 = bytearray(v11 + 1)
    for i in range(v11):
        v103[i] = v111[i] ^ v109[i]
    v103[v11] = 0

    flag = bytes(v103).rstrip(b"\x00").decode("ascii")
    print("Flag:", flag)

    # second layer: decrypt C2 from v102 using v103 as repeating key
    v10 = strnlen(v102, 63)
    v102 = bytearray(v102)
    if v10:
        for j in range(v10):
            v102[j] ^= v103[j % v11]
        v102[v10] = 0

    c2 = bytes(v102).rstrip(b"\x00").decode("ascii")
    print("C2:", c2)


if __name__ == "__main__":
    main()

Output:

Flag: ACS{7AekmeIgxZupZWG64VSWtJhL7FNHnQZmaxsmQUmGJyAcEC2suM}
C2: 10.100.0.11

[Final] scenario/Step4_Rec0very

This challenge centered on a backdoor that encrypts its payload by generating a keystream derived from several inputs—including the username, client ID, session ID, salt, and other parameters.

image

image

The payload is encrypted via an XOR operation with this keystream, and all values required for keystream generation are conveniently embedded in the file name (e.g., ctf_342321803_200506211_1763569287.bin).

image

The magic header of the encrypted file is C2ST.

image

The keystream logic was fairly elaborate, but I managed to re-create the generation process in a script to decrypt and recover the original plaintext.

import struct
import sys

LOOKUP_TABLE = [
    0x13, 0x37, 0xC0, 0xDE, 0x42, 0xF0, 0x0D, 0xAC,
    0xBE, 0xEF, 0xBA, 0xAD, 0xF0, 0x0D, 0x12, 0x34,
]

def rol8(value, r_bits):
    """Rotate left within 64-bit space."""
    r_bits &= 63
    mask = 0xFFFFFFFFFFFFFFFF
    return ((value << r_bits) & mask) | (value >> (64 - r_bits))

def ror8(value, r_bits):
    """Rotate right within 64-bit space."""
    r_bits &= 63
    mask = 0xFFFFFFFFFFFFFFFF
    return (value >> r_bits) | ((value << (64 - r_bits)) & mask)

def parse_header(data):
    """Split the blob into header fields and ciphertext."""
    if data[:4] != b"C2ST":
        raise ValueError("Invalid magic header")

    version = data[4]  # noqa: F841 (not otherwise used)
    username_len = data[5]
    username = data[6: 6 + username_len]

    offset = 6 + username_len
    seed1, seed2, seed3, payload_size, checksum = struct.unpack(
        ">IIIII", data[offset: offset + 20]
    )
    ciphertext = data[offset + 20:]

    return {
        "username": username,
        "seed1": seed1,
        "seed2": seed2,
        "seed3": seed3,
        "payload_size": payload_size,
        "checksum": checksum,
        "ciphertext": ciphertext,
    }

def initialize_state(username, checksum):
    """Derive the initial internal state."""
    v18 = 0x8141414100000000
    v19 = 0x0282828200000000
    v47 = 0x671C8306FBCD9387

    if not username:
        return v18, v19, v47, checksum

    v43 = 0x811C9DC5
    for byte in username:
        v43 = (0x1000193 * (byte ^ v43)) & 0xFFFFFFFF

    v19 = (v43 << 33) & 0xFFFFFFFFFFFFFFFF
    v18 = (v43 << 32) & 0xFFFFFFFFFFFFFFFF
    v46 = (v43 ^ 0xAC0DF042DEC03713) & 0xFFFFFFFFFFFFFFFF
    tmp = rol8(v46, 16) ^ 0x6C7967656E657261
    v47 = rol8(tmp, 13)

    return v18, v19, v47, checksum

def generate_keystream(username, seeds, payload_size, checksum):
    """Produce the keystream bytes used for XOR decryption."""
    seed1, seed2, seed3 = seeds
    v18, v19, v47, v56_val = initialize_state(username, checksum)

    v22 = (v56_val | v18) & 0xFFFFFFFFFFFFFFFF
    v23 = (seed2 | (seed1 << 32)) & 0xFFFFFFFFFFFFFFFF
    v24 = (
        v19
        ^ v23
        ^ ((seed3 << 17) & 0xFFFFFFFFFFFFFFFF)
        ^ ((2 * v56_val) & 0xFFFFFFFFFFFFFFFF)
    )

    v25 = rol8(v24 ^ 0xDF629D27AEB35266, 3)
    v26 = rol8(seed3 ^ v24 ^ 0x507D7F91C3DE80D3, 7)

    tmp_v48 = rol8(v56_val ^ 0x34120DF0ADBAEFBE, 32) ^ 0x7465646279746573
    v48 = rol8(tmp_v48, 29)

    n19ll = 19
    keystream = bytearray()

    for i in range(payload_size):
        username_byte = username[i % len(username)] if username else 90

        t2 = rol8(v56_val, (i % 0xB) + 5)
        t3 = rol8(seed3, (i % 7) + 3)
        t4 = rol8(v22, (i % 0xD) + 1)

        mul_res = i * 0xF0F0F0F0F0F0F0F1
        high_64 = mul_res >> 64
        inner_term = (i // 0x11) + (high_64 & 0xF0)
        shift_v23 = (i - inner_term + 1) & 0xFF
        t5 = rol8(v23, shift_v23)

        mul_res2 = i * 0xCCCCCCCCCCCCCCCD
        high_64_2 = mul_res2 >> 64
        inner_term2 = (i // 5) + (high_64_2 & 0xFC)
        shift_n90 = (i - inner_term2 + 1) & 0xFF
        t6 = rol8(username_byte, shift_n90)

        v28 = n19ll ^ t2 ^ t3 ^ t4 ^ t5 ^ t6
        v29 = v26 ^ ((seed3 + v28) & 0xFFFFFFFFFFFFFFFF)
        v30 = rol8(v29, 14)

        v25 = rol8((v25 + v28 + v22) & 0xFFFFFFFFFFFFFFFF, 3)
        v26 = rol8(v29, 5)

        v31 = v48 ^ v26 ^ rol8(v22, 11)
        v32 = (v47 + v25 + rol8(v23, i % 0x13)) & 0xFFFFFFFFFFFFFFFF

        v47 = rol8(v32, 7)
        v48 = rol8(v31, 11)

        v33 = (
            v25
            ^ v30
            ^ rol8(v28, (i % 0x17) + 1)
            ^ ror8(v31, 30)
            ^ rol8(v32, 24)
        )

        keystream_byte = (v33 >> (8 * (i & 7))) & 0xFF
        keystream.append(keystream_byte)

        mul_res3 = i * 0x0842108421084211
        v35 = mul_res3 >> 64

        tmp_v22 = (v22 ^ (v33 + payload_size + v28)) & 0xFFFFFFFFFFFFFFFF
        v22 = rol8(tmp_v22, (i % 29) + 1)

        term_v23 = ((((i - v35) >> 1) + v35) >> 4)
        shift_v23_2 = (i - 31 * term_v23 + 1) & 0xFF
        v23 = rol8(v22 ^ v23 ^ keystream_byte, shift_v23_2)

        if i < payload_size - 1:
            n19ll = LOOKUP_TABLE[(i + 1) & 0xF]

    return keystream

def decrypt_file(filepath):
    with open(filepath, "rb") as handle:
        data = handle.read()

    try:
        header = parse_header(data)
    except ValueError as exc:
        print(f"[-] {exc}")
        return

    username = header["username"]
    payload_size = header["payload_size"]
    ciphertext = header["ciphertext"]

    if len(ciphertext) != payload_size:
        print(
            f"[-] Warning: Payload size mismatch. Expected {payload_size}, "
            f"got {len(ciphertext)}"
        )

    print(f"[+] Username: {username}")
    print(f"[+] Seeds: {header['seed1']}, {header['seed2']}, {header['seed3']}")
    print(f"[+] Payload Size: {payload_size}")

    keystream = generate_keystream(
        username,
        (header["seed1"], header["seed2"], header["seed3"]),
        payload_size,
        header["checksum"],
    )

    plaintext = bytearray(k ^ c for k, c in zip(keystream, ciphertext))
    out_filename = filepath + ".decrypted"

    with open(out_filename, "wb") as handle:
        handle.write(plaintext)

    print(f"[+] Decrypted saved to: {out_filename}")

if __name__ == "__main__":
    decrypt_file("ctf_342321803_200506211_1763569287.bin")

Result:

image

HOME
flag
8ACS{srBebksi59pTd95PDFNnRwQy1GU3gq1aC0SB7X54wFxfCuUvPf}

[Final] rev/pumpguardian

Description: Analyze the water treatment plant’s pump control program. Secrets hidden within the system are usually hidden.

This is a Rust TUI application that emulates a pump control system. The program allows us to start, stop, and adjust the pump’s speed via user commands.

image

The application maintains a history capable of storing up to 12 recent commands before resetting.

Once the list of 12 recent commands (e.g., start, stop, set 50) is filled, the program parses each one into a tag and a parameter, then applies a transformation to both.

image

After processing, the transformed tags and parameters are compared against hardcoded reference values.

image

To bypass this verification, I needed to patch the appropriate values so that my transformed input matched the expected hardcoded ones.

image

image

Once patched, I pressed F9 to continue program execution and obtain the flag.

image

Flag: ACS{R4taTu1i_Is_a_B2sT!!!}

[Final] rev/Silent AIS

Description: An AIS monitoring system simulates vessel traffic at sea. But something seems to have gone terribly wrong with one of the ships… can you help?

The program emulated an AIS vessel monitoring system, displaying each ship’s timestamp, name, status, and coordinates in the terminal. Our task was to “rescue” any ship that appeared with the status ‘Not under command’.

image

As soon as a vessel showed this status, I took note of its timestamp and coordinates.

image

Then, I selected option 2 in the menu to attempt the rescue.

image

To pass the rescue checks, the program verifies the timestamp, location, and a special ticket value. I retrieved the required ticket—000000000000000002CF8AEE359B44ED—by examining the debugger (detailed below).

image

With the correct timestamp and coordinates, most checks should pass; however, due to floating-point operations affecting the timestamp and coordinates comparison, I had to manually patch certain registers to ensure the check was bypassed successfully.

image

Specifically, patching the relevant values ensured that variables like v257 matched TARGET_GRID_ID and is_timestamp_bad was zero. Next comes the ticket verification. By inspecting the rsi and rcx registers, I identified our target ticket value as 000000000000000002CF8AEE359B44ED.

image

image

Once all required checks were satisfied—and any obstacles patched as necessary—the flag was finally displayed:

image

Flag: ACS{h0p3_15_7he_th1n9_w1th_s1gn4l5_nev3r_ce4s3}


Conclusion

ACS 2025 was the perfect conclusion to this three-year journey. Achieving 1st place in Busan was a team effort, and I’m incredibly proud of what KMA.LightBlue accomplished. Thanks to KISA and the organizers for a great series of events!