I've been attending Defcon for over a decade now, with a group of friends I participate in contests with. This is the first year where I used LLMs heavily to help solve challenges. I thought I'd share a few of my favorite "vibe coded" solutions from a contest we placed second in this year (SpyVSpy). The challenges contain codewords or "flags" that need to be found, which are then submitted for points.
This particular contest had a variety of challenges, many involving steganography and cryptography.
GitHub Copilot on VS Code was used heavily during the competition. About 34 different throwaway Python scripts were generated to analyze and solve challenges. This post contains a write-up on a few of my favorites.
Strange QR Code
Challenge text:
During a routine sweep of the casino floor, Agent Vega spotted something odd: a QR code sticker slapped onto the back of a restroom door, right above the handle. At first, it seemed like a typical ad — “Scan for VIP rewards!” — but on closer inspection, the QR pattern was odd and did not scan properly. Our analysts believe it’s an intentionally corrupted QR code, designed to embed a hidden message or codeword that only someone trained can extract. We need you to examine the data and isolate the embedded string.
From the image, we can see that in addition to black and white, there are six other colors. I first asked copilot to extract a list of colors from the image and it wrote this function:
def get_image_colors(image_path, max_colors=10):
"""Get the most common colors in an image."""
try:
img = Image.open(image_path)
if img.mode != 'RGB':
img = img.convert('RGB')
# Get all unique colors and their counts
colors = img.getcolors(maxcolors=256*256*256)
if colors is None:
print("Too many colors to analyze")
return []
# Sort by frequency (most common first)
colors.sort(key=lambda x: x[0], reverse=True)
print(f"\nMost common colors in {os.path.basename(image_path)}:")
for i, (count, color) in enumerate(colors[:max_colors]):
hex_color = rgb_to_hex(color)
print(f" {i+1}. {hex_color} (RGB: {color}) - {count} pixels")
return colors[:max_colors]
except Exception as e:
print(f"Error analyzing image: {e}")
return []
Resulting output:
Analyzing original image colors...
Most common colors in StrangeQRCode.png:
1. #000000 (RGB: (0, 0, 0)) - 323712 pixels
2. #ffffff (RGB: (255, 255, 255)) - 273132 pixels
3. #0000ff (RGB: (0, 0, 255)) - 75648 pixels
4. #ff00ff (RGB: (255, 0, 255)) - 72000 pixels
5. #ff0000 (RGB: (255, 0, 0)) - 66240 pixels
6. #00ff00 (RGB: (0, 255, 0)) - 64320 pixels
7. #ffff00 (RGB: (255, 255, 0)) - 61072 pixels
8. #00ffff (RGB: (0, 255, 255)) - 61056 pixels
After my initial analysis, I used the following prompt to update my script and solve the problem:
Update the script to generate every possible combination of 6 bits (64 total). A 1 corresponds to color #FFFFFF, 0 corresponds to color #000000.
Apply substitutions to the following colors (the first bit affects the first color, the second bit affects the second color, and so on for all 6 colors):
#0000ff
#ff00ff
#ff0000
#00ff00
#ffff00
#00ffff
For example, for 00111100 change:
#0000ff to black,
#ff00ff to black,
#ff0000 to white,
#00ff00 to white,
#00ff00 to white,
#00ff00 to white,
#ffff00 to black,
#00ffff to black.
This process should result in 64 different images, which are QR codes. Attempt to decode them and dump the value.
Here is a snippet of the script it generated:
Output:
================================================================================
SUMMARY
================================================================================
Total combinations processed: 64
Successfully generated images: 64
Successfully decoded QR codes: 3
Success rate: 4.7%
--------------------------------------------------------------------------------
SUCCESSFULLY DECODED QR CODES:
--------------------------------------------------------------------------------
Pattern 000111 (dec: 7): 'Gr3y'
Pattern 011010 (dec: 26): 'd1th'
Pattern 110001 (dec: 49): 'Mer3'
--------------------------------------------------------------------------------
FAILED TO DECODE (61 patterns):
--------------------------------------------------------------------------------
000000 000001 000010 000011 000100 000101 000110 001000
001001 001010 001011 001100 001101 001110 001111 010000
010001 010010 010011 010100 010101 010110 010111 011000
011001 011011 011100 011101 011110 011111 100000 100001
100010 100011 100100 100101 100110 100111 101000 101001
101010 101011 101100 101101 101110 101111 110000 110010
110011 110100 110101 110110 110111 111000 111001 111010
111011 111100 111101 111110 111111
Actual flag: Mer3d1thGr3y. In past years, this is something that would probably have taken me about two hours to research all the libraries I'd need and code a solution in python. It took about 20 minutes to vibe code a solution.
Ransom Note
Challenge text:
Security at Casino Royale intercepted a message delivered to the VIP manager’s office — a crudely assembled ransom note, cut from casino brochures, playing cards, and betting slips. While the staff panicked over the missing high roller, Agent Ledger suspected the note itself was the real message. Gray Faction operatives often embed ciphers inside ransom notes. We’ve transcribed the ransom note text and marked the key elements below. Crack it, and we’ll know what password to give at the private room.
Screenshot of PDF file containing ransom note:
I looked at this for about 30 minutes, and even had GPT 5 take a look:
Here’s what I checked so far:
Pulled the PDF’s raw bytes and structure, scanned for embedded files, JavaScript, open actions, optional content layers, object streams, and XObjects (images).
Parsed each page’s content streams looking for “invisible text” tricks like white text (rg 1 1 1), invisible rendering (Tr 3), or hidden strings in Tj/TJ.
Extracted metadata and searched for extra data after the final %%EOF (a common hiding spot).
What I found:
No embedded files, JavaScript, open actions, or optional content groups.
No images/XObjects to extract.
No white-colored text, no invisible text rendering, and no suspicious strings tucked in content streams.
Nothing appended after the final %%EOF.
The only visible message is the one you saw.
I saved a concise scan report you can open: Download the JSON report.
If you want, I can go one level deeper and brute-decode any compressed object streams, then diff visible text vs. raw text to catch obfuscated characters or zero-width spaces. I can also sweep for acrostics or index-based patterns (e.g., “every nth letter”, line/word indices). Want me to run that next?
No immediate help there, but I eventually had the idea to extract the text properties for the various characters in the note (which I had copilot do).
Output:
============================================================
RANSOM NOTE PDF ANALYSIS
============================================================
=== PyMuPDF Analysis ===
Page 1:
----------------------------------------
Text: 'T'
Font: Tahoma
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (72.0999984741211, 184.67501831054688, 87.44459533691406, 216.38893127441406)
Text: 'H'
Font: HGSGothicE
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (87.0999984741211, 188.37979125976562, 102.70735168457031, 214.65478515625)
Text: 'I'
Font: Elephant Pro
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (102.87000274658203, 186.69818115234375, 114.16825103759766, 218.22817993164062)
Text: 'S'
Font: Calibri
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (114.1500015258789, 191.24375915527344, 126.2102279663086, 217.51876831054688)
Text: 'I'
Font: OCRAExtended
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (126.1500015258789, 188.40606689453125, 173.57009887695312, 215.5744171142578)
Text: 'S'
Font: Daytona Condensed
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (173.47000122070312, 184.2808837890625, 192.34097290039062, 216.94070434570312)
Text: 'G'
Font: Ebrima
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (202.75, 182.5992889404297, 220.774658203125, 216.46775817871094)
Text: 'R'
Font: Walbaum Display
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (220.77000427246094, 183.96559143066406, 238.4268035888672, 218.412109375)
Text: 'A'
Font: OldEnglishTextMT
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (238.8000030517578, 188.32723999023438, 257.9544677734375, 214.60223388671875)
Text: 'Y'
Font: Rockwell Nova Extra Bold
Size: 26.274999618530273
Color: #000000 (0)
Bold: True
Italic: False
Position: (258.3299865722656, 184.0181427001953, 288.84051513671875, 217.3348388671875)
Text: 'S'
Font: Daytona Condensed Light
Size: 26.274999618530273
Color: #000000 (0)
Bold: False
Italic: False
Position: (299.6300048828125, 184.2808837890625, 311.7427673339844, 216.94070434570312)
Looking through the output, it looks like the first character of the font name used for each character forms a secret message (we can see "THE CODEWORD" in the snippet above). I asked copilot to concatenate the first character of each font name together to get the solution:
THECODEWORDISSHARKATTACKTHECODEWORDISSHARKATTACKTHECODEWORDISSHARKATTACKTHECODEWORDISSHARKATTACKTHECODELORDISSHARKATTACKTHECODEWORDISSHARKATTACKTHECODEWORDISSHARKATTACKTHECODEWORDISSHARKATTACK
The flag for the challenge was "SHARKATTACK". Once I had the idea to extract font properties, this challenge was very easy to solve and have copilot code a solution.
Square Words
Challenge text:
Agent Ledger recovered a tiny scrap of paper near the cashier cage — just a crumpled slip, almost thrown away. On it: a long string of random letters and numbers, with no context, no labels, no obvious pattern.
Most would ignore it as garbage data or misprinted figures — but Whack Faction insiders know better.
We’ve cleaned up the sequence and transcribed it in the attached file. Break the number code and reveal the message hidden within.
Text file contents:
37 F 68 39 31 I 62 A N
G 1 27 21 T 74 4 98 I
F 55 H 57 S 22 T 19 3
29 T 99 91 77 5 16 42 H
2 49 Y E T 51 R 95 35
74 L 82 46 D 25 69 H E
A 27 65 I 85 12 W 71 53
18 N T 84 O 37 33 N 39
Y 54 D 88 E 96 X S T
This one I ran through GPT5 which actually got close to a solution:
Alright, this looks like a 9×9 “pointer grid” (a square of mixed numbers and letters). The numbers are 2-digit coordinates: **rc** → “row r, column c”. When you land on a number you jump again; when you land on a letter you keep that letter. Doing that for each cell gives a fully lettered 9×9.
Using that rule (and treating single-digit numbers as (n,n) so they still point inside the square), the grid resolves to this letter square:
TFHHFILAN
GTYGTIYSI
FTHRSTTNH
ITTYWTITH
THYETTRES
ILNTDTEHE
AYDIOFWAY
ANT?OTHNH
YEDNE?XST
The "pointer grid" idea does look to be correct, it just didn't extract the text properly. So I asked copilot.
Prompt:
Analyze square_words.txt - A 9x9 grid containing letters and 2-digit numbers with whitespace separating columns and newlines separating rows.
For each 2 digit number in the grid, do a lookup (row, col) using 1 based indexing which should resolve to a single character. Output all of these characters.```
Output:
COORDINATE POINTER ANALYSIS (1-based indexing):
Each two-digit number interpreted as (row, col) coordinates using 1-based indexing
Grid with 1-based coordinates:
1 2 3 4 5 6 7 8 9
1: 37 F 68 39 31 I 62 A N
2: G 1 27 21 T 74 4 98 I
3: F 55 H 57 S 22 T 19 3
4: 29 T 99 91 77 5 16 42 H
5: 2 49 Y E T 51 R 95 35
6: 74 L 82 46 D 25 69 H E
7: A 27 65 I 85 12 W 71 53
8: 18 N T 84 O 37 33 N 39
9: Y 54 D 88 E 96 X S T
Following the pointers:
Step 1: 37 -> row 3, col 7 = 'T'
Step 2: 68 -> row 6, col 8 = 'H'
Step 3: 39 -> row 3, col 9 = 3 = digit '3'
Step 4: 31 -> row 3, col 1 = 'F'
Step 5: 62 -> row 6, col 2 = 'L'
Skipping 1 (not a two-digit number)
Step 7: 27 -> row 2, col 7 = 4 = digit '4'
Step 8: 21 -> row 2, col 1 = 'G'
Step 9: 74 -> row 7, col 4 = 'I'
Skipping 4 (not a two-digit number)
...
🎯 POINTER_MESSAGE: 'TH3FL4GISTR1NITYWITH2ESIN5TE4DOFAYATTH3END'
Conclusion
LLMs are certainly a challenge for CTF contest designers. The challenges have to be designed in such a way to make them difficult for an LLM to solve without a lot of user direction. This probably makes the challenges a lot more difficult than they would be without the existence of LLMs.
The contests this year seemed to have a lot more challenges than in previous years. So not only are the contest participants using LLMs, but the designers are too. :)