Exploiting Weak Salts: Dictionary Attack with Brute-Force

Challenge Info:

  • Platform: picoCTF 2025 - Guess My Cheese (Part 2)
  • Category: Cryptography
  • Difficulty: Medium
  • Key Concepts: Dictionary Attack, Salt Brute-Force, SHA-256, Binary Encoding

Understanding the Challenge

So "Guess My Cheese (Part 2)" is basically password cracking with a twist. We're playing the attacker trying to bypass a security system that supposedly got "beefed up" after part 1. We connect to a live server that generates a unique hash every session, and we need to figure out both the original input (the "cheese") and the secret "salt" they used to encrypt it.

Challenge Overview

It's hosted on picoGym.

The description gives us the link to cheese_list.txt and how to connect.

It tells us the hashing algorithm (SHA-256) and that the salt is 2 nibbles.

Connecting to the Server

When we connect, here's what we see:

Type 'g' to guess.

Challenge Requirements

  • Target: Find a secret cheese name from a downloadable list (cheese_list.txt). This limits our search to a known set of words.
  • Protection: The cheese is hashed with SHA-256. This is a one-way function - you can't reverse it, only verify guesses.
  • Salt: Random data gets added to the cheese before hashing. In crypto, a salt is extra random stuff that defends against dictionary attacks and rainbow tables.

A peek at the cheese_list.txt file showing our password candidates.

Critical Clues

  • Algorithm: They explicitly say SHA-256. Now we know exactly what tool to use.
  • Salt Structure: The prompt says: "Exactly 2 nibbles of hexadecimal-character salt." This specific wording is the key weakness.
  • Dynamic Nature: The target hash changes every time you reconnect. The server makes a new random salt (and maybe picks a new cheese) for each session. So we're on the clock.

Identifying the Vulnerability

Understanding the Salt Weakness

A "salt" is basically random noise added to a password before hashing.

  • Why it exists: If two people use "password123", they'd normally get identical hashes. Add a random salt, and their hashes become different. This stops attackers from using pre-made hash tables (Rainbow Tables).
  • The Bug: A good salt should be huge (like 16 bytes, which has $3.4 \times 10^{38}$ possibilities). Here, the salt is only 1 Byte (256 possibilities).
  • What this means: Since the salt is tiny, we don't need luck. We can just try every single possible salt for every cheese name. It's like guessing a PIN code that's only 1 digit long!

The Mathematics of Salt Space

The challenge says "2 nibbles". Let's break that down:

  • 1 Nibble = 4 bits (half a byte). Can represent 0-15 (Hex: 0-F).
  • 2 Nibbles = 8 bits ($4 + 4$) = exactly 1 Byte.

Search Space: One byte can only be 0-255 (Hex: 00-FF).

This creates a huge security hole. Salts are supposed to add randomness to stop guessing attacks. Usually salts are way bigger (16 bytes). But here? Only 256 choices. Ridiculously small. A computer can check all of them in milliseconds.

Feasibility Analysis

Before writing code, smart CTF players prove brute-force is doable within the server timeout. This prevents wasting time on something that'd take years.

What we're dealing with:

  • $D$: Dictionary Size (Number of cheeses) = 600 entries
  • $S$: Salt Space (1 Byte) = $2^8 = 256$
  • $V$: Variations per word (Original, Lowercase, Uppercase, Underscores) $\approx 4$

Total Search Space ($C$): Total operations needed = dictionary size × salt possibilities × string variations

$$C = D \times S \times V$$ $$C = 600 \times 256 \times 4$$ $$C = 614,400 \text{ hashes}$$

Time it'll Take ($t$): A standard Python script on a normal CPU can compute about $500,000$ SHA-256 hashes per second (being conservative). Compiled languages or tools like Hashcat could do millions, but Python works fine here.

$$t = \frac{C}{\text{Hash Rate}}$$ $$t = \frac{614,400 \text{ hashes}}{500,000 \text{ hashes/sec}} \approx 1.2 \text{ seconds}$$

Bottom line: Since $1.2$ seconds is way less than typical socket timeouts (usually 30-60 seconds) for CTF challenges, we're golden. We've got plenty of time to crack it and send our answer before the server boots us.

Attack Strategy

Since we have a finite list of passwords (cheese_list.txt) and a tiny salt range (0-255), we can do a Hybrid Dictionary Attack:

  1. Loop: Grab a cheese from the list (like "Cheddar").
  2. Combine: Mix it with every possible salt value (0-255).
  3. Hash: Calculate the SHA-256 hash of the combo.
  4. Check: Compare it to the server's target hash. Match? We got the credentials.

Development and Troubleshooting

Attempt 1: Manual Cracking (Failed)

First, we tried connecting manually with nc verbal-sleep.picoctf.net 61338, copying the hash, and pasting it into a local script.

  • Why it failed: The challenge is session-based. The second you close nc or it times out, the server throws away that session's secret. Even if we cracked the hash 10 seconds later, the answer would be useless for a new connection.
  • What we learned: We need to automate this. The cracking has to happen while the connection is still alive.

Attempt 2: String-Based Automation (Failed)

We used pwntools to automate the connection, fixing the timeout issue. The script tried combining cheese with salt as text (like "Cheddar" + "63").

  • Why it failed: Wrong encoding assumption. We thought "hexadecimal salt" meant ASCII characters '0'-'9' and 'a'-'f'. So salt "63" would be 2 bytes in memory (0x36, 0x33).
  • What we learned: In crypto, "hex" usually means the raw byte value, not the text string. The hint "2 nibbles" screams binary data, since nibbles are binary storage units, not text characters.

Attempt 3: Raw Byte Implementation (Success!)

We changed our approach to treat salt as raw binary data. Instead of appending characters "63", we appended the single byte value \x63 (like "Cheddar" + b'\x63'). Fun fact: \x63 in ASCII is the letter c.

Understanding Binary vs String Encoding

Computers store everything as numbers (bytes).

  • The string "63" is two bytes: 54 (for '6') and 51 (for '3').
  • The hex value 0x63 is one byte: 99 (which happens to be the letter 'c').

The server was adding the number 99 to the cheese, not the characters "6" and "3". Hash the wrong bytes, get the wrong hash.

Think of it like this: When you type '5' on a keyboard, the computer stores number 53 (ASCII code for character '5'), not the number 5. That's the difference between text and raw data.

The Encoding Difference: Here's why the hashes were different. The actual data going into SHA-256 was fundamentally different at the binary level.

MethodWhat We ThoughtData in Memory (Bytes)SHA-256 HashResult
String"Brie" + "63"42 72 69 65 36 338c45...FAIL
Raw Byte"Brie" + b'\x63'42 72 69 65 63031e...SUCCESS

By using raw bytes, we matched exactly what the server was doing.


Complete Exploit Implementation

This script is the final version that actually works. It uses pwntools to handle networking, keeping the connection alive while Python does the brute-forcing.

Note: While we tested both prepending and appending the salt, the challenge actually uses Suffix (Cheese + Salt). This script does that.

Script Execution Flow

  1. Connect: Use remote() to open a live connection to the server.
  2. Get Target: Read the server's output to find the "target hash" we need to match.
  3. The Big Loop: Loop through every cheese in our list.
  4. The Small Loop: For each cheese, loop through 0-255 (every possible 1-byte salt).
  5. Check: Combine cheese + salt, hash it, see if it matches.
  6. Win: If it matches, send the cheese and salt back to get the flag.

Answer Submission Process

The if found_answer: block handles talking to the server once we crack the credentials.

  • io.sendlineafter(b"do?", b"g"): Server asks "What would you like to do?". We send g to pick (g)uess my cheese.
  • io.sendlineafter(b"cheese?", found_answer.encode()): Server wants the cheese name. We send the cracked cheese (like "explorateur").
  • io.sendlineafter(b"salt?", found_salt_hex.encode()): Server wants the salt. Important: we send the hex string (like "63") because that's what the text prompt expects, even though hashing used raw byte \x63.

The Exploit Script

from pwn import *
import hashlib
import sys

# --- CONFIGURATION ---
HOST = "verbal-sleep.picoctf.net"
PORT = 61338
WORDLIST = "cheese_list.txt"

def solve():
    print("[-] Starting the attack...")

    # 1. Load the Dictionary
    try:
        with open(WORDLIST, "r", encoding="utf-8", errors="ignore") as f:
            cheeses = [line.strip() for line in f.readlines() if line.strip()]
    except FileNotFoundError:
        print(f"[!] Error: {WORDLIST} not found.")
        return

    # 2. Connect to Server
    io = remote(HOST, PORT)

    # 3. Grab the Target Hash
    io.recvuntil(b"guess it: ")
    target_hash = io.recvline().strip().decode().split()[-1]
    print(f"[+] Got target hash: {target_hash}")
    
    # 4. Brute Force Time
    print("[-] Brute-forcing with raw bytes...")
    
    found_answer = None
    found_salt_hex = None

    for cheese in cheeses:
        # Try variations (Original, Lowercase, Uppercase, Underscore)
        variations = list(set([cheese, cheese.lower(), cheese.upper(), cheese.replace(" ", "_")]))

        for variant in variations:
            variant_bytes = variant.encode('utf-8')
            
            # Try every byte (0x00 to 0xFF)
            for i in range(256):
                salt_raw = bytes([i])
                
                # Check: Suffix (Cheese + Salt)
                if hashlib.sha256(variant_bytes + salt_raw).hexdigest() == target_hash:
                    found_answer = variant
                    found_salt_hex = f"{i:02x}"
                    print(f"\n[+] CRACKED! (Suffix)")
                    print(f"    Cheese: {variant}")
                    print(f"    Salt:   {found_salt_hex}")
                    break
            if found_answer: break
        if found_answer: break

    # 5. Send the Answer
    if found_answer:
        io.sendlineafter(b"do?", b"g")
        io.sendlineafter(b"cheese?", found_answer.encode())
        print(f"[*] Submitting salt: {found_salt_hex}")
        io.sendlineafter(b"salt?", found_salt_hex.encode())
        
        # 6. Get the Flag
        io.interactive()
    else:
        print("[!] Attack failed. Not in dictionary or unknown salt method.")
        io.close()

if __name__ == "__main__":
    solve()

Successful Exploitation

When we run the script, the brute-forcer finds the target combo in about 1 second, way before any timeout.

  • Cheese: explorateur
  • Salt: 63 (Hex for ASCII character 'c')
  • How it works: SHA256("explorateur" + b'\x63') produces the exact hash the server wanted.

What we see:

[+] Opening connection to verbal-sleep.picoctf.net on port 61338: Done
[+] Got target hash: 031e...
[-] Brute-forcing with raw bytes...

[+] CRACKED! (Suffix)
    Cheese: explorateur
    Salt:   63
[*] Submitting salt: 63
[*] Switching to interactive mode
Congrats! Here is your flag: picoCTF{cHeEsYs5d45bcdd}


Key Takeaways

Security Lessons

  • Small salt space (1 byte) is catastrophically weak
  • Rainbow table protection requires large, random salts (minimum 16 bytes)
  • Binary encoding vs string encoding creates different hash outputs
  • Session-based attacks require live connection automation

Attack Methodology

  1. Calculate feasibility before implementing attack
  2. Automate connection management for session-based challenges
  3. Understand binary encoding vs string representation
  4. Test both prefix and suffix salt positions
  5. Verify encoding matches server implementation