← Back to Writeups
CUSTOMMediumReversing

Randser

XESXOR9/10/20263 min read
#reversing#custom#medium#static_analysis#prng_stream_reconstruction#serial_formula_derivation#xor_reduction

Randser

Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-03-31 | Status: Solved Techniques: static_analysis, prng_stream_reconstruction, serial_formula_derivation, xor_reduction

Summary

Task: a 32-bit ELF asks for a serial matching a generated username and UID, then prints a flag. Solution: reverse the PRNG-based login/UID generation, derive the serial formula from the XOR-plus-7 check, and recover the real flag by XOR-reducing 100 hardcoded 16-byte strings.

Randser — spbctf

Challenge

Enter the correct serial for your username, and program will output flag.

Binary: tasks/spbctf/Randser/randser.elf

Source: https://rev-kids20.forkbomb.ru/files/rev/re3/randser.elf

The binary generates a random-looking username and UID, asks for a serial, and prints the flag only after validation. The main goals are to understand the serial algorithm and verify whether the flag depends on the supplied serial.

Recon

Initial triage showed:

  • ELF 32-bit i386
  • dynamically linked
  • not stripped
  • imports include srand, rand, time, sprintf, and strcmp ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The most relevant functions were:

  • gen_login
  • gen_uid
  • check
  • print_flag

That already suggests a classic pattern: generate some pseudo-random values, transform them into an input-dependent serial, then compare with user input.

Reversing

gen_login seeds the PRNG with time(NULL) and creates the username as:

c
srand(time(NULL));
sprintf(login, "user%u", rand());

gen_uid does not reseed anything. It continues the same PRNG stream and emits one decimal digit for each login character: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

c
for each login[i]:
    uid[i] = '0' + (rand() % 10)

So the login and UID are both derived from the same rand() sequence.

The real serial check is inside check(login, uid, serial). For each position, the program computes:

text
byte = (login[i] XOR uid[i]) + 7

Then it appends that byte as lowercase hexadecimal using sprintf("%02x"). The concatenated hex string is compared to the provided serial with strcmp.

Serial generation

The valid serial is therefore:

python
serial = ''.join(f'{((ord(login[i]) ^ ord(uid[i])) + 7):02x}' for i in range(len(login)))

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

Minimal helper:

python
def gen_serial(login: str, uid: str) -> str:
    return ''.join(f'{((ord(a) ^ ord(b)) + 7):02x}' for a, b in zip(login, uid))

This is enough to pass validation once the generated login and uid are known.

Flag recovery

The more interesting part is print_flag. After validation succeeds, it does not use the serial contents anymore. Instead, it walks over a table of 100 pointers to 16-byte hex strings stored in .rodata and XORs them column-wise.

In other words, if the table is: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

text
row_0, row_1, ..., row_99

and each row has 16 bytes, then the output byte at position i is:

text
row_0[i] XOR row_1[i] XOR ... XOR row_99[i]

Parsing the table and XORing all 100 rows for positions 0..15 gives:

text
REDACTED

Short recovery snippet:

python
from functools import reduce
from operator import ixor

table = [
    # 100 extracted 16-byte values from .rodata
]

flag_bytes = []
for i in range(16):
    v = 0
    for row in table:
        v ^= row[i]
    flag_bytes.append(v)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

print(''.join(f'{b:02x}' for b in flag_bytes))

So the flag body is independent from the entered serial beyond the fact that the check must succeed first.

Final flag

text
FLAG{REDACTED}

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