DawgCTF Shinyclean Rust Remover Pro - Pez1181/CTF GitHub Wiki
π§Ό shinyclean_pro β DawgCTF 2025 (Reverse Challenge)
π Challenge Type: Reverse Engineering
π§ Mechanism: Stateful transformation via a lookup table
π§ Skills Tested: Ghidra navigation, byte-level reversing, basic Rust internals
ποΈ Overview
This binary is a 64-bit Rust program that asks you to enter a "challenge phrase." If you enter the correct one, it tells you that you win. If not, it says βLoser! Try again?β
At first glance, this seems like a black-box guessing game β but since it's a reverse engineering challenge, we're expected to analyze the binary, understand how the input is validated, and work backwards to recover the correct input.
π Initial Analysis
We start by identifying the main function using:
nm -C shinyclean_pro | grep main
This shows us several entries, including:
shinyclean2::main
shinyclean2::main::{{closure}}
- A wrapper
main
fromlibc
We then open the binary in Ghidra, navigate to shinyclean2::main
, and begin decompiling.
π§ What the Program Does
From the decompiled Rust code, we learn that the program:
- Prompts the user to enter a string.
- Spawns a new thread to transform the user input.
- Receives back transformed bytes from the thread.
- Compares those transformed bytes to a hardcoded list.
- If all bytes match, it prints a success message.
- If not, it prints βLoser!β
This gives us a clear path: we need to reverse the transformation, so we can figure out what input string will yield the expected transformed output.
π¬ Digging Into the Transformation Logic
The actual transformation logic is in the function shinyclean2::a
, which is called by the thread.
shinyclean2::a
:
Inside - It receives the input string one byte at a time via a Rust channel.
- It keeps track of a
state
byte (initial value is0x75
). - For each input byte:
- It adds the byte to the
state
. - Then uses the new
state
as an index into a 256-byte lookup table. - The byte at that index is the transformed output.
- It adds the byte to the
- This transformed byte is sent back to the main thread.
In summary:
state = 0x75;
for each input_byte:
index = (state + input_byte) % 256;
output = table[index];
state += input_byte;
So, even if you type the same character twice, the result will be different because the state
keeps changing!
π― What We Need To Solve It
To find the correct input (aka the flag), we need:
- The target output that the program is expecting.
- The 256-byte transformation table it uses.
- A script that reverses the transformation logic.
π§Ύ The Target Output
The program compares the 21 transformed bytes to a hardcoded array. We extracted this from the main
function as:
target = [
0xea, 0xd9, 0x31, 0x22, 0xd3, 0xe6, 0x97, 0x70,
0x16, 0xa2, 0xa8, 0x1b, 0x61, 0xfc, 0x76, 0x68,
0x7b, 0xab, 0xb8, 0x27, 0x96
]
This is what our input, after going through the transformation logic, must match.
π¦ The Lookup Table
The transformation table is stored at memory address 0x00161298
. We used Ghidra to dump all 256 bytes into a Python list (abbreviated here for space):
table = [0x9f, 0xd2, 0xd6, 0xa8, 0x99, 0x76, 0xb8, 0x75, ...]
This table is constant, and maps state
values to transformation outputs.
π Solver Script
We wrote a script that:
- Starts with
state = 0x75
- For each byte in the
target
:- Tries every printable ASCII character (
0x20
to0x7e
) - Checks if
(state + candidate_byte) % 256
matches the target byte - If it does, we know we found a valid input byte
- We add it to the flag, update
state
, and move on
- Tries every printable ASCII character (
Hereβs the full script:
# target output values from acStack_a5
target = [
0xea, 0xd9, 0x31, 0x22, 0xd3, 0xe6, 0x97, 0x70,
0x16, 0xa2, 0xa8, 0x1b, 0x61, 0xfc, 0x76, 0x68,
0x7b, 0xab, 0xb8, 0x27, 0x96
]
# 256-byte transformation table from Ghidra
table = [0x9f, 0xd2, 0xd6, 0xa8, 0x99, 0x76, 0xb8, 0x75, 0xe2, 0x0e, 0x50, 0x67, 0xc9, 0x3a, ...] # truncated for readability
# Reconstruct original input (aka the flag)
flag = []
state = 0x75
for expected in target:
for b in range(0x20, 0x7f): # Try printable ASCII
if table[(state + b) % 256] == expected:
flag.append(b)
state = (state + b) % 256
break
else:
flag.append(ord('?')) # fallback if nothing matches
print("Flag:", bytes(flag).decode())
π Recovered Flag
DawgCTF{S0lUt10n_N0t_St4t1c}
(Redacted: DawgCTF{S0lUt10n_N0t_St4t1c}
)
π‘ What You Learned
- How to trace control flow in a Rust binary
- How to extract and interpret memory-stored constants
- How to recognize and reverse a stateful transformation algorithm
- How to script a solution using just logic and iteration β no brute force needed!
This was a clever challenge that rewarded patient reverse engineering over guessing. By reading the program flow, extracting the table, and mimicking the transformation logic in Python, we cracked it cleanly.
Shiny work! π«‘