67xbet
- Challenge: 67xbet
- Category: Crypto
- Flag:
LYKNCTF{5348a970a3f64b1d9c2521b0da23416a}
My initial read / first impressions
The challenge page is a small betting themed site called 67xbet. It shows five random-looking numbers and asks us to guess the sixth one:
Looking at the page source, the frontend is really simple. Pressing Regenerate calls:
and then submitting a guess calls:
The important JavaScript is:
So the server gives us five numbers and a hash. Then we have to send the same five numbers, the hash, and our predicted sixth number back to the server.
That means the hash is probably just there so we cannot edit the first five numbers. The actual challenge is to recover whatever PRNG state generated them.
False start: drand48
The page title says:
and the visible page has a bunch of soccer match text, including DR Congo. My first thought was that DR might be hinting at drand48, which would make sense for a random-number challenge.
But the leaked numbers did not fit drand48. For example, one batch was:
I tried reconstructing the 48-bit LCG state from the outputs, but even with a pretty wide rounding window it did not line up. So that clue was either a troll or just not the real PRNG.
The real PRNG: V8 Math.random
The numbers looked much more like JavaScript Math.random() output. Modern V8 uses a xorshift128+ based PRNG internally, and Math.random() returns a double built from the high bits of one 64-bit state word.
For a generated state word s0, the returned number is basically:
That means every displayed random number leaks 52 bits from a 64-bit state word. Since the PRNG state is 128 bits total, five outputs are way more than enough information to recover the state.
There is one annoying V8 detail though: Math.random() uses a small cache of random values. It generates values internally, stores them, and then returns them in reverse order. So the five numbers shown by the page are usually reverse-chronological relative to the xorshift transitions.
So the plan became:
/api/random
-> get 5 leaked Math.random() values
-> reverse them to match V8's internal generation order
-> convert each float into its 52-bit mantissa leak
-> solve the xorshift128+ state as linear equations over GF(2)
-> step backwards once to get the next value the cache will pop
-> submit that as the hidden 6th number
Recovering the state
The xorshift transition is linear over bits, so this does not need brute force. I represented each of the 128 unknown state bits as a bit in a symbolic row, ran the xorshift transition symbolically, and added equations for the 52 leaked bits of each output.
The mantissa extraction is done by adding 1.0 to the random number and reading the IEEE-754 double bits:
Then each leaked mantissa bit becomes one equation in GF(2). After solving the linear system, I verified the recovered state by regenerating the leaked outputs exactly. Once that worked, I used the inverse xorshift transition once, because of the V8 cache reversal, and converted that previous state word into the next Math.random() value to submit.
For my successful run, the script got this batch:
and predicted:
Submitting that returned the flag immediately.
Solution Script
Here is the final solve script I used. It gets a fresh batch from /api/random, recovers the V8 state, predicts the sixth value, and submits it to /api/validate.
#!/usr/bin/env python3
import json
import struct
import sys
import time
import requests
BASE = "http://573a5020-cd14-4357-a78a-ca8be97a2c38.51.79.140.18.nip.io:8080"
MASK64 = (1 << 64) - 1
MANT_MASK = (1 << 52) - 1
def mantissa_from_float(x):
x = float(x)
bits = struct.unpack("<Q", struct.pack("<d", x + 1.0))[0]
return bits & MANT_MASK
def to_double_from_state0(s0):
return (s0 >> 12) / float(1 << 52)
def xs_step(s0, s1):
x = s0
y = s1
s0 = y
x ^= (x << 23) & MASK64
x ^= x >> 17
x ^= y
x ^= y >> 26
s1 = x & MASK64
return s0, s1
def unxorshift_right(y, shift):
x = 0
for i in range(63, -1, -1):
bit = (y >> i) & 1
if i + shift < 64:
bit ^= (x >> (i + shift)) & 1
x |= bit << i
return x & MASK64
def unxorshift_left(y, shift):
x = 0
for i in range(64):
bit = (y >> i) & 1
if i - shift >= 0:
bit ^= (x >> (i - shift)) & 1
x |= bit << i
return x & MASK64
def xs_prev(s0, s1):
old_s1 = s0
v = s1 ^ old_s1 ^ (old_s1 >> 26)
t = unxorshift_right(v, 17)
old_s0 = unxorshift_left(t, 23)
return old_s0 & MASK64, old_s1 & MASK64
def xor_word(a, b):
return [x ^ y for x, y in zip(a, b)]
def shl_word(a, n):
return [0] * n + a[:64 - n]
def shr_word(a, n):
return a[n:] + [0] * n
def sym_step(s0, s1):
x = s0[:]
y = s1[:]
t = xor_word(x, shl_word(x, 23))
t = xor_word(t, shr_word(t, 17))
t = xor_word(t, y)
t = xor_word(t, shr_word(y, 26))
return y, t
def build_rows(seq):
# Variables:
# bits 0..63 = state0
# bits 64..127 = state1
s0 = [1 << i for i in range(64)]
s1 = [1 << (64 + i) for i in range(64)]
rows = []
rhs = []
for val in seq:
mant = mantissa_from_float(val)
# V8 Math.random returns:
# double = (state0 >> 12) / 2^52
for j in range(52):
rows.append(s0[12 + j])
rhs.append((mant >> j) & 1)
s0, s1 = sym_step(s0, s1)
return rows, rhs
def solve_gf2(rows, rhs, nvars=128):
mat = [r | (b << nvars) for r, b in zip(rows, rhs)]
row = 0
pivots = []
for col in range(nvars):
bit = 1 << col
pivot = None
for r in range(row, len(mat)):
if mat[r] & bit:
pivot = r
break
if pivot is None:
continue
mat[row], mat[pivot] = mat[pivot], mat[row]
for r in range(len(mat)):
if r != row and (mat[r] & bit):
mat[r] ^= mat[row]
pivots.append(col)
row += 1
if row == len(mat):
break
var_mask = (1 << nvars) - 1
for r in mat:
if (r & var_mask) == 0 and ((r >> nvars) & 1):
return None
sol = 0
for r, col in enumerate(pivots):
if (mat[r] >> nvars) & 1:
sol |= 1 << col
return sol
def word_from_solution(sol, offset):
out = 0
for i in range(64):
if (sol >> (offset + i)) & 1:
out |= 1 << i
return out
def recover_state_for_chronological_sequence(seq):
rows, rhs = build_rows(seq)
sol = solve_gf2(rows, rhs)
if sol is None:
return None
s0 = word_from_solution(sol, 0)
s1 = word_from_solution(sol, 64)
# Exact verification.
a, b = s0, s1
for val in seq:
if (a >> 12) != mantissa_from_float(val):
return None
a, b = xs_step(a, b)
return s0, s1
def candidate_answers(numbers):
out = []
# Real Node/V8 Math.random uses a 64-value cache and pops values in reverse.
# So the 5 shown numbers are usually reverse-chronological.
seq = list(reversed(numbers))
st = recover_state_for_chronological_sequence(seq)
if st is not None:
first_s0, first_s1 = st
prev_s0, _ = xs_prev(first_s0, first_s1)
ans = to_double_from_state0(prev_s0)
out.append(("v8-cache-reversed", ans))
# Fallback in case the challenge manually uses the xorshift transition directly.
seq = list(numbers)
st = recover_state_for_chronological_sequence(seq)
if st is not None:
s0, s1 = st
for _ in range(len(seq) - 1):
s0, s1 = xs_step(s0, s1)
next_s0, _ = xs_step(s0, s1)
ans = to_double_from_state0(next_s0)
out.append(("v8-direct", ans))
return out
def main():
sess = requests.Session()
for attempt in range(1, 31):
r = sess.get(BASE + "/api/random", timeout=10)
r.raise_for_status()
data = r.json()
numbers = data["numbers"]
h = data["hash"]
print(f"\n[+] Attempt {attempt}")
print("[+] Leaked numbers:")
for i, n in enumerate(numbers, 1):
print(f" {i}. {n}")
candidates = candidate_answers(numbers)
if not candidates:
print("[-] Could not fit this batch. Probably crossed V8's 64-number cache boundary; retrying...")
time.sleep(0.15)
continue
for name, answer in candidates:
answer_str = format(answer, ".17g")
print(f"[+] Trying {name}: {answer_str}")
payload = {
"numbers": numbers,
"answer": float(answer_str),
"hash": h,
}
vr = sess.post(BASE + "/api/validate", json=payload, timeout=10)
vr.raise_for_status()
res = vr.json()
print("[+] Response:", json.dumps(res))
if "flag" in res:
print("\n" + res["flag"])
return
time.sleep(0.15)
print("[-] No flag after 30 attempts.")
sys.exit(1)
if __name__ == "__main__":
main()
Running it gave:
[+] Attempt 1
[+] Leaked numbers:
1. 0.7487126656249559
2. 0.9116571242095224
3. 0.1522931383032915
4. 0.7242501335984008
5. 0.13998386089484827
[+] Trying v8-cache-reversed: 0.68763746819741245
[+] Response: {"flag": "LYKNCTF{5348a970a3f64b1d9c2521b0da23416a}"}
LYKNCTF{5348a970a3f64b1d9c2521b0da23416a}