← Back to Writeups
CUSTOMMediumReversing

Угадайка (Guessing Game)

XESXOR9/2/20265 min read
#reversing#custom#medium#control_flow_analysis#custom_charset_mapping#rust_binary_reversing#unicode_codepoint_summing#xor_password_check#linux

Угадайка (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
$ 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:

  1. For each block: reads N bytes of UTF-8 data from specific offsets
  2. Sums all Unicode codepoints in the block
  3. Computes sum % 65 to get an index
  4. The index maps to a character via the table:
IndexCharacter
0-25a-z
26-51A-Z
52-610-9
62{
63_
64}
  1. Result — a 50-character flag

Solution

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoding Script

python
#!/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:

  1. Patch the password check (NOP the conditional jump)
  2. Patch the counter check (replace jle with jmp)
  3. Run the binary and get the flag directly
bash
# 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

  1. Full dynamic analysis: run under GDB with breakpoint on handle_error, extract flag from memory after decoding
  2. Binary patching: bypass all checks (password, counter) via NOP/JMP patches
  3. Emulation: use Unicorn Engine to emulate only the handle_error function with proper context
  4. Frida hooking: intercept the handle_error call 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 rand crate 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 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍