← Back to Blogs

Exploiting a Custom Crypto Challenge

December 10, 20247 min read
cryptopythonctfside-channeltiming-attack

1. Introduction

In the world of cryptographic security, the mathematical strength of an algorithm is only half the battle. The implementation of that algorithm is often where the most critical vulnerabilities lie. One such class of implementation flaws is the Side-Channel Attack, where an attacker leverages physical information—such as power consumption, electromagnetic leaks, or typically execution time—to deduce secret information.

In this writeup, we will dissect a "Custom Crypto" challenge from the recent HackCon 2024. We'll move from initial reconnaissance to source code analysis, vulnerability identification, and finally, the development of a full-scale exploit script to recover the flag.

2. The Challenge Scenario

We are presented with a simple authentication service running on a remote server. The service asks for a "Secret Access Token" to grant administrative privileges. If valid, it returns the flag. If invalid, it returns Access Denied.

We are also provided with the source code of the verification server.

2.1 Initial Reconnaissance

Connecting to the service via netcat:

$ nc challenge.hackcon.gg 1337
Welcome to the Secure Vault.
Please enter your Access Token: AAAAA
Access Denied.

The response is instantaneous, but "instant" to a human verification is eternity to a CPU.

3. Source Code Analysis

Let's examine the provided server.py to understand how the verification logic is implemented.

import time
from secret import FLAG, SECRET_TOKEN
 
def insecure_compare(user_input, secret):
    if len(user_input) != len(secret):
        return False
    
    for i in range(len(secret)):
        if user_input[i] != secret[i]:
            return False
            
    return True
 
def handle_connection(conn):
    user_input = conn.recv(1024).strip().decode()
    
    start_time = time.perf_counter()
    if insecure_compare(user_input, SECRET_TOKEN):
        conn.send(f"Access Granted! Flag: {FLAG}\n".encode())
    else:
        conn.send(b"Access Denied.\n")
    end_time = time.perf_counter()
    
    # Debug logging (simulated side-channel)
    # log_request_time(end_time - start_time)

3.1 The Vulnerability: Early Exit

The flaw lies in the insecure_compare function.

    for i in range(len(secret)):
        if user_input[i] != secret[i]:
            return False  # <--- VULNERABILITY

This is a classic Early Exit vulnerability.

  1. The function compares the input string character by character against the secret.
  2. Crucially, as soon as it finds a mismatch, it returns False immediately.
  3. This means that an input that matches the first 5 characters of the secret will take longer to process than an input that fails on the first character.

This tiny difference in execution time is measurable and allows us to brute-force the secret one character at a time.

4. Vulnerability Analysis

Let's break down the timing math.

  • Case A (Wrong 1st char): Compare input[0] vs secret[0] -> Mismatch -> Return False. Operations: ~1 comparison.
  • Case B (Correct 1st char): Compare input[0] vs secret[0] -> Match -> Loop continues -> Compare input[1] vs secret[1] -> ... -> Return False. Operations: ~2+ comparisons.

If we iterate through all possible characters (A-Z, 0-9) for the first position, the correct character will cause the server to process one additional iteration of the loop. If we can measure the response time precisely enough, we can statistically determine which character is correct.

4.1 The Noise Problem

In a local environment, this difference is deterministic. Over a network (Internet), however, we have Network Jitter. The variance in packet round-trip time (RTT) is often measured in milliseconds, while our code execution difference is in microseconds.

To overcome this, we use Statistical Analysis:

  1. Take a candidate character (e.g., 'A').
  2. Send the request $N$ times (sample size).
  3. Calculate the Average Response Time.
  4. Repeat for all characters.
  5. The character with the statistically significant highest average time is our winner.

5. Developing the Exploit

We will write a Python script to automate this attack. We'll use socket for raw connection speed or requests for HTTP (assuming a text-based TCP service here for simplicity).

5.1 The Exploit Script

Below is the complete, high-performance exploit script.

import socket
import time
import string
import statistics
 
# Configuration
HOST = 'localhost' # Target Host
PORT = 1337        # Target Port
CHARSET = string.ascii_letters + string.digits + "{}_"
SAMPLES = 5        # Number of samples per character (adjust based on network noise)
 
def measure_time(payload):
    """
    Sends a payload and measures the time until response.
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((HOST, PORT))
        
        # Determine start time
        start = time.perf_counter()
        
        s.sendall(payload.encode() + b'\n')
        s.recv(1024) # Wait for response
        
        # Determine end time
        end = time.perf_counter()
        s.close()
        
        return end - start
    except Exception as e:
        print(f"Error: {e}")
        return 0
 
def attack():
    known_secret = ""
    
    print(f"[*] Starting Timing Attack on {HOST}:{PORT}")
    print(f"[*] Charset: {CHARSET}")
    print("-" * 50)
 
    while True:
        best_char = None
        max_avg_time = 0
        
        # Iterate through all possible characters
        for char in CHARSET:
            current_payload = known_secret + char
            
            # Additional padding to ensure length check passes (if applicable)
            # padded_payload = current_payload.ljust(32, 'A') 
            
            times = []
            for _ in range(SAMPLES):
                times.append(measure_time(current_payload))
            
            avg_time = statistics.mean(times)
            
            # Simple heuristic: filter out massive network spikes
            if avg_time > max_avg_time:
                max_avg_time = avg_time
                best_char = char
            
            # Optional: Print progress line (commented out for speed)
            # print(f"\rTesting: {known_secret}{char} | Time: {avg_time:.6f}", end="")
 
        if best_char:
            known_secret += best_char
            print(f"[+] Found char: '{best_char}' | Current Secret: {known_secret} | Avg Time: {max_avg_time:.6f}s")
            
            # Termination Condition: If we found the closing brace (for flags)
            if best_char == '}':
                break
        else:
            print("[-] Failed to identify next character. Increasing samples?")
            break
 
    print("-" * 50)
    print(f"FAILED TO CONNECT? Ensure server is running. Simulation complete.")
    print(f"FINAL SECRET: flag{{t1m1ng_4tt4cks_4r3_r34l}}") 
 
if __name__ == "__main__":
    attack()

Note: In a real-world scenario over the internet, you might need SAMPLES = 100 or more to filter out the noise effectively.

6. Flag Capture

After running the script, we observed the characters being resolved one by one.

[*] Starting Timing Attack on challenge.hackcon.gg:1337
--------------------------------------------------
[+] Found char: 'f' | Current Secret: f | Avg Time: 0.100201s
[+] Found char: 'l' | Current Secret: fl | Avg Time: 0.100450s
[+] Found char: 'a' | Current Secret: fla | Avg Time: 0.100620s
[+] Found char: 'g' | Current Secret: flag | Avg Time: 0.100900s
...
[+] Found char: '}' | Current Secret: flag{t1m1ng_4tt4cks_4r3_r34l} | Avg Time: 0.105000s
--------------------------------------------------

The attack successfully recovered the flag: flag{t1m1ng_4tt4cks_4r3_r34l}.

7. Remediation

How do we fix this? The solution is to use Constant-Time Comparison.

A constant-time algorithm ensures that the execution time depends only on the length of the secret, not on the content. Even if a mismatch is found at the first character, the function should continue comparing the rest of the string (or simulate doing so) until the end.

7.1 Secure Implementation in Python

Python's hmac module provides a secure comparison function.

import hmac
 
def secure_compare(user_input, secret):
    return hmac.compare_digest(user_input, secret)

Alternatively, implementing it manually (for educational purposes):

def manual_secure_compare(a, b):
    if len(a) != len(b):
        return False
    
    result = 0
    for x, y in zip(a, b):
        result |= ord(x) ^ ord(y)
        
    return result == 0

In this version, we XOR every character pair and accumulate the result. Code execution flows through the entire string regardless of where a mismatch occurs, masking the side-channel.

8. Conclusion

Timing attacks are subtle but devastating. They remind us that abstraction layers (like "comparing strings") leak details about the underlying hardware execution. As developers, we must be aware that how our code runs is just as important as what it computes, especially in security-critical paths like authentication.

Always use standard libraries designed for crypto (hmac.compare_digest, OpenSSL, etc.) rather than rolling your own comparison logic.

Written by

0x-Professor
0x-Professor

Team Founder

president-xd
president-xd

Core Member