summaryrefslogtreecommitdiff
path: root/posts/bctf23_electronical.md
blob: 3f82ecb28fb8896c29a08c3d60911a4f163e19c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
title: BCTF23 crypto/Electronical (medium) Writeup
date: 2023-10-26 12:00
---

> I do all my ciphering electronically. https://electronical.chall.pwnoh.io/

When going to the linked site, you get told to encrypt any message or view the
site's source code. After submitting a message to encrypt, it returns some hex
string.

The source is:
```python
from Crypto.Cipher import AES
from flask import Flask, request, abort, send_file
import math
import os

app = Flask(__name__)

key = os.urandom(32)
flag = os.environ.get('FLAG', 'bctf{fake_flag_fake_flag_fake_flag_fake_flag}')

cipher = AES.new(key, AES.MODE_ECB)

def encrypt(message: str) -> bytes:
	length = math.ceil(len(message) / 16) * 16
	padded = message.encode().ljust(length, b'\0')
	return cipher.encrypt(padded)

def decrypt(msg: str) -> bytes:
	return cipher.decrypt(msg)

@app.get('/encrypt')
def handle_encrypt():
	param = request.args.get('message')

	if not param:
		return abort(400, "Bad")
	if not isinstance(param, str):
		return abort(400, "Bad")

	print(encrypt(param + flag))

	return encrypt(param + flag).hex()

@app.get('/source')
def handle_source():
	return send_file(__file__, "text/plain")

@app.get('/')
def handle_home():
	return """
		<style>
			form {
				display: flex;
				flex-direction: column;
				max-width: 20em;
				gap: .5em;
			}

			input {
				padding: .4em;
			}
		</style>
		<form action="/encrypt">
			<h2><i>ELECTRONICAL</i></h2>
			<label for="message">Message to encrypt:</label>
			<input id="message" name="message"></label>
			<input type="submit" value="Submit">
			<a href="/source">Source code</a>
		</form>
	"""

if __name__ == "__main__":
	app.run()
```
It seems that the flag is appended to the user's message and then encrypted with
AES-ECB. The total message is also padded to be a multiple of 16 bytes.

According to Wikipedia, ECB (electronic codebook) works by dividing a message
into blocks of a certain size (like 16 bytes). The problem however is that ECB
doesn't attempt to make any encrypted block unique like by adding a salt or
nonce, so any blocks of data that are identical would also be identical when
encrypted. Wikipedia also has an interesting example of encrypting an image of
Tux and a mountain (on French Wikipedia) with AES.

![Tux AES](images/bctf23_electronical-tux_aes.png)

![Mountain AES](images/bctf23_electronical-mountain_aes.png)

Through some more searching online, it seems a way to exploit this is with
something called a Chosen Plaintext Attack. Since the message before the flag is
controlled by us the user (attacker?) and the flag is appended to the end, the
provided message can be made in a way that only one byte of the flag needs to be
bruteforced at a time.

Let's say that this is our message: `thischallengesucksFLAG{5om3_!mp0r74nt_$3cr37}`

This string is 45 characters, so the server would pad this with 3 \0 characters
to make it evenly divisible by 16 characters.

```
b'thischallengesucksFLAG{5om3_!mp0r74nt_$3cr37}\x00\x00\x00'
```

We know what "thischallengesucks" is, but FLAG and anything else after is
appended by the server and is what we're trying to find.

"thischallengesucks" is 18 characters, but if we send a 15 character string,
then the first block to be encrypted would be "thischallengesu?", where ? is the
mystery character.

For readability purposes, I'm going to use repeated "0" characters needed
instead of "thischallengesucks".

When passing "000000000000000?" to the server, a certain hex string would be
returned (newlines every 32 characters not included in original):

```
b57189530dacbb9c5707c1cb0b044a34
5377049685bb9553a73e4408565505dd
0c614f69c4749b10f8cbc9c735fd7314
5a9ae527825603a8eb0dba6a0347a4e5
```
Replacing the ? with any other character would result in only the first row being
changed, like with "000000000000000A":

```
b2457a857e82a1d5ad919a4bdaf9133a
7835c84bc75d836fad8ca5fbcec086ff
937cf83a682fa26162a65f2295b2b119
6b398dd6f75e212b1633c5189bdb5689
```
Since the other three blocks remained the same, the last character in the
message being sent simply needs to bruteforced with every printable character
until it results in the same block from ?. In this case, that character would be
F:

```
b57189530dacbb9c5707c1cb0b044a34
5377049685bb9553a73e4408565505dd
0c614f69c4749b10f8cbc9c735fd7314
5a9ae527825603a8eb0dba6a0347a4e5
```
```
(0000000000000, 14 characters long)
5ec61f1209adfeff202edbba28339f83
4f7cc7a4c0c553380874383e93408678
ca91d95b091956edb162da583b51051b
33b91ab14b8807348fc98bf223b4b3a5

(0000000000000FL, 16 characters long)
5ec61f1209adfeff202edbba28339f83
4f7cc7a4c0c553380874383e93408678
ca91d95b091956edb162da583b51051b
33b91ab14b8807348fc98bf223b4b3a5
```
Then the 0 left pad would be decreased by one character and the process repeats
until the whole block is done. However, a flag usually won't be just 16
characters long. I had some difficulty trying to bruteforce the 17th character
and above because I was prepending and appending the zeros within a single block
(between 0 and 15 padded 0s), but that was among a few other issues I had that
were the result of the message I was sending being in the format of "pad +
known_flag + brute_single_char + pad" where this only worked for the first
block. This did not work later because those messages would have the pad bytes
in the middle of the message, which did not go well.

In the end, I realized that I can check the target block hexstring by sending
only the padded 0 bytes (or anything else of that length) without other
characters and then append my known bytes of the flag and a single other character
to fill that block to brute force that last character.

A visual representation is this:

```
Block size: 8 characters

7 pad, 0 known
XXXXXXX?
XXXXXXXF

6 pad, 1 known
XXXXXX??
XXXXXXFL

5 pad, 2 known
XXXXX???
XXXXXFLA

...

0 pad, 7 known
FLAG{5o?

8 known
FLAG{5om
```
This only decrypts the first block, so how I decrypted each additional block was
by prepending another block of pad characters (blocksize - 1) and repeating the
process.

```
7 pad, 7 known
XXXXXXXFLAG{5om?
XXXXXXXFLAG{5om3

6 pad, 8 known
XXXXXXFLAG{5om3?
XXXXXXFLAG{5om3_

...

5 pad, 26 known 
XXXXXFLAG{5om3_!mp0r74nt_$3cr37?
XXXXXFLAG{5om3_!mp0r74nt_$3cr37}

...
0 pad, 31 known
FLAG{5om3_!mp0r74nt_$3cr37}\0\0?
FLAG{5om3_!mp0r74nt_$3cr37}\0\0\0
```
After some automating help with python, I was able to finally get the flag.

```
Flag: bctf{1_c4n7_b3l13v3_u_f0und_my_c0d3b00k}
```

My python file to solve this was:
```python
from requests import get
from requests.utils import quote

# list of characters that will be bruteforced, these are the printable chars
chars = [chr(i) for i in range(ord(' '), ord('~') + 1)]
# nul character is also checked because that's the pad character
chars += '\0'

def encrypt(msg):
	#url = "https://electronical.chall.pwnoh.io/encrypt?message="
	url = "http://localhost:5000/encrypt?message="
	return get(url + quote(msg)).content;

def calc_padding_for_known():
	# divided by 2 because each hex byte is 2 characters long
	cur = len(encrypt("0")) // 2

	# 16 chosen because that's the padding chosen in app.py on the server
	for i in range(2, 16):
		tmp = len(encrypt("0" * i)) // 2
		if tmp > cur:
			return tmp, tmp - cur, i - 1
	# shouldn't come here
	return 0,0,0

totalblocks, bs, pad = calc_padding_for_known()

print(f"Block size of padding: {bs}, {pad}")

# first block are known to not be the flag (is all 0 being encrypted)
# second block is what's being bruteforced
# third block's last character is unknown and being compared with second block
#msg = "0" * (bs + bs - 1) + "a" * 1 + "0" * (bs - 1)
#cur = encrypt(msg)
#print(cur)
flag = ""
curflag = ""

tbs = bs * 2

for j in range(totalblocks // bs):
	for i in range(1, bs + 1):
		known = "0" * (bs * (1 + j) - len(flag) - 1)
		msg = known
		target = encrypt(msg).decode("utf-8")

		print(f"\nNew target message: {msg}")
		print("New target message return:")
		print('\n'.join([target[A:A + tbs] for A in range(0, len(target), tbs)]))

		target = target[tbs * (0 + j):tbs * (1 + j)]
		print(f"New target block: {target}")

		for c in chars:
			msg = known + flag + c
			print(f"Current character: {c}")
			print(f"Current message: {msg}")
			print(f"Target block: {target}")
			cur = encrypt(msg).decode("utf-8")
			print('\n'.join([cur[A:A + tbs] for A in range(0, len(cur), tbs)]))

			print(f"Current block: {cur[:tbs]}")

			if (cur[tbs * (0 + j):tbs * (1 + j)] == target):
				flag += c
				print(flag)
				break

			print("\n")
	curflag += flag
print(flag)
```