Modes of Operation – Part 2: ECB Oracle
How to attack a system use AES ECB for encrypt secret data.
Introduction
As we discuss in previous post (https://substack.com/home/post/p-167695517) ECB is simplest mode in encryption data. Each block is encrypted in parallel and independently and identical input will create identical output with same key. In this scenario, there are an API takes a plaintext as input, then add a plaintext with a secret flag and padded them. The output is cipher text after encrypt by AES-128. Our aim is to recover the FLAG value. The server code some thing like this:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes
KEY: bytes = get_random_bytes(16) # Secret key
FLAG: str = "FLAG{Welc0me_t0_crypTogRaphy_101}" # The flag to recover
def encrypt(plaintext_hex: str) -> dict:
"""
Encrypts the given plaintext (hex-encoded) with AES-ECB after appending the flag and padding.
Returns a dictionary with the ciphertext in hex, or an error message.
"""
try:
plaintext = bytes.fromhex(plaintext_hex)
padded = pad(plaintext + FLAG.encode(), 16)
cipher = AES.new(KEY, AES.MODE_ECB)
encrypted = cipher.encrypt(padded)
return {"ciphertext": encrypted.hex()}
except (ValueError, Exception) as e:
return {"error": str(e)}Some assumptions:
The server are expose /
encryptso we can send any plaintext as we want.We can get and store cipher text each time we get a response from the request.
The server does not limit number of requests we can sent.
We already know the FLAG format, or at least the most the end character of the FLAG.
Exploitation
Acquire some basic information
Verify the server use ECB: Send 3 requests with same text, example “11111” and if all of them request the same cipher text then we can know the server use ECB
Find block size: Send few requests with plaintext is 15B, 17B, 33B … then observe cipher texts. The block size typically 16 Bytes (128 bits).
Oracle attack idea
Assume the block size is 16 bytes (32 hexadecimal characters). To recover the first character of the FLAG, we send a request containing 15 dummy characters, such as '1'. Since the encryption operates in fixed 16-byte blocks, the first block of the cipher text will correspond to the plaintext composed of those 15 '1's followed by the first byte of the FLAG.
Since identical plaintext blocks produce identical cipher text blocks in ECB mode, we can brute-force the first character of the FLAG by sending 256 possible inputs (one for each ASCII character) by append the character at the end of request plaintext. For each attempt, we compare the first cipher text block from the server's response with the known cipher text block in previous step. When they match, we've found the correct character.
Once the first character is known, we shift left 1 position of the input and repeat the process to discover the next character. For example, if we've already found that the first character is 'W', we append 'W' as the second-to-last character and brute-force the final byte to uncover the next character of the FLAG.
If the FLAG size is longer than a block, we can continue this approach but in this time we will compare the cipher text in second block, not first block.
Implement detail
In here we only brute force from 33 to 127, those are printable characters in ASCII.
| def send_request(plaintext: str) -> str: | |
| """ | |
| Encodes the plaintext to hex, sends it to the encrypt function, and returns the ciphertext hex string. | |
| """ | |
| encoded_hex = plaintext.encode().hex() | |
| response = encrypt(encoded_hex) | |
| return response["ciphertext"] | |
| def ecb_oracle() -> tuple[str, int]: | |
| """ | |
| Performs a byte-at-a-time ECB decryption attack to recover the flag. | |
| Returns the recovered flag and the total number of requests made. | |
| """ | |
| block_size = 16 | |
| recovered_flag = "" | |
| block_index = 0 | |
| total_requests = 0 | |
| while True: | |
| pad_length = block_size * (block_index + 1) - len(recovered_flag) - 1 | |
| prefix = "1" * pad_length | |
| print(f"Prefix: {prefix}") | |
| reference_ciphertext = send_request(prefix) | |
| total_requests += 1 | |
| start = block_size * 2 * block_index | |
| end = block_size * 2 * (block_index + 1) | |
| for ascii_code in range(33, 127): | |
| guess = prefix + recovered_flag + chr(ascii_code) | |
| print(f"Trying: {guess}") | |
| guess_ciphertext = send_request(guess) | |
| total_requests += 1 | |
| if reference_ciphertext[start:end] == guess_ciphertext[start:end]: | |
| recovered_flag += chr(ascii_code) | |
| print(f"Recovered so far: {recovered_flag}") | |
| if chr(ascii_code) == "}": | |
| return recovered_flag, total_requests | |
| break | |
| if len(recovered_flag) == (block_size * (block_index + 1) - 1): | |
| block_index += 1 | |
| def main() -> None: | |
| flag, total_requests = ecb_oracle() | |
| print(f"FLAG: {flag}") | |
| print(f"Total requests: {total_requests}") | |
| if __name__ == "__main__": | |
| main() |
Output
.....
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101x
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101y
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101z
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101{
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101|
Trying: 111111111111111FLAG{Welc0me_t0_crypTogRaphy_101}
Recovered so far: FLAG{Welc0me_t0_crypTogRaphy_101}
FLAG: FLAG{Welc0me_t0_crypTogRaphy_101}
Total requests: 2015🚀🚀🚀🚀 After 2015 requests, we can acquire the FLAG.
References
https://aes.cryptohack.org/ecb_oracle/
https://www.ctfrecipes.com/cryptography/symmetric-cryptography/aes/mode-of-operation/ecb/ecb-oracle






Wow!!! Nice
Wow!!! Nice