|
| 1 | +--- |
| 2 | +title: Hourcle |
| 3 | +date: 2025-03-27T18:33:57+03:00 |
| 4 | +description: Writeup for Hourcle [Cyber Apocalypse CTF 2025] |
| 5 | +author: h3pha |
| 6 | +tags: |
| 7 | +- crypto |
| 8 | +draft: false |
| 9 | +--- |
| 10 | +___ |
| 11 | + |
| 12 | +## Challenge Description |
| 13 | + |
| 14 | +> A powerful enchantment meant to obscure has been carelessly repurposed, revealing more than it conceals. A fool sought security, yet created an opening for those who dare to peer beyond the illusion. Can you exploit the very spell meant to guard its secrets and twist it to your will? |
| 15 | +
|
| 16 | +## Intuition |
| 17 | + |
| 18 | +We are given this file (`server.py`): |
| 19 | +```python |
| 20 | +from Crypto.Cipher import AES |
| 21 | +from Crypto.Util.Padding import pad |
| 22 | +import os, string, random, re |
| 23 | + |
| 24 | +KEY = os.urandom(32) |
| 25 | + |
| 26 | +password = ''.join([random.choice(string.ascii_letters+string.digits) for _ in range(20)]) |
| 27 | + |
| 28 | +def encrypt_creds(user): |
| 29 | + padded = pad((user + password).encode(), 16) |
| 30 | + IV = os.urandom(16) |
| 31 | + cipher = AES.new(KEY, AES.MODE_CBC, iv=IV) |
| 32 | + ciphertext = cipher.decrypt(padded) |
| 33 | + return ciphertext |
| 34 | + |
| 35 | +def admin_login(pwd): |
| 36 | + return pwd == password |
| 37 | + |
| 38 | + |
| 39 | +def show_menu(): |
| 40 | + return input(''' |
| 41 | +========================================= |
| 42 | +|| || |
| 43 | +|| 🏰 Eldoria's Shadow Keep 🏰 || |
| 44 | +|| || |
| 45 | +|| [1] Seal Your Name in the Archives || |
| 46 | +|| [2] Enter the Forbidden Sanctum || |
| 47 | +|| [3] Depart from the Realm || |
| 48 | +|| || |
| 49 | +========================================= |
| 50 | +
|
| 51 | +Choose your path, traveler :: ''') |
| 52 | + |
| 53 | +def main(): |
| 54 | + while True: |
| 55 | + ch = show_menu() |
| 56 | + print() |
| 57 | + if ch == '1': |
| 58 | + username = input('[+] Speak thy name, so it may be sealed in the archives :: ') |
| 59 | + pattern = re.compile(r"^\w{16,}$") |
| 60 | + if not pattern.match(username): |
| 61 | + print('[-] The ancient scribes only accept proper names-no forbidden symbols allowed.') |
| 62 | + continue |
| 63 | + encrypted_creds = encrypt_creds(username) |
| 64 | + print(f'[+] Thy credentials have been sealed in the encrypted scrolls: {encrypted_creds.hex()}') |
| 65 | + elif ch == '2': |
| 66 | + pwd = input('[+] Whisper the sacred incantation to enter the Forbidden Sanctum :: ') |
| 67 | + if admin_login(pwd): |
| 68 | + print(f"[+] The gates open before you, Keeper of Secrets! {open('flag.txt').read()}") |
| 69 | + exit() |
| 70 | + else: |
| 71 | + print('[-] You salt not pass!') |
| 72 | + elif ch == '3': |
| 73 | + print('[+] Thou turnest away from the shadows and fade into the mist...') |
| 74 | + exit() |
| 75 | + else: |
| 76 | + print('[-] The oracle does not understand thy words.') |
| 77 | + |
| 78 | +if __name__ == '__main__': |
| 79 | + main() |
| 80 | +``` |
| 81 | + |
| 82 | +To get the flag we need the secret password that the server generates. We can provide a username and the server will append the password to it, encrypt it and return the ciphertext. |
| 83 | + |
| 84 | +The first thing that I observed is that the server uses `AES` in `CBC` mode, but instead of encrypting the text, it decrypts it and returns the result. |
| 85 | + |
| 86 | +I have solved a similar CTF challenge in which I explained how to attack a system like this [here](https://dothidden.xyz/kalmarctf_2025/very_serious_cryptography/). Actually I used the same script to solve this challenge, I just adapted it for this situation. |
| 87 | + |
| 88 | +## Solution |
| 89 | + |
| 90 | +> Note: the script takes a while to run on a remote target. |
| 91 | +
|
| 92 | +Solver: |
| 93 | +```python |
| 94 | +from pwn import * |
| 95 | + |
| 96 | +charset = string.ascii_letters+string.digits |
| 97 | +def send_input_list(p, input_list): |
| 98 | + output_list = [] |
| 99 | + for i in input_list: |
| 100 | + p.readuntil(b":: ") |
| 101 | + p.sendline(b"1") |
| 102 | + p.readuntil(b":: ") |
| 103 | + p.sendline(i.encode()) |
| 104 | + p.readuntil(b"lls: ") |
| 105 | + output = bytes.fromhex(p.recvline()[:-1].decode()) |
| 106 | + output_list.append(output) |
| 107 | + return output_list |
| 108 | + |
| 109 | +password = "" |
| 110 | +input_size = 16 + 16 + 15 |
| 111 | +# 16 is the minimum allowed, and we add 15 to make sure that the first character of the password |
| 112 | +# is at the end of the block, I also added one more block because the length of the passowrd is 20 |
| 113 | + |
| 114 | +# p = process(["python", "server.py"]) |
| 115 | +p = remote("94.237.55.91", 38990) |
| 116 | + |
| 117 | +while len(password) != 20: |
| 118 | + try: |
| 119 | + # ensure that the character we are searching is at the end of the block |
| 120 | + padding = "_" * (input_size - len(password)) |
| 121 | + # this is where the original flag is encrypted |
| 122 | + original = send_input_list(p, [padding])[0] |
| 123 | + # create all possible variants of the characters withing the flag |
| 124 | + brute_input = [padding + password + c for c in charset] |
| 125 | + # send the variants, and receive all encryptions |
| 126 | + brute_output = send_input_list(p, brute_input) |
| 127 | + # this is the position of the end of the block |
| 128 | + character_position = len(padding + password) + 1 |
| 129 | + for i in range(len(brute_output)): |
| 130 | + if brute_output[i][32:character_position] == original[32:character_position]: |
| 131 | + password += charset[i] |
| 132 | + print(password) |
| 133 | + |
| 134 | + except EOFError: |
| 135 | + p.close() |
| 136 | + p = remote("94.237.55.91", 38990) |
| 137 | + # p = process(["python", "server.py"]) |
| 138 | + password = "" |
| 139 | + |
| 140 | +p.sendline(b"2") |
| 141 | +p.sendline(password) |
| 142 | +p.interactive() |
| 143 | +``` |
| 144 | + |
| 145 | + |
| 146 | +### Flag |
| 147 | + |
| 148 | +`HTB{encrypting_with_CBC_decryption_is_as_insecure_as_ECB___they_also_both_fail_the_penguin_test_6f5aea60aea8dee076ad6ff61d768d05}` |
| 149 | + |
0 commit comments