csawq23
This commit is contained in:
parent
270da29054
commit
d98befe85a
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -0,0 +1,14 @@
|
|||
# CSAW quals 2023 post
|
||||
|
||||
i played CSAW quals this past weekend
|
||||
|
||||
there were some good parts and some bad parts. overall i think CSAW has needed a lot more QC in the
|
||||
2 years that i've been playing it
|
||||
|
||||
anyway, given that there are official writeups (looking at them, the quality varies greatly by
|
||||
challenge author, but y'know whatever) i'm going to limit this to two of the challenges i thought
|
||||
were actually good and we can solve them dragon style :)
|
||||
|
||||
writeup index:
|
||||
- [crypto: blocky noncense](#copying-and-pasting-code-you-don-t-entirely-understand-for-fun-and-profit-blocky-noncense)
|
||||
- [crypto: mental poker](#poker-i-barely-even-mental-poker)
|
|
@ -0,0 +1,393 @@
|
|||
## copying and pasting code you don't entirely understand for fun and profit: *blocky noncense*
|
||||
|
||||
> I designed this foolproof signing blockchain. I'll even let you sign as many signatures as you
|
||||
> want!
|
||||
|
||||
it's time for everyone's favorite part of crypto challenges, *source review*
|
||||
|
||||
### `blocks.sage`
|
||||
|
||||
```python
|
||||
from Crypto.Cipher import AES
|
||||
import sig_sage as sig # this is generated from sig.sage
|
||||
import hashlib
|
||||
|
||||
class Chain:
|
||||
def __init__(self, seed):
|
||||
self.flag = b"csaw{[REDACTED]}"
|
||||
self.ecdsa = sig.ECDSA(seed)
|
||||
self.blocks = {0: [hashlib.sha1(self.flag).digest(), self.ecdsa.sign(self.flag)]}
|
||||
|
||||
def commit(self, message, num):
|
||||
formatted = self.blocks[num-1][0] + message
|
||||
sig = self.ecdsa.sign(formatted)
|
||||
self.blocks[num] = [hashlib.sha256(message).digest(), sig]
|
||||
|
||||
def view_messages(self):
|
||||
return self.blocks
|
||||
|
||||
def verify_sig(self, r, s, message):
|
||||
t = self.ecdsa.verify(r, s, message)
|
||||
return t
|
||||
```
|
||||
|
||||
this is the definition of the blockchain, we can see that the first block encodes the SHA-1 hash of
|
||||
the flag, and every subsequent block is the SHA-256 (!) hash of the previous block hash combined
|
||||
with the message, and a signature of the same data
|
||||
|
||||
### `sig.sage`
|
||||
|
||||
```python
|
||||
from Crypto.Util.number import *
|
||||
from Crypto.Cipher import AES
|
||||
import random
|
||||
import hashlib
|
||||
|
||||
def _hash(msg):
|
||||
return bytes_to_long(hashlib.sha1(msg).digest())
|
||||
```
|
||||
|
||||
the hashes used for signature calculations are SHA-1. tbh i'm not sure why some hashes are SHA-1 and
|
||||
others are SHA-256, and it's not relevant for the solution but we need to keep it in mind to be able
|
||||
to replicate operations in the solution code
|
||||
|
||||
```python
|
||||
class LCG:
|
||||
def __init__(self, seed, q):
|
||||
self.q = q
|
||||
self.a = random.randint(2,self.q)
|
||||
self.b = random.randint(2,self.a)
|
||||
self.c = random.randint(2,self.b)
|
||||
self.d = random.randint(2,self.c)
|
||||
self.seed = seed
|
||||
|
||||
def next(self):
|
||||
seed = (self.a*self.seed^3 + self.b*self.seed^2 + self.c*self.seed + self.d) % self.q
|
||||
self.seed = seed
|
||||
return seed
|
||||
```
|
||||
|
||||
this defines a Linear Congruential Generator, a type of PRNG that uses a polynomial over previous
|
||||
state to generate new state. this one uses a cubic polynomial with random coefficients
|
||||
|
||||
```python
|
||||
class ECDSA:
|
||||
def __init__(self, seed):
|
||||
self.p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
self.E = EllipticCurve(GF(self.p), [0, 7])
|
||||
self.G = self.E.lift_x(55066263022277343669578718895168534326250603453777594175500187360389116729240)
|
||||
self.order = 115792089237316195423570985008687907852837564279074904382605163141518161494337
|
||||
|
||||
self.priv_key = random.randint(2,self.order)
|
||||
self.pub_key = self.G*self.priv_key
|
||||
|
||||
self.lcg = LCG(seed, self.order)
|
||||
```
|
||||
|
||||
it's always useful to double check elliptic curve params, so if we look up these numbers we can find
|
||||
out that this is the curve `secp256k1` (also used in bitcoin). so it's just a standard curve, no
|
||||
cheese going on
|
||||
|
||||
the other interesting bit here is the LCG is initialized with a modulus equal to the order of
|
||||
`secp256k1` - this is sort of convenient for later
|
||||
|
||||
```python
|
||||
def sign(self, msg):
|
||||
nonce = self.lcg.next()
|
||||
hashed = _hash(msg)
|
||||
|
||||
r = int((self.G*nonce)[0]) % self.order
|
||||
assert r != 0
|
||||
s = (pow(nonce,-1,self.order)*(hashed + r*self.priv_key)) % self.order
|
||||
return (r,s)
|
||||
```
|
||||
|
||||
the sign function generates an ECDSA signature using the SHA-1 hash of the given message, and a
|
||||
nonce value is computed using the LCG
|
||||
|
||||
one important fact you might know about ECDSA is that it's very dangerous to reuse nonces because it
|
||||
allows recovery of the private key. however, nonce reuse doesn't seem to be an issue here, so we
|
||||
have to find something else
|
||||
|
||||
also i cut out the `verify` function because we don't actually care
|
||||
|
||||
### `chall.sage`
|
||||
|
||||
it basically just makes a menu to interact with the challenge, it's not super interesting
|
||||
|
||||
### elliptic curve crypto
|
||||
|
||||
[skip this section](#duckduckbing-solve-this-for-me)
|
||||
|
||||
i'm going to actually go over ECC even though you really don't actually need to know what's going on
|
||||
at all for this. just for context
|
||||
|
||||
**Group**: it's kind of like a generalization of addition (or multiplication??? don't think about it
|
||||
too hard). for example, the integers with the integer addition operation are a group. specifically,
|
||||
groups are a **set of elements** with a **binary operator** that takes two elements and produces a
|
||||
third element that's in the set, and there's an **identity element** and an **inverse for every
|
||||
element**. also the operation doesn't have to be commutative
|
||||
|
||||
**Field**: a generalization of addition, subtraction, multiplication, and division. something we're
|
||||
interested in here is a *finite field* which is also called a *Galois field* (after this weird
|
||||
french revolutionary who solved all of math in his teens and then died in a duel -- fucking
|
||||
frenchmen am i right). a convenient type of finite field is integers modulo a prime number `p`. you
|
||||
can take a moment to convince yourself that all arithmetic still works kinda like normal when it's
|
||||
all modulo a prime number (also think of division as a multiplicative inverse). or don't, i'm not a
|
||||
textbook author. this is called `GF(p)`
|
||||
|
||||
**Elliptic Curve**: an elliptic curve is defined by the equation `y^2 = x^3 + ax + b`. elliptic
|
||||
curves can also be defined over *Galois fields*, in which case all the coordinates of points on the
|
||||
curve will be integers. elliptic curves can conveniently form *groups* with the group operation of
|
||||
point multiplication. don't worry about point multiplication except knowing that it takes two curve
|
||||
points and produces a third point
|
||||
|
||||
when considering some elliptic curve `E` on `GF(p)`, a group can be formed using a generator point
|
||||
`G` that is on the curve, and that is capable of producing every other point in the group by using
|
||||
the group operator some number of times. so we say that `G` "multiplied" by some scalar `s` as in
|
||||
`G*s` means that you are applying the group operation on `G` with itself `s` times. confusingly, we
|
||||
notate this `G*s` despite the fact that it's really more analogous to "raising to a power". but
|
||||
sometimes, if you have two elliptic curve points `A` and `B`, then `A*B` means simply doing the
|
||||
group operation on `A` and `B` once. math people are normal, don't worry about it
|
||||
|
||||
the elliptic curve group also has an *order* `q`, which is the number of elements in the group. the
|
||||
order is not necessarily the same as the size of the field that the curve is defined on (`p != q`)
|
||||
|
||||
there are a bunch of standard curves which are predefined for use in cryptography, so you don't have
|
||||
to generate your own curves from scratch, which is hard. NIST produces several, and `secp256k1` is
|
||||
one of the NIST curves
|
||||
|
||||
the property that makes this useful for cryptography is the difficulty of the *Discrete Logarithm
|
||||
Problem*. basically, if you do `G*s` for some secret `s`, it's computationally Very Hard™ to recover
|
||||
what `s` was
|
||||
|
||||
and now, finally, we get to signatures. first, generate a keypair. `d` is the private key, a scalar
|
||||
in the range of `0..q`, and the public key is `Q = G*d`, a curve point
|
||||
|
||||
to sign a message `m`, take its hash `H(m)`. then, generate a random nonce `k` (the RNG must be very
|
||||
strong). produce two values
|
||||
```text
|
||||
r = (G*k).x % q
|
||||
s = (k^-1) * (H(m) + r*d) % q
|
||||
```
|
||||
|
||||
(btw here `(expr).x` means the x-coordinate of a curve point. also sorry for the confusing mixture
|
||||
of group operation and regular arithmetic in `Zmod(q)`. for further questions, consult the nearest
|
||||
wikipedia and/or crypto girl infodump)
|
||||
|
||||
the signature consists of the pair `(r, s)`, which you can also see in the challenge code
|
||||
|
||||
next, to verify a signature
|
||||
|
||||
```text
|
||||
u1 = H(m)*s^-1 % n
|
||||
u2 = r*s^-1 % n
|
||||
|
||||
r == (u1*G + u2*Q).x
|
||||
```
|
||||
|
||||
extracting the private key from a signature would require solving the discrete log problem, and
|
||||
signatures cannot be forged without the private key
|
||||
|
||||
and that's basically it. now you know about elliptic curves
|
||||
|
||||
### duckduckbing solve this for me
|
||||
|
||||
top 10 CTF skills you need to have in 2023 -- search online for the solution to a problem
|
||||
|
||||
so i immediately went and searched for something like "ECDSA LCG attack", y'know just based on the
|
||||
algorithms that are present here and what we want to do, and find this 2023 paper:
|
||||
<https://eprint.iacr.org/2023/305.pdf>
|
||||
|
||||
that's pretty convenient, but we don't have time to read a crypto paper, so scroll down to where
|
||||
they list the PoC repo:
|
||||
<https://github.com/kudelskisecurity/ecdsa-polynomial-nonce-recurrence-attack>
|
||||
|
||||
(the key takeaway is that you can construct a polynomial out of the signatures, knowing the
|
||||
structure of the recurrence relation for the nonces, and then one of the roots of the polynomial is
|
||||
the private key used for the signatures)
|
||||
|
||||
we need to adapt this to the challenge implementation of #blockchain, so next we take a look at the
|
||||
basic implementation of the attack, ignoring all the stuff that is specific to bitcoin and ethereum,
|
||||
which is in `original-attack/recurrence_nonces.py`
|
||||
|
||||
first, there's a comment explaining how many signatures we need to collect for our case
|
||||
|
||||
```python
|
||||
# N = the number of signatures to use, N >= 4
|
||||
# the degree of the recurrence relation is N-3
|
||||
# the number of unknown coefficients in the recurrence equation is N-2
|
||||
# the degree of the final polynomial in d is 1 + Sum_(i=1)^(i=N-3)i
|
||||
```
|
||||
|
||||
since the LCG has 4 unknown coefficients, we need `N=6`
|
||||
|
||||
now, we simply need to do the pro gamer strategy of copying and pasting code we barely understand
|
||||
|
||||
```python
|
||||
N = 6
|
||||
order=115792089237316195423570985008687907852837564279074904382605163141518161494337
|
||||
|
||||
Z = GF(order)
|
||||
R = PolynomialRing(Z, names=('dd',))
|
||||
(dd,) = R._first_ngens(1)
|
||||
|
||||
# the polynomial we construct will have degree 1 + Sum_(i=1)^(i=N-3)i in dd
|
||||
# our task here is to compute this polynomial in a constructive way starting from the N signatures in the given list order
|
||||
# the generic formula will be given in terms of differences of nonces, i.e. k_ij = k_i - k_j where i and j are the signature indexes
|
||||
# each k_ij is a first-degree polynomial in dd
|
||||
# this function has the goal of returning it given i and j
|
||||
def k_ij_poly(i, j):
|
||||
hi = Z(h[i])
|
||||
hj = Z(h[j])
|
||||
s_invi = Z(s_inv[i])
|
||||
s_invj = Z(s_inv[j])
|
||||
ri = Z(r[i])
|
||||
rj = Z(r[j])
|
||||
poly = dd*(ri*s_invi - rj*s_invj) + hi*s_invi - hj*s_invj
|
||||
return poly
|
||||
|
||||
# the idea is to compute the polynomial recursively from the given degree down to 0
|
||||
# the algorithm is as follows:
|
||||
# for 4 signatures the second degree polynomial is:
|
||||
# k_12*k_12 - k_23*k_01
|
||||
# so we can compute its coefficients.
|
||||
# the polynomial for N signatures has degree 1 + Sum_(i=1)^(i=N-3)i and can be derived from the one for N-1 signatures
|
||||
|
||||
# let's define dpoly(i, j) recursively as the dpoly of degree i starting with index j
|
||||
|
||||
def dpoly(n, i, j):
|
||||
if i == 0:
|
||||
return (k_ij_poly(j+1, j+2))*(k_ij_poly(j+1, j+2)) - (k_ij_poly(j+2, j+3))*(k_ij_poly(j+0, j+1))
|
||||
else:
|
||||
left = dpoly(n, i-1, j)
|
||||
for m in range(1,i+2):
|
||||
left = left*(k_ij_poly(j+m, j+i+2))
|
||||
right = dpoly(n, i-1, j+1)
|
||||
for m in range(1,i+2):
|
||||
right = right*(k_ij_poly(j, j+m))
|
||||
return (left - right)
|
||||
```
|
||||
|
||||
### rest of the fucking owl
|
||||
|
||||
now with the "hard" stuff out of the way we're actually gaming
|
||||
|
||||
let's build the rest of the solution
|
||||
|
||||
```python
|
||||
def _hex(x):
|
||||
return binascii.hexlify(x).decode()
|
||||
|
||||
def _hash(msg):
|
||||
return bytes_to_long(hashlib.sha1(msg).digest())
|
||||
|
||||
conn = nclib.Netcat(("crypto.csaw.io", 5002))
|
||||
```
|
||||
|
||||
so the strategy is: we make 6 messages with known contents, and then we retrieve the contents of the
|
||||
blockchain, which includes the message hashes (note: SHA-256 hashes, the ones used for block
|
||||
chaining, not the SHA-1 hashes used for the signature itself) and signatures. this also retrieves
|
||||
the original block containing the flag, but since we don't know the flag we don't include that in
|
||||
the attack algorithm
|
||||
|
||||
```python
|
||||
h = []
|
||||
sgns = []
|
||||
msgs = []
|
||||
|
||||
def get_messages(count):
|
||||
all_msgs = []
|
||||
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"2")
|
||||
|
||||
for i in range(count):
|
||||
conn.recv_until(f"Block {i}\n".encode())
|
||||
msg_line = conn.recv_line()
|
||||
print(msg_line)
|
||||
msg = binascii.unhexlify(msg_line.strip().split(b" ")[1])
|
||||
sig_line = conn.recv_line()
|
||||
sig_rs = sig_line.decode().split("(")[1].split(")")[0]
|
||||
r, s = sig_rs.split(", ")
|
||||
r = int(r)
|
||||
s = int(s)
|
||||
all_msgs.append((msg, r, s))
|
||||
|
||||
return all_msgs
|
||||
|
||||
for i in range(N):
|
||||
msg = f"meow{i}".encode()
|
||||
msgs.append(msg)
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"1")
|
||||
conn.send_line(_hex(msg))
|
||||
|
||||
all_msgs = get_messages(N+1)
|
||||
```
|
||||
|
||||
next, we process the data into a form suitable for the paper's PoC code
|
||||
|
||||
```python
|
||||
for i in range(N):
|
||||
(_, r, s) = all_msgs[i+1]
|
||||
prev_msg = all_msgs[i][0]
|
||||
real_hash = _hash(prev_msg + msgs[i])
|
||||
h.append(real_hash)
|
||||
sgns.append((r,s))
|
||||
|
||||
s_inv = []
|
||||
s = []
|
||||
r = []
|
||||
for i in range(N):
|
||||
s.append(sgns[i][1])
|
||||
r.append(sgns[i][0])
|
||||
s_inv.append(ecdsa.numbertheory.inverse_mod(s[i], order))
|
||||
```
|
||||
|
||||
finally, do the attack
|
||||
|
||||
```python
|
||||
poly_target = dpoly(N-4, N-4, 0)
|
||||
d_guesses = poly_target.roots()
|
||||
print("results!!!!!\n\n\n")
|
||||
print(d_guesses)
|
||||
```
|
||||
|
||||
and send it to the server, which should then give back the flag
|
||||
|
||||
```python
|
||||
if len(d_guesses) == 1:
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"4")
|
||||
res = str(d_guesses[0][0]).encode()
|
||||
print("SENDING", res)
|
||||
conn.send_line(res)
|
||||
print(conn.recv_all(timeout=5))
|
||||
```
|
||||
|
||||
let's run this on the local copy and you can see it prints the flag
|
||||
|
||||
```bash
|
||||
ncat --exec "/usr/bin/sage chall.sage" -lp 1337 &
|
||||
|
||||
sage attack.sage
|
||||
|
||||
b'Message 0058d5ebdef0730f71787e6ff2617ffdd3d0060f\n'
|
||||
b'Message 5be17c92f7ecb50ab5a44969957c3c036122f7dbd8e2cd4fd7a5c1974ba22f66\n'
|
||||
b'Message fec07ac858649497dedb5136d9c3eff1e61394c549286888fb0a893d74f035f2\n'
|
||||
b'Message 1dbbae23136fd579d032d52cb6c5b516ead212103f0d2285092b9c5f1412bced\n'
|
||||
b'Message 4231d9b332338107def060b2a271bf6d6aded7b643b54744538e99901b38886a\n'
|
||||
b'Message 2014ed4b6e2ba09e8d3c5be90d81881d4ac30531adc7644436122386d2ee42b3\n'
|
||||
b'Message 844005fbfdd684f7e9e07f751a48d2550b288f702bb3ed0004735471a41b1229\n'
|
||||
(((k12*k12-k23*k01)*k13*k23-(k23*k23-k34*k12)*k01*k02)*k14*k24*k34-((k23*k23-k34*k12)*k24*k34-(k34*k34-k45*k23)*k12*k13)*k01*k02*k03)results!!!!!
|
||||
|
||||
|
||||
|
||||
[(109627846673527107412168645315617142862787762347616623632546915862323406286018, 1)]
|
||||
SENDING b'109627846673527107412168645315617142862787762347616623632546915862323406286018'
|
||||
b"So you think you can get the flag huh? Try your luck.\nPrivate Key: You must be our admin. Here's the flag b'csaw{[REDACTED]}'\n"
|
||||
```
|
||||
|
||||
full attack script:
|
||||
<https://git.lain.faith/haskal/writeups/raw/branch/main/2023/csawq/attack_blocky.sage>
|
|
@ -0,0 +1,371 @@
|
|||
## 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>
|
|
@ -0,0 +1,5 @@
|
|||
## that's it
|
||||
|
||||
```
|
||||
:wq
|
||||
```
|
|
@ -0,0 +1,133 @@
|
|||
import nclib
|
||||
import ecdsa
|
||||
import hashlib
|
||||
import binascii
|
||||
from Crypto.Util.number import *
|
||||
|
||||
def _hex(x):
|
||||
return binascii.hexlify(x).decode()
|
||||
|
||||
def _hash(msg):
|
||||
return bytes_to_long(hashlib.sha1(msg).digest())
|
||||
|
||||
conn = nclib.Netcat(("crypto.csaw.io", 5002))
|
||||
# conn = nclib.Netcat(("localhost", 1337))
|
||||
|
||||
N = 6
|
||||
h = []
|
||||
sgns = []
|
||||
msgs = []
|
||||
|
||||
def get_messages(count):
|
||||
all_msgs = []
|
||||
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"2")
|
||||
|
||||
for i in range(count):
|
||||
conn.recv_until(f"Block {i}\n".encode())
|
||||
msg_line = conn.recv_line()
|
||||
print(msg_line)
|
||||
msg = binascii.unhexlify(msg_line.strip().split(b" ")[1])
|
||||
sig_line = conn.recv_line()
|
||||
sig_rs = sig_line.decode().split("(")[1].split(")")[0]
|
||||
r, s = sig_rs.split(", ")
|
||||
r = int(r)
|
||||
s = int(s)
|
||||
all_msgs.append((msg, r, s))
|
||||
|
||||
return all_msgs
|
||||
|
||||
for i in range(N):
|
||||
msg = f"meow{i}".encode()
|
||||
msgs.append(msg)
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"1")
|
||||
conn.send_line(_hex(msg))
|
||||
|
||||
all_msgs = get_messages(N+1)
|
||||
|
||||
for i in range(N):
|
||||
(_, r, s) = all_msgs[i+1]
|
||||
prev_msg = all_msgs[i][0]
|
||||
real_hash = _hash(prev_msg + msgs[i])
|
||||
h.append(real_hash)
|
||||
sgns.append((r,s))
|
||||
|
||||
order=115792089237316195423570985008687907852837564279074904382605163141518161494337
|
||||
|
||||
s_inv = []
|
||||
s = []
|
||||
r = []
|
||||
for i in range(N):
|
||||
s.append(sgns[i][1])
|
||||
r.append(sgns[i][0])
|
||||
s_inv.append(ecdsa.numbertheory.inverse_mod(s[i], order))
|
||||
|
||||
Z = GF(order)
|
||||
R = PolynomialRing(Z, names=('dd',))
|
||||
(dd,) = R._first_ngens(1)
|
||||
|
||||
# the polynomial we construct will have degree 1 + Sum_(i=1)^(i=N-3)i in dd
|
||||
# our task here is to compute this polynomial in a constructive way starting from the N signatures in the given list order
|
||||
# the generic formula will be given in terms of differences of nonces, i.e. k_ij = k_i - k_j where i and j are the signature indexes
|
||||
# each k_ij is a first-degree polynomial in dd
|
||||
# this function has the goal of returning it given i and j
|
||||
def k_ij_poly(i, j):
|
||||
hi = Z(h[i])
|
||||
hj = Z(h[j])
|
||||
s_invi = Z(s_inv[i])
|
||||
s_invj = Z(s_inv[j])
|
||||
ri = Z(r[i])
|
||||
rj = Z(r[j])
|
||||
poly = dd*(ri*s_invi - rj*s_invj) + hi*s_invi - hj*s_invj
|
||||
return poly
|
||||
|
||||
# the idea is to compute the polynomial recursively from the given degree down to 0
|
||||
# the algorithm is as follows:
|
||||
# for 4 signatures the second degree polynomial is:
|
||||
# k_12*k_12 - k_23*k_01
|
||||
# so we can compute its coefficients.
|
||||
# the polynomial for N signatures has degree 1 + Sum_(i=1)^(i=N-3)i and can be derived from the one for N-1 signatures
|
||||
|
||||
# let's define dpoly(i, j) recursively as the dpoly of degree i starting with index j
|
||||
|
||||
def dpoly(n, i, j):
|
||||
if i == 0:
|
||||
return (k_ij_poly(j+1, j+2))*(k_ij_poly(j+1, j+2)) - (k_ij_poly(j+2, j+3))*(k_ij_poly(j+0, j+1))
|
||||
else:
|
||||
left = dpoly(n, i-1, j)
|
||||
for m in range(1,i+2):
|
||||
left = left*(k_ij_poly(j+m, j+i+2))
|
||||
right = dpoly(n, i-1, j+1)
|
||||
for m in range(1,i+2):
|
||||
right = right*(k_ij_poly(j, j+m))
|
||||
return (left - right)
|
||||
|
||||
def print_dpoly(n, i, j):
|
||||
if i == 0:
|
||||
print('(k', j+1, j+2, '*k', j+1, j+2, '-k', j+2, j+3, '*k', j+0, j+1, ')', sep='', end='')
|
||||
else:
|
||||
print('(', sep='', end='')
|
||||
print_dpoly(n, i-1, j)
|
||||
for m in range(1,i+2):
|
||||
print('*k', j+m, j+i+2, sep='', end='')
|
||||
print('-', sep='', end='')
|
||||
print_dpoly(n, i-1, j+1)
|
||||
for m in range(1,i+2):
|
||||
print('*k', j, j+m, sep='', end='')
|
||||
print(')', sep='', end='')
|
||||
|
||||
print_dpoly(N-4, N-4, 0)
|
||||
poly_target = dpoly(N-4, N-4, 0)
|
||||
d_guesses = poly_target.roots()
|
||||
print("results!!!!!\n\n\n")
|
||||
print(d_guesses)
|
||||
|
||||
if len(d_guesses) == 1:
|
||||
conn.recv_until(b": ")
|
||||
conn.send_line(b"4")
|
||||
res = str(d_guesses[0][0]).encode()
|
||||
print("SENDING", res)
|
||||
conn.send_line(res)
|
||||
print(conn.recv_all(timeout=5))
|
|
@ -0,0 +1,161 @@
|
|||
from pwn import *
|
||||
import ast
|
||||
import random
|
||||
from math import gcd
|
||||
from Crypto.Util.number import bytes_to_long, long_to_bytes
|
||||
|
||||
from server import PRNG, Card, card_str_to_Card, determine_winner
|
||||
|
||||
import tqdm
|
||||
|
||||
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
|
||||
|
||||
def exploit():
|
||||
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)
|
||||
|
||||
# r = process(["python3", "server.py"])
|
||||
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 = 65537
|
||||
# player_d = pow(player_e, -1, phi)
|
||||
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())
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
|
||||
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())
|
||||
|
||||
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)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
exploit()
|
|
@ -0,0 +1,16 @@
|
|||
builddir = build
|
||||
|
||||
pandocflags = --filter writefreely-validate --wrap=none
|
||||
|
||||
postid = bdg7m2ck2d
|
||||
|
||||
rule pandoc
|
||||
command = pandoc $pandocflags -o $out $in
|
||||
description = pandoc $out
|
||||
|
||||
rule upload
|
||||
command = writefreely-cli $postid $in
|
||||
description = upload $postid
|
||||
|
||||
build $builddir/post-full.md: pandoc 00-intro.md 01-crypto-blocky.md 02-crypto-poker.md 03-end.md
|
||||
build upload: upload $builddir/post-full.md
|
Loading…
Reference in New Issue