← Back to Writeups
CUSTOMEasyReversing

Slowpoke

XESXOR9/10/20264 min read
#reversing#custom#easy#deterministic_binary_execution#ld_preload_function_stub#ptrace_antidebug_bypass#signed_char_modulo#static_emulation#xor_identity_collapse#linux

Slowpoke

Platform: rev_kids20 | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-25 | Status: Solved Techniques: deterministic_binary_execution, ld_preload_function_stub, ptrace_antidebug_bypass, signed_char_modulo, static_emulation, xor_identity_collapse

Summary

Task: x86-64 ELF that takes no input, deterministically builds and prints a flag while stalling with sleep() and guarding with ptrace(PTRACE_TRACEME). Solution: stub out sleep()/ptrace() with an LD_PRELOAD shim and just run it (or fully emulate the XOR/signed-char-modulo pipeline in Python) to recover the printed flag.

Slowpoke — Reverse Kids 2.0 (rev_kids20)

Description

Slowpoke https://rev-kids20.forkbomb.ru/files/rev/re7/slowpoke.elf

A 64-bit Linux ELF binary that takes no input. It deterministically generates a fixed string and prints it as flag_is_<payload>. The name "Slowpoke" hints at the many sleep() calls that stall execution between every step. Goal: recover the printed flag.

Analysis

file / checksec summary:

  • ELF 64-bit LSB, x86-64, dynamically linked, not stripped, GCC (Ubuntu 5.4.0) / glibc.
  • BuildID 9d14a1ab27788540945649dd0f2eae99f4b71cbc.
  • Imports of interest: puts, strlen, strcat, ptrace, sleep, random.
  • Notable strings: Go out, dirty debugger!, flag_is_.
  • The only function of interest is main at 0x4006f6. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Full algorithm reverse-engineered from main

  1. Anti-debug. ptrace(PTRACE_TRACEME, 0, 1, 0) (call at 0x40080a). If the return value is non-zero (process already traced), it prints Go out, dirty debugger! and exits with code 1. Otherwise it continues.
  2. A 32-byte buffer buf (at rbp-0x120) is zeroed.
  3. Init loop (0x4008a2): buf[i] = i for i = 0..31.
  4. XOR-with-index loop (0x400917, i from 31 down to 0): buf[31-i] ^= i. Since buf[31-i] currently equals (31-i), and (31-i) ^ i == 31 for every i in 0..31 (because 31 = 0b11111, so 31-i == 31^i with no borrow), every byte collapses to 0x1f (31). A neat constant-folding trick that hides the buffer's real dependency.
  5. XOR-with-key loop (0x4009a9, i from 31 down to 0): buf[31-i] ^= key[i], where key is 32 hardcoded bytes at rbp-0x160..rbp-0x141. Net effect: buf[j] = 31 ^ key[31-j].
  6. Lowercase-mapping loop (0x400a48, i = 0..31): if the byte (read as a signed char) is not in ('a'..'z'] (i.e. not 0x60 < c <= 0x7a), then buf[i] = (c % 26) + 'a'. Because char is signed on x86-64 GCC, the modulo can yield negative remainders, which is why some output characters fall below 'a' (e.g. 0x5b '[', 0x5d ']') — an (almost certainly unintended) signed-char bug that the shipped binary nonetheless relies on.
  7. buf[31] = 0 — null terminator (0x400b1c).
  8. The 8-byte literal flag_is_ (stored via movabs 0x5f73695f67616c66) is reversed in place (0x400b4a).
  9. strcat(buf, reversed("flag_is_")) (0x400c4e) appends the reversed prefix.
  10. The whole concatenated buffer is reversed again in place (0x400c62). Net result: the printed string = "flag_is_" + reverse(payload).
  11. puts(buf) prints the final string. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Why the noise doesn't matter

Every step is separated by sleep(random()%1000 + 500) (the random()%1000 uses the 0x20c49ba5e353f7cf divide-by-1000 magic constant). These are pure delays. random() is never seeded, so glibc uses its default seed 1 and the output is fully deterministic across runs. The sleeps and randomness are decoys — they have zero effect on the flag.

Hardcoded key (index 0..31)

fb 4b ba 76 77 ed 9d e5 02 d1 9a b1 62 97 53 c0
82 c8 fd ec 12 b9 a4 c9 ce 0c ec 16 dd a5 7e b8

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

Solution

Because the output depends only on hardcoded data, the fastest path is to just run the binary with the delays and anti-debug stubbed out.

Approach A — LD_PRELOAD stub + run (fastest)

stub.c:

c
#include <unistd.h>
unsigned int sleep(unsigned int s){ return 0; }   /* skip all delays */
long ptrace(int a, ...){ return 0; }               /* defeat PTRACE_TRACEME */

Run under Docker on linux/amd64 with ubuntu:16.04 (matches the GCC 5.4.0 build environment):

bash
curl -o slowpoke.elf https://rev-kids20.forkbomb.ru/files/rev/re7/slowpoke.elf
docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w ubuntu:16.04 bash -c \
  "gcc -shared -fPIC -o stub.so stub.c; chmod +x slowpoke.elf; LD_PRELOAD=/w/stub.so ./slowpoke.elf"
# -> flag_is_REDACTED

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

The ptrace stub returns 0 so the anti-debug check passes; the sleep stub makes the otherwise-minutes-long run finish instantly.

Approach B — full static emulation in Python

Reproduces the identical output without running the binary, capturing the crucial signed-char % 26 semantics:

python
#!/usr/bin/env python3
import ctypes
c8 = lambda x: ctypes.c_int8(x).value   # interpret byte as signed char

key = [0xfb,0x4b,0xba,0x76,0x77,0xed,0x9d,0xe5,0x02,0xd1,0x9a,0xb1,0x62,0x97,0x53,0xc0,
       0x82,0xc8,0xfd,0xec,0x12,0xb9,0xa4,0xc9,0xce,0x0c,0xec,0x16,0xdd,0xa5,0x7e,0xb8]
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

buf = [i for i in range(32)]
for i in range(31, -1, -1):           # (31-i) ^ i == 31 -> all bytes become 31
    buf[31 - i] ^= i
for i in range(31, -1, -1):           # XOR with reversed key
    buf[31 - i] ^= key[i]
for i in range(32):                   # signed-char % 26 + 'a' for non-lowercase
    v = c8(buf[i] & 0xff)
    if not (0x60 < v <= 0x7a):
        q = int(v / 26)               # C truncates toward zero
        buf[i] = (c8(v - q*26) + 0x61) & 0xff

ft = list(b"flag_is_")[::-1]          # reverse prefix
full = (buf[:31] + ft)[::-1]          # append, then reverse whole
print(bytes(full).decode())           # flag_is_REDACTED

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

The accepted flag on the platform is the payload only — the flag_is_ prefix is just a hint. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍