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 from libc

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:

  1. Prompts the user to enter a string.
  2. Spawns a new thread to transform the user input.
  3. Receives back transformed bytes from the thread.
  4. Compares those transformed bytes to a hardcoded list.
  5. If all bytes match, it prints a success message.
  6. 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.

Inside shinyclean2::a:

  • It receives the input string one byte at a time via a Rust channel.
  • It keeps track of a state byte (initial value is 0x75).
  • 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.
  • 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:

  1. The target output that the program is expecting.
  2. The 256-byte transformation table it uses.
  3. 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 to 0x7e)
    • 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

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! 🫑