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:
- Loop: Grab a cheese from the list (like "Cheddar").
- Combine: Mix it with every possible salt value (0-255).
- Hash: Calculate the SHA-256 hash of the combo.
- 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
ncor 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') and51(for '3'). - The hex value
0x63is 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.
| Method | What We Thought | Data in Memory (Bytes) | SHA-256 Hash | Result |
|---|---|---|---|---|
| String | "Brie" + "63" | 42 72 69 65 36 33 | 8c45... | FAIL |
| Raw Byte | "Brie" + b'\x63' | 42 72 69 65 63 | 031e... | 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
- Connect: Use
remote()to open a live connection to the server. - Get Target: Read the server's output to find the "target hash" we need to match.
- The Big Loop: Loop through every cheese in our list.
- The Small Loop: For each cheese, loop through 0-255 (every possible 1-byte salt).
- Check: Combine
cheese + salt, hash it, see if it matches. - 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 sendgto 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
- Calculate feasibility before implementing attack
- Automate connection management for session-based challenges
- Understand binary encoding vs string representation
- Test both prefix and suffix salt positions
- Verify encoding matches server implementation