Хмак! Будь здоров!
Хмак! Будь здоров!
Platform: duckerz | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-01-09 | Status: Solved Techniques: collision_crafting, hash_compression_attack
Summary
Task: HMAC verification service with custom insecure_compare function that compresses 32-byte digest to 4 bytes via block_xor. Solution: Use oracle to get block_xor of target message, craft fake MAC with same compressed value by placing target bytes with zeros.
Хмак! Будь здоров! — duckerz CTF
Description
"Ты никогда не сможешь взломать этот код! Тут все по новейшим технологиям."
Server: tasks.duckerz.ru:30017
Files: apchi.py
Source Code
python read-only • copy allowed#!/usr/bin/env python3 import os, hmac, hashlib, functools, operator from flask import Flask, request, abort, jsonify SECRET = os.getenv("SECRET_KEY", "dev_secret").encode() FLAG = os.getenv("FLAG", "DUCKERZ{not_fake_flag}") FLAG_CMD = b"give_me_flag_plz" app = Flask(__name__) def block_xor(digest: bytes) -> bytes: out = bytearray() for i in range(0, 32, 8): out.append(functools.reduce(operator.xor, digest[i:i+8])) return bytes(out) def insecure_compare(candidate: bytes, real: bytes) -> bool: return block_xor(candidate) == block_xor(real) @app.get("/") def index(): return "<!--Empty-->" @app.post("/oracle") def oracle(): data = request.get_json(force=True) msg = data.get("msg", "").encode() digest = hmac.new(SECRET, msg, hashlib.sha256).digest() return jsonify(block_xor=block_xor(digest).hex()) @app.post("/verify") def verify(): try: d = request.get_json(force=True) msg = d["msg"].encode() mac_in = bytes.fromhex(d["mac"]) if len(mac_in) != 32: abort(400, "digest must be 32 bytes") except Exception: abort(400, "bad json") real = hmac.new(SECRET, msg, hashlib.sha256).digest() if insecure_compare(mac_in, real): if msg == FLAG_CMD: return FLAG return "OK" abort(403, "INVALID")
Analysis
Service Architecture
The service provides two endpoints:
/oracle- accepts a message, computes HMAC-SHA256 and returnsblock_xorof it/verify- verifies MAC for a message and returns the flag ifmsg == "give_me_flag_plz"
Vulnerability: Weak Comparison Function
The key vulnerability is in the insecure_compare function:
python read-only • copy alloweddef block_xor(digest: bytes) -> bytes: out = bytearray() for i in range(0, 32, 8): out.append(functools.reduce(operator.xor, digest[i:i+8])) return bytes(out) def insecure_compare(candidate: bytes, real: bytes) -> bool: return block_xor(candidate) == block_xor(real)
Problem: Instead of comparing full 32-byte HMACs, the function compresses them to 4 bytes!
How block_xor Works
32-byte HMAC:
[b0 b1 b2 b3 b4 b5 b6 b7] [b8 b9 ... b15] [b16 ... b23] [b24 ... b31]
↓ ↓ ↓ ↓
XOR of all 8 bytes XOR of all 8 XOR of all 8 XOR of all 8
↓ ↓ ↓ ↓
[x0] [x1] [x2] [x3]
Result: 4 bytes instead of 32!
Collision Space
- Original HMAC: 2^256 possible values
- After block_xor: 2^32 possible values
- Collisions per block_xor: 2^224 (an astronomical number!)
This means that for any 4-byte block_xor there exists a huge number of 32-byte values that produce the same result.
Solution
Attack Strategy
- Get
block_xorfrom the real HMAC for"give_me_flag_plz"via/oracle - Construct a fake 32-byte MAC with the same
block_xor - Send to
/verifyand get the flag
Constructing the Fake MAC
For each 8-byte block, the XOR of all bytes must equal the target byte.
Simplest approach: put the target byte first, remaining 7 bytes are zeros:
Target block_xor: cf b9 e6 2b
Block 0: cf ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 = cf ✓
Block 1: b9 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 = b9 ✓
Block 2: e6 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 = e6 ✓
Block 3: 2b ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 ^ 00 = 2b ✓
Fake MAC: cf00000000000000 b900000000000000 e600000000000000 2b00000000000000
Exploit
python read-only • copy allowed#!/usr/bin/env python3 """ Exploit for "Хмак! Будь здоров!" - duckerz CTF Vulnerability: insecure_compare uses block_xor which compresses 32-byte HMAC to 4 bytes, creating massive collision space. """ import requests URL = "http://tasks.duckerz.ru:30017" FLAG_CMD = "give_me_flag_plz" def main(): # Step 1: Get block_xor for flag command via oracle print("[*] Querying oracle for block_xor...") resp = requests.post(f"{URL}/oracle", json={"msg": FLAG_CMD}) target_xor = bytes.fromhex(resp.json()["block_xor"]) print(f"[+] Target block_xor: {target_xor.hex()}") # Step 2: Craft fake MAC with same block_xor # Each 8-byte block XORs to corresponding target byte fake_mac = bytearray(32) for i, target_byte in enumerate(target_xor): fake_mac[i * 8] = target_byte print(f"[+] Crafted fake MAC: {fake_mac.hex()}") # Step 3: Verify and get flag print("[*] Sending to /verify...") resp = requests.post(f"{URL}/verify", json={ "msg": FLAG_CMD, "mac": fake_mac.hex() }) if resp.status_code == 200: print(f"[+] Flag: {resp.text}") else: print(f"[-] Failed: {resp.status_code} - {resp.text}") if __name__ == "__main__": main()
Execution
bash read-only • copy allowed$ python3 solve.py [*] Querying oracle for block_xor... [+] Target block_xor: cfb9e62b [+] Crafted fake MAC: cf00000000000000b900000000000000e6000000000000002b00000000000000 [*] Sending to /verify... [+] Flag: DUCKERZ{REDACTED}
Lessons
- Never use custom comparison functions for cryptography - use
hmac.compare_digest()or similar - Compressing digests destroys security - 32 bytes → 4 bytes = loss of 224 bits of entropy
- The function name was a hint -
insecure_comparedirectly indicates the problem - Oracles are dangerous - even partial information about HMAC can be used for an attack
Alternative Approaches
The fake MAC could be constructed in other ways:
python read-only • copy allowed# Option 1: All bytes the same # If target = 0xcf, then 0xcf ^ 0xcf ^ 0xcf ^ 0xcf ^ 0xcf ^ 0xcf ^ 0xcf ^ 0xcf = 0xcf (for odd count) # Doesn't work for 8 bytes (even number) # Option 2: Distribute bits # target = 0xcf = 11001111 # Can split into: 0x80 ^ 0x40 ^ 0x08 ^ 0x04 ^ 0x02 ^ 0x01 ^ 0x00 ^ 0x00 = 0xcf # Option 3: Random 7 bytes + computed 8th import os for i in range(4): block = bytearray(os.urandom(7)) block.append(target_xor[i] ^ functools.reduce(operator.xor, block))
But the simplest approach (target byte + zeros) works perfectly.