Гран-при (Grand Prix)
Гран-при (Grand Prix)
Platform: duckerz | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-02-09 | Status: Solved Techniques: hash_length_extension_attack, parameter_override, signature_forgery
Summary
Server: http://tasks.duckerz.ru:30034 Solves: 6 teams Author: kappapapapa
Гран-при (Grand Prix) — duckerz CTF
Description
"В ряды честных гонщиков затесался подозрительный участник. Расследуй это дело."
Server: http://tasks.duckerz.ru:30034
Solves: 6 teams
Author: kappapapapa
Analysis
Service Architecture
Flask/Werkzeug web application — "Duck Racing" game:
- 5 ducks participate in the race: Duckstappen, GoldenQuack, Ducklerc, Duckonelli, and Ф (user's duck)
- GoldenQuack has base speed 2 (others have 1) — this is the "suspicious participant" who always wins
- Distance: 300, actions are executed every 8 seconds from a queue
- Actions:
increase_speed(moves duck 15 steps) andboost_progress(increases max speed by 1)
Signature Scheme
To submit actions, you need to provide:
- Signature (hex):
sha256(secret || message)wheresecret = sha256(password)— a 64-character hex string - Message (hex):
user:<user>&action:<action>&duck:<duck>
Vulnerability: Hash Length Extension
The H(secret || message) construction with a Merkle–Damgård hash (SHA-256) is vulnerable to Hash Length Extension Attack:
- Knowing
H(secret || message)and the length ofsecret, you can computeH(secret || message || padding || attacker_data)without knowing the secret - Use the known hash as the internal SHA-256 state
- Compute padding for
secret (64 bytes) || original_message - Continue hashing with appended data
Key Observations
- Key length = 64:
secret = sha256(password)— this is a hex string of 64 characters (not 32 raw bytes) - Last parameter parsing: Server takes the LAST
duck:value from the message — this allows target override via HLE - Access control by user: Only
user:hacker228can use thedisqual_duckaction — regular users get "No, you're not a hacker!" - Bots: Users
max,kimi,charles, andhacker228are bots that regularly submit signed actions
The disqual_duck Action
From the race logs, we can see that the hacker228 bot sends disqual_duck actions to disqualify other ducks. This is the only way to remove GoldenQuack from the race.
Solution
Step 1: Capture hacker228's Signature
Wait for the hacker228 bot to submit an action and capture from logs:
Signature: 3f27101c92d74f9b7559d493678b6a2d33f2ad8e74b0b4b889afd4c9866f01df
Message: user:hacker228&action:disqual_duck&duck:Ducklerc
Step 2: Hash Length Extension Attack
Using a custom SHA-256 HLE implementation (hashpumpy wouldn't install), we extend hacker228's signature for other targets:
Computation:
- Take the known hash
3f27101c...as the internal SHA-256 state - Compute SHA-256 padding for
secret (64 bytes) || original_message (48 bytes)= 112 bytes - Append
&duck:<target>after padding - Continue SHA-256 from the captured state
Result — forged message:
user:hacker228&action:disqual_duck&duck:Ducklerc\x80\x00...\x03\x80&duck:GoldenQuack
Server parses the LAST duck: → disqualifies GoldenQuack instead of Ducklerc.
Step 3: Disqualify All Competitors
For each duck (GoldenQuack, Duckstappen, Duckonelli) we create an HLE forgery, and for Ducklerc we use hacker228's original signature.
Step 4: Victory
- Restart the race, clear logs
- Submit HLE forgeries to disqualify GoldenQuack, Duckstappen, Duckonelli
- Submit the original signature to disqualify Ducklerc
- Start the race
- All 4 competitors are disqualified — duck "Ф" is the only one on the track
- "Ф" reaches 300 and wins
- The winner's speech contains the flag
Exploit
python read-only • copy allowed#!/usr/bin/env python3 """ Exploit for "Гран-при" (Grand Prix) — duckerz CTF Hash Length Extension Attack on sha256(secret || message) signature scheme. Extends hacker228's signed disqual_duck action to target different ducks. """ import struct import hashlib import requests URL = "http://tasks.duckerz.ru:30034" # === SHA-256 Hash Length Extension Implementation === def sha256_padding(msg_len): """Compute SHA-256 padding for a message of given byte length.""" pad = b'\x80' # Need (msg_len + 1 + pad_zeros) % 64 == 56 pad_zeros = (55 - msg_len) % 64 pad += b'\x00' * pad_zeros pad += struct.pack('>Q', msg_len * 8) # bit length, big-endian return pad def sha256_extend(original_hash_hex, original_msg, key_len, append_data): """ Hash Length Extension: compute sha256(key || original_msg || padding || append_data) given sha256(key || original_msg) and len(key). Returns (new_hash_hex, forged_message_bytes) """ # Parse original hash into SHA-256 state (8 x 32-bit words) h = bytes.fromhex(original_hash_hex) state = struct.unpack('>8I', h) # Total length of key || original_msg total_original = key_len + len(original_msg) # Compute padding that would follow key || original_msg padding = sha256_padding(total_original) # The forged message (without key prefix) is: # original_msg || padding || append_data forged_msg = original_msg + padding + append_data # Length already processed (key || original_msg || padding) processed_len = total_original + len(padding) # Continue SHA-256 from captured state with append_data # We need a SHA-256 that starts from custom state new_hash = sha256_from_state(state, append_data, processed_len) return new_hash, forged_msg def sha256_from_state(state, data, prior_len): """Continue SHA-256 from a given internal state.""" # Use hashlib's internal _sha256 or implement manually # Python's hashlib doesn't expose state manipulation, # so we implement the SHA-256 compression function import ctypes import struct K = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ] def rotr(x, n): return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF def compress(state, block): w = list(struct.unpack('>16I', block)) for i in range(16, 64): s0 = rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3) s1 = rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10) w.append((w[i-16] + s0 + w[i-7] + s1) & 0xFFFFFFFF) a, b, c, d, e, f, g, h = state for i in range(64): S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) ch = (e & f) ^ (~e & g) & 0xFFFFFFFF temp1 = (h + S1 + ch + K[i] + w[i]) & 0xFFFFFFFF S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) maj = (a & b) ^ (a & c) ^ (b & c) temp2 = (S0 + maj) & 0xFFFFFFFF h = g g = f f = e e = (d + temp1) & 0xFFFFFFFF d = c c = b b = a a = (temp1 + temp2) & 0xFFFFFFFF return tuple((s + v) & 0xFFFFFFFF for s, v in zip(state, (a, b, c, d, e, f, g, h))) # Pad the append data total_len = prior_len + len(data) padded = data + b'\x80' padded += b'\x00' * ((55 - (total_len % 64)) % 64) padded += struct.pack('>Q', total_len * 8) # Process each 64-byte block current_state = state for i in range(0, len(padded), 64): current_state = compress(current_state, padded[i:i+64]) return struct.pack('>8I', *current_state).hex() # === Exploit Logic === def main(): s = requests.Session() # Known hacker228 signature from race logs known_hash = "3f27101c92d74f9b7559d493678b6a2d33f2ad8e74b0b4b889afd4c9866f01df" known_msg = b"user:hacker228&action:disqual_duck&duck:Ducklerc" key_len = 64 # sha256(password) as hex string = 64 chars targets = ["GoldenQuack", "Duckstappen", "Duckonelli"] for target in targets: append = f"&duck:{target}".encode() new_hash, forged_msg = sha256_extend(known_hash, known_msg, key_len, append) print(f"[*] Disqualifying {target}") print(f" New hash: {new_hash}") print(f" Forged msg len: {len(forged_msg)}") resp = s.post(f"{URL}/submit_action", json={ "signature": new_hash, "message": forged_msg.hex() }) print(f" Response: {resp.text}") # Ducklerc — use original signature directly print("[*] Disqualifying Ducklerc (original signature)") resp = s.post(f"{URL}/submit_action", json={ "signature": known_hash, "message": known_msg.hex() }) print(f" Response: {resp.text}") # Start race print("[*] Starting race...") resp = s.post(f"{URL}/start_race") print(f" Response: {resp.text}") # Wait and check winner import time time.sleep(30) resp = s.get(f"{URL}/race_status") print(f"[+] Race result: {resp.text}") if __name__ == "__main__": main()
Theory: Hash Length Extension Attack
Why It Works
SHA-256 (and other Merkle–Damgård hashes) process data in 64-byte blocks:
H(M) = compress(compress(compress(IV, block1), block2), block3 || padding)
Knowing H(secret || msg):
- This is the internal SHA-256 state after processing
secret || msg || padding - You can continue hashing with new data
- Result =
H(secret || msg || padding || new_data)— a valid signature!
Defense
The correct approach is to use HMAC: H(K ⊕ opad || H(K ⊕ ipad || message)), which is not vulnerable to HLE.
Lessons
H(secret || message)— NEVER use as MAC — always vulnerable to Hash Length Extension- Use HMAC — it's specifically designed to protect against this attack
- Last parameter parsing creates a vector for parameter pollution with HLE
- Key length is critical —
sha256(password)as hex string = 64 characters, not 32 bytes - Bots in CTF — observing bot behavior can reveal hidden actions (disqual_duck)