Slyfizzbuzz
I co-authored a challenge with ks
for HSPACE CTF, which was Belluminar-type.
No team solved it during the CTF, but Jinseo Kim
and diff
from GoN
solved it after the CTF ended.
Description
FizzBuzz라는 게임을 아시나요? 감사하게도, Slyfizz께서 살신성인(殺身成仁)을 통해 이 챌린지의 마스코트가 되어 주셨습니다!
SlyFizzbuzz를 공부하여서 100번 연속 Slyfizz의 무빙을 맞춰보세요!
nc x.x.x.x 1013
chall.py
import random
import os
FLAG = open("flag", "r").read()
def fizzbuzz(n):
fb = "sly"
if n % 3 == 0:
fb += "fizz"
if n % 5 == 0:
fb += "buzz"
return fb
for rounds in range(100000):
cmd = input("> ")
if cmd == "roll":
dice = random.getrandbits(8)
print(fizzbuzz(dice))
else:
for _ in range(100):
assert fizzbuzz(random.getrandbits(8)) == input("Guess> ")
break
else:
exit()
print(f"Here is your flag: {FLAG}")
The challenge extracts random.getrandbits(8)
, and tell us if the number is multiple of 3 or multiple of 5. Which is not much different from the original Fizzbuzz game.
The goal is to recover the state, and predict random.getrandbits(8)
’s slyfizzbuzz result 100 times.
If you are familiar with Python’s random, which is implemented with the Mersenne Twister, you probably know that we can recover the twister’s state if we can calculate 19937 bits from the output.
If any of the quotients was an even number, and the multiple flag is set to True, we can easily know that one LSB is equal to zero, because it is an even number. And easily recover one bit. Unfortunately, 3 and 5 is both an odd number.
The output "slyfizzbuzz"
happens around in 1/15 probability, because the number has to be a multiple of 15.
Let’s observe those numbers within range(0, 256)
.
for i in range(256):
if i % 15 == 0:
bin_str = bin(i)[2:]
bin_str = "0" * (8 - len(bin_str)) + bin_str
print(bin_str + f": {i}")
00000000: 0
00001111: 15
00011110: 30
00101101: 45
00111100: 60
01001011: 75
01011010: 90
01101001: 105
01111000: 120
10000111: 135
10010110: 150
10100101: 165
10110100: 180
11000011: 195
11010010: 210
11100001: 225
11110000: 240
11111111: 255
Except for 0 and 255, those numbers have an interesting common property.
Sum of upper 4 bits and lower 4 bits is always equal to 0b1111
.
This interesting property happens because 15 is exactly one less than 16.
By adding 15 from the previous number, it adds 1 to upper 4 bits, and subtracts 1 from lower 4 bits.
So until 0b11110000
, the sum of both 4 bits remains 0b1111
.
In the other form, so that we can extract information from the result, bin(i)[:4] ^ bin(i)[4:] == 0b1111
.
(^
means XOR here.)
But 0 and 255 doesn’t satisfy that property. Instead, bin(i)[:4] ^ bin(i)[4:] == 0b0000
for those 2 exceptional cases.
Fortunately, all 4 bits of 0b1111
and 0b0000
are equal, so we can extract 3 bits of information from that.
My implementation looks like:
diff = Twister._xor(dat[:4], dat[4:])
diff = Twister._xor(diff[:3], diff[1:])
solver.insert(diff[0], 0)
solver.insert(diff[1], 0)
solver.insert(diff[2], 0)
There are 18 multiples of 15 in range(0, 256)
so the probability is 18/256. And we can get 3 bits of information from that.
On average, given 100000 - 1 results, around 21093.5 bits of information is recieved, which is sufficient to recover the Mersenne Twister state.
If we only insert information bits of 0 instead of 1, the correct state, and the zero state(which all bits are 0) always coexist.
The correct one can be selected manually, but the easier(and much correcter, is that even a word?) way is to set the initial state before inserting bits.
Since every initial random state works with seed, twister’s index is equal to 624, and first value of twister is equal to 0x80000000
. You don’t believe me?
Python 3.11.1 (v3.11.1:a7a450f84a, Dec 6 2022, 15:24:06) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import random
>>> random.getstate()[1][0]
2147483648
>>>
So we set the initial state with the code below.
twister = Twister()
solver = Solver()
twister.index = 624
solver.insert(twister.state[0][0], 1)
for i in range(1, 32):
solver.insert(twister.state[0][i], 0)
This is my final solve code.
It should take less than 15 minutes on most laptops.
ex.py
import random
from pwn import *
class Twister:
N = 624
M = 397
A = 0x9908b0df
def __init__(self):
self.state = [ [ (1 << (32 * i + (31 - j))) for j in range(32) ] for i in range(624)]
self.index = 0
@staticmethod
def _xor(a, b):
return [x ^ y for x, y in zip(a, b)]
@staticmethod
def _and(a, x):
return [ v if (x >> (31 - i)) & 1 else 0 for i, v in enumerate(a) ]
@staticmethod
def _shiftr(a, x):
return [0] * x + a[:-x]
@staticmethod
def _shiftl(a, x):
return a[x:] + [0] * x
def get32bits(self):
if self.index >= self.N:
for kk in range(self.N):
y = self.state[kk][:1] + self.state[(kk + 1) % self.N][1:]
z = [ y[-1] if (self.A >> (31 - i)) & 1 else 0 for i in range(32) ]
self.state[kk] = self._xor(self.state[(kk + self.M) % self.N], self._shiftr(y, 1))
self.state[kk] = self._xor(self.state[kk], z)
self.index = 0
y = self.state[self.index]
y = self._xor(y, self._shiftr(y, 11))
y = self._xor(y, self._and(self._shiftl(y, 7), 0x9d2c5680))
y = self._xor(y, self._and(self._shiftl(y, 15), 0xefc60000))
y = self._xor(y, self._shiftr(y, 18))
self.index += 1
return y
def getrandbits(self, bit):
return self.get32bits()[:bit]
def randbytes(self, n):
left = n
res = []
while left >= 4:
left -= 4
q = self.get32bits()
for i in range(4):
res.append(q[(3 - i) * 8:(4 - i) * 8][::-1])
if left:
q = self.get32bits()
for i in range(left):
res.append(q[(left - 1 - i) * 8:(left - i) * 8][::-1])
assert len(res) == n
return res
class Solver:
def __init__(self):
self.equations = []
self.outputs = []
def insert(self, equation, output):
for eq, o in zip(self.equations, self.outputs):
lsb = eq & -eq
if equation & lsb:
equation ^= eq
output ^= o
if equation == 0:
if output == 0:
return
raise ValueError("Impossible generated bits.")
lsb = equation & -equation
for i in range(len(self.equations)):
if self.equations[i] & lsb:
self.equations[i] ^= equation
self.outputs[i] ^= output
self.equations.append(equation)
self.outputs.append(output)
def solve(self):
num = 0
for i, eq in enumerate(self.equations):
if self.outputs[i]:
# Assume every free variable is 0
num |= eq & -eq
state = [ (num >> (32 * i)) & 0xFFFFFFFF for i in range(624) ]
return state
def fizzbuzz(n):
fb = "sly"
if n % 3 == 0:
fb += "fizz"
if n % 5 == 0:
fb += "buzz"
return fb
if __name__ == "__main__":
io = remote("localhost", 1013)
size = 5000
twister = Twister()
solver = Solver()
twister.index = 624
solver.insert(twister.state[0][0], 1)
for i in range(1, 32):
solver.insert(twister.state[0][i], 0)
cnt = 0
while True:
print("Getting Data...")
res = [0] * size
for i in range(size):
io.sendline(b"roll")
for i in range(size):
io.recvuntil(b"> ")
if io.recvline()[:-1] == b"slyfizzbuzz":
res[i] = 1
cnt += size
dats = [twister.getrandbits(8) for _ in range(size)]
for i in range(size):
dat = dats[i]
if res[i] == 0:
continue
diff = Twister._xor(dat[:4], dat[4:])
diff = Twister._xor(diff[:3], diff[1:])
solver.insert(diff[0], 0)
solver.insert(diff[1], 0)
solver.insert(diff[2], 0)
rank = len(solver.equations)
print(f"{rank = }", end = "\r")
if rank == 19968:
break
if rank == 19968:
break
state = solver.solve()
random.setstate((3, tuple(state + [624]), None))
for _ in range(cnt):
random.getrandbits(8)
io.sendlineafter(b"> ", b"asdf")
for _ in range(100):
io.sendlineafter(b"Guess> ", fizzbuzz(random.getrandbits(8)).encode())
io.interactive()
flag
hspace{Fizzer_Buzzer_Chicken_Slyfizzbuzzer}