|
| 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}` |
0 commit comments