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.

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.

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:

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}

[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.

init_expected_values()initializes 69 target bytes at0x55555555A086.

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


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

The solving strategy was straightforward once the transformation was identified:
- Extract all expected bytes from
0x55555555A086. - 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).
- Undo the XOR with
- 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

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
EncryptionRound2plus extra byte mixing.



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

Then I re-tested with the first block fixed:
ACS{i7K9mQ2Zp4xYt6Vw????????????????????????????????????????????????????????????????}
When the program hit the next breakpoint, the entire flag was recovered.

The final admin password:
ACS{i7K9mQ2Zp4xYt6VwRj8Nf1Lcl0veDgXs9Tu4Pq7Wz2Ey6Kv1Mr8ivy5Bn0Gh3Cl98d9f9e9g939d0019}

[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.

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.

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

[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.

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

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.

malware_main_decrypt_flag_and_c2 then:
Uses
v101as a small key to derive a seed.Runs a PRNG over
v109to build a keystreamv111, then XORs it withv109to obtainv103.v103decrypts to the following ASCII string:ACS{7AekmeIgxZupZWG64VSWtJhL7FNHnQZmaxsmQUmGJyAcEC2suM}(which matches theACS{...}format mentioned in the challenge description).Then it takes
v102(fromunk_4CE0E0) and XORs it byte-wise withv103(as a repeating key) to get the final config string, which is theC2 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.


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

The magic header of the encrypted file is C2ST.

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:

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.

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.

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

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


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

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’.

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

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

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

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.

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.


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

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!