reversefreemedium

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/
static_analysisdisassemblymodular_inversesplitmix64_inversionxorshift_self_inverseelf_section_parsingside_channel_deobfuscation

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 & 7 must be 0 (length must be a multiple of 8), else it prints the error string and exit(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 loads r15 = ENC[i] (global array ENC at vaddr 0x70c0), r14 = input_chunk[i], then calls specte_byte(0x1337, 0x1337).
  • The return of specte_byte (a "leaked" byte) is accumulated; main compares the accumulated count to len/8, adjusting a score, and prints CORRECT when all chunks match.

The Spectre machinery is obfuscation

  • specte_bytereadMemoryByte performs a classic Flush+Reload: it flushes arr2 cache lines with clflush, runs spec_func / carrierFunc (bounds-checked indirect calls that speculatively execute get_from_array), then times reloads with rdtscp against CACHE_HIT_THRESHOLD, recording hits in results[] and a priority_queue (PQ) ordered by hit count.
  • specEnvTime (offset 0x1363) is the actual secret-dependent gadget. It returns a "hit" / leak byte when hashy(r14) == r15, i.e. when hashy(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 inverting hashy.

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 >> 33 is 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