SpecCTF
kitctf
Task: a not-stripped x86-64 PIE C++ flag-checker that simulates a Spectre Flush+Reload side channel (clflush/rdtscp/sched_setaffinity/priority_queue). Solution: the cache-timing apparatus is a red herring — the secret-dependent branch reduces to hashy(chunk) == ENC[i] where hashy is the invertible splitmix64 finalizer; invert it (self-inverse xorshift since shift>32 + modular-inverse multiply) over the 6 extracted ENC constants to recover the flag.
$ ls tags/ techniques/
SpecCTF — KITCTF (GPN CTF)
Description
We present for our desert a slightly deranged ghost, even though they may be unstable, they are still delicious. Share a meal and a CPU with this ghost and we're sure you'll be satisfied
English summary: A single 64-bit ELF PIE executable specCTF is given. It is a flag-checker that wraps its secret comparison inside a simulated Spectre / Flush+Reload cache side-channel. The goal is to recover the argv[1] value that the binary accepts as CORRECT.
The title and description are puns pointing directly at the Spectre CPU vulnerability:
- "ghost" = Spectre (a specter / ghost).
- "slightly deranged" / "unstable" = speculative execution and unstable speculation.
- "Share a meal and a CPU" = cache side-channel on a shared CPU core.
- The flag itself confirms the theme:
5peCUl4t1V3Ly_DEliC1oU5.
Analysis
Recon
file specCTF → ELF 64-bit LSB PIE, x86-64, dynamically linked, not stripped, Debian GCC 12.2.0, C++ / libstdc++.
Demangled symbols of interest:
specEnvTime, specte_byte, spec_func, carrierFunc, readMemoryByte,
get_from_array, init_attack, train, distTrue, distFalse, hashy,
_GLOBAL__sub_I_ENC (static initializer)
Strings include NOPE, CORRECT, and references to
priority_queue<int,...,compareChars>, sched_setaffinity, rdtscp, clflush.
The combination clflush + rdtscp + sched_setaffinity + a cache-hit-count priority queue
is the unmistakable signature of a Spectre / Flush+Reload simulation.
main (offset 0x1d3a)
- The flag is
argv[1]. strlen(argv[1])is taken;len & 7must be0(length must be a multiple of 8), else it prints the error string andexit(0x539).- The input is processed 8 bytes at a time; each 8-byte chunk is read as a little-endian
uint64. - For chunk index
i: it loadsr15 = ENC[i](global arrayENCat vaddr0x70c0),r14 = input_chunk[i], then callsspecte_byte(0x1337, 0x1337). - The return of
specte_byte(a "leaked" byte) is accumulated; main compares the accumulated count tolen/8, adjusting a score, and printsCORRECTwhen all chunks match.
The Spectre machinery is obfuscation
specte_byte→readMemoryByteperforms a classic Flush+Reload: it flushesarr2cache lines withclflush, runsspec_func/carrierFunc(bounds-checked indirect calls that speculatively executeget_from_array), then times reloads withrdtscpagainstCACHE_HIT_THRESHOLD, recording hits inresults[]and apriority_queue(PQ) ordered by hit count.specEnvTime(offset0x1363) is the actual secret-dependent gadget. It returns a "hit" / leak byte whenhashy(r14) == r15, i.e. whenhashy(input_chunk) == ENC[i]. Otherwise it returns a different (miss) byte.- Net effect: the entire cache-timing apparatus is a fancy, side-channel-flavored way to compute
the boolean predicate
hashy(chunk) == ENC[i]. No dynamic execution or real cache timing is needed to solve — it reduces to invertinghashy.
hashy (offset 0x1239) is the splitmix64 finalizer
Disassembly logic on a 64-bit value x (M = 2**64 - 1):
x ^= x >> 33
x *= 0xF451AF97D512CACD ; (-0x0bae5068a2ead353 as unsigned)
x ^= x >> 33
x ^= 0xC2CEAADE1A351C23 ; (-0x3d315521e5cae3dd as unsigned)
x ^= x >> 33
This is the standard splitmix64 finalizer (xorshift–multiply–xorshift–xorconst–xorshift), which is fully invertible:
x ^= x >> 33is self-inverse in a single pass because the shift (33) is greater than half the word size (32) — the top bits are recovered first and never re-collide.- The multiply is inverted by multiplying with the modular inverse of the odd constant:
inv_mul = pow(mul, -1, 2**64). - The xor-const is inverted by xoring the same constant.
Solution
Extracting ENC
ENC is at vaddr 0x70c0 in .data (size 0x38 = 56 bytes). Since this is a PIE and
readelf was unavailable on the host, the ELF section headers were parsed in Python to map
vaddr → file offset (.data vaddr 0x70a0 → file offset 0x60a0), then the 7 little-endian
uint64 values were read:
0xee7590ece97175e5
0x32050538cc51ebea
0xd3a0f1efa162aeed
0x44cde0d9c2d3a245
0x99321a20f8b0a7af
0x72d2aa1cbaabe81f
0x0000000000000000 (null terminator / padding — 6 real chunks)
Inverting hashy on the 6 non-zero values and concatenating the little-endian bytes yields the
48-byte flag (48 = 6 chunks of 8, satisfying the len % 8 == 0 constraint).
Solver
#!/usr/bin/env python3 import struct M = (1 << 64) - 1 mul = (-0xbae5068a2ead353) & M xc = (-0x3d315521e5cae3dd) & M inv_mul = pow(mul, -1, 1 << 64) def xs33(x): # invert (and apply) x ^= x>>33 (self-inverse, shift>32) return (x ^ (x >> 33)) & M def hashy(x): x &= M x ^= x >> 33 x = (x * mul) & M x ^= x >> 33 x ^= xc x ^= x >> 33 return x def unhashy(h): x = h & M x = xs33(x) # invert last x ^= x>>33 x ^= xc # invert xor const x = xs33(x) # invert x ^= x>>33 x = (x * inv_mul) & M # invert multiply x = xs33(x) # invert first x ^= x>>33 return x enc = [0xee7590ece97175e5, 0x32050538cc51ebea, 0xd3a0f1efa162aeed, 0x44cde0d9c2d3a245, 0x99321a20f8b0a7af, 0x72d2aa1cbaabe81f] out = b'' for e in enc: p = unhashy(e) assert hashy(p) == e out += p.to_bytes(8, 'little') print(out.decode()) # GPNCTF{tHIS_mE4L_Is_5peCUl4t1V3Ly_DEliC1oU5!!!!}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [pwn][Pro]Taste— grodno_new_year_2026
- [reverse][Pro]Challenge7— tamuctf
- [reverse][Pro]bedtime— dicectf_2026
- [reverse][free]Auto Cooker— GPNCTF 2026
- [reverse][Pro]Call my name softly - TaipanByte CTF— taipanbyte