writeups/2023/csawq/02-crypto-poker.md

372 lines
11 KiB
Markdown
Raw Permalink Normal View History

2023-09-19 08:17:07 +00:00
## poker? i barely even- (mental poker)
> Let's play some mental poker.
it's another crypto challenge !
let's look at the source
```python
class PRNG:
def __init__(self, seed = int(os.urandom(8).hex(),16)):
self.seed = seed
self.state = [self.seed]
self.index = 64
for i in range(63):
self.state.append((3 * (self.state[i] ^ (self.state[i-1] >> 4)) + i+1)%64)
def __str__(self):
return f"{self.state}"
def getnum(self):
if self.index >= 64:
for i in range(64):
y = (self.state[i] & 0x20) + (self.state[(i+1)%64] & 0x1f)
val = y >> 1
val = val ^ self.state[(i+42)%64]
if y & 1:
val = val ^ 37
self.state[i] = val
self.index = 0
seed = self.state[self.index]
self.index += 1
return (seed*15 + 17)%(2**6)
```
this implements a Mersenne Twister PRNG, seeded with `os.urandom`. the output is limited to 6 bits,
and seemingly it uses 64 bits of randomness to initialize the PRNG
then we have a fairly uninteresting implementation of poker which i'm going to skip over. there
isn't anything particularly wrong with it
```python
def shuffle(deck):
new_deck = []
for i in range(len(deck)):
x = rng.getnum()
if deck[x] not in new_deck:
new_deck.append(deck[x])
elif deck[i] not in new_deck:
new_deck.append(deck[i])
else:
for card in deck:
if card not in new_deck:
new_deck.append(card)
break
return new_deck
```
this shuffles the deck based on the PRNG above
ok and now for the weird stuff
```python
p = getPrime(300)
q = getPrime(300)
phi = (p-1)*(q-1)
N = p*q
print(f"Let's play some mental poker!\nIf you win 10 consecutive times, I'll give you a prize!\nHere are the primes we will use to generate our RSA public and private exponents --> {p}, {q}")
```
we get an RSA *private key* just like, for free. then (not shown here), we're prompted for a public
and private exponent for RSA, and the computer also comes up with its own public and private
exponent like this
```python
while computer_e < 2 or computer_d < 1:
e_array = []
for _ in range(6):
e_array.append(str(rng.getnum()))
computer_e = int(''.join(e_array))
if gcd(computer_e, phi) == 1:
computer_d = pow(computer_e,-1,phi)
```
interestingly, the card shuffling for the first round is done before the computer's exponents are
generated
then, on each round, we get an encrypted deck -- that's encrypted with our public exponent as well
as the computer's
```python
enc_deck = []
for card in deck:
enc_deck.append(pow(pow(bytes_to_long(str(card).encode()),computer_e,N),player_e,N))
assert len(set(enc_deck)) == len(deck_str)
print(f"Here is the shuffled encrypted deck --> {enc_deck}")
```
we are tasked with shuffling the deck and returning it, still in encrypted form. then, the computer
decrypts the deck and runs the poker algorithm to determine a winner
if you win 10 rounds, you get the (encrypted) flag
### the bug
the flaw in this happens in the PRNG. since this is used to generate the computer's private
exponent, if we can guess PRNG outputs then we can recover enough info to be able to decrypt the
deck fully
but first, there's a kind of pointless but convenient bug in the way your exponent is inputted. the
only checks done on your input are
```python
if gcd(player_e, phi) == 1:
# accept
if (player_e*player_d)%phi == 1:
# accept
```
therefore, we can provide a `player_e` and `player_d` that are both `1`. this makes the logic to
decrypt the cards slightly easier
the issue with the PRNG is that despite at first glance looking like it is seeded with 8 bytes of
`urandom`, it's actually only seeded with 10 real bits of randomness
in the constructor, the state array is initialized with
```python
for i in range(63):
self.state.append((3 * (self.state[i] ^ (self.state[i-1] >> 4)) + i+1)%64)
```
`state[0]` initially contains the full seed, but according to this code we can see only the lower 6
bits (due to the modulo) combined with 4 more bits (due to the bit shift) actually influence the
values of the other items in the state array. then, when the first call to `getnum` happens, the
twist operation is immediately performed, which normalizes all the values to be modulo 64, so
`state[0]` also ends up only dependent on the lower 6 bits of the seed value
we can verify this for ourselves
```python
[ins] In [1]: from server import PRNG
[ins] In [2]: PRNG(0x1337c00 | 420).getnum()
Out[2]: 35
[nav] In [3]: PRNG(420).getnum()
Out[3]: 35
```
so basically, we have an extremely bruteforceable search space of 10 bits. that's a lot better than
64!!
### it's pokin' time / poked all over the place
given some copying and pasting of server code as well as importing it directly from the server
module, the solution script can basically simulate the same thing the server is doing each round
```python
from server import PRNG, Card, card_str_to_Card, determine_winner
def sim_round(deck, rng, phi, computer_e, computer_d):
deck = shuffle(rng, deck)
print("local hacks 1", rng.seed, rng.index, rng.state)
while computer_e < 2 or computer_d < 1:
e_array = []
for _ in range(6):
e_array.append(str(rng.getnum()))
computer_e = int(''.join(e_array))
if gcd(computer_e, phi) == 1:
computer_d = pow(computer_e,-1,phi)
return computer_e, computer_d, deck
def shuffle(rng, deck):
new_deck = []
for i in range(len(deck)):
x = rng.getnum()
if deck[x] not in new_deck:
new_deck.append(deck[x])
elif deck[i] not in new_deck:
new_deck.append(deck[i])
else:
for card in deck:
if card not in new_deck:
new_deck.append(card)
break
return new_deck
```
next, we need to generate the deck
```python
deck = []
deck_str = []
for suit in ["Spades", "Hearts", "Diamonds", "Clubs"]:
for i in range(16):
deck.append(Card(suit, i))
deck_str.append(str(deck[-1]))
deck_str = set(deck_str)
server_deck = list(deck)
```
`server_deck` will hold the copy of the deck that we think the server currently holds
we initialize the game by receiving `p` and `q` and sending `player_e` and `player_d`
```python
r = remote("crypto.csaw.io", 5001)
r.recvuntil(b"Here are")
r.recvuntil(b"--> ")
p, q = r.recvline().decode().strip().split(", ")
p = int(p)
q = int(q)
phi = (p-1)*(q-1)
N = p*q
print("p=", p)
print("q=", q)
player_e = 1
player_d = 1
print("player_e=", player_e)
print("player_d=", player_d)
r.recvuntil(b">>")
r.sendline(str(player_e).encode())
r.recvuntil(b">>")
r.sendline(str(player_d).encode())
```
next, we get the deck for the first round
```python
r.recvuntil(b"***")
print(r.recvuntil(b"--> "))
deck = r.recvline().strip().decode()
deck = ast.literal_eval(deck)
```
now, we do the brute force to guess what the server's PRNG was seeded with. this is done over the 10
bits of seed search space, and the offset to the beginning of the PRNG outputs that are used to
generate the exponent
```python
test_item = deck[0]
found_seed = None
for seed in tqdm.trange(2**10):
prng = PRNG(seed)
rand_values = []
for i in range(256):
rand_values.append(prng.getnum())
found = False
for offset in range(249):
e_array = [str(x) for x in rand_values[offset:offset+6]]
computer_e = int(''.join(e_array))
if gcd(computer_e, phi) != 1:
continue
computer_d = pow(computer_e, -1, phi)
test_card = long_to_bytes(pow(test_item, computer_d, N)).decode('iso-8859-1')
if test_card in deck_str:
print("found seed", test_card, seed)
found = True
found_seed = seed
break
if found:
break
```
now we just play the game, simulating the server's steps as we go and decrypting the cards we
receive using the recovered keys
```python
prng = PRNG(found_seed)
computer_e, computer_d = -1, 0
for rndnum in range(10):
if rndnum > 0:
print(r.recvuntil(b"***"))
roundline = r.recvline()
print("roundline", roundline)
print(r.recvuntil(b"--> "))
deck = r.recvline().strip().decode()
deck = ast.literal_eval(deck)
# print("recv deck", deck)
computer_e, computer_d, server_deck = sim_round(server_deck, prng, phi, computer_e, computer_d)
print(computer_e, computer_d, prng.seed, prng.index, prng.state)
dec_cards = []
for enc_card in deck:
dec_card = long_to_bytes(pow(enc_card, computer_d, N)).decode()
dec_cards.append(card_str_to_Card(dec_card))
print([str(c) for c in dec_cards])
print([str(c) for c in server_deck])
```
since i don't actually know how to make a computer play poker, we simply ask the server functions
whether a deck would be favorable for us, and keep shuffling until it is
(i love some good ctf code cheese)
```python
while True:
random.shuffle(dec_cards)
computer_cards, player_cards = dec_cards[0:5], dec_cards[5:10]
computer_winner, player_winner = determine_winner(computer_cards, player_cards)
if player_winner and not computer_winner:
break
```
encrypt with the computer exponent and send our cooked deck order to the server
```python
server_deck = list(dec_cards)
enc_deck = []
for card in dec_cards:
enc_deck.append(pow(bytes_to_long(str(card).encode()),computer_e,N))
for c in enc_deck:
r.recvuntil(b">> ")
r.sendline(str(c).encode())
```
and after 10 rounds we should have the flag, which we can decrypt with the same computer exponent as
we've been using
```python
r.recvuntil(b"HAHAHA")
r.recvline()
val = ast.literal_eval(r.recvline().decode())
print(long_to_bytes(pow(bytes_to_long(val),computer_d,N)))
```
let's run this locally,
```bash
ncat --exec "/usr/bin/python server.py" -lp 1337 &
python3 exploit.py
[+] Opening connection to localhost on port 1337: Done
p= 1272817120605235218138374468827494843305725876454046855144283728239720564668615378794696017
q= 1119723139502382006362751135685189913928207952277558814504020165396321544235726326572905597
player_e= 1
player_d= 1
roundline b'******* Round 1, current streak is 0 **********\n'
b'Here is the shuffled encrypted deck --> '
28%|██████████████████ | 284/1024 [00:24<01:05, 11.37it/s]found seed One of Clubs 284
28%|██████████████████ | 284/1024 [00:24<01:04, 11.43it/s]
[... lots of spam ...]
My hand is Seven of Hearts, Six of Diamonds, Four of Clubs, Two of Clubs, Zero of Clubs --> I have a High card
Your hand is Joker of Hearts, Ace of Diamonds, Ten of Clubs, Nine of Spades, Six of Spades --> You have a High card
b'csawctf{test_test_test_test}\n'
[*] Closed connection to localhost port 1337
```
full attack script:
<https://git.lain.faith/haskal/writeups/raw/branch/main/2023/csawq/attack_poker.py>