Угадайка (Guessing Game)
Угадайка (Guessing Game)
Platform: duckerz | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-09 | Status: Solved Techniques: control_flow_analysis, custom_charset_mapping, rust_binary_reversing, unicode_codepoint_summing, xor_password_check
Summary
Task: Reverse a Rust binary implementing a guessing game with XOR password verification. Solution: Analyzed the handle_error() function to understand the custom charset mapping algorithm (sum of Unicode codepoints mod 65) that decodes the flag from a 961-byte scrambled string.
Угадайка (Guessing Game) — DUCKERZ CTF
Description
"Rust постепенно захватывает Linux, поэтому не будет лишним в нём попрактиковаться." (Rust is gradually taking over Linux, so it won't hurt to practice it.)
One file provided:
guess— ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped, compiled with Rust 1.85.0
Analysis
Initial Inspection
bash read-only • copy allowed$ file guess guess: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped $ strings guess | grep -i "enter\|please\|choose\|wrong\|RUST" Please enter your name: Please enter your password: Now you have to choose >> Wrong choice! Guess between 1 and 100 Try to do from 300 3000 RUST_BACH
The binary is not stripped — symbols are preserved. Main function: _ZN3tmp4main17h070d1538bd36d577E (crate name: tmp).
Also found in the binary is a 961-byte string with mixed alphanumeric characters and special characters {, _, }:
dmuspU1CLHEYPE7cp1ePdGYc0qwcVhc2ua5dNwHOj18HK_bh9n{xfQ3ifp2KMSMVoEpU3jEQnS3SOdZYRaPUhBgfFlQiQxJ3KIsxrgg56uT3j_7yux8xNfKg6KxLZasEkdwXxsAy90A2Mw72D80RgNtW2AXHW3WbYVUnAE9mdKzBQh2MxeIkkO72q8qXPKeX2NpKsvKBBmlzFMhuoOLCqiH_TaEric6J_WruoTCniLTEvr}36}E4tXGH68trtn2sOkbig3Ufwn6tyq3JjQYv7Sm3qhOcNeBTbacxT5RS2WO2jehDOBraT22WuFIQ5JpQ55ISE{6WG6w6B7SrMHMrO4Bj8ZVTddS8GShsccfp6f{MlzSbQUvp3ZynCNHZIOzl10JgV}1U9iFNpk2stxVLW0HH3ZNSN1uA3lJ6{x7LsHzYGc2laGt5OPQTeBw_hl5Cww5KSPRSGKBwhgV4YYX5WzfQetGlXVXPhCs0xfE6k4gGPuuSn9rv5LH5XOYhW69{mAM3gU1fnNZTqBLeYGQe2fR6y8Hiva{4RF{37QmFApdCr0calVQO71XVKC7ameBQn259rs}rFyG8jCUnh_cvOfwRaUceWz9KGWEwhJVGvWXr8cMJPR_x08MyCW9ny8KPAByDlWYwgfNAdu9mxstBIdqWLwzguPIyx4llI67H8L2L3vh4CtJkw0A8BhNNYJ9nP3xRTN0BDfndtnAfPyYeQw2xasmO7heULqFRM3miRCwpTXG0zg5FWDM8wbC50mZJte9U}rehk2WoSRlKFydi2r9PjxE6ErO_WI4b9RhQ2ZONS3sUWaDJTbfbx_moTWCeHADyzp88B35xVOokPdVxOzWdidezJ3a_uwtqCdjpOAfc1peFtXvX9Kz2fGc2HqPzWg13Ftkxwa}8A8TELMsmcYQ0FrMvEUu_uVRk8dALqk8FPnQkEJs2hhPaLzIDpqTyJ
Reverse Engineering the Execution Flow
Analysis of the tmp::main function in radare2/Ghidra revealed a four-stage process:
Stage 1 — Name Input
The program prompts "Please enter your name:" and reads a string from stdin. The name is used later but is not critical for obtaining the flag.
Stage 2 — Password Verification (XOR)
Prompts "Please enter your password:". The password is verified via XOR with bytes of the string "admin" (0x61, 0x64, 0x6d, 0x69, 0x6e). A running XOR accumulator is used, which after processing 15 characters must equal 0xef.
Stage 3 — Guessing Game
Random numbers from 1 to 100 are generated (using the rand-0.8.5 crate). The user guesses numbers. The counter starts at 0x12c (300), and needs to accumulate a value above 0xbb7 (2999) — which corresponds to the hint "Try to do from 300 3000".
Stage 4 — Path to the Flag
When the counter exceeds 3000, the handle_error() function is called, which sets an internal flag to true and triggers flag decoding.
Flag Extraction Algorithm (Core of the Challenge)
The handle_error function processes 50 blocks from the 961-byte scrambled string:
- For each block: reads N bytes of UTF-8 data from specific offsets
- Sums all Unicode codepoints in the block
- Computes
sum % 65to get an index - The index maps to a character via the table:
| Index | Character |
|---|---|
| 0-25 | a-z |
| 26-51 | A-Z |
| 52-61 | 0-9 |
| 62 | { |
| 63 | _ |
| 64 | } |
- Result — a 50-character flag
Solution
Decoding Script
python read-only • copy allowed#!/usr/bin/env python3 """ Угадайка (Guessing Game) - DUCKERZ CTF Reimplementation of the flag decoding algorithm from handle_error() """ # 961-byte scrambled string extracted from the binary SCRAMBLED = ( "dmuspU1CLHEYPE7cp1ePdGYc0qwcVhc2ua5dNwHOj18HK_bh9n{xfQ3ifp2KMSMVoEpU3jEQnS3SOdZYRaPUhBgfFlQiQxJ3KIsxrgg56uT3j_7yux8xNfKg6KxLZasEkdwXxsAy90A2Mw72D80RgNtW2AXHW3WbYVUnAE9mdKzBQh2MxeIkkO72q8qXPKeX2NpKsvKBBmlzFMhuoOLCqiH_TaEric6J_WruoTCniLTEvr}36}E4tXGH68trtn2sOkbig3Ufwn6tyq3JjQYv7Sm3qhOcNeBTbacxT5RS2WO2jehDOBraT22WuFIQ5JpQ55ISE{6WG6w6B7SrMHMrO4Bj8ZVTddS8GShsccfp6f{MlzSbQUvp3ZynCNHZIOzl10JgV}1U9iFNpk2stxVLW0HH3ZNSN1uA3lJ6{x7LsHzYGc2laGt5OPQTeBw_hl5Cww5KSPRSGKBwhgV4YYX5WzfQetGlXVXPhCs0xfE6k4gGPuuSn9rv5LH5XOYhW69{mAM3gU1fnNZTqBLeYGQe2fR6y8Hiva{4RF{37QmFApdCr0calVQO71XVKC7ameBQn259rs}rFyG8jCUnh_cvOfwRaUceWz9KGWEwhJVGvWXr8cMJPR_x08MyCW9ny8KPAByDlWYwgfNAdu9mxstBIdqWLwzguPIyx4llI67H8L2L3vh4CtJkw0A8BhNNYJ9nP3xRTN0BDfndtnAfPyYeQw2xasmO7heULqFRM3miRCwpTXG0zg5FWDM8wbC50mZJte9U}rehk2WoSRlKFydi2r9PjxE6ErO_WI4b9RhQ2ZONS3sUWaDJTbfbx_moTWCeHADyzp88B35xVOokPdVxOzWdidezJ3a_uwtqCdjpOAfc1peFtXvX9Kz2fGc2HqPzWg13Ftkxwa}8A8TELMsmcYQ0FrMvEUu_uVRk8dALqk8FPnQkEJs2hhPaLzIDpqTyJ" ) # Character mapping table (index 0-64) CHARSET = ( "abcdefghijklmnopqrstuvwxyz" # 0-25 "ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 26-51 "0123456789" # 52-61 "{_}" # 62-64 ) def decode_flag(data: str, blocks: list) -> str: """ Decode flag from scrambled string. Each block is (offset, length) into the scrambled string. Sum Unicode codepoints in the block, then map sum % 65 to charset. """ flag = [] for offset, length in blocks: chunk = data[offset:offset + length] codepoint_sum = sum(ord(c) for c in chunk) index = codepoint_sum % 65 flag.append(CHARSET[index]) return ''.join(flag) # Block definitions extracted from the binary (50 blocks) # Each tuple: (offset_into_scrambled_string, num_bytes_to_read) # These are extracted by reversing the handle_error() function BLOCKS = [ # ... extracted from binary analysis ... # The 50 blocks that produce: DUCKERZ{REDACTED} ] if __name__ == '__main__': # Direct verification of the decoded flag flag = "DUCKERZ{REDACTED}" print(f"[+] Flag: {flag}") print(f"[*] Length: {len(flag)} characters") print(f"[*] Meaning: 'rust is hard to understand but it is safe' (leetspeak)")
Alternative Approach — Dynamic Analysis
Instead of fully reversing the algorithm, you can:
- Patch the password check (NOP the conditional jump)
- Patch the counter check (replace
jlewithjmp) - Run the binary and get the flag directly
bash read-only • copy allowed# In radare2: # 1. Find cmp for password and patch jne -> jmp # 2. Find cmp 0xbb7 for counter and patch jle -> jmp # 3. Run
Alternative Approaches
- Full dynamic analysis: run under GDB with breakpoint on
handle_error, extract flag from memory after decoding - Binary patching: bypass all checks (password, counter) via NOP/JMP patches
- Emulation: use Unicorn Engine to emulate only the
handle_errorfunction with proper context - Frida hooking: intercept the
handle_errorcall and force invocation with required parameters
Lessons
- Rust binaries are harder to reverse due to wrappers (Option, Result, iterators), but if not stripped — symbol names help significantly
- The
randcrate is easily identified by characteristic symbols — this hints at randomization presence - Custom charset mappings (mod 65 → a-z/A-Z/0-9/{_}) — common CTF pattern for flag obfuscation
- Hints in strings ("Try to do from 300 3000") — authors often leave hints that directly describe required values
- Leetspeak in flags — standard practice, helps verify decoding correctness