Mips Mops
Mips Mops
Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: forward_model_reconstruction, ground_truth_validation, pairwise_equation_solving, static_disassembly, uninitialized_stack_resolution, z3_inversion
Summary
Walkthrough of the CUSTOM Reversing Mips Mops. Task: stripped MIPSEL PIE keygen validating a 32-char serial; the flag IS the correct serial. Solution: statically reverse the 64-bit-on-32-bit transform (fixing a compiler-emitted word-swapped multip
Description
Do you like dogs? How about mops? How about mips?
apt -y install qemu-user-static libc6-mipsel-cross qemu-mipsel -L /usr/mipsel-linux-gnu ./mips_mops.elf <serial>
We are given mips_mops.elf, a stripped 32-bit MIPSEL PIE keygen. It takes a
32-character serial as argv[1] and, on success, prints
[+] Serial is correct! Your flag is: %s where the %s is the serial itself.
The flag IS the correct serial — solving the keygen recovers it directly.
Analysis
Recon
$ file mips_mops.elf
ELF 32-bit LSB pie executable, MIPS, MIPS32 rel2, dynamically linked,
interpreter /lib/ld.so.1, stripped, BuildID=774af042d235b1a5b3b1bda804333072fa258d8f
Strings expose the keygen flow: [*] USAGE: %s serial_number,
[*] Checking serial..., [-] Wrong length!, [-] Bad characters in serial,
[-] No., plus a large ASCII-art pug ("mops").
The transform (main @ file offset 0x7f0)
An 8-entry array A of uint64 lives at fp+0x30 (little-endian word pairs);
the serial pointer is at fp+0x28. The check is:
- Length:
strlen(serial) == 0x20elseWrong length!. - Charset: every byte
0x20 <= c < 0x7felseBad characters in serial. - Init
A[idx]for idx = 0..6 (note the loop isslti ...,7, so A[7] is never initialized — see the subtlety below):n = idx + 1s1 = (n*0x0101010101010101 + 0x6efec0b2c164718f) mod 2^64s2 = rotr64(s1, 37)(asm renders this as a word-swapped "rotr by 5")mult = (idx*0x4ba299375e3615c1 + 0x5b867ed88b434e15) mod 2^64A[idx] = (s2 * mult) mod 2^64
- k-loop, k = 0..31:
A[k%8] = rotr64(A[k%8] XOR serial[31-k], 40)(word-swapped "rotr by 8"). The serial is absorbed in reverse index order. - Finalize each slot with a per-slot add/xor of a 64-bit constant (see
FINin the model). - Compute 16 output words
Bfrom the 4 pairs (P=A[2m],Q=A[2m+1], all 32-bit halves):B[4m+0] = Phi + QloB[4m+1] = Plo - QhiB[4m+2] = Plo + QhiB[4m+3] = Phi - Qlo(all mod 2^32)
- Final check:
B[i] + const_i == 0for all 16 i, i.e.B[i] == (-const_i) mod 2^32.
The bug that broke the naive model — word-swapped multiply constant
The single biggest trap: in step 3 the multiply constant is
0x4ba299375e3615c1, which is K1 = 0x5e3615c14ba29937 with its two 32-bit
halves swapped. This swap is an artifact of how the compiler expanded a
64-bit multiply of the small integer idx by the constant on 32-bit MIPS:
mul $v1,$s3,0x5e3615c1
mul $v0,$s2,0x4ba29937 ; cross terms of the 64-bit product
A naive model that used 0x5e3615c14ba29937 directly produced wrong A values
and never matched the ground truth. Only after decoding the exact instruction
sequence with capstone and reproducing the cross-term expansion did the forward
model agree with qemu.
The A[7] uninitialized-stack subtlety
A[7] is used in the k-loop (k = 7,15,23,31 → slot 7) and in pair m=3
(B[12..15]), yet it is never written by the init loop. On a real Linux
process the initial stack is zero-filled, so the intended A[7] = 0. Under
qemu-user invoked via the dynamic loader, that stack slot holds leftover loader
garbage (observed 0x3ffffc7040048000), which is why running the real binary
under qemu printed No. even for the correct flag. The four flag bytes fed by
slot 7 are serial[0], serial[8], serial[16], serial[24] (positions 0, 8, 16, 24).
Solution
Narrative: recon → static RE of the transform → Python forward model → validation against qemu ground truth → discovery of the swapped-constant bug → z3 inversion → resolving the A[7] ambiguity via readable text → verification.
Corrected forward model
python read-only • copy allowed#!/usr/bin/env python3 # CORRECTED forward model of mips_mops.elf serial check (verified vs qemu) M64 = (1 << 64) - 1 M32 = (1 << 32) - 1 def rotr64(x, n): x &= M64 return ((x >> n) | (x << (64 - n))) & M64 C1 = 0x0101010101010101 ADD1 = 0x6efec0b2c164718f K1s = 0x4ba299375e3615c1 # word-SWAPPED K1 (the crucial bug fix) K2 = 0x5b867ed88b434e15 def init_A(a7=0): A = [0] * 8 for idx in range(7): # slti ...,7 -> A[7] left uninitialized n = idx + 1 s1 = (n * C1 + ADD1) & M64 s2 = rotr64(s1, 37) mult = (idx * K1s + K2) & M64 A[idx] = (s2 * mult) & M64 A[7] = a7 # intended value is 0 (zeroed stack) return A def kloop(A, serial): A = A[:] for k in range(32): A[k % 8] = rotr64(A[k % 8] ^ serial[31 - k], 40) return A FIN = [('+', 0x2d2307d7e9a1fa83), ('^', 0xe887f2d23433c7d6), ('+', 0x572c1d4bf123b0b7), ('+', 0x8f138e135a7ac1fd), ('^', 0x6c107ff75570821a), ('+', 0x28ddb3b8fb191d0f), ('+', 0x33330da68ea7cacd), ('^', 0xea4d2d5c43f59642)] def finalize(A): A = A[:] for i, (o, c) in enumerate(FIN): A[i] = (A[i] + c) & M64 if o == '+' else A[i] ^ c return A def compute_B(A): B = [0] * 16 for m in range(4): P, Q = A[2 * m], A[2 * m + 1] Plo, Phi = P & M32, (P >> 32) & M32 Qlo, Qhi = Q & M32, (Q >> 32) & M32 B[4 * m] = (Phi + Qlo) & M32 B[4 * m + 1] = (Plo - Qhi) & M32 B[4 * m + 2] = (Plo + Qhi) & M32 B[4 * m + 3] = (Phi - Qlo) & M32 return B def full_forward(serial, a7=0): return compute_B(finalize(kloop(init_A(a7), serial))) consts = [0xe70fe185, 0xc8729904, 0x245f0ca6, 0x145a73f3, 0x90c30f85, 0x2aa087ee, 0xdae0dc24, 0x1ec0a50d, 0xdb4a13ab, 0x1007bac8, 0x4ab83c5c, 0x2b7ed257, 0x88b96c6e, 0x3464f8fb, 0x60209ee7, 0xeaa55af2] TARGET = [(-c) & M32 for c in consts] def unfinalize(A): A = A[:] for i, (o, c) in enumerate(FIN): A[i] = (A[i] - c) & M64 if o == '+' else A[i] ^ c return A
z3-based inverse solver
python read-only • copy allowedimport itertools from z3 import BitVec, BitVecVal, RotateRight, ZeroExt, Solver, sat from model import (M32, TARGET, unfinalize, init_A, full_forward) def solve_2x(v): # solve 2*x == v (mod 2^32); v must be even, two roots v &= M32 if v & 1: return [] h = v >> 1 return [h, (h + (1 << 31)) & M32] def recover_A_fin(): # invert compute_B pairwise: B0,B3 -> 2*Phi, 2*Qlo ; B1,B2 -> 2*Plo, 2*Qhi cp = [] for m in range(4): B0, B1, B2, B3 = TARGET[4 * m:4 * m + 4] opts = [] for Phi in solve_2x((B0 + B3) & M32): for Qlo in solve_2x((B0 - B3) & M32): if (Phi + Qlo) & M32 != B0 or (Phi - Qlo) & M32 != B3: continue for Plo in solve_2x((B2 + B1) & M32): for Qhi in solve_2x((B2 - B1) & M32): if (Plo - Qhi) & M32 != B1 or (Plo + Qhi) & M32 != B2: continue opts.append(((Phi << 32) | Plo, (Qhi << 32) | Qlo)) cp.append(opts) res = [] for c in itertools.product(*cp): A = [0] * 8 for m in range(4): A[2 * m], A[2 * m + 1] = c[m] res.append(A) return res def invert_slot(S_i, E_i): # 4 bytes b s.t. chaining rotr64(st ^ b, 40) from init S_i reaches E_i s = Solver() bs = [BitVec(f'b{k}', 8) for k in range(4)] st = BitVecVal(S_i, 64) for n in range(4): st = RotateRight(st ^ ZeroExt(56, bs[n]), 40) s.add(st == BitVecVal(E_i, 64)) if s.check() == sat: m = s.model() return [m[bs[n]].as_long() for n in range(4)] return None for A_fin in recover_A_fin(): A_afterk = unfinalize(A_fin) S = init_A() # S[7] = 0 (intended) serial = [None] * 32 ok = True for i in range(8): b = invert_slot(S[i], A_afterk[i]) if b is None: ok = False break for n in range(4): serial[31 - (i + 8 * n)] = b[n] if not ok: continue s = bytes(serial) if full_forward(s) == TARGET: print(repr(s))
Slots 0..6 have deterministic init, so all 28 of their bytes solve uniquely to
printable ASCII. Slot 7's four bytes depend on the unknown A[7] init, but the
surrounding readable text pinned them: .pbctf{y.ur_seri.l_is_LO.E_mips} →
s, o, a, V → spbctf{REDACTED}.
Verification: full_forward(flag, a7=0) == TARGET exactly, and patching
A[7]=0 in gdb makes the real binary print
[+] Serial is correct! Your flag is: spbctf{REDACTED}.
Ground-truth / environment tips
- Static analysis: radare2 (
aaa,pdf) + capstone for exact decoding of the 64-bit-arithmetic-on-32-bit-MIPS expansion (this is how the swapped constant was caught). - Ground truth:
qemu-user-static+libc6-mipsel-crossinside a Dockerdebian:bookworm-slim --platform linux/amd64container. macOS has no native qemu-user. - qemu could not auto-load the PIE (rc=1, no syscalls under
-strace). Workaround — invoke the dynamic loader explicitly:qemu-mipsel-static -L /usr/mipsel-linux-gnu /usr/mipsel-linux-gnu/lib/ld.so.1 ./mips_mops.elf ARG. - Under that invocation the PIE base was
0x3f7e0000(found via-d page: the small 2-page r-x region that isn't ld.so@0x40000000or libc@0x3f5f0000). Breakpoint at check =base+0x12f4; read B at$s8+0x70, A at$s8+0x30, serial ptr at[$s8+0x28]. The qemu MIPS gdbstub does not populate$fpin gdb-multiarch — use$s8; syscall catchpoints are unsupported. - Unicorn pitfall: its MIPS mode mis-models the historical load-delay slot
for consecutive
lw(value appears one instruction late), making it unusable here. - binfmt pitfall:
multiarch/qemu-user-static --reset -p yescan register qemu as the interpreter for the host arch too and break normal amd64 container execution; a Docker VM restart clears thebinfmt_miscstate.
Key Takeaways / Lessons
- Mastering forward_model_reconstruction, ground_truth_validation, pairwise_equation_solving, static_disassembly, uninitialized_stack_resolution, z3_inversion was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-02 • CUSTOM • Mips Mops • Difficulty: Medium