n1ogin - Crypto
Challenge: n1ogin
Author: Shotokhan & Tiugi
Description: Time-based CBC Padding Oracle Attack
CTF: N1CTF 2021
Category: Crypto
Task
To prevent any adversary sitting in the middle from eavesdropping, we apply hybrid encryption in our n1ogin system.
nc 43.155.59.224 7777
We are provided 4 files: server.py, packet.pcapng, n1ogin.pub, client.py.
Link to download them (if not active, ask us): https://drive.google.com/file/d/1FprtxVB6Tt7ZZsfuYn9LGDS_iT2INXEZ/view?usp=sharing
In short: client uses server’s public key n1ogin.pub to create a packet in which it sends a password by combining different cryptography functions, server verifies it using the private key and the appropriate functions, according to the protocol; our goal is to login as admin, and in packet.pcapng there is a successful authentication, so it’s a known ciphertext scenario.
Now, let’s see the actual protocol.
The client sends a JSON packet:
where, conceptually:
(e, n)
are taken from n1ogin.pub, whereas aes_key
and hmac_key
are randomly generated, along with the iv
:
aes_data
is iv + cipher + mac
, where cipher
is the AES-CBC encryption of a content
, and mac
is a repeated HMAC-MD5 computed starting from iv + cipher
:
The content
is like that (choice
can be either “register” or “login”):
username
and password
are user-provided.
The server receives the packet (denoted as envelope
) and handles it using an handle
function.
The handle
function wraps all the code in a try-except block, returning a generic error
message. It performs the following actions:
- Loads the JSON packet;
- Decrypts
rsa_data
using server’s private key in functionRSA_decrypt
, obtainingaes_key || hmac_key
; - Decrypts
aes_data
, with the functionAES_decrypt
, which also checks themac
for integrity and authenticity and returns some error message, which in any case is mapped to the genericerror
message mentioned before; - Loads
content
from the decrypted JSON; - Checks if the
nonce
was already observed; - Checks the time window of the
timestamp
; - Calls
login
,register
or returnserror
according to the value ofchoice
.
Let’s go deeper in any single step.
If the JSON fails to parse, an exception occurs and an error
is returned.
RSA_decrypt
function is very simple, it decrypts the ciphertext and then check PKCS1 padding. If the padding is okay, it returns the last 48 bytes of the plaintext (see PKCS1 padding specs), else it returns 48 random bytes, to prevent Bleichenbacher’s attack.
AES_decrypt
function first separates aes_key
and hmac_key
, then it separates iv
, cipher
and mac
; so it decrypts cipher
using aes_key
and iv
, then it checks the PKCS7 padding and returns an error message if the check fails. At last it checks the mac
, using the same procedure used by the client to generate it, and returns an error message if the check fails. Remember that both the error messages are masked under the hood of the same error
message, which will be sent to the client in case of error.
The nonce
is checked against a set, which is empty every time the program starts; this means that the nonce
check makes sense only if a replay attack is attempted within the same connection.
The timestamp
check has an absolute time window of 30 seconds: abs(int(time.time()) - timestamp) < 30
.
At this point, login
or register
is called.
Users are handled in a dict {username:password_hash}
.
register
doesn’t allow to overwrite entries in Users dict, and doesn’t allow len(username) > 20
.
login
checks the existence of the username
and checks the password
hash against the one stored in Users dict. If the check is okay, it calls echo_shell
function, which allows to get the flag if you managed to login as admin.
The password
hash is computed using the following function:
The procedure is similar to the one used for the mac
, but it just uses MD5 iteratively (not HMAC-MD5). SALT
is secret.
This is everything about the authentication protocol. Let’s analyze the public key and the pcap file.
n1ogin.pub is a 2048 bit RSA public key, generated with openssl (there is a comment in server.py stating that).
In the capture file there is a single TCP connection, with the following conversation:
Welcome to the n1ogin system!
> {"rsa_data": "391b06a1740b8c9cf1c8d2bb66ba5b191caa8534b4be18c22ce81069658dd2cd3ca3a8d1a3fc8dfab4b68a6b076bf89be807404e0a98dd1bf9daaf8ba34e0556131d3e56cae61c0302d24a177481209e82de7ecf91c2fe66aa39162d7af9c2fdabaf0c444badfc6b82b071fda8e3b26d4d3e57dba25c36298601ae0153c73b7469c472ac4702531c38849772e7c6e24313e6eb7def64a7bec1c21150c1fded52b3ca716d4444b4d75836dff8c92a371f6256ee7a48034f6d5ea949d982f9f05c04d3d7cce10bd11b806cc02088b42fa0cb069390700fb586287ba224ea0b210ebd0479a4f1d2ef5f914bcc861125b7d8d714cf0feecb515c1b1ef869e91ca179", "aes_data": "1709bf9489f6df6dc31491cee4711f7a2a3e050f1ed3e9772442e8a8483e341313713383dd31fbf0133d55e977b8edf54ba832002ee4ee52da32c260b083a35b01626201c36dad6fca7b2be2aa03d90bf5c9a601a24149f55cdcd39f0bf6a032bfabeebee5259a21e188f5c5f8776cd9d7c072054781169174bddbc390e6da21bd7b85f76c93f48914fb1958ac89e464511d9a17fb2174aab825cb13eb3f0dfa"}
admin login ok!
admin@local> 777777777777777
777777777777777
admin@local> exit
This is a successful authentication as admin, which gives us some hints.
Attack vectors
The first idea that comes to mind is a replay attack.
The nonce
is useless in blocking replay attacks, because the collection of observed nonces is reset for each connection.
Anyway, we are blocked by the timestamp
.
The rsa_data
packet seems not vulnerable, because it is encrypted using a 2048 bit RSA key using PKCS1 padding, and is protected against Bleichenbacher’s attack: whether the pad check fails or not, it follows the same code path, resulting in the PKCS7 padding check failing after AES decryption. This is because the resulting aes_key
is taken from decrypted message, which is random if the PKCS1 pad check fails, else it is s * M
, where M
is aes_key || hmac_key
, and s
is the chosen value for the multiplication with C = rsa_data
, that is, according to the attack: C' = C * s**e mod n
.
It’s useful to split cipher
from aes_data
in blocks; we have a partially known plaintext, because we know how content
is built. We can see from cipher
that we have 8 blocks; let’s see the plaintext, filling unknown bytes in timestamp
, nonce
and password
:
{"choice": "logi
n", "timestamp":
1637422729, "no
nce": "163ae3421
69b5674", "usern
ame": "admin", "
password": "AAAA
AAAAAAAAAAAAA"}
The password will be at least 4 characters and at most 17 characters. From server.py, we have the hint that it follows the “strong password policy”, so it contains lowercase, uppercase, digits and punctuation characters.
If we wanted to play with these blocks, like duplicating some of them, doing CBC bitflipping and so on, we would broke the mac
check.
It also seems that we can’t do a CBC padding oracle attack because we’ve got a generic error
message.
Forging a valid mac
by using approaches like hash length extension attack isn’t feasible, because it is computed using HMAC.
We don’t even have the hash of admin’s password
, so it’s difficult to work on MD5 collisions. An idea was that, in theory, if you compute MD5 many times starting from two different values, at a certain point the two processes should converge to the same value. But maybe this is not practical in our case, with a number of iterations equal to 7777. Anyway, we observe that this number of iteration is enough to take a sensitive computation time.
This observation leads us to the conclusion that we can use the elapsed time as oracle for the CBC padding oracle attack. In fact, in AES_decrypt
the server first checks the padding and returns if the check fails, then it checks the mac
. The mac
check takes more time, ~100 ms delta on the challenge server (it’s hard to say for sure, because it’s hard to make a remote timing attack: there is network jitter and there are the other users stressing the server in a non-deterministic way).
Before diving into the technical details of the exploit, it’s useful to carry here AES_decrypt
function, so long as it is our target:
We also included the imported libraries, for completeness.
Exploit
In order to perform a remote time-based CBC padding oracle attack we have to understand how this type of attack works and how to extract the error type from the elapsed time.
A padding oracle attack consists of multiple decryption requests aimed at obtaining informations through the response of the server: if the padding validation is successful we obtain a decrypted byte, otherwise we need to retry with a different input. Using this approach we can decrypt a block with at most 256*BLOCK_SIZE requests, far fewer than the usual 256^BLOCK_SIZE (256 raised to BLOCK_SIZE).
Let’s analyze briefly our exploit code (a modified version of the code from https://github.com/jjcomline/padding-oracle-attack):
Assume the server runs a less secure code that sends us the more specific error when one of it occurs. When our simplified oracle decrypts an aes_data
made of an IV
, a ciphertext
of length BLOCK_SIZE (16) and a HMAC
it follows the following procedure:
- divide the
aes_data
in its parts:iv, cipher, mac = enc_data[:16], enc_data[16:-16], enc_data[-16:]
- perform the
AES_decrypt
using the single block of thecipher
andaes_key
:dec = decrypt(cipher, key)
- xor the decrypted block with the
iv
:data = xor(dec, iv)
- checks if the padding is correct:
pad(data[:-data[-1]]) == data
- return
OK
or an error not related to this process if the padding is correct,PAD_ERROR
otherwise.
If we don’t get a PAD_ERROR
we can recover the decrypted byte:
So to decrypt the entire ciphertext we choose a block at a time and first we iterate over the last byte.
After having found it, we have to change the known part of the ciphertext in order to have it decrypted as the expected padding for the next round and iterate over the next bytes from right to left.
This attack works fine when the server gives us a clear error message, but the challenge’s server returns, instead, a generic error. Using the fact that the hmac generation is quite slow we can setup a time attack to recover the real error (“pad error” vs “hmac error”).
Let’s see the code:
Sending N
times a fixed request, that fails with a known error, we obtain N
measurements for the elapsed time
that differ because of multiple factors; the major ones were the jitter of the network and the lack of time isolation of the server, which was also attacked by other players. To obtain a reliable reference time to compare our test padding
request to, we calculate the minimum among N
elapsed times
and assume it as the one with the minimum impact of random variables.
Found the reference times for the pad error
and mac error
, we use as ref_value
the mean between them; we send our payload and compute the elapsed time
adjusting it with the same method as before: if N is big the result has a high probability of being correct (and we get a more reliable estimation of latency
), but the process becomes really slow.
To retrieve the password of the admin from aes_data
, we combine the function is_padding_ok
with the CBC padding oracle attack to decrypt the last two blocks of the ciphertext, trying a second time on every success to be sure that we didn’t encounter a false positive.
In order to keep the process time-short enough, we set the N
constant to 10 and introduced a gen
function to reduce the brute-force space to only the printable characters. Unluckily, the exploit took us too much time and we finally found the password one hour after the end of the CTF.
The password was R,YR35B7^r@'U3FV
and after a successful login we requested the FLAG
and got n1ctf{R3m0t3_t1m1ng_4ttack_1s_p0ssibl3__4nd_u_sh0uld_v3r1fy_th3_MAC_f1rs7}