Randser
Randser
Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: static_analysis, prng_stream_reconstruction, serial_formula_derivation, xor_reduction
Summary
Walkthrough of the CUSTOM Reversing Randser. 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 che
Challenge
Enter the correct serial for your username, and program will output flag.
Binary:
tasks/spbctf/Randser/randser.elfSource: 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, andstrcmp
The most relevant functions were:
gen_logingen_uidcheckprint_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 read-only • copy allowedsrand(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 read-only • copy allowedfor 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 read-only • copy allowedbyte = (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 read-only • copy allowedserial = ''.join(f'{((ord(login[i]) ^ ord(uid[i])) + 7):02x}' for i in range(len(login)))
Minimal helper:
python read-only • copy alloweddef 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 read-only • copy allowedrow_0, row_1, ..., row_99
and each row has 16 bytes, then the output byte at position i is:
text read-only • copy allowedrow_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 read-only • copy allowedREDACTED
Short recovery snippet:
python read-only • copy allowedfrom 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 read-only • copy allowedFLAG{REDACTED}
Key Takeaways / Lessons
- Mastering static_analysis, prng_stream_reconstruction, serial_formula_derivation, xor_reduction was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-03-31 • CUSTOM • Randser • Difficulty: Medium