ctf-agent 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +131 -0
- package/Dockerfile +192 -0
- package/README.md +522 -0
- package/agents/ctf-analyzer.md +58 -0
- package/agents/ctf-controller.md +151 -0
- package/agents/ctf-speedrun.md +74 -0
- package/bin/ctf-agent.js +119 -0
- package/docker-compose.yml +13 -0
- package/mcp_config.json +19 -0
- package/package.json +44 -0
- package/references/ctf-triage-ladder.md +68 -0
- package/references/exploit-databases.md +575 -0
- package/references/llm-safety-and-policy-compliance.md +173 -0
- package/references/multi-agent-orchestration-and-policy-routing.md +278 -0
- package/references/security-events-and-intelligence.md +566 -0
- package/references/version-matrix.md +59 -0
- package/rules/ctf-execution-rules.md +44 -0
- package/rules/ctf-safety-framing-rules.md +140 -0
- package/rules/flag-validation-rules.md +25 -0
- package/scripts/__init__.py +1 -0
- package/scripts/ctf-tools.lock +205 -0
- package/scripts/ctf_init.py +1026 -0
- package/scripts/ctf_update.py +583 -0
- package/scripts/ctfd_client.py +81 -0
- package/scripts/cve_lookup.py +453 -0
- package/scripts/extract_flags.py +155 -0
- package/scripts/install_as_agent.py +278 -0
- package/scripts/install_ctf_tools.sh +603 -0
- package/scripts/parallel_triage.py +395 -0
- package/scripts/prompt_policy_sanitizer.py +283 -0
- package/scripts/scope_guard.py +444 -0
- package/scripts/skill_validator.py +504 -0
- package/scripts/workspace_cleaner.py +141 -0
- package/skills/ctf-ai-ml/SKILL.md +120 -0
- package/skills/ctf-ai-ml/adversarial-ml.md +623 -0
- package/skills/ctf-ai-ml/llm-attacks.md +487 -0
- package/skills/ctf-ai-ml/model-attacks.md +422 -0
- package/skills/ctf-crypto/SKILL.md +320 -0
- package/skills/ctf-crypto/advanced-math.md +798 -0
- package/skills/ctf-crypto/classic-ciphers.md +651 -0
- package/skills/ctf-crypto/ecc-attacks.md +347 -0
- package/skills/ctf-crypto/exotic-crypto-2.md +380 -0
- package/skills/ctf-crypto/exotic-crypto.md +528 -0
- package/skills/ctf-crypto/historical.md +113 -0
- package/skills/ctf-crypto/lattice-and-lwe.md +524 -0
- package/skills/ctf-crypto/modern-ciphers-2.md +563 -0
- package/skills/ctf-crypto/modern-ciphers-3.md +453 -0
- package/skills/ctf-crypto/modern-ciphers.md +649 -0
- package/skills/ctf-crypto/prng-attacks.md +257 -0
- package/skills/ctf-crypto/prng.md +664 -0
- package/skills/ctf-crypto/rsa-attacks-2.md +792 -0
- package/skills/ctf-crypto/rsa-attacks.md +487 -0
- package/skills/ctf-crypto/stream-ciphers.md +390 -0
- package/skills/ctf-crypto/zkp-and-advanced.md +456 -0
- package/skills/ctf-forensics/3d-printing.md +121 -0
- package/skills/ctf-forensics/SKILL.md +379 -0
- package/skills/ctf-forensics/disk-advanced.md +497 -0
- package/skills/ctf-forensics/disk-and-memory.md +491 -0
- package/skills/ctf-forensics/disk-recovery.md +699 -0
- package/skills/ctf-forensics/linux-forensics.md +511 -0
- package/skills/ctf-forensics/network-advanced.md +583 -0
- package/skills/ctf-forensics/network.md +645 -0
- package/skills/ctf-forensics/peripheral-capture.md +287 -0
- package/skills/ctf-forensics/signals-and-hardware.md +713 -0
- package/skills/ctf-forensics/steganography.md +694 -0
- package/skills/ctf-forensics/stego-advanced-2.md +475 -0
- package/skills/ctf-forensics/stego-advanced.md +481 -0
- package/skills/ctf-forensics/stego-image.md +691 -0
- package/skills/ctf-forensics/windows.md +625 -0
- package/skills/ctf-malware/SKILL.md +181 -0
- package/skills/ctf-malware/c2-and-protocols.md +274 -0
- package/skills/ctf-malware/pe-and-dotnet.md +108 -0
- package/skills/ctf-malware/scripts-and-obfuscation.md +449 -0
- package/skills/ctf-misc/SKILL.md +498 -0
- package/skills/ctf-misc/bashjails.md +323 -0
- package/skills/ctf-misc/ctfd-navigation.md +465 -0
- package/skills/ctf-misc/dns.md +255 -0
- package/skills/ctf-misc/encodings-advanced.md +504 -0
- package/skills/ctf-misc/encodings.md +431 -0
- package/skills/ctf-misc/games-and-vms-2.md +254 -0
- package/skills/ctf-misc/games-and-vms-3.md +690 -0
- package/skills/ctf-misc/games-and-vms-4.md +229 -0
- package/skills/ctf-misc/games-and-vms.md +529 -0
- package/skills/ctf-misc/linux-privesc.md +333 -0
- package/skills/ctf-misc/pyjails.md +671 -0
- package/skills/ctf-misc/rf-sdr.md +91 -0
- package/skills/ctf-osint/SKILL.md +198 -0
- package/skills/ctf-osint/geolocation-and-media.md +464 -0
- package/skills/ctf-osint/social-media.md +312 -0
- package/skills/ctf-osint/web-and-dns.md +341 -0
- package/skills/ctf-pwn/SKILL.md +214 -0
- package/skills/ctf-pwn/advanced-exploits-2.md +579 -0
- package/skills/ctf-pwn/advanced-exploits-3.md +598 -0
- package/skills/ctf-pwn/advanced-exploits-4.md +590 -0
- package/skills/ctf-pwn/advanced-exploits-5.md +119 -0
- package/skills/ctf-pwn/advanced-exploits.md +773 -0
- package/skills/ctf-pwn/advanced.md +326 -0
- package/skills/ctf-pwn/field-notes.md +245 -0
- package/skills/ctf-pwn/format-string.md +694 -0
- package/skills/ctf-pwn/heap-fsop.md +285 -0
- package/skills/ctf-pwn/heap-techniques-2.md +333 -0
- package/skills/ctf-pwn/heap-techniques.md +513 -0
- package/skills/ctf-pwn/kernel-bypass.md +421 -0
- package/skills/ctf-pwn/kernel-techniques.md +366 -0
- package/skills/ctf-pwn/kernel.md +636 -0
- package/skills/ctf-pwn/overflow-basics.md +611 -0
- package/skills/ctf-pwn/rop-advanced.md +725 -0
- package/skills/ctf-pwn/rop-and-shellcode.md +659 -0
- package/skills/ctf-pwn/sandbox-escape.md +313 -0
- package/skills/ctf-reverse/SKILL.md +163 -0
- package/skills/ctf-reverse/anti-analysis-ctf.md +204 -0
- package/skills/ctf-reverse/anti-analysis.md +693 -0
- package/skills/ctf-reverse/field-notes.md +376 -0
- package/skills/ctf-reverse/languages-compiled.md +666 -0
- package/skills/ctf-reverse/languages-platforms.md +592 -0
- package/skills/ctf-reverse/languages.md +553 -0
- package/skills/ctf-reverse/patterns-ctf-2.md +397 -0
- package/skills/ctf-reverse/patterns-ctf-3.md +797 -0
- package/skills/ctf-reverse/patterns-ctf.md +670 -0
- package/skills/ctf-reverse/patterns-runtime.md +274 -0
- package/skills/ctf-reverse/patterns.md +572 -0
- package/skills/ctf-reverse/platforms-hardware.md +387 -0
- package/skills/ctf-reverse/platforms.md +664 -0
- package/skills/ctf-reverse/tools-advanced-2.md +421 -0
- package/skills/ctf-reverse/tools-advanced.md +407 -0
- package/skills/ctf-reverse/tools-dynamic.md +679 -0
- package/skills/ctf-reverse/tools-emulation.md +319 -0
- package/skills/ctf-reverse/tools.md +573 -0
- package/skills/ctf-web/SKILL.md +153 -0
- package/skills/ctf-web/auth-and-access-2.md +82 -0
- package/skills/ctf-web/auth-and-access.md +783 -0
- package/skills/ctf-web/auth-infra.md +321 -0
- package/skills/ctf-web/auth-jwt.md +186 -0
- package/skills/ctf-web/client-side-advanced.md +739 -0
- package/skills/ctf-web/client-side.md +529 -0
- package/skills/ctf-web/cves.md +373 -0
- package/skills/ctf-web/field-notes.md +482 -0
- package/skills/ctf-web/node-and-prototype.md +200 -0
- package/skills/ctf-web/server-side-2.md +337 -0
- package/skills/ctf-web/server-side-advanced-2.md +559 -0
- package/skills/ctf-web/server-side-advanced-3.md +125 -0
- package/skills/ctf-web/server-side-advanced-4.md +480 -0
- package/skills/ctf-web/server-side-advanced.md +378 -0
- package/skills/ctf-web/server-side-deser.md +443 -0
- package/skills/ctf-web/server-side-exec-2.md +799 -0
- package/skills/ctf-web/server-side-exec.md +457 -0
- package/skills/ctf-web/server-side.md +629 -0
- package/skills/ctf-web/sql-injection.md +790 -0
- package/skills/ctf-web/web3.md +374 -0
- package/skills/ctf-writeup/SKILL.md +90 -0
- package/skills/solve-challenge/SKILL.md +269 -0
- package/skills.json +16 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# CTF Forensics - Image Steganography
|
|
2
|
+
|
|
3
|
+
Techniques specific to hiding data in image formats (JPEG, PNG, BMP, GIF). For non-image steganography (PDF, audio, terminal, text), see [steganography.md](steganography.md). For advanced techniques (FFT, SSTV, audio, video, JPEG XL), see [stego-advanced.md](stego-advanced.md) and [stego-advanced-2.md](stego-advanced-2.md).
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [JPEG Unused Quantization Table LSB Steganography (EHAX 2026)](#jpeg-unused-quantization-table-lsb-steganography-ehax-2026)
|
|
7
|
+
- [BMP Bitplane QR Code Extraction + Steghide (BYPASS CTF 2025)](#bmp-bitplane-qr-code-extraction--steghide-bypass-ctf-2025)
|
|
8
|
+
- [Image Jigsaw Puzzle Reassembly via Edge Matching (BYPASS CTF 2025)](#image-jigsaw-puzzle-reassembly-via-edge-matching-bypass-ctf-2025)
|
|
9
|
+
- [F5 JPEG DCT Coefficient Ratio Detection (ApoorvCTF 2026)](#f5-jpeg-dct-coefficient-ratio-detection-apoorvctf-2026)
|
|
10
|
+
- [PNG Unused Palette Entry Steganography (ApoorvCTF 2026)](#png-unused-palette-entry-steganography-apoorvctf-2026)
|
|
11
|
+
- [QR Code Tile Reconstruction (UTCTF 2026)](#qr-code-tile-reconstruction-utctf-2026)
|
|
12
|
+
- [Seed-Based Pixel Permutation + Multi-Bitplane QR (L3m0nCTF 2025)](#seed-based-pixel-permutation--multi-bitplane-qr-l3m0nctf-2025)
|
|
13
|
+
- [JPEG Thumbnail Pixel-to-Text Mapping (RuCTF 2013)](#jpeg-thumbnail-pixel-to-text-mapping-ructf-2013)
|
|
14
|
+
- [Conditional LSB Extraction — Near-Black Pixel Filter (BaltCTF 2013)](#conditional-lsb-extraction--near-black-pixel-filter-baltctf-2013)
|
|
15
|
+
- [JPEG Slack Space Steganography (BSidesSF 2025)](#jpeg-slack-space-steganography-bsidessf-2025)
|
|
16
|
+
- [Nearest-Neighbor Interpolation Steganography (BSidesSF 2025)](#nearest-neighbor-interpolation-steganography-bsidessf-2025)
|
|
17
|
+
- [RGB Parity Steganography (Break In 2016)](#rgb-parity-steganography-break-in-2016)
|
|
18
|
+
- [Pixel Coordinate Chain Steganography (H4ckIT CTF 2016)](#pixel-coordinate-chain-steganography-h4ckit-ctf-2016)
|
|
19
|
+
- [AVI Frame Differential Pixel Steganography (H4ckIT CTF 2016)](#avi-frame-differential-pixel-steganography-h4ckit-ctf-2016)
|
|
20
|
+
- [JPEG Single-Bit-Flip Brute Force with OCR (SECCON 2017)](#jpeg-single-bit-flip-brute-force-with-ocr-seccon-2017)
|
|
21
|
+
- [GIF Frame PLTE Chunk Concatenation to ELF (IceCTF 2018)](#gif-frame-plte-chunk-concatenation-to-elf-icectf-2018)
|
|
22
|
+
- [Nested-Resize QR Overlay at Survivor Pixels (SECCON 2018)](#nested-resize-qr-overlay-at-survivor-pixels-seccon-2018)
|
|
23
|
+
- [ImageMagick +append Puzzle Stitching + gaps Solver (X-MAS CTF 2018)](#imagemagick-append-puzzle-stitching--gaps-solver-x-mas-ctf-2018)
|
|
24
|
+
- [Steghide Passphrase in JPEG Header Metadata (Saudi/Oman CTF 2019)](#steghide-passphrase-in-jpeg-header-metadata-saudioman-ctf-2019)
|
|
25
|
+
- [Corrupted PNG Magic and Lowercase Chunk Repair (Pragyan CTF 2019)](#corrupted-png-magic-and-lowercase-chunk-repair-pragyan-ctf-2019)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## JPEG Unused Quantization Table LSB Steganography (EHAX 2026)
|
|
30
|
+
|
|
31
|
+
**Pattern (Jpeg Soul):** "Insignificant" hint points to least significant bits in JPEG quantization tables (DQT). JPEG can embed DQT tables (ID 2, 3) that are never referenced by frame markers — invisible to renderers but carry hidden data.
|
|
32
|
+
|
|
33
|
+
**Detection:** JPEG has more DQT tables than components reference. Standard JPEG uses 2 tables (luminance + chrominance); extra tables with IDs 2, 3 are suspicious.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from PIL import Image
|
|
37
|
+
|
|
38
|
+
img = Image.open('challenge.jpg')
|
|
39
|
+
|
|
40
|
+
# Access quantization tables (PIL exposes them as dict)
|
|
41
|
+
# Standard: tables 0 (luminance) and 1 (chrominance)
|
|
42
|
+
# Hidden: tables 2, 3 (unreferenced by SOF marker)
|
|
43
|
+
qtables = img.quantization
|
|
44
|
+
|
|
45
|
+
bits = []
|
|
46
|
+
for table_id in sorted(qtables.keys()):
|
|
47
|
+
if table_id >= 2: # Unused tables
|
|
48
|
+
table = qtables[table_id]
|
|
49
|
+
for i in range(64): # 8x8 = 64 values per DQT
|
|
50
|
+
bits.append(table[i] & 1) # Extract LSB
|
|
51
|
+
|
|
52
|
+
# Convert bits to ASCII
|
|
53
|
+
flag = ''
|
|
54
|
+
for i in range(0, len(bits) - 7, 8):
|
|
55
|
+
byte = int(''.join(str(b) for b in bits[i:i+8]), 2)
|
|
56
|
+
if 32 <= byte <= 126:
|
|
57
|
+
flag += chr(byte)
|
|
58
|
+
print(flag)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Manual DQT extraction (when PIL doesn't expose all tables):**
|
|
62
|
+
```python
|
|
63
|
+
# Parse JPEG manually to find all DQT markers (0xFFDB)
|
|
64
|
+
data = open('challenge.jpg', 'rb').read()
|
|
65
|
+
pos = 0
|
|
66
|
+
while pos < len(data) - 1:
|
|
67
|
+
if data[pos] == 0xFF and data[pos+1] == 0xDB:
|
|
68
|
+
length = int.from_bytes(data[pos+2:pos+4], 'big')
|
|
69
|
+
dqt_data = data[pos+4:pos+2+length]
|
|
70
|
+
table_id = dqt_data[0] & 0x0F
|
|
71
|
+
precision = (dqt_data[0] >> 4) & 0x0F # 0=8-bit, 1=16-bit
|
|
72
|
+
values = list(dqt_data[1:65]) if precision == 0 else []
|
|
73
|
+
print(f"DQT table {table_id}: {values[:8]}...")
|
|
74
|
+
pos += 2 + length
|
|
75
|
+
else:
|
|
76
|
+
pos += 1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Key insight:** JPEG quantization tables are metadata — they survive recompression and most image processing. Unused table IDs (2-15) can carry arbitrary data without affecting the image.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## BMP Bitplane QR Code Extraction + Steghide (BYPASS CTF 2025)
|
|
84
|
+
|
|
85
|
+
**Pattern (Gold Challenge):** BMP image with QR code hidden in a specific bitplane. Extract the QR code to obtain a steghide password.
|
|
86
|
+
|
|
87
|
+
**Technique:** Extract individual bitplanes (bits 0-2) for each RGB channel, render as images, scan for QR codes.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from PIL import Image
|
|
91
|
+
import numpy as np
|
|
92
|
+
|
|
93
|
+
img = Image.open('challenge.bmp')
|
|
94
|
+
pixels = np.array(img)
|
|
95
|
+
|
|
96
|
+
# Extract individual bitplanes
|
|
97
|
+
for ch_idx, ch_name in enumerate(['R', 'G', 'B']):
|
|
98
|
+
for bit in range(3): # Check bits 0, 1, 2
|
|
99
|
+
channel = pixels[:, :, ch_idx]
|
|
100
|
+
bit_plane = ((channel >> bit) & 1) * 255
|
|
101
|
+
Image.fromarray(bit_plane.astype(np.uint8)).save(f'bit_{ch_name}_{bit}.png')
|
|
102
|
+
|
|
103
|
+
# Combined LSB across all channels
|
|
104
|
+
lsb_img = np.zeros_like(pixels)
|
|
105
|
+
for ch in range(3):
|
|
106
|
+
lsb_img[:, :, ch] = (pixels[:, :, ch] & 1) * 255
|
|
107
|
+
Image.fromarray(lsb_img).save('lsb_all.png')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Full attack chain:**
|
|
111
|
+
1. Extract bitplanes → find QR code in specific bitplane (often bit 1, not bit 0)
|
|
112
|
+
2. Scan QR with `zbarimg bit_G_1.png` → get steghide password
|
|
113
|
+
3. `steghide extract -sf challenge.bmp -p <password>` → extract hidden file
|
|
114
|
+
|
|
115
|
+
**Key insight:** Standard LSB (least significant bit) tools check bit 0 only. Hidden QR codes may be in bit 1 or bit 2 — always check multiple bitplanes systematically. BMP format preserves exact pixel values (no compression artifacts).
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Image Jigsaw Puzzle Reassembly via Edge Matching (BYPASS CTF 2025)
|
|
120
|
+
|
|
121
|
+
**Pattern (Jigsaw Puzzle):** Archive containing multiple puzzle piece images that must be reassembled into the original image. Reassembled image contains the flag (possibly ROT13 encoded).
|
|
122
|
+
|
|
123
|
+
**Technique:** Compute pixel intensity differences at shared edges between all piece pairs, then greedily place pieces to minimize total edge difference.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from PIL import Image
|
|
127
|
+
import numpy as np
|
|
128
|
+
import os
|
|
129
|
+
|
|
130
|
+
# Load all pieces
|
|
131
|
+
pieces = {}
|
|
132
|
+
for f in sorted(os.listdir('pieces/')):
|
|
133
|
+
pieces[f] = np.array(Image.open(f'pieces/{f}'))
|
|
134
|
+
|
|
135
|
+
piece_list = list(pieces.keys())
|
|
136
|
+
n = len(piece_list)
|
|
137
|
+
grid_size = int(n ** 0.5) # e.g., 25 pieces → 5x5
|
|
138
|
+
|
|
139
|
+
# Calculate edge compatibility
|
|
140
|
+
def edge_diff(img1, img2, direction):
|
|
141
|
+
if direction == 'right':
|
|
142
|
+
return np.sum(np.abs(img1[:, -1].astype(int) - img2[:, 0].astype(int)))
|
|
143
|
+
elif direction == 'bottom':
|
|
144
|
+
return np.sum(np.abs(img1[-1, :].astype(int) - img2[0, :].astype(int)))
|
|
145
|
+
|
|
146
|
+
# Build compatibility matrices
|
|
147
|
+
right_compat = np.full((n, n), float('inf'))
|
|
148
|
+
bottom_compat = np.full((n, n), float('inf'))
|
|
149
|
+
for i in range(n):
|
|
150
|
+
for j in range(n):
|
|
151
|
+
if i != j:
|
|
152
|
+
right_compat[i, j] = edge_diff(pieces[piece_list[i]], pieces[piece_list[j]], 'right')
|
|
153
|
+
bottom_compat[i, j] = edge_diff(pieces[piece_list[i]], pieces[piece_list[j]], 'bottom')
|
|
154
|
+
|
|
155
|
+
# Greedy placement
|
|
156
|
+
grid = [[None] * grid_size for _ in range(grid_size)]
|
|
157
|
+
used = set()
|
|
158
|
+
for row in range(grid_size):
|
|
159
|
+
for col in range(grid_size):
|
|
160
|
+
best_piece, best_diff = None, float('inf')
|
|
161
|
+
for idx in range(n):
|
|
162
|
+
if idx in used:
|
|
163
|
+
continue
|
|
164
|
+
diff = 0
|
|
165
|
+
if col > 0:
|
|
166
|
+
diff += right_compat[grid[row][col-1], idx]
|
|
167
|
+
if row > 0:
|
|
168
|
+
diff += bottom_compat[grid[row-1][col], idx]
|
|
169
|
+
if diff < best_diff:
|
|
170
|
+
best_diff, best_piece = diff, idx
|
|
171
|
+
grid[row][col] = best_piece
|
|
172
|
+
used.add(best_piece)
|
|
173
|
+
|
|
174
|
+
# Reassemble
|
|
175
|
+
piece_h, piece_w = pieces[piece_list[0]].shape[:2]
|
|
176
|
+
final = Image.new('RGB', (grid_size * piece_w, grid_size * piece_h))
|
|
177
|
+
for row in range(grid_size):
|
|
178
|
+
for col in range(grid_size):
|
|
179
|
+
final.paste(Image.open(f'pieces/{piece_list[grid[row][col]]}'),
|
|
180
|
+
(col * piece_w, row * piece_h))
|
|
181
|
+
final.save('reassembled.png')
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Post-processing:** Check if reassembled image text is ROT13 encoded. Decode with `tr 'A-Za-z' 'N-ZA-Mn-za-m'`.
|
|
185
|
+
|
|
186
|
+
**Key insight:** Edge-matching works by minimizing pixel differences at shared borders. The greedy approach (place piece with smallest total edge difference to already-placed neighbors) works well for most CTF puzzles. For harder puzzles, add backtracking.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## F5 JPEG DCT Coefficient Ratio Detection (ApoorvCTF 2026)
|
|
191
|
+
|
|
192
|
+
**Pattern (Engraver's Fault):** Detect F5 steganography in JPEG images by analyzing DCT coefficient distributions. F5 decrements ±1 AC coefficients toward 0, creating a measurable ratio shift.
|
|
193
|
+
|
|
194
|
+
**Detection metric — ±1/±2 AC coefficient ratio:**
|
|
195
|
+
```python
|
|
196
|
+
import numpy as np
|
|
197
|
+
from PIL import Image
|
|
198
|
+
import jpegio # or use jpeg_toolbox
|
|
199
|
+
|
|
200
|
+
def f5_ratio(jpeg_path):
|
|
201
|
+
"""Ratio below 0.15 indicates F5 modification; above 0.20 indicates clean."""
|
|
202
|
+
jpg = jpegio.read(jpeg_path)
|
|
203
|
+
coeffs = jpg.coef_arrays[0].flatten() # Luminance Y channel
|
|
204
|
+
coeffs = coeffs[coeffs != 0] # Remove DC/zeros
|
|
205
|
+
count_1 = np.sum(np.abs(coeffs) == 1)
|
|
206
|
+
count_2 = np.sum(np.abs(coeffs) == 2)
|
|
207
|
+
return count_1 / max(count_2, 1)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Sparse image edge case:** Images with >80% zero DCT coefficients give misleading ±1/±2 ratios. Use a secondary metric:
|
|
211
|
+
```python
|
|
212
|
+
def f5_sparse_check(jpeg_path):
|
|
213
|
+
"""For sparse images, ±2/±3 ratio below 2.5 indicates modification."""
|
|
214
|
+
jpg = jpegio.read(jpeg_path)
|
|
215
|
+
coeffs = jpg.coef_arrays[0].flatten()
|
|
216
|
+
count_2 = np.sum(np.abs(coeffs) == 2)
|
|
217
|
+
count_3 = np.sum(np.abs(coeffs) == 3)
|
|
218
|
+
return count_2 / max(count_3, 1)
|
|
219
|
+
|
|
220
|
+
# Combined classifier:
|
|
221
|
+
r12 = f5_ratio(path)
|
|
222
|
+
r23 = f5_sparse_check(path)
|
|
223
|
+
is_modified = r12 < 0.15 or (r12 < 0.25 and r23 < 2.5)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Key insight:** F5 steganography shifts ±1 coefficients toward 0, reducing the ±1/±2 ratio. Natural JPEGs have ratio 0.25-0.45; F5-modified drop below 0.10. Sparse images (mostly flat/white) need the secondary ±2/±3 metric because their ±1 counts are inherently low.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## PNG Unused Palette Entry Steganography (ApoorvCTF 2026)
|
|
231
|
+
|
|
232
|
+
**Pattern (The Gotham Files):** Paletted PNG (8-bit indexed color) hides data in palette entries that no pixel references. The image uses indices 0-199 but the PLTE chunk has 256 entries — indices 200-255 contain hidden ASCII in their red channel values.
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from PIL import Image
|
|
236
|
+
import struct
|
|
237
|
+
|
|
238
|
+
def extract_unused_plte(png_path):
|
|
239
|
+
img = Image.open(png_path)
|
|
240
|
+
palette = img.getpalette() # Flat list: [R0,G0,B0, R1,G1,B1, ...]
|
|
241
|
+
pixels = list(img.getdata())
|
|
242
|
+
used_indices = set(pixels)
|
|
243
|
+
|
|
244
|
+
# Extract red channel from unused palette entries
|
|
245
|
+
flag = ''
|
|
246
|
+
for i in range(256):
|
|
247
|
+
if i not in used_indices:
|
|
248
|
+
r = palette[i * 3] # Red channel
|
|
249
|
+
if 32 <= r <= 126:
|
|
250
|
+
flag += chr(r)
|
|
251
|
+
return flag
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Key insight:** PNG palette can have up to 256 entries but images typically use fewer. Unused entries are invisible to viewers but persist in the file. Metadata hints like "collector", "the entries that don't make it to the page", or "red light" point to this technique. Always check which palette indices are actually referenced vs. allocated.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## QR Code Tile Reconstruction (UTCTF 2026)
|
|
259
|
+
|
|
260
|
+
**Pattern (QRecreate):** QR code split into tiles/pieces that must be reassembled. Tiles may be scrambled, rotated, or have missing alignment patterns.
|
|
261
|
+
|
|
262
|
+
**Reconstruction workflow:**
|
|
263
|
+
```python
|
|
264
|
+
from PIL import Image
|
|
265
|
+
import numpy as np
|
|
266
|
+
|
|
267
|
+
# Load scrambled tiles
|
|
268
|
+
tiles = []
|
|
269
|
+
for i in range(N_TILES):
|
|
270
|
+
tile = Image.open(f'tile_{i}.png')
|
|
271
|
+
tiles.append(np.array(tile))
|
|
272
|
+
|
|
273
|
+
# Strategy 1: Edge matching (like jigsaw puzzle)
|
|
274
|
+
# Each tile edge has a unique bit pattern — match adjacent edges
|
|
275
|
+
def edge_signature(tile, side):
|
|
276
|
+
if side == 'top': return tuple(tile[0, :].flatten())
|
|
277
|
+
if side == 'bottom': return tuple(tile[-1, :].flatten())
|
|
278
|
+
if side == 'left': return tuple(tile[:, 0].flatten())
|
|
279
|
+
if side == 'right': return tuple(tile[:, -1].flatten())
|
|
280
|
+
|
|
281
|
+
# Strategy 2: QR structure constraints
|
|
282
|
+
# - Finder patterns (large squares) MUST be at 3 corners
|
|
283
|
+
# - Timing patterns (alternating B/W) run between finders
|
|
284
|
+
# - Use these as anchors to orient remaining tiles
|
|
285
|
+
|
|
286
|
+
# Strategy 3: Brute force small grids
|
|
287
|
+
# For 3x3 or 4x4 grids, try all permutations and scan with zbarimg
|
|
288
|
+
from itertools import permutations
|
|
289
|
+
import subprocess
|
|
290
|
+
|
|
291
|
+
grid_size = 3
|
|
292
|
+
tile_size = tiles[0].shape[0]
|
|
293
|
+
for perm in permutations(range(len(tiles))):
|
|
294
|
+
img = Image.new('L', (grid_size * tile_size, grid_size * tile_size))
|
|
295
|
+
for idx, tile_idx in enumerate(perm):
|
|
296
|
+
row, col = divmod(idx, grid_size)
|
|
297
|
+
img.paste(Image.fromarray(tiles[tile_idx]),
|
|
298
|
+
(col * tile_size, row * tile_size))
|
|
299
|
+
img.save('/tmp/qr_attempt.png')
|
|
300
|
+
result = subprocess.run(['zbarimg', '/tmp/qr_attempt.png'],
|
|
301
|
+
capture_output=True, text=True)
|
|
302
|
+
if result.stdout.strip():
|
|
303
|
+
print(f"DECODED: {result.stdout}")
|
|
304
|
+
break
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Key insight:** QR codes have structural constraints (finder patterns, timing patterns, format info) that drastically reduce the search space. Use QR structure as anchors before brute-forcing tile positions.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Seed-Based Pixel Permutation + Multi-Bitplane QR (L3m0nCTF 2025)
|
|
312
|
+
|
|
313
|
+
**Pattern (Lost Signal):** Image with randomized pixel colors hides a QR code. Pixels are visited in a seed-determined permutation order, and data is interleaved across multiple bitplanes of the luminance (Y) channel.
|
|
314
|
+
|
|
315
|
+
**Extraction workflow:**
|
|
316
|
+
1. Convert image to YCbCr and extract Y (luminance) channel
|
|
317
|
+
2. Generate the pixel visit order using the known seed
|
|
318
|
+
3. Extract LSB bits from multiple bitplanes in interleaved order
|
|
319
|
+
4. Reconstruct as a binary image and scan as QR code
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
from PIL import Image
|
|
323
|
+
import numpy as np
|
|
324
|
+
|
|
325
|
+
SEED = 739391 # Given or brute-forced
|
|
326
|
+
|
|
327
|
+
# 1. Extract Y channel
|
|
328
|
+
img = Image.open("challenge.png").convert("YCbCr")
|
|
329
|
+
Y = np.array(img.split()[0], dtype=np.uint8)
|
|
330
|
+
h, w = Y.shape
|
|
331
|
+
|
|
332
|
+
# 2. Generate deterministic pixel permutation
|
|
333
|
+
rng = np.random.RandomState(SEED)
|
|
334
|
+
perm = np.arange(h * w)
|
|
335
|
+
rng.shuffle(perm)
|
|
336
|
+
|
|
337
|
+
# 3. Extract bits from multiple bitplanes (interleaved)
|
|
338
|
+
bitplanes = [0, 1] # LSB0 and LSB1
|
|
339
|
+
total_bits = h * w
|
|
340
|
+
bits = np.zeros(total_bits, dtype=np.uint8)
|
|
341
|
+
|
|
342
|
+
for i in range(total_bits):
|
|
343
|
+
pix_idx = perm[i // len(bitplanes)]
|
|
344
|
+
bp = bitplanes[i % len(bitplanes)]
|
|
345
|
+
y, x = divmod(pix_idx, w)
|
|
346
|
+
bits[i] = (Y[y, x] >> bp) & 1
|
|
347
|
+
|
|
348
|
+
# 4. Reconstruct QR code
|
|
349
|
+
qr = bits.reshape((h, w))
|
|
350
|
+
qr_img = Image.fromarray((255 * (1 - qr)).astype(np.uint8))
|
|
351
|
+
qr_img.save("recovered_qr.png")
|
|
352
|
+
# zbarimg recovered_qr.png
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Key insight:** The seed defines a deterministic pixel visit order (Fisher-Yates shuffle via `RandomState`). Without the correct seed, output is random noise. Bits from different bitplanes are interleaved (bit 0 from pixel N, bit 1 from pixel N, bit 0 from pixel N+1, ...), doubling the data density. Try the Y (luminance) channel first — it has the highest contrast for hidden binary data.
|
|
356
|
+
|
|
357
|
+
**Seed recovery:** If the seed is unknown, look for it in: EXIF metadata, filename, image dimensions, challenge description numbers, or brute-force small ranges.
|
|
358
|
+
|
|
359
|
+
**Detection:** Image appears as random colored noise but has suspicious dimensions (perfect square, power of 2). Challenge mentions "seed", "random", or "signal".
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## JPEG Thumbnail Pixel-to-Text Mapping (RuCTF 2013)
|
|
364
|
+
|
|
365
|
+
**Pattern:** JPEG contains an embedded thumbnail where dark pixels map 1:1 to character positions in visible text on the main image.
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
from PIL import Image
|
|
369
|
+
# Extract thumbnail: exiftool -b -ThumbnailImage secret.jpg > thumb.jpg
|
|
370
|
+
thumb = Image.open('thumb.jpg')
|
|
371
|
+
text_lines = ["line1 of visible text...", "line2..."] # OCR or type from photo
|
|
372
|
+
result = ''
|
|
373
|
+
for y in range(thumb.height):
|
|
374
|
+
for x in range(thumb.width):
|
|
375
|
+
r, g, b = thumb.getpixel((x, y))[:3]
|
|
376
|
+
if r < 100 and g < 100 and b < 100: # Dark pixel = selected char
|
|
377
|
+
result += text_lines[y][x]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Key insight:** Extract thumbnails with `exiftool -b -ThumbnailImage`. Dark pixels act as a selection mask over the photographed text. Use OCR (ABBYY FineReader, Tesseract) to get the text grid, then map dark thumbnail pixels to character positions.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## Conditional LSB Extraction — Near-Black Pixel Filter (BaltCTF 2013)
|
|
385
|
+
|
|
386
|
+
**Pattern:** Only pixels with R<=1 AND G<=1 AND B<=1 carry steganographic data. Standard LSB tools miss the data because they process all pixels.
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from PIL import Image
|
|
390
|
+
img = Image.open('image.png')
|
|
391
|
+
bits = ''
|
|
392
|
+
for pixel in img.getdata():
|
|
393
|
+
r, g, b = pixel[0], pixel[1], pixel[2]
|
|
394
|
+
if not (r <= 1 and g <= 1 and b <= 1):
|
|
395
|
+
continue # Skip non-carrier pixels
|
|
396
|
+
bits += str(r & 1) + str(g & 1) + str(b & 1)
|
|
397
|
+
# Convert bits to bytes
|
|
398
|
+
flag = bytes(int(bits[i:i+8], 2) for i in range(0, len(bits)-7, 8))
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Key insight:** When standard `zsteg`/`stegsolve` find nothing, try filtering pixels by value range before LSB extraction. The carrier pixels may be restricted to near-black, near-white, or specific color ranges.
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## JPEG Slack Space Steganography (BSidesSF 2025)
|
|
406
|
+
|
|
407
|
+
JPEG compression pads images to 8x8 pixel block boundaries. Data hidden in the padding pixels beyond the visible image dimensions:
|
|
408
|
+
|
|
409
|
+
1. **Identify padded dimensions:** JPEG rounds up to nearest multiple of 8. A 253x195 image pads to 256x200
|
|
410
|
+
2. **Extract slack pixels:** Use tools to extend visible region to true block dimensions
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
# Extend image to see slack pixels
|
|
414
|
+
python3 jpeg_uncrop.py input.jpg --width 256 --height 200
|
|
415
|
+
# Or use ImageMagick to force full decode
|
|
416
|
+
magick input.jpg -define jpeg:size=256x200 extended.png
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
3. **Decode binary from slack pixels:** Black=0, white=1 in the padding region. Common encoding:
|
|
420
|
+
- 2 bytes: magic number
|
|
421
|
+
- 1 byte: key length
|
|
422
|
+
- N bytes: encryption key
|
|
423
|
+
- 1 byte: message length
|
|
424
|
+
- N bytes: encrypted message
|
|
425
|
+
|
|
426
|
+
**Key insight:** Most image editors and viewers crop to the stated dimensions, hiding the padding. Use `jpegtran -crop` or raw DCT decoders to access full block data.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Nearest-Neighbor Interpolation Steganography (BSidesSF 2025)
|
|
431
|
+
|
|
432
|
+
Hidden data encoded as a pixel grid at regular intervals within a high-resolution image. Downscaling with nearest-neighbor interpolation extracts only the hidden pixels:
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
# Hidden pixels spaced 16 apart in a 4096x3072 image
|
|
436
|
+
# Downscale by 16x with nearest-neighbor to recover 256x192 hidden image
|
|
437
|
+
magick flag.webp -interpolate nearest-neighbor -interpolative-resize 256x192 flag_visible.png
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Key insight:** Nearest-neighbor interpolation selects exact pixel values (no blending), preserving the hidden data. Bilinear or bicubic interpolation would average surrounding pixels, destroying the message. The challenge name or description often hints at the interpolation method.
|
|
441
|
+
|
|
442
|
+
**Detection:** Open in image viewer and zoom to see repeating pixel patterns at regular intervals. Calculate GCD of image dimensions and suspected grid spacing.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## RGB Parity Steganography (Break In 2016)
|
|
447
|
+
|
|
448
|
+
Hidden image encoded in the parity of pixel RGB sums. Sum R+G+B per pixel -- even sum = white, odd sum = black. Renders a binary bitmap containing the hidden message.
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
from PIL import Image
|
|
452
|
+
img = Image.open('image.png')
|
|
453
|
+
out = Image.new('1', img.size)
|
|
454
|
+
for x in range(img.width):
|
|
455
|
+
for y in range(img.height):
|
|
456
|
+
r, g, b = img.getpixel((x, y))[:3]
|
|
457
|
+
out.putpixel((x, y), (r + g + b) % 2)
|
|
458
|
+
out.save('hidden.png')
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Key insight:** Unlike LSB (Least Significant Bit) stego (single channel, single bit), parity stego uses the combined sum of all channels. Look for challenge hints about "pairs", "couples", or "adding colors".
|
|
462
|
+
|
|
463
|
+
**Detection:** Image appears normal but pixel RGB sums show non-random parity distribution.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Pixel Coordinate Chain Steganography (H4ckIT CTF 2016)
|
|
468
|
+
|
|
469
|
+
Each pixel encodes a data byte in the red channel and the coordinates of the next pixel to read in the green and blue channels, forming a linked-list traversal through the image.
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
from PIL import Image
|
|
473
|
+
|
|
474
|
+
def extract_coordinate_chain(image_path, start_x=0, start_y=0):
|
|
475
|
+
"""Follow coordinate chain: R=data, G=next_x, B=next_y"""
|
|
476
|
+
img = Image.open(image_path)
|
|
477
|
+
flag = ""
|
|
478
|
+
x, y = start_x, start_y
|
|
479
|
+
visited = set()
|
|
480
|
+
|
|
481
|
+
while (x, y) not in visited:
|
|
482
|
+
visited.add((x, y))
|
|
483
|
+
r, g, b = img.getpixel((x, y))[:3]
|
|
484
|
+
|
|
485
|
+
if r == 0: # null terminator
|
|
486
|
+
break
|
|
487
|
+
|
|
488
|
+
flag += chr(r)
|
|
489
|
+
x, y = g, b # next pixel coordinates from green and blue channels
|
|
490
|
+
|
|
491
|
+
return flag
|
|
492
|
+
|
|
493
|
+
# Variants:
|
|
494
|
+
# - (R,G) = coordinates, B = data byte
|
|
495
|
+
# - Coordinates stored as (G*256+B) for images wider than 256px
|
|
496
|
+
# - Starting pixel indicated by metadata or known offset
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Key insight:** Linked-list pixel traversal hides both the message and the reading order. Standard LSB analysis misses this because only specific pixels carry data. Look for images where green/blue channels have suspiciously structured values (small numbers that could be coordinates).
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
## AVI Frame Differential Pixel Steganography (H4ckIT CTF 2016)
|
|
504
|
+
|
|
505
|
+
Compare consecutive video frames pixel-by-pixel. Pixels that increment by exactly 1 encode a "1" bit; unchanged pixels encode "0". Collect bits to form a Brainfuck program or binary message.
|
|
506
|
+
|
|
507
|
+
```python
|
|
508
|
+
from PIL import Image
|
|
509
|
+
import subprocess
|
|
510
|
+
|
|
511
|
+
def extract_frame_differential(frame_dir, num_frames):
|
|
512
|
+
"""Compare consecutive frames: incremented pixel = 1, same = 0"""
|
|
513
|
+
bits = ""
|
|
514
|
+
|
|
515
|
+
for i in range(num_frames - 1):
|
|
516
|
+
img1 = Image.open(f"{frame_dir}/frame_{i:04d}.png")
|
|
517
|
+
img2 = Image.open(f"{frame_dir}/frame_{i+1:04d}.png")
|
|
518
|
+
|
|
519
|
+
pixels1 = list(img1.getdata())
|
|
520
|
+
pixels2 = list(img2.getdata())
|
|
521
|
+
|
|
522
|
+
for p1, p2 in zip(pixels1, pixels2):
|
|
523
|
+
if p1 != p2:
|
|
524
|
+
# Pixel changed (incremented by 1) = bit "1"
|
|
525
|
+
bits += "1"
|
|
526
|
+
else:
|
|
527
|
+
bits += "0"
|
|
528
|
+
|
|
529
|
+
# Convert bits to ASCII or interpret as Brainfuck
|
|
530
|
+
message = ""
|
|
531
|
+
for i in range(0, len(bits), 8):
|
|
532
|
+
byte = int(bits[i:i+8], 2)
|
|
533
|
+
if 32 <= byte < 127:
|
|
534
|
+
message += chr(byte)
|
|
535
|
+
|
|
536
|
+
return message
|
|
537
|
+
|
|
538
|
+
# Extract frames from AVI first:
|
|
539
|
+
# binwalk video.avi (extracts embedded PNG/BMP frames)
|
|
540
|
+
# or: ffmpeg -i video.avi frame_%04d.png
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**Key insight:** Frame differential steganography hides data in the temporal domain rather than spatial. Standard image stego tools analyze single frames and miss inter-frame changes. Extract all frames, then diff consecutive pairs looking for single-pixel-value increments.
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
### JPEG Single-Bit-Flip Brute Force with OCR (SECCON 2017)
|
|
548
|
+
|
|
549
|
+
Corrupted JPEG with a single bitflip. Generate all single-bit variants and scan with OCR:
|
|
550
|
+
|
|
551
|
+
```python
|
|
552
|
+
data = open('corrupted.jpg', 'rb').read()
|
|
553
|
+
for byte_pos in range(len(data)):
|
|
554
|
+
for bit in range(8):
|
|
555
|
+
candidate = data[:byte_pos] + bytes([data[byte_pos] ^ (1 << bit)]) + data[byte_pos+1:]
|
|
556
|
+
with open(f'attempt_{byte_pos}_{bit}.jpg', 'wb') as f:
|
|
557
|
+
f.write(candidate)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
```bash
|
|
561
|
+
# Automated OCR scan for flag
|
|
562
|
+
for f in attempt_*.jpg; do
|
|
563
|
+
result=$(tesseract "$f" stdout 2>/dev/null)
|
|
564
|
+
if echo "$result" | grep -qi "flag\|ctf\|SECCON"; then
|
|
565
|
+
echo "FOUND in $f: $result"
|
|
566
|
+
fi
|
|
567
|
+
done
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Key insight:** For small files (< 10KB), the total search space for single-bit flips is `8 * file_size` — typically under 80,000 candidates, easily brute-forceable. Use thumbnail generation as a fast validity check (corrupt JPEGs fail to decode), then OCR on survivors. JPEG compressed data rule: `0xFF` is always followed by `0x00` (stuffed byte) or a marker — violations indicate the corruption location.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## GIF Frame PLTE Chunk Concatenation to ELF (IceCTF 2018)
|
|
575
|
+
|
|
576
|
+
**Pattern:** A GIF hides a Linux ELF binary by breaking it into indexed PNG frames. Each frame's `PLTE` (palette) chunk holds the next slice of the binary — the actual pixel data is irrelevant. Extract with Pillow: iterate frames, convert each to PNG, walk the PNG chunks, concatenate every `PLTE` body, and the result is a valid ELF file.
|
|
577
|
+
|
|
578
|
+
```python
|
|
579
|
+
from PIL import Image, ImagePalette
|
|
580
|
+
import struct
|
|
581
|
+
|
|
582
|
+
def read_png_plte(png_bytes):
|
|
583
|
+
i = 8 # skip PNG magic
|
|
584
|
+
while i < len(png_bytes):
|
|
585
|
+
length = struct.unpack(">I", png_bytes[i:i+4])[0]
|
|
586
|
+
ctype = png_bytes[i+4:i+8]
|
|
587
|
+
body = png_bytes[i+8:i+8+length]
|
|
588
|
+
if ctype == b"PLTE":
|
|
589
|
+
return body
|
|
590
|
+
i += 12 + length
|
|
591
|
+
return b""
|
|
592
|
+
|
|
593
|
+
payload = bytearray()
|
|
594
|
+
with Image.open("carrier.gif") as gif:
|
|
595
|
+
for frame in range(gif.n_frames):
|
|
596
|
+
gif.seek(frame)
|
|
597
|
+
png_buf = io.BytesIO()
|
|
598
|
+
gif.save(png_buf, "PNG")
|
|
599
|
+
payload += read_png_plte(png_buf.getvalue())
|
|
600
|
+
|
|
601
|
+
open("recovered.elf", "wb").write(payload)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Key insight:** GIF frames are internally stored with their own palettes. When you re-encode each frame as a PNG, the palette survives as a `PLTE` chunk — an ignored but byte-accurate container. Any stego carrier that uses a multi-frame format with per-frame metadata (GIF palettes, APNG frame data, PDF page streams, MKV tracks) lets you embed data in the *metadata channel* instead of the pixel channel, bypassing most LSB-style detection. When a GIF looks like a harmless animation but contains extra frames or palette entries, dump chunk-by-chunk before touching the pixels.
|
|
605
|
+
|
|
606
|
+
**References:** IceCTF 2018 — ilovebees, writeup 11418
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Nested-Resize QR Overlay at Survivor Pixels (SECCON 2018)
|
|
611
|
+
|
|
612
|
+
**Pattern:** Challenge PNG decodes to two different QR codes depending on how many times it is scaled down with nearest-neighbor interpolation (500 → 250 → 100 → 50). Track which source pixels survive every reduction: for a 10× chain with `PIL.Image.resize(size, Image.NEAREST)`, survivors sit at indices `(10i+7, 10j+7)`. Overlay a second QR at exactly those positions so it only emerges after the chained resize.
|
|
613
|
+
|
|
614
|
+
```python
|
|
615
|
+
from PIL import Image
|
|
616
|
+
big = Image.open('qr1.png') # 500x500 visible QR
|
|
617
|
+
small = Image.open('qr2.png') # 50x50 hidden QR
|
|
618
|
+
px = big.load()
|
|
619
|
+
sx = small.load()
|
|
620
|
+
for i in range(50):
|
|
621
|
+
for j in range(50):
|
|
622
|
+
px[10*i+7, 10*j+7] = sx[i, j]
|
|
623
|
+
big.save('trap.png')
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**Key insight:** Nearest-neighbor resize keeps exactly one pixel per source block; its offset depends on rounding (PIL picks `floor(original*scale)+0.5`). Compute the survivor index once per resize step, then compose the nested stego at those indices. Works for any number of cascaded resizes as long as the interpolation is nearest-neighbor.
|
|
627
|
+
|
|
628
|
+
**References:** SECCON 2018 — QRChecker, writeup 12014
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## ImageMagick +append Puzzle Stitching + gaps Solver (X-MAS CTF 2018)
|
|
633
|
+
|
|
634
|
+
**Pattern:** Disk image contains N puzzle-piece PNGs carved out by `foremost` or `scalpel`. Stitch all pieces horizontally with ImageMagick `convert +append`, then feed the strip to the `gaps` jigsaw solver (https://github.com/nemanja-m/gaps) with the known piece size (often stored in EXIF) to auto-reassemble.
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
foremost -t png -i disk.img -o pieces
|
|
638
|
+
convert +append pieces/*.png strip.png
|
|
639
|
+
gaps --image=strip.png --size=273
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Key insight:** CTF jigsaw challenges rarely require manual work. Carve pieces, stitch, run `gaps` — it uses a genetic algorithm to reassemble in minutes. Read `exiftool` on each piece for the size hint.
|
|
643
|
+
|
|
644
|
+
**References:** X-MAS CTF 2018 — Message from Santa, writeup 12662
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## Steghide Passphrase in JPEG Header Metadata (Saudi/Oman CTF 2019)
|
|
649
|
+
|
|
650
|
+
**Pattern:** JPEG file with `steghide`-embedded payload whose passphrase is hidden in plain ASCII inside the JPEG header/metadata region. Standard tools (`exiftool`, `strings`) may miss it if the byte range isn't flagged as a proper EXIF/comment tag, but `xxd` on the first few hundred bytes reveals the string.
|
|
651
|
+
|
|
652
|
+
```bash
|
|
653
|
+
# Scan header for suspicious ASCII
|
|
654
|
+
xxd info.jpg | head -20
|
|
655
|
+
# 00000010: ffdb 0043 0008 6261 6469 7362 6164 0008 ...C..badisbad..
|
|
656
|
+
# ^^^^^^^^^^^^^^^^^ passphrase at offset 0x18
|
|
657
|
+
|
|
658
|
+
# Confirm steghide payload with the passphrase
|
|
659
|
+
steghide --info info.jpg # prompts for passphrase
|
|
660
|
+
steghide extract -sf info.jpg -p badisbad
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
**Key insight:** Always scan the first ~256 bytes of a JPEG with `xxd`/`hexdump -C` for ASCII runs — authors sometimes stuff passphrases into reserved areas of JFIF/APPn segments where `exiftool` doesn't surface them, but they're trivially visible in a hex view. Pair the leak with `steghide`, `outguess`, or `stegseek` wordlist seeding.
|
|
664
|
+
|
|
665
|
+
**References:** Quals Saudi and Oman National Cyber Security CTF 2019 — Hack a nice day, writeup 13232
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Corrupted PNG Magic and Lowercase Chunk Repair (Pragyan CTF 2019)
|
|
670
|
+
|
|
671
|
+
**Pattern:** PNG is unreadable because the 8-byte magic is tampered (e.g. `89 50 4E 47 2E 0A 2E 0A` instead of the correct `89 50 4E 47 0D 0A 1A 0A`) and critical chunk names are lowercased (`idat` instead of `IDAT`). PNG decoders treat lowercase chunk names as "ancillary" and skip them, so the image looks empty until the case is fixed. Metadata (e.g. `exiftool` Artist field) then yields the next step.
|
|
672
|
+
|
|
673
|
+
```bash
|
|
674
|
+
# Step 1: patch magic bytes
|
|
675
|
+
printf '\x89PNG\r\n\x1a\n' | dd of=broken.png conv=notrunc bs=1 count=8
|
|
676
|
+
|
|
677
|
+
# Step 2: re-capitalise critical chunk names (IHDR, IDAT, IEND, PLTE)
|
|
678
|
+
python3 -c "
|
|
679
|
+
d = open('broken.png','rb').read()
|
|
680
|
+
d = d.replace(b'idat', b'IDAT').replace(b'iend', b'IEND')
|
|
681
|
+
open('fixed.png','wb').write(d)
|
|
682
|
+
"
|
|
683
|
+
|
|
684
|
+
# Step 3: pull hidden metadata
|
|
685
|
+
exiftool fixed.png | grep -Ei 'artist|comment|desc'
|
|
686
|
+
# Artist : md5_MEf89jf4h9 -> use md5(...) as zip password
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**Key insight:** PNG has two orthogonal parseability gates: the 8-byte signature and the case of each chunk name (first letter uppercase = critical). Fix both before concluding the file is empty. `pngcheck -v` flags exactly which byte/chunk is wrong. Once readable, treat EXIF `Artist`, `Description`, and `tEXt`/`iTXt` chunks as prime hiding spots.
|
|
690
|
+
|
|
691
|
+
**References:** Pragyan CTF 2019 — Magic PNGs, writeup 13833
|