Skip to content

Commit 2d5a6fc

Browse files
committed
google ctf writeups
1 parent f09bc96 commit 2d5a6fc

File tree

12 files changed

+1021
-0
lines changed

12 files changed

+1021
-0
lines changed

2018-06-23-google-ctf/README.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Google CTF 2018 Quals
2+
3+
Team: c7f.m0d3, akrasuski1, chivay, rodbert, eternal, sasza, nazywam, monk, shalom
4+
5+
### Table of contents
6+
7+
* [Perfect secrecy (crypto)](crypto_secrecy)
8+
* [MITM (crypto)](crypto_mitm)
9+
* [Dogestore (crypto)](crypto_dogestore)
10+
* [Translate (web)](web_translate)
11+
* [Cat chat (web)](web_catchat)
12+
* [Shall we play a game (re)](re_shallweplay)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Dogestore (crypto, 267p, 27 sovled)
2+
3+
In the task we get [server code](fragment.rs), [encrypted flag](encrypted_secret) and endpoint to connect to.
4+
What the code does is pretty simple, even if you don't know Rust:
5+
6+
1. Read input bytes from user.
7+
2. Decrypt the data using AES-CTR.
8+
3. Deserialize the data by matching neighbouring bytes together in pairs, so for example [1,2,3,4,5,6] becomes [(1,2),(3,4),(5,6)].
9+
4. Decode the data by treating the first byte in the pair as letter and second byte as number of repetitions of this letter to which 1 is added, thus for example pair ('A',3) becomes 'AAAA', then all those bytes are connected into a single vector.
10+
5. Calculate sha3_256 hash over the resulting bytes.
11+
6. Send calcualted hash value to the user.
12+
13+
The main vulnerability here is quite obvious and simple to notice:
14+
```
15+
iv = get_iv();
16+
openssl::symm::decrypt(
17+
openssl::symm::Cipher::aes_256_ctr(),
18+
&key,
19+
Some(&iv),
20+
data
21+
)
22+
```
23+
24+
AES in CTR mode does not use IV, but a counter instead.
25+
Counter should not be repeating in predictable manner, and in no circumstances should be constant, like in our code.
26+
This is because AES-CTR is a stream cipher, which generates keystream by AES encryption using provided key and counter values.
27+
For given key and counter, it will generate exactly the same keystream every time.
28+
Therefore in reality, what we have here is simply XOR of the data we send, with some constant keystream.
29+
30+
This means, that in order to decrypt the flag we basically need to leak this keystream from the server.
31+
Initially we though that `res.extend(vec![letter; size as usize + 1].iter())` is vulnerable, because if `size` would be `255` and it's `uint8` then `size+1` would overflow to `0`, but unfortunately we have `size as usize` so it won't work.
32+
33+
After some brainstorming we figured we could try to generate hash collisions and leverage birthday attack idea.
34+
Let's imagine we set all payload bytes to 0 and focus only on the first 4 bytes of the payload.
35+
36+
By setting those 4 bytes to random values we get some data `AxBy` where `A` and `B` are `letters` and `x` and `y` are counters by which those letters will be multiplied during `decode` step.
37+
We can calculate hash of this input and save it as reference.
38+
39+
Now if we bitflip the counters, there is a chance that we get two new counters `v` and `z` such that `x+y == v+z`..
40+
If we were lucky and we initially got `A == B` then such scenario will give a collision of the hashes, because the hashed string will have the same prefix `lettter * (x+y+1)` in both cases.
41+
42+
If we were not so lucky, we create new `AxBy` payload and try again.
43+
It takes a while, but we can generate collisions such way.
44+
45+
If this collision happens then we know the decrypted `A` and `B` were identical characters, so `payload[0]^KEY[0] == payload[2]^KEY[2]`.
46+
And we know `payload` bytes we sent, so we can transform this to `KEY[0]^KEY[2] == payload[0]^payload[2]`.
47+
48+
We can then shift right by 2 bytes, and calcualte collision for `KEY[2]^KEY[4]` and so on.
49+
50+
If we can now guess the first KEY byte, we can recover all even KEY bytes.
51+
52+
The described algorith is:
53+
54+
```python
55+
def find(key_byte_number, get_result_fun=get_result):
56+
payload = [0] * 110
57+
attempts = 0
58+
while True:
59+
attempts += 1
60+
if attempts % 5 == key_byte_number % 5:
61+
print(key_byte_number, attempts)
62+
payload[key_byte_number] = 0
63+
payload[key_byte_number + 1] = random.randint(0, 255)
64+
payload[key_byte_number + 2] = random.randint(0, 255)
65+
payload[key_byte_number + 3] = random.randint(0, 255)
66+
res = get_result_fun(payload)
67+
for i in range(4):
68+
pay2 = payload[:]
69+
pay2[key_byte_number + 1] ^= 1 << i
70+
pay2[key_byte_number + 3] ^= 1 << i
71+
r2 = get_result_fun(pay2)
72+
if res == r2:
73+
print("KEY[%d] ^ KEY[%d] = %d" % (key_byte_number, key_byte_number + 2, payload[key_byte_number + 2]))
74+
print(res, r2, payload, pay2)
75+
return payload[key_byte_number + 2]
76+
```
77+
78+
As usual, we would like to test this offline in some sanity test scenario, to verify it works:
79+
80+
```python
81+
def sanity_test():
82+
secret = "alamakotaa"
83+
84+
def decrypt(data):
85+
return xor_string(secret, data)
86+
87+
def deserialize(decrypted):
88+
return chunk(decrypted, 2)
89+
90+
def decode(secret):
91+
return "".join([a * (ord(b) + 1) for a, b in secret])
92+
93+
def mimick_server(data):
94+
import sha3
95+
decrypted = decrypt(data)
96+
secret = deserialize(decrypted)
97+
expanded = decode(secret)
98+
return sha3.sha3_256(expanded).digest()
99+
100+
def fake_get_result(data):
101+
payload_bytes = "".join(map(chr, data))
102+
return base64.b64encode(mimick_server(payload_bytes))
103+
104+
flag = xor_string("CTF{XXXXX}", secret)
105+
found = []
106+
for i in range(0, len(secret) - 2, 2):
107+
found.append(find(i, fake_get_result))
108+
print(found)
109+
```
110+
111+
We mimick the server code in python, replacing AES with simple xor, and instantly we get all the collisions and the result: `[0, 0, 14, 14]`.
112+
Which is true since `'a'^'a' == 0` and `'a'^'o' == 14`.
113+
114+
We can therefore run this code against the real server to recover the even KEY bytes, we simply need to use a different `get_result` function:
115+
```
116+
from crypto_commons.netcat.netcat_commons import nc, send
117+
118+
119+
def get_result(payload):
120+
url = "dogestore.ctfcompetition.com"
121+
port = 1337
122+
while True:
123+
try:
124+
s = nc(url, port)
125+
payload_bytes = "".join(map(chr, payload))
126+
send(s, payload_bytes)
127+
result = s.recv(9999)
128+
return result
129+
except:
130+
pass
131+
```
132+
133+
And after some long while we recover: `[191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178, 26, 115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]`
134+
135+
Now we need to guess the first KEY character.
136+
We can actually brute-force it locally, because half of the keystream should already recover some reasonable flag part from the payload we have.
137+
We can therefore simply check every possible value for `KEY[0]`, fill odd bytes with `\0` and decrypt the flag:
138+
139+
```python
140+
def brute_first():
141+
found = [191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178,
142+
26,
143+
115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]
144+
with codecs.open("encrypted_secret") as flag_file:
145+
flag = flag_file.read()
146+
for first in range(256):
147+
real_even_keystream = [chr(first)]
148+
for c in found:
149+
real_even_keystream.append(chr(ord(real_even_keystream[-1]) ^ c))
150+
with_zeros = reduce(lambda x, y: x + y, map(list, zip(real_even_keystream, ['0'] * len(found))))
151+
xored = xor_string(flag, "".join(with_zeros))
152+
even_chars = "".join([xored[i] for i in range(0, len(xored), 2)])
153+
print(first, even_chars)
154+
155+
brute_first()
156+
```
157+
158+
We get a nice string:
159+
```
160+
(174, 'HFHFHDHDHDSAaACTF{SADASDSDCTF{L_E_R_OY_JENKINS}ASDCTF{\n')
161+
```
162+
163+
This looks like a good one, so the initial KEY byte is 174.
164+
165+
Now we can proceed to recover odd bytes of the keystream.
166+
The idea here is pretty simple:
167+
168+
1. Let's pre-calculate sha3_256 hashes for strings `A`, `AA`, `AAA`,... and so on, for very large lengths, specifically for 55*256, because this is the longest string we can get in the task to hash, because counter 256 for each letter. We store those hashes in a list in order.
169+
2. Let's set all letters to the same one, for example 'A'. We can do that since we already know the keystream for all of them, and we can simply set value `'A'^KEY[i]` for `i-th` byte and once it's xored with `KEY[i]` during decryption it will become `A`.
170+
3. Let's calculate reference hash for the letters `A` and original counters. We can now check where on the hash list this value is, and therefore how many `A` it has.
171+
4. Now let's XOR the first counter with `1<<1`, basically flipping the lowest bit, and calculate new hash. We can now look for index of this hash in our list, and this will tell us how many `A` it has. If it's less then initially, then we flipped the bit from 1 to 0, and if it's more then we flipped from 0 to 1, either way we know the original bit value. We can now do the same for `1<<2` and other bits, to recover the whole counter value.
172+
5. We proceed like this for next counters, until we recover all of them.
173+
174+
In code it looks like this:
175+
```python
176+
def recover_counters(keybytes, get_result_fun=get_result):
177+
hashes = []
178+
with codecs.open("hashes", 'r') as hashes_file:
179+
for line in hashes_file:
180+
hashes.append(line[:-1])
181+
# prepare payload with 'A' on even positions
182+
payload = [0] * (len(keybytes) * 2)
183+
for i in range(0, len(keybytes) * 2, 2):
184+
payload[i] = ord(xor_string(keybytes[i / 2], 'A'))
185+
counter_bytes = []
186+
for counter in range(1, len(keybytes) * 2, 2):
187+
print('recovering counter', counter)
188+
reference_hash = get_result_fun(payload)
189+
reference_number_of_A = hashes.index(reference_hash)
190+
bits = []
191+
for bit in range(8):
192+
new_payload = payload[:]
193+
new_payload[counter] ^= 1 << bit
194+
new_hash = get_result_fun(new_payload)
195+
new_A_number = hashes.index(new_hash)
196+
if new_A_number > reference_number_of_A: # we lighted a bit so it was 0
197+
bits.append('0')
198+
else:
199+
bits.append('1')
200+
original_counter = int("".join(bits[::-1]), 2)
201+
print('original counter', original_counter)
202+
counter_bytes.append(original_counter)
203+
return map(chr, counter_bytes)
204+
```
205+
206+
We can now extend our sanity test to include this code:
207+
208+
```python
209+
def sanity_test():
210+
secret = "alamakotaa"
211+
212+
def decrypt(data):
213+
return xor_string(secret, data)
214+
215+
def deserialize(decrypted):
216+
return chunk(decrypted, 2)
217+
218+
def decode(secret):
219+
return "".join([a * (ord(b) + 1) for a, b in secret])
220+
221+
def mimick_server(data):
222+
import sha3
223+
decrypted = decrypt(data)
224+
secret = deserialize(decrypted)
225+
expanded = decode(secret)
226+
return sha3.sha3_256(expanded).digest()
227+
228+
def fake_get_result(data):
229+
payload_bytes = "".join(map(chr, data))
230+
return mimick_server(payload_bytes).encode("base64")
231+
232+
flag = xor_string("CTF{XXXXX}", secret)
233+
found = []
234+
for i in range(0, len(secret) - 2, 2):
235+
found.append(find(i, fake_get_result))
236+
print(found)
237+
238+
real_found = [chr(ord(flag[0]) ^ ord('C'))]
239+
for c in found:
240+
real_found.append(chr(ord(real_found[-1]) ^ c))
241+
print(real_found)
242+
counters = recover_counters(real_found, fake_get_result)
243+
print(counters)
244+
print(reduce(lambda x, y: x + y, map(lambda x: x[0] + x[1], zip(real_found, counters))))
245+
```
246+
247+
And once we run this we get the `secret` value back, so it all works.
248+
We can therefore plug the counter recovery to the real data:
249+
250+
```python
251+
def recover_from_letters():
252+
found = [191, 119, 132, 188, 171, 242, 33, 15, 50, 0, 32, 130, 110, 51, 57, 36, 108, 223, 132, 48, 58, 47, 190, 144, 54, 115, 250, 91, 13, 16, 25, 193, 178,
253+
26, 115, 140, 231, 65, 99, 180, 221, 121, 92, 206, 16, 64, 152, 181, 231, 228, 136, 149, 177, 237, 0]
254+
with codecs.open("encrypted_secret") as flag_file:
255+
flag = flag_file.read()
256+
real_found = [chr(174)]
257+
for c in found:
258+
real_found.append(chr(ord(real_found[-1]) ^ c))
259+
print(real_found)
260+
counters = recover_counters(real_found)
261+
print(counters)
262+
keystream = reduce(lambda x, y: x + y, map(lambda x: x[0] + x[1], zip(real_found, counters)))
263+
print(keystream)
264+
print(decode(deserialize(xor_string(flag, keystream))))
265+
266+
267+
recover_from_letters()
268+
```
269+
270+
We use also the `decode` and `deserialize`, just as the server does when decrypting the flag.
271+
After a while we finally get: `CTF{LLLLLLLLL___EEEEE____RRRRRRRRRRR_OYYYYYYYYYY_JEEEEEEENKKKINNSSS}`
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const FLAG_SIZE: usize = 56;
2+
const FLAG_DATA_SIZE: usize = FLAG_SIZE * 2;
3+
4+
#[derive(Debug, Copy, Clone)]
5+
struct Unit {
6+
letter: u8,
7+
size: u8,
8+
}
9+
10+
fn deserialize(data: &Vec<u8>) -> Vec<Unit> {
11+
let mut secret = Vec::new();
12+
for (letter, size) in data.iter().tuples() {
13+
secret.push(Unit {
14+
letter: *letter,
15+
size: *size,
16+
});
17+
}
18+
secret
19+
}
20+
21+
fn decode(data: &Vec<Unit>) -> Vec<u8> {
22+
let mut res = Vec::new();
23+
for &Unit { letter, size } in data.iter() {
24+
res.extend(vec![letter; size as usize + 1].iter())
25+
}
26+
res
27+
}
28+
29+
fn decrypt(data: &Vec<u8>) -> Vec<u8> {
30+
key = get_key();
31+
iv = get_iv();
32+
openssl::symm::decrypt(
33+
openssl::symm::Cipher::aes_256_ctr(),
34+
&key,
35+
Some(&iv),
36+
data
37+
).unwrap()
38+
}
39+
40+
fn store(data: &Vec<u8>) -> String {
41+
assert!(
42+
data.len() == FLAG_DATA_SIZE,
43+
"Wrong data size ({} vs {})",
44+
data.len(),
45+
FLAG_DATA_SIZE
46+
);
47+
let decrypted = decrypt(data);
48+
let secret = deserialize(&decrypted);
49+
let expanded = decode(&secret);
50+
base64::encode(&compute_sha3(&expanded)[..])
51+
}
52+

0 commit comments

Comments
 (0)