SecretServer -- HITCON CTF 2017 Quals
Points: Dynamic
Solves: 48
Description:
AES is unbreakable. Right?
nc 52.193.157.19 9999
flag format: hitcon{…} (use the one in GitHub as test)
Given:
secretserver-03f9e1472f1088fcf5571d3288e759e3.py
Solution:
(by L0rdComm4nder and goulov)
TLDR
This attack has several steps
- Get the encrypted flag by IV manipulation
- Get the 1st block by IV manipulation using the fact that
hitcon{
is as long asget-sha
and use 128 average tries to find each byte of 1st block - Find the length of padding
- Use 3. to get the 1st half of 2nd block using the fact that
.strip()
removes\n
both at the beginning and at the end of a string - Repeat 2. to get the 2nd half of 2nd block.
Description
There might be some more efficient solutions but the one that follows is the first that came to our mind and there is minor effort involved. Some interesting facts we get by looking at the given file.
-
Chall uses AES in CBC mode
-
Notice that the padding function looks like PKCS7 but the unpadding function does not check that. In particular, the unpadding function drops as many characters as the last one. This will be usefull in steps 3. and 4.
def pad(msg):
pad_length = 16-len(msg)%16
return msg+chr(pad_length)*pad_length
def unpad(msg):
return msg[:-ord(msg[-1])]
-
Saddly, there is no padding error in decryption :-(
-
Send message uses always the same
iv
but therecv_msg
decrypts with whateveriv
is given.
def send_msg(msg):
iv = '2jpmLoSsOlQrqyqE'
encrypted = encrypt(iv,msg)
msg = iv+encrypted
msg = base64.b64encode(msg)
print msg
return
def recv_msg():
msg = raw_input()
try:
msg = base64.b64decode(msg)
assert len(msg)<500
decrypted = decrypt(msg[:16],msg[16:])
return decrypted
except:
print 'Error'
exit(0)
- Messages are always sent encrypted but
a. we can get the encryption of some fixed messages send_msg('Welcome!!')
and send_msg('command not found')
b. we can get the encryption of the uknown flag send_msg(flag)
.
c. we can get the encryption of several different hash-functions
if __name__ == '__main__':
proof_of_work()
with open('flag.txt') as f:
flag = f.read().strip()
assert flag.startswith('hitcon{') and flag.endswith('}')
send_msg('Welcome!!')
while True:
try:
msg = recv_msg().strip()
if msg.startswith('exit-here'):
exit(0)
elif msg.startswith('get-flag'):
send_msg(flag)
elif msg.startswith('get-md5'):
send_msg(MD5.new(msg[7:]).digest())
elif msg.startswith('get-time'):
send_msg(str(time.time()))
elif msg.startswith('get-sha1'):
send_msg(SHA.new(msg[8:]).digest())
elif msg.startswith('get-sha256'):
send_msg(SHA256.new(msg[10:]).digest())
elif msg.startswith('get-hmac'):
send_msg(HMAC.new(msg[8:]).digest())
else:
send_msg('command not found')
except:
exit(0)
Step 1. Get the encrypted flag by IV manipulation
Recall the AES CBC mode of operation in particular the decryption.
Recall that P_i = D(C_i) ^ C_{i-1}
where C_0
is the IV
block.
It is known that changing the bit j
of block C_i
will affect bit j
of block P_{i+1}
while destroying block P_i
as the decryption of C_i
is now rubish.
However, if we do it to IV
, we are ok as IV
is discarded in the end.
So after the proof of work we get IV + E('Welcome!!' + '\x07' * 7)
.
In order to get the encrypted flag we need to transform it into IV2 + E('get-flag' + '\x08' * 8)
. This is simple to do.
One knows that P_1 = D(C_1) ^ IV
and we know P1
, C1
and IV
. We want to get P1bar = 'get-flag' + '\x08' * 8
.
Defining IV2 = IV ^ P1 ^ P1bar
we get
D(C1) ^ IV2 = D(C1) ^ IV ^ P1 ^ P1bar = P1 ^ P1 ^ P1bar = P1bar
So done. We can get the flag ciphered.
target = 'get-flag' + '\x08'*8
source = 'Welcome!!' + '\x07'*7
iv2 = ''
for i in range(16):
iv2 += chr(ord(target[i]) ^ ord(source[i]) ^ ord(iv[i]))
Step 2. Get the 1st block by IV manipulation
Now we have IV + E(hitcon{......})
Using the fact that the flag starts with hitcon{
we can get the 1st block iterating the technique of Step 1.
Notice that hitcon{
is as long as get-sha
, and so transform the IV
as above in order to get IV2 + E(get-sha......})
.
Now rotate the 8th character of IV
among the 256 possible chars. Notice that if you send these 256 messages to the server, all but one will return send_msg('command not found')
.
The only one that will succeed and will be different is the one where the 8th character of D(C1) ^ IV2 = '1'
as it will call the get-sha1
operation.
This will allow us to get the 8th character of the flag
(for char 8 of P1) D(C1) ^ IV2 = '1' and D(C1) ^ IV = P1 ==> P1 = IV ^ IV2 ^ '1'
You will get it on average 128 calls.
How to get the 9th character? The idea is to shift the get-sha
one position to the right (using chars 2 to 8 instead of 1 to 7) and apply the same technique to the 9th char.
Notice that at this stage we know chars 1 to 8 of the flag and so we can do it.
The problem is that if we do it then the message will not begin with get-sha1
as required in main
.
The trick here is to pad with \n
to the left. Since main
does strip()
after decryption it will drop the \n
at the beginning.
(honestly do not know if this was the intended solution but it worked for us).
Iterate this and get the 1st block of flag: hitcon{Paddin9_1
Step3. Find the length of padding
For the next steps it will be important to find the length of the padding. Notice that if you control the last byte of the last block (in this case C3
) you can discard as many bytes as you want while unpadding as unpadding only checks the very last byte of the plaintext to assess padding.
So we apply Step 1. to transform the ciphered flag to get-sha1
and rotate the last byte of C2
among all the possible 256 values.
This in turn will rotate the last byte of C3
among the possible 256 values and since
- the length of the message is 48 (3 blocks)
- the length of
get-sha1
is 8
it will return
- 40 different ciphers (we can remove up to 40 chars and still get a message that starts with
get-sha1
), each of them once. - one cipher repeated 216 times, the cipher of
command not found
, that is when we remove more than 40 chars.
Repeat for get-md5
. Since get-md5
is of length 7 it will return
- 41 different ciphers (we can remove up to 41 chars and still get a message that starts with
get-md5
), each of them once. - one cipher repeated 215 times, the cipher of
command not found
, that is when we remove more than 41 chars.
The char that is in MD5 and not in SHA1 is the one that gives us the length of the flag.
(for char 16 of P3) D(C3) ^ C2bar = '41' and D(C3) ^ C2 = pad_value ==> pad_value = C2 ^ C2bar ^ '41'
This requires 2 * 256 calls to the server, and in this case we got pad_value = 16
.
Notice that the decryption of C2
will be garbage, hence it will not return P2
but in this case we do not care about it.
Step 4. Get the 1st half of 2nd block
This step is a mix of previous ones.
a. From IV + E(hitcon{Paddin9_1)
(notice we know the 16 chars of P1
), prepare IV2 + E('\n' * 9 + 'get-md5' + C2 + C3)
.
From this we can manipulate the last byte of C2
, to affect the last byte of P3
, so that when unpad
is done everything is discarded except the 1st byte of P2
and so we otain E(MD5(1st byte of P2))
.
If you want to keep just 1 byte of P2
(that is 17 of the whole message) just change the last byte of C2
to
newchar = C2[-1] ^ (48 - 17) ^ pad_value ==> new_padding = D(C3) ^ C2bar = D(C3) ^ C2[-1] ^ (48 - 17) ^ pad_value = pad_value ^ (48 - 17) ^ pad_value = (48 -17)
The problem with this trick is that by changing the last byte of C2
we are damaging its decryption and so we do not get the original P2
.
The way we sorted this was to add a dummy block in the middle and prepare IV2 + E('\n' * 9 + 'get-md5' + C2 + C2 + C3)
instead.
By applying the tricks on the 2nd copy of C2
, we do not damage the decryption of the 1st C2
and consequently can get the original P2
there.
Notice that you need to discard 16 more chars in this case.
b. Now from IV + E('Welcome!!' + '\x07' * 7)
prepare IV2 + E('get-md5' + char + '\x08' * 8)
for all possible 256 chars and compare this to the cipher obtained above.
One of them will match, and this will give us the 1st char of P2
.
Use this char and prepare
a. with dropping one less char.
b. with IV2 + E('get-md5' + 1st char of P2 + char + '\x08' * 8)
This will return the 2nd char of P2
. Iterate up to 9 chars (this is the most you can extend in b.)
We got P2 = '5_ve3y_h4r'
Step 5. Get the 2nd half of 2nd block
This is just the repetition of Step 2. discarding the 1st block of the encrypted flag and considering 5_ve3y_
instead of hitcon{
.
We will use in this case C1
as the IV
.
This got us the remaining 9 chars: hitcon{Paddin9_15_ve3y_h4rd__!!}
VERY IMPORTANT: You can do all these decryption tricks with CBC only if you know what is the plaintext you want to match!