$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Banking service encrypts JWT tokens and user data with AES-CBC using fixed KEY/IV; login endpoint leaks padding validity via distinct error messages. Solution: CBC padding oracle attack with batched queries to decrypt user data, recover IV from known JWT header, extract card number, and withdraw exact balance to get flag.
A banking-like service with login, retrieve user data, draw money options. Target credentials provided:
D-Cryp7/Cryp70Gr47Hy!.
A server provides 4 options: Login (option 1), Retrieve encrypted user data (option 2), Draw money (option 3), and Exit (option 4). The server uses AES-CBC encryption with a fixed KEY and IV (generated once at module level with os.urandom()) for encrypting JWT tokens and user data. The goal is to drain the target account balance to exactly zero to receive the flag.
server.py — The critical vulnerability is in the login handler (option 1). When a token is provided:
cipher = AES.new(KEY, AES.MODE_CBC, IV) try: token = unpad(cipher.decrypt(bytes.fromhex(creds["token"])), 16) except: send("Decryption error!") # ← padding failure try: payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) ... except: send("Validation error!") # ← JWT validation failure
Two distinct error messages create a padding oracle: "Decryption error!" means PKCS7 padding was invalid, while "Validation error!" means padding was valid but the JWT was malformed. This distinction lets us determine whether any crafted ciphertext decrypts to valid PKCS7 padding.
Key observations:
KEY = os.urandom(16) and IV = os.urandom(16) are generated once at module level and reused for ALL encrypt/decrypt operations (login tokens AND user data)AES-CBC(pad(card_number + str(balance))) — encrypted with the same KEY and IVassert money_left >= 0 preventing overdraft — we must withdraw the exact integer amountSince the same KEY and IV are used everywhere, we can:
Login with the provided credentials D-Cryp7 / Cryp70Gr47Hy!. This returns an AES-CBC encrypted JWT token (144 bytes = 9 blocks). Then use option 2 to get the encrypted user data (32 bytes = 2 blocks): AES-CBC(pad(card_number + str(balance))).
The padding oracle works by crafting 2-block ciphertexts C' || C_target where C' is a manipulated block. The server decrypts C_target to get D_K(C_target), then XORs with C' to get the "plaintext". If the result has valid PKCS7 padding, we get "Validation error!" instead of "Decryption error!".
By systematically trying all 256 values for each byte position (from byte 15 down to byte 0), we recover the intermediate value D_K(C_target) for any ciphertext block.
Critical optimization — batching: A naive sequential approach requires ~4000-7000 individual round-trips, which would exceed the server timeout. Instead, we pipeline 64 oracle queries per batch: send all 64 "option 1" + credentials pairs at once over the socket, then read all 64 responses. This reduces wall-clock time from hours to ~3 minutes.
def oracle_batch(self, queries): """Pipeline a batch: send all, then read all.""" payload = b"" for q in queries: c = json.dumps({"token": q}) payload += f"1\n{c}\n".encode() self.io.send(payload) results = [] for _ in queries: resp = self.io.recvuntil(b"> ", timeout=60).decode() results.append("Decryption error" not in resp) return results
We decrypt 3 blocks total:
Last-byte verification: For byte position 15 (the last byte), multiple values can produce valid padding (e.g., 0x01 is valid, but so is 0x02 0x02 if the second-to-last byte happens to be 0x02). We verify by flipping byte 14 — if the padding is still valid, the last byte truly decrypts to 0x01.
The JWT token starts with a predictable base64url-encoded header. The two common PyJWT formats are:
eyJhbGciOiJIUzI1 — from {"alg":"HS256","typ":"JWT"}eyJ0eXAiOiJKV1Qi — from {"typ":"JWT","alg":"HS256"}Since in CBC mode: plaintext_0 = D_K(ciphertext_0) XOR IV, we can compute:
IV = D_K(token_block_0) XOR known_jwt_header_prefix
We try both prefixes and validate by checking if the decrypted data block 0 (card number) contains only plausible ASCII characters.
With the IV recovered:
card_block = D_K(data_block_0) XOR IV
balance_block = D_K(data_block_1) XOR data_block_0
plaintext = card_block + balance_block # then PKCS7 unpad
Result: card = 4153217235184108, balance = 1337.0
Login again, use option 3 with the card number 4153217235184108, withdraw exactly 1337 → balance drops to 0 → flag revealed.
Total oracle calls: ~7043 (~3 minutes with batching).
#!/usr/bin/env python3 """ CBC Padding Oracle Attack for HackTheBox Shambles Optimized with BATCHED oracle queries to beat server timeout. Attack: 1. Login with known creds → get encrypted JWT token 2. Get encrypted data (card_number + balance) 3. Batched padding oracle to decrypt: - Data blocks → intermediate values - Token block 0 → IV recovery via known JWT header 4. Parse card_number + balance → withdraw all → flag """ from pwn import * import json, sys, re context.log_level = "info" HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337 BATCH = 64 # queries per batch class Solver: def __init__(self): self.io = None self.calls = 0 def connect(self): self.io = remote(HOST, PORT) self.io.timeout = 60 self.io.recvuntil(b"> ") log.success("Connected") def login(self, user, pwd): self.io.sendline(b"1") self.io.recvuntil(b": ") creds = json.dumps({"token": "", "username": user, "password": pwd}) self.io.sendline(creds.encode()) resp = self.io.recvuntil(b"> ").decode() if "Token:" in resp: tok = resp.split("Token: ")[1].split("\n")[0].strip() log.success(f"Logged in, token={len(tok) // 2}B") return tok return None def get_enc_data(self): self.io.sendline(b"2") resp = self.io.recvuntil(b"> ").decode() if "Encrypted data:" in resp: enc = resp.split("Encrypted data: ")[1].split("\n")[0].strip() log.success(f"Enc data ({len(enc) // 2}B): {enc}") return enc return None def oracle_batch(self, queries): """Pipeline a batch: send all, then read all. Returns list[bool].""" payload = b"" for q in queries: c = json.dumps({"token": q}) payload += f"1\n{c}\n".encode() self.io.send(payload) results = [] for _ in queries: resp = self.io.recvuntil(b"> ", timeout=60).decode() results.append("Decryption error" not in resp) self.calls += len(queries) return results def decrypt_block(self, target, label=""): """Padding oracle → intermediate value D_K(target). Batched for speed.""" bs = 16 inter = [0] * bs for pos in range(bs - 1, -1, -1): pv = bs - pos base = [0] * bs for i in range(pos + 1, bs): base[i] = inter[i] ^ pv # build all 256 candidates qs = [] for g in range(256): a = list(base) a[pos] = g qs.append(bytes(a).hex() + target.hex()) found = False cands = [] for s in range(0, 256, BATCH): batch = qs[s : s + BATCH] res = self.oracle_batch(batch) for i, v in enumerate(res): if v: cands.append(s + i) # non-last byte: one correct guess exists, stop early if cands and pos != bs - 1: inter[pos] = cands[0] ^ pv found = True break # last byte: verify to exclude false positives (0x02 0x02 etc) if pos == bs - 1: for g in cands: a = list(base) a[pos] = g a[pos - 1] ^= 1 vr = self.oracle_batch([bytes(a).hex() + target.hex()]) if vr[0]: inter[pos] = g ^ pv found = True break if not found: log.error(f"[{label}] byte {pos} failed") return None sys.stdout.write(f"\r [{label}] {16 - pos:2d}/16 (calls {self.calls})") sys.stdout.flush() print() return bytes(inter) def withdraw(self, card, amount): self.io.sendline(b"3") self.io.recvuntil(b": ") self.io.sendline(card.encode()) self.io.recvuntil(b": ") self.io.sendline(str(amount).encode()) return self.io.recvuntil(b"> ", timeout=10).decode() def close(self): if self.io: self.io.close() def xb(a, b): return bytes(x ^ y for x, y in zip(a, b)) def main(): s = Solver() s.connect() tok_hex = s.login("D-Cryp7", "Cryp70Gr47Hy!") assert tok_hex, "login failed" enc_hex = s.get_enc_data() assert enc_hex, "no data" ct = bytes.fromhex(enc_hex) tok = bytes.fromhex(tok_hex) nb = len(ct) // 16 log.info(f"Data {nb} blk, Token {len(tok) // 16} blk — starting oracle") dblk = [ct[i * 16 : (i + 1) * 16] for i in range(nb)] # --- decrypt data blocks (intermediate) --- inters = [] for i, blk in enumerate(dblk): log.info(f"Data block {i}") r = s.decrypt_block(blk, f"D{i}") assert r, f"block {i} fail" inters.append(r) # --- decrypt token block 0 for IV recovery --- log.info("Token block 0 (IV recovery)") itk = s.decrypt_block(tok[:16], "TK") assert itk # --- recover IV using known JWT header --- prefixes = [ b"eyJ0eXAiOiJKV1Qi", # {"typ":"JWT","alg":"HS256"} b"eyJhbGciOiJIUzI1", # {"alg":"HS256","typ":"JWT"} ] iv = None for pfx in prefixes: candidate_iv = xb(itk, pfx) p0 = xb(inters[0], candidate_iv) try: d = p0.decode("ascii") if all(c.isdigit() or c in ".-_abcdefABCDEF " for c in d): iv = candidate_iv log.success(f"IV ok prefix={pfx}") break except: continue if iv is None: iv = xb(itk, prefixes[0]) log.warning("prefix heuristic failed, using first") # --- assemble plaintext --- pt = xb(inters[0], iv) for i in range(1, nb): pt += xb(inters[i], dblk[i - 1]) # unpad pl = pt[-1] if 1 <= pl <= 16 and all(b == pl for b in pt[-pl:]): pt = pt[:-pl] text = pt.decode("utf-8", errors="replace") log.success(f"Plaintext: '{text}'") # --- parse card + balance --- card = text[:16] bal = text[16:] if "." in bal: m = re.search(r"(\d+\.\d+)$", text) if m: bal = m.group(1) card = text[: m.start()] log.info(f"Card: {card} Balance: {bal}") bal_int = int(float(bal)) log.info(f"Withdrawing {bal_int}") resp = s.withdraw(card, bal_int) log.info(f"Response: {resp}") flag = re.search(r"HTB\{[^}]+\}", resp) if flag: log.success(f"FLAG: {flag.group()}") log.info(f"Total oracle calls: {s.calls}") s.close() if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md