← Back to Writeups
CUSTOMMediumCrypto

Гран-при (Grand Prix)

XESXOR9/10/20267 min read
#crypto#custom#medium#hash_length_extension_attack#parameter_override#signature_forgery

Гран-при (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) and boost_progress (increases max speed by 1) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Signature Scheme

To submit actions, you need to provide:

  • Signature (hex): sha256(secret || message) where secret = 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:

  1. Knowing H(secret || message) and the length of secret, you can compute H(secret || message || padding || attacker_data) without knowing the secret
  2. Use the known hash as the internal SHA-256 state
  3. Compute padding for secret (64 bytes) || original_message
  4. Continue hashing with appended data ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Observations

  1. Key length = 64: secret = sha256(password) — this is a hex string of 64 characters (not 32 raw bytes)
  2. Last parameter parsing: Server takes the LAST duck: value from the message — this allows target override via HLE
  3. Access control by user: Only user:hacker228 can use the disqual_duck action — regular users get "No, you're not a hacker!"
  4. Bots: Users max, kimi, charles, and hacker228 are 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:

  1. Take the known hash 3f27101c... as the internal SHA-256 state
  2. Compute SHA-256 padding for secret (64 bytes) || original_message (48 bytes) = 112 bytes
  3. Append &duck:<target> after padding
  4. 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

  1. Restart the race, clear logs
  2. Submit HLE forgeries to disqualify GoldenQuack, Duckstappen, Duckonelli
  3. Submit the original signature to disqualify Ducklerc
  4. Start the race
  5. All 4 competitors are disqualified — duck "Ф" is the only one on the track
  6. "Ф" reaches 300 and wins
  7. The winner's speech contains the flag

Exploit

python
#!/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):

  1. This is the internal SHA-256 state after processing secret || msg || padding
  2. You can continue hashing with new data
  3. 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

  1. H(secret || message) — NEVER use as MAC — always vulnerable to Hash Length Extension
  2. Use HMAC — it's specifically designed to protect against this attack
  3. Last parameter parsing creates a vector for parameter pollution with HLE
  4. Key length is critical — sha256(password) as hex string = 64 characters, not 32 bytes
  5. Bots in CTF — observing bot behavior can reveal hidden actions (disqual_duck) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍