Skip to content

Commit f49ca5e

Browse files
authored
Merge pull request #106 from ubcctf/add-cryptoverse-writeup
Add cryptoverse writeup
2 parents f1253ba + 0ac4766 commit f49ca5e

File tree

2 files changed

+216
-0
lines changed

2 files changed

+216
-0
lines changed

_posts/2022-10-22-cryptoverse-2022.md

+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
---
2+
layout: post
3+
title: "[CryptoVerse 2022] A Tale of Two Systems"
4+
author: vEvergarden
5+
---
6+
7+
*Cross-posted @ [kevinliu.me/posts/cryptoverse/](https://kevinliu.me/posts/cryptoverse/)*
8+
9+
![Scoreboard](/assets/images/cryptoverse/scoreboard.png)
10+
11+
Another win for Maple Bacon!
12+
13+
# A Tale of Two Systems
14+
15+
> It's impossible to get a secure cryptosystem by combining two insecure cryptosystems.
16+
17+
This challenge is split into two stages independent from each other.
18+
19+
## Stage 1
20+
21+
```python
22+
NBITS = 1024
23+
24+
def system_one(m: bytes):
25+
a, b = [getRandomNBitInteger(NBITS) for _ in range(2)]
26+
p = getPrime(2 * NBITS)
27+
q = getPrime(NBITS // 2)
28+
g = inverse(a, p) * q % p
29+
30+
ct = (b * g + bytes_to_long(m)) % p
31+
32+
print(f"p = {p}")
33+
print(f"g = {g}")
34+
print(f"ct = {ct}\n")
35+
```
36+
37+
At first, this system doesn't seem solvable because $a$ and $b$ are randomly generated and used in the encryption process. However, since we know the relative sizes of the parameters, we can apply some ✨ lattice magic ✨ to recover $a$ and $b$, and subsequently $m$.
38+
39+
First, the sizes of the internal variables are bounded by:
40+
- $a$ - 1024 bits
41+
- $b$ - 1024 bits
42+
- $p$ - 2048 bits
43+
- $q$ - 512 bits
44+
- $g$ - 2048 bits
45+
- $ct$ - 2048 bits
46+
47+
From the encryption process, we have that:
48+
49+
$$g \equiv a^{-1}q \mod p$$
50+
51+
$$ga \equiv q \mod p$$
52+
53+
Thus, there exists some integer $k$ such that $ga = pk + q$.
54+
55+
Since $ga$ is 3072 bits and $p$ is 2048 bits, $k$ will be around 1024 bits.
56+
57+
Now, consider the lattice:
58+
59+
$$\begin{bmatrix}
60+
p & 1 \\
61+
g & 1
62+
\end{bmatrix}$$
63+
64+
Notice that we can express the vector $(q, a-k)$ as a linear combination of the rows of the lattice:
65+
66+
$$(q, a-k) = a(g, 1) + -k(p, 1)$$
67+
68+
Since $q$ is 512 bits while $a$ and $k$ are 1024 bits, this vector is "small" compared to the original rows with 2048 bit numbers. So, we can apply LLL and hope that the small basis vector is $(q, a-k)$:
69+
70+
```python
71+
m = Matrix([[p, 1], [g, 1]])
72+
print(m.LLL())
73+
```
74+
75+
Now that we have $q$ and $a$ from the lattice reduction, we can decrypt the message.
76+
77+
Observe that:
78+
79+
$$a \cdot ct \equiv abg + am \equiv (g^{-1}q)bg + am \equiv bq + am \mod p$$
80+
81+
However, since $p$ is 2048-bit and $bg$ and $am$ are both around ~1500 bits,
82+
83+
$$bq + am < p$$
84+
85+
From this, recovering $m$ is easy:
86+
87+
$$m \equiv (bq + am)a^{-1} \mod q$$
88+
89+
```python
90+
m = Matrix([[p, 1], [g, 1]])
91+
m = m.LLL()
92+
q = m[0][0]
93+
a = inverse(g, p) * q % p
94+
temp = a * ct % p
95+
m = temp * inverse(a, q) % q
96+
print(long_to_bytes(m))
97+
```
98+
99+
Flag: `cvctf{n0_On3_1S_u53l355_1n_7h15_w0r1d_...`
100+
101+
## Stage 2
102+
103+
```python
104+
def system_two(m: bytes):
105+
p, q = [getPrime(NBITS // 2) for _ in range(2)]
106+
n = p * q
107+
e = 0x10001
108+
ct = pow(bytes_to_long(m), e, n)
109+
110+
print(f"n = {n}")
111+
print(f"e = {e}")
112+
print(f"ct = {ct}")
113+
114+
# what if q is reversed?
115+
q = int('0b' + ''.join(reversed(bin(q)[2:])), 2)
116+
hint = p + q - 2 * (p & q)
117+
print(f"hint = {hint}")
118+
```
119+
120+
First, let's make sense of the hint: what does `x + y - 2 * (x & y)` represent for two arbitrary integers $x$ and $y$?
121+
122+
Let's say $a_i$ represents the $i$th bit of a number $a$. Working in binary, $(x \\& y)_i$ is 1 if and only if $x_i = y_i = 1$. When we add $x$ and $y$ in those positions, the sum of $1+1$ in binary will carry over to the next position. But this is perfectly cancelled out when we subtract $(x \\& y)$ shifted to the left by 1.
123+
124+
In other words, if $x_i = y_i = 0$ or if $x_i=y_i=1$, then the corresponding bit $(x \\& y)_i$ is 0. This is just the XOR operation between $x$ and $y$!
125+
126+
Our goal is to factor $n$, and we currently know:
127+
- $n = pq$
128+
- $p \oplus rev(q)$
129+
130+
### Similar: XORSA
131+
A similar problem appeared in [PlaidCTF](https://ctftime.org/task/15578), where $p \oplus q$ was given. The central idea of one solution is to find the bits of $p$ and $q$ one by one.
132+
133+
Starting from the lowest bit, there are always two possibilities for $(p, q)$ since we know $(p \oplus q)$. We can try both of these possibilities recursively, for a naive solution of $2^k$ where $k$ is the number of bits in $n$. But, we can prune the majority of the search space by checking that
134+
$$p_0 \cdot q_0 \equiv n \mod 2^l$$
135+
136+
for our current $p_0$ and $q_0$ (lower bits of $p$ and $q$). This optimization is enough to solve the XORSA challenge.
137+
138+
### Back on track
139+
Let's apply a similar idea to our problem, but instead of starting only from the lower bits, we recurse over the lower and higher bits simultaneously.
140+
141+
Specifically, we can guess the highest bits of $rev(q)$ and the highest bits of $p$. At the same time, we can guess the lowest bits of $rev(q)$ and the lowest bits of $p$. Note that the highest bits of $rev(q)$ are the lowest bits of $q$, and vice versa.
142+
143+
To optimize this brute force, at each step we prune over the lower bits by checking that:
144+
145+
$$p_{low} \cdot q_{low} \equiv n \mod 2^l$$
146+
147+
Additionally, we prune over the top bits:
148+
- If we set the remaining bits of $p_{high}$ and $q_{high}$ to $1$, the product $p_{high}q_{high}$ must be greater than $n$
149+
- If we set the remaining bits of $p_{high}$ and $q_{high}$ to $0$, the product $p_{high}q_{high}$ must be less than $n$.
150+
151+
The final solve script:
152+
```python
153+
n = 153342396916538105228389...
154+
e = 65537
155+
ct = 1073382308863262771844706024...
156+
# p xor (reverse q)
157+
xor = 35510848380770904338319...
158+
NBITS = 512
159+
160+
# q = highQ || lowQ and p = highP || lowP, where all high/low have idx bits
161+
def find(idx, lowP, highP, lowQ, highQ):
162+
163+
if idx == NBITS:
164+
assert highP * highQ == n
165+
print("FOUND!")
166+
print(highP)
167+
print(highQ)
168+
exit()
169+
170+
171+
highX = (xor >> (NBITS - 1 -idx)) & 1
172+
lowX = (xor >> idx) & 1
173+
174+
possibleLow = []
175+
possibleHigh = []
176+
177+
# find possible (highP, lowQ) pairs from the MSB of the XOR
178+
if highX == 1:
179+
possibleHigh.append(((highP << 1) | 1, lowQ))
180+
possibleHigh.append((highP << 1, lowQ + (1 << idx)))
181+
else:
182+
possibleHigh.append((highP << 1, lowQ))
183+
possibleHigh.append(((highP << 1) | 1, lowQ + (1 << idx)))
184+
185+
# find possible (lowP, highQ) pairs from the LSB of the XOR
186+
if lowX == 1:
187+
possibleLow.append((lowP, (highQ << 1) | 1))
188+
possibleLow.append((lowP + (1 << idx), highQ << 1))
189+
else:
190+
possibleLow.append((lowP, highQ << 1))
191+
possibleLow.append((lowP + (1 << idx), (highQ << 1) | 1))
192+
193+
194+
for highP, lowQ in possibleHigh:
195+
for lowP, highQ in possibleLow:
196+
# prune lower bits
197+
if lowP * lowQ % (1 << (idx + 1)) != n % (1 << (idx + 1)):
198+
continue
199+
200+
pad = NBITS-1-idx
201+
202+
# check upper bit bounds
203+
if (highP << pad) * (highQ << pad) > n:
204+
continue
205+
206+
if ((highP << pad) + (1 << pad) - 1) * ((highQ << pad) + (1 << pad) - 1) < n:
207+
continue
208+
209+
find(idx+1, lowP, highP, lowQ, highQ)
210+
211+
find(0, 0, 0, 0, 0)
212+
```
213+
214+
Partial flag: `_wH0_l16h73N5_tHe_BurD3nS_0F_4n07h3R.-_-}`
215+
216+
Final flag: `cvctf{n0_On3_1S_u53l355_1n_7h15_w0r1d_..._wH0_l16h73N5_tHe_BurD3nS_0F_4n07h3R.-_-}`
30.4 KB
Loading

0 commit comments

Comments
 (0)