Cryptography hides what a message says. Steganography hides that there is a message at all. The data sits inside something that looks harmless: a photo, a song, a PDF, the whitespace at the end of a line.
In CTFs, stego challenges look random until you have a method. Once you check things in the same order every time, most challenges fall within the first few steps. In real investigations the same techniques appear in malware that downloads payloads hidden in image pixels, and in data exfiltration through innocent-looking files.
This guide gives you that method first, then goes through each type of carrier with the real commands and small scripts.
The method
Run these steps in order on every file. The early steps are cheap, and they solve most easy and medium challenges.
1. Identify file, magic bytes → is it really what the extension says?
2. Metadata exiftool → comments, GPS, thumbnails, author
3. Strings strings, grep → plain-text flags, base64, URLs, passwords
4. Embedded binwalk, foremost, EOF check → files hidden inside or appended after the end
5. Structure pngcheck, hex editor → broken headers, wrong dimensions, bad CRCs
6. Visual bit planes, channels, contrast → text hidden in LSBs or near-identical colours
7. Tools zsteg, steghide, outguess... → tool-specific embedding
8. Passwords stegseek, john, context clues → passphrase-protected payloads
9. Decode CyberChef, file → the extracted data is often encoded again
Read the challenge text carefully. The title, description, and file names are often the hint ("Can you see the least important thing?" means LSB; "listen closely" means audio; a person's name may be the password).
Toolkit
| Tool | Install (Debian / Kali / Ubuntu) | Used for |
|---|---|---|
| file, strings, xxd | sudo apt install file binutils xxd |
Identification, text, hex view |
| ExifTool | sudo apt install libimage-exiftool-perl |
Metadata |
| binwalk, foremost | sudo apt install binwalk foremost |
Embedded and appended files |
| pngcheck | sudo apt install pngcheck |
PNG structure and CRC errors |
| zsteg | sudo gem install zsteg |
LSB in PNG and BMP |
| steghide | sudo apt install steghide |
JPEG, BMP, WAV, AU |
| stegseek | .deb from releases (sudo apt install ./stegseek_0.6-1.deb) |
Very fast steghide password cracking |
| outguess | sudo apt install outguess |
JPEG |
| Stegsolve | wget http://www.caesum.com/handbook/Stegsolve.jar -O stegsolve.jar then java -jar stegsolve.jar |
Bit planes, channels, frames, XOR |
| stegoVeritas | pip3 install stegoveritas && stegoveritas_install_deps |
Automated image checks |
| stego-lsb | pip3 install stego-lsb |
WAV and image LSB |
| sox, ffmpeg, Audacity, Sonic Visualiser | sudo apt install sox ffmpeg audacity sonic-visualiser |
Audio and video |
| multimon-ng | sudo apt install multimon-ng |
DTMF and Morse decoding |
| ImageMagick | sudo apt install imagemagick |
Frames, contrast, conversions |
| zbarimg | sudo apt install zbar-tools |
QR codes and barcodes |
| stegsnow | sudo apt install stegsnow |
Whitespace in text files |
| Python 3 + Pillow | pip3 install pillow |
Custom scripts |
| CyberChef | web (can also be downloaded to run offline) | Decoding layers |
All-in-one options:
- stego-toolkit Docker image, which ships most of these tools plus screening scripts:
docker run -it --rm -v "$(pwd)":/data dominicbreuker/stego-toolkit /bin/bash, thencheck_jpg.sh image.jpgorcheck_png.sh image.png. - Aperi'Solve runs zsteg, steghide, binwalk, exiftool, and bit-plane views in the browser. Only upload CTF files there, never real case evidence.
Step 1: What is this file really?
Extensions lie. Check the magic bytes.
file challenge.png
xxd challenge.png | head -n 4
| Format | Starts with (hex) | ASCII | Ends with |
|---|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
.PNG.... |
49 45 4E 44 AE 42 60 82 (IEND + CRC) |
| JPEG | FF D8 FF |
ÿØÿ |
FF D9 |
| GIF | 47 49 46 38 39 61 / ...37 61 |
GIF89a / GIF87a |
3B |
| BMP | 42 4D |
BM |
— |
| ZIP / DOCX / XLSX / APK / JAR | 50 4B 03 04 |
PK.. |
end-of-central-directory 50 4B 05 06 |
25 50 44 46 |
%PDF |
%%EOF |
|
| WAV | 52 49 46 46 xx xx xx xx 57 41 56 45 |
RIFF....WAVE |
— |
| MP3 | 49 44 33 or FF FB |
ID3 |
— |
| 7-Zip | 37 7A BC AF 27 1C |
7z¼¯'. |
— |
| RAR | 52 61 72 21 1A 07 |
Rar!.. |
— |
| ELF | 7F 45 4C 46 |
.ELF |
— |
If file just says data, the header is probably damaged on purpose. Compare the first bytes with the table and fix them:
# write a correct PNG signature over the first 8 bytes, keeping the rest of the file
printf '\x89PNG\r\n\x1a\n' | dd of=broken.png bs=1 seek=0 count=8 conv=notrunc
file broken.png
Step 2: Metadata
exiftool image.jpg
exiftool -a -u -g1 image.jpg # every tag, including unknown and duplicate ones, grouped
exiftool -n -GPSLatitude -GPSLongitude image.jpg # decimal coordinates, paste into a map
exiftool -b -ThumbnailImage image.jpg > thumb.jpg
exiftool -b -PreviewImage image.jpg > preview.jpg
Fields to look at: Comment, UserComment, XPComment, ImageDescription, Artist, Copyright, Software, Make/Model, and GPS.
Thumbnail trick: the embedded thumbnail is generated when the photo is taken. If someone edits the main image later (crops out or paints over the flag), the thumbnail often still shows the original.
Step 3: Strings
strings -n 6 image.png | head -n 50
strings -n 6 image.png | grep -i -E "flag|ctf|key|pass|secret"
strings -n 6 -e l file.bin # UTF-16LE text (Windows files)
grep -a -o -E "[A-Za-z0-9+/]{20,}={0,2}" image.png # long base64-looking runs
Look for plain flags, base64 blobs (often ending in =), hex strings, URLs, and PK or Rar! markers that show an archive is embedded.
Step 4: Embedded and appended data
The simplest trick is joining files together: cat image.jpg secret.zip > challenge.jpg. The image still opens normally because viewers stop reading at the end-of-image marker.
binwalk image.png # list signatures found inside the file
binwalk -e image.png # extract known types (binwalk 2: _image.png.extracted/, binwalk 3: extractions/)
binwalk -M -e image.png # extract recursively (archives inside archives)
binwalk -E image.png # entropy graph: a flat, high block means compressed or encrypted data
foremost -i image.png -o carved/ # carving by header/footer, catches things binwalk misses
When binwalk gives you an offset, you can cut the data out manually:
dd if=image.jpg of=hidden.zip bs=1 skip=48213
ZIP archives are read from the end of the file, so a polyglot often opens directly: unzip image.jpg or 7z x image.jpg.
Checking for data after the end of the image
# after_eof.py: print and save anything after the PNG IEND chunk
data = open('image.png', 'rb').read()
end = data.find(b'IEND') + 8 # 4 bytes 'IEND' + 4-byte CRC
print(f'{len(data) - end} bytes after IEND')
open('trailer.bin', 'wb').write(data[end:])
Then run file trailer.bin and xxd trailer.bin | head. For JPEG, binwalk is more reliable than searching for FF D9, because the embedded thumbnail has its own FF D9.
Password-protected archives
zip2john hidden.zip > zip.hash
john --wordlist=/usr/share/wordlists/rockyou.txt zip.hash
john --show zip.hash
fcrackzip -u -D -p /usr/share/wordlists/rockyou.txt hidden.zip
unzip -z hidden.zip # archive comment, sometimes holds the hint or the password
On Kali, unpack rockyou first: sudo gunzip /usr/share/wordlists/rockyou.txt.gz. If the ZIP uses legacy ZipCrypto and you know about 12 bytes of one file inside it (for example a PNG header), bkcrack recovers the keys without the password.
Step 5: Structure: broken headers and hidden rows
PNG dimensions and CRC
A PNG is a signature followed by chunks. Each chunk is length | type | data | CRC32(type + data). The first chunk, IHDR, stores width and height. A classic challenge reduces the height so the bottom of the image (with the flag) is never drawn. The pixel data is still there, but the CRC no longer matches:
pngcheck -v image.png
# image.png CRC error in chunk IHDR (computed 5c7a9b1e, expected 0f3a6e2d)
Because the original CRC is still in the file, you can brute-force the dimensions that produce it:
# fix_ihdr.py: find the width/height that match the stored IHDR CRC
import struct, zlib
from itertools import chain, product
data = bytearray(open('image.png', 'rb').read())
ihdr = data[12:29] # 'IHDR' + 13 bytes of header data
stored_crc = struct.unpack('>I', data[29:33])[0]
w0, h0 = struct.unpack('>II', data[16:24])
# usually only the height was changed, so try that first
candidates = chain(((w0, h) for h in range(1, 10000)),
product(range(1, 4000), range(1, 4000)))
for w, h in candidates:
if zlib.crc32(ihdr[:4] + struct.pack('>II', w, h) + ihdr[12:]) == stored_crc:
print(f'original size: {w}x{h} (file says {w0}x{h0})')
data[16:24] = struct.pack('>II', w, h)
open('fixed.png', 'wb').write(data)
break
else:
print('no match: the CRC itself may have been changed too')
If the CRC was also recalculated, just raise the height in a hex editor (bytes 20–23, big-endian) and open the image. Extra rows appear if the compressed data contains them.
JPEG height
JPEG has no CRC, so you can simply increase the height stored in the SOF (start of frame) marker:
# jpeg_taller.py: double the height in the SOF0 header
import struct
data = bytearray(open('image.jpg', 'rb').read())
i = data.find(b'\xff\xc0') # SOF0 (baseline); progressive JPEGs use FF C2
h, w = struct.unpack('>HH', data[i + 5:i + 9])
print(f'current size {w}x{h}')
data[i + 5:i + 7] = struct.pack('>H', h * 2)
open('taller.jpg', 'wb').write(data)
BMP
BMP stores width and height as little-endian 32-bit integers at offsets 0x12 and 0x16. There is no checksum, so edit them directly.
Step 6: Visual analysis
Bit planes (LSB)
Each colour channel of a pixel is 8 bits. Changing the least significant bit changes the value by 1 out of 255, which is invisible to the eye. Hiding data in LSBs is the most common image stego technique.
Stegsolve: open the image and use the arrows to go through Red plane 0, Green plane 0, Blue plane 0, Alpha plane 0, and so on. Text or a QR code often appears in one plane. Other useful menus:
- Analyse → Data Extract: choose bits, channels, and bit order, then preview the extracted bytes (look for a header like
PKor89 50 4E 47, or readable text) - Analyse → Frame Browser: step through GIF frames
- Analyse → Image Combiner: XOR, subtract, or add two images
The same bit planes in Python:
# planes.py: save every bit plane of every channel as a black/white image
from PIL import Image
img = Image.open('image.png').convert('RGBA')
for c, name in enumerate('RGBA'):
channel = img.getchannel(c)
for bit in range(8):
channel.point(lambda v, b=bit: 255 if (v >> b) & 1 else 0).save(f'plane_{name}{bit}.png')
Low-contrast content
Text drawn one or two shades away from the background is invisible to you but obvious to software:
convert image.png -equalize equalized.png # ImageMagick 6 (use `magick` on ImageMagick 7)
convert image.png -auto-level -contrast-stretch 0 stretched.png
In GIMP, Colors → Curves or Colors → Levels does the same interactively. Also check the alpha channel: fully transparent pixels still have RGB values.
Comparing two images
When you get two nearly identical images (or an image and a known original found online):
from PIL import Image, ImageChops
a = Image.open('a.png').convert('RGB')
b = Image.open('b.png').convert('RGB')
diff = ImageChops.difference(a, b)
diff.point(lambda v: 255 if v else 0).save('diff.png') # any changed pixel becomes white
Step 7: LSB extraction in PNG and BMP
zsteg
zsteg tries many LSB variations at once. It only works on PNG and BMP.
zsteg image.png # common combinations
zsteg -a image.png # all combinations (slower, much more output)
zsteg -E "b1,rgb,lsb,xy" image.png > payload.bin
file payload.bin
How to read a zsteg result like b1,rgb,lsb,xy .. text: "flag{...}":
| Part | Meaning |
|---|---|
b1 |
Use 1 bit per channel (the lowest). b2 = the two lowest bits, and so on |
rgb |
Channel order: red, then green, then blue. bgr, r, a, and others also exist |
lsb / msb |
Bit order when building bytes: least significant bit first, or most significant first |
xy |
Pixel order: row by row from top left. yx = column by column |
Pass the same string to -E to extract the full payload. It may be a file (check with file) rather than text.
Manual LSB extraction
When a challenge uses an unusual order, write the extractor yourself:
# lsb.py: 1 LSB per channel, R G B, row order, MSB-first bytes
from PIL import Image
img = Image.open('image.png').convert('RGB')
bits = ''.join(str(v & 1) for pixel in img.getdata() for v in pixel)
data = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits) - 7, 8))
print(data[:120])
open('lsb.bin', 'wb').write(data)
Variants to try: one channel only (pixel[2] for blue), column order (loop x then y with img.getpixel((x, y))), bit 1 instead of bit 0 ((v >> 1) & 1), and reversed bit order inside each byte (int(bits[i:i+8][::-1], 2)). Payloads often start with a length prefix or a magic value, so check the first bytes.
Password-protected image LSB
| Tool | Format | Extraction |
|---|---|---|
| cloacked-pixel | PNG (AES-encrypted LSB) | python lsb.py extract image.png out.txt <password> |
| OpenStego | PNG | GUI: Extract Data tab, with the password |
| steghide | BMP | steghide extract -sf image.bmp -p <password> |
Step 8: JPEG steganography
JPEG compression rewrites pixel values, so pixel LSBs don't survive it. JPEG stego tools hide data in the DCT coefficients instead, which is why zsteg and Stegsolve bit planes find nothing in JPEGs.
steghide
steghide info image.jpg # asks for a passphrase; try Enter (empty)
steghide extract -sf image.jpg -p "" # empty passphrase
steghide extract -sf image.jpg -p "sunshine" -xf out.txt # known passphrase, chosen output file
steghide supports JPEG, BMP, WAV, and AU. It is the first tool to try on any of them.
stegseek (cracking steghide)
stegseek image.jpg /usr/share/wordlists/rockyou.txt # writes image.jpg.out when it succeeds
stegseek --seed image.jpg # checks for steghide data without any password
stegseek goes through all of rockyou.txt in a few seconds. If rockyou fails, build a wordlist from the challenge: names, places, and words from the description, the file name, or EXIF fields. cewl https://example.com -w words.txt collects words from a related website.
Other JPEG tools
outguess -r image.jpg out.txt # no key
outguess -k "password" -r image.jpg out.txt # with key
jsteg reveal image.jpg out.txt # go install lukechampine.com/jsteg/cmd/jsteg@latest
stegoveritas image.jpg # runs many checks, results in ./results/
Step 9: GIFs and video
convert anim.gif -coalesce frame_%03d.png # ImageMagick, one full PNG per frame
ffmpeg -i anim.gif frame_%03d.png # same with ffmpeg
identify -format "%s: %T\n" anim.gif # frame number and delay (in 1/100 s)
Things to check: a single frame that shows the flag for 10 ms, frame delays that encode data (short and long delays as 0/1 or Morse), and palette tricks (two palette entries with the same colour).
Video files are containers. List what's inside before looking at frames:
ffprobe -hide_banner video.mkv # streams: video, audio, subtitles, attachments
mkdir -p frames && ffmpeg -i video.mp4 frames/%05d.png
ffmpeg -i video.mp4 -vn -acodec pcm_s16le audio.wav
ffmpeg -i video.mkv -map 0:s:0 subs.srt # first subtitle track
Step 10: Audio steganography
Spectrogram (always check first)
Text or images can be drawn into the frequency spectrum. They are invisible in the waveform and often inaudible, but obvious in a spectrogram.
sox audio.wav -n spectrogram -x 3000 -o spectrogram.png
ffmpeg -i audio.mp3 audio.wav # sox builds often can't read MP3; convert first
- Audacity: open the track's menu (the name on the left of the track) → Spectrogram. Then use Spectrogram Settings to raise the maximum frequency and try a logarithmic scale.
- Sonic Visualiser: Layer → Add Spectrogram. It has more control over window size and colour, which helps with faint content.
Check the top of the range (15–22 kHz). Content placed there is inaudible to most people.
Morse, DTMF, and SSTV
| What you hear | What it is | How to decode |
|---|---|---|
| Beeps of two lengths | Morse | Read the waveform in Audacity, or multimon-ng -a MORSE_CW |
| Phone keypad tones | DTMF | multimon-ng -a DTMF |
| Screeching, modem-like sound, 30 s to 2 min | SSTV (slow-scan TV image) | sstv -d audio.wav -o result.png or QSSTV |
| Fast chirps / bursts | AFSK, POCSAG, and other radio modes | multimon-ng -a AFSK1200 -a POCSAG512 ... |
multimon-ng reads raw audio at 22050 Hz. Convert first, then decode:
sox -R -t wav audio.wav -esigned-integer -b16 -r 22050 -c 1 -t raw audio.raw
multimon-ng -t raw -a DTMF audio.raw
multimon-ng -t raw -a MORSE_CW audio.raw
DTMF digits are often another encoding: decimal ASCII (102 108 97 103), or phone keypad multi-tap (7777 = s).
For SSTV, install the sstv decoder (git clone https://github.com/colaclanth/sstv && cd sstv && pip3 install .) and run sstv -d audio.wav -o result.png.
Sample LSB
stegolsb wavsteg -r -i audio.wav -o out.txt -n 1 -b 1000 # 1 LSB per sample, recover 1000 bytes
stegolsb wavsteg -r -i audio.wav -o out.txt -n 2 -b 1000 # 2 LSBs per sample
steghide extract -sf audio.wav -p "" # steghide works on WAV too
stegseek audio.wav /usr/share/wordlists/rockyou.txt
Manual extraction for 16-bit PCM WAV files:
# wav_lsb.py: LSB of every 16-bit little-endian sample
import wave
with wave.open('audio.wav', 'rb') as w:
frames = w.readframes(w.getnframes())
bits = ''.join(str(b & 1) for b in frames[::2]) # low byte of each sample
data = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits) - 7, 8))
print(data[:120])
Other audio tricks
sox audio.wav reversed.wav reverse # reversed speech
sox audio.wav slow.wav speed 0.5 # sped-up voice
sox audio.wav diff.wav remix 1,2i # left minus right: cancels shared audio, leaves what differs
The last command is powerful. If a voice was mixed into one stereo channel under loud music, subtracting the channels removes the music and leaves the voice.
DeepSound (Windows) hides encrypted files in WAV/FLAC. Open the file in DeepSound to extract. If it asks for a password, crack it with John the Ripper: deepsound2john.py audio.wav > ds.hash && john --wordlist=/usr/share/wordlists/rockyou.txt ds.hash. MP3Stego hides data in MP3 files: decode -X -P <password> audio.mp3.
Step 11: Text and whitespace
Trailing spaces and tabs
cat -A message.txt # shows tabs as ^I and line ends as $
stegsnow -C message.txt # SNOW whitespace steganography, no password
stegsnow -C -p "password" message.txt
Lines that end in runs of spaces and tabs usually mean SNOW (stegsnow). A file made only of spaces, tabs, and newlines is probably the Whitespace programming language. Run it in a Whitespace interpreter.
Zero-width Unicode characters
Text copied from a chat or web page can contain invisible characters such as U+200B (zero-width space), U+200C, U+200D, U+2060, and U+FEFF.
grep -c -P '[\x{200B}\x{200C}\x{200D}\x{2060}\x{FEFF}]' message.txt
# zwc.py: show which invisible characters are present and decode the common 2-symbol scheme
text = open('message.txt', encoding='utf-8').read()
zw = [c for c in text if c in '']
print(len(zw), 'invisible chars:', sorted({f'U+{ord(c):04X}' for c in zw}))
bits = ''.join('0' if c == '' else '1' for c in zw if c in '')
print(bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits) - 7, 8)))
The mapping from characters to bits depends on the tool that encoded it. If the output is garbage, try swapping 0 and 1, or decode with the Unicode Steganography tool or StegCloak.
Hidden in plain sight
- First letter of each line or word (acrostic)
- Uppercase and lowercase pattern as binary, or bold and normal as a Bacon cipher (A/B)
- Homoglyphs: Latin
avs Cyrillicаlook the same but have different bytes (xxdshows it)
Step 12: Documents and other carriers
pdfinfo doc.pdf
pdftotext doc.pdf - | less # finds white-on-white text and text under images
pdfimages -all doc.pdf img # extract every embedded image
qpdf --qdf --object-streams=disable doc.pdf readable.pdf # decompress so you can read it in a text editor
pdf-parser.py --stats doc.pdf # from Didier Stevens' pdf-tools
grep -a -c "%%EOF" doc.pdf # more than 1 = incremental updates, older versions still inside
Office files
DOCX, XLSX, and PPTX files are ZIP archives:
unzip -o report.docx -d report/
grep -r -i -E "flag|password" report/
ls report/word/media/ # embedded images: run them through this guide again
olevba report.docm # VBA macros (pip3 install oletools)
Also check hidden worksheets, comments, text formatted as hidden (<w:vanish/> in document.xml), and custom XML parts.
QR codes, archives, and file systems
zbarimg qr.png # decode QR codes and barcodes
unzip -z archive.zip # ZIP comment
zipinfo -v archive.zip # per-file comments and extra fields
Damaged QR codes can be rebuilt by hand in QRazyBox. On Windows (NTFS) disks, check alternate data streams: dir /r in cmd, or Get-Item file.txt -Stream * and Get-Content file.txt -Stream secret in PowerShell.
Step 13: Decode what you extract
Extracted data is often encoded several more times. Run file on every blob, then check for:
| Looks like | Try |
|---|---|
A-Z a-z 0-9 + /, ends with = |
base64 -d |
Only A-Z 2-7, ends with = |
base32 -d |
Only 0-9 a-f |
xxd -r -p |
Only 0 and 1 |
binary → ASCII (group by 8) |
| Dots and dashes | Morse |
Readable but shifted text (synt{...}) |
ROT13: tr 'A-Za-z' 'N-ZA-Mn-za-m' |
| Random bytes | XOR with a key found elsewhere, or encryption |
Starts with 1f 8b / 78 9c / 42 5a 68 |
gzip / zlib / bzip2 compressed |
CyberChef's Magic operation tries common decodings automatically and is a good first attempt.
Quick reference by file type
| File | Check first | Then |
|---|---|---|
| PNG | pngcheck -v, zsteg -a, binwalk |
Stegsolve planes, IHDR size fix, data after IEND |
| BMP | zsteg -a, steghide |
Header dimensions, manual LSB |
| JPEG | exiftool (and thumbnail), steghide/stegseek, binwalk |
outguess, jsteg, SOF height |
| GIF | Split frames, frame delays | Palette, Stegsolve frame browser |
| WAV | Spectrogram, steghide/stegseek, stegolsb wavsteg |
DTMF / Morse / SSTV, channel difference, DeepSound |
| MP3 | Spectrogram, exiftool (ID3 tags), binwalk |
MP3Stego |
| TXT | cat -A, zero-width check, stegsnow |
Whitespace language, acrostics, Bacon |
| PDF / Office | pdftotext / unzip, strings |
pdfimages, olevba, incremental versions |
Unknown data |
xxd header, binwalk -E entropy |
Fix magic bytes, XOR with a single byte |
A first-pass triage script
#!/usr/bin/env bash
# stegtriage.sh <file>: cheap checks first, results saved in ./triage_<file>/
set -u
f="$1"
out="triage_$(basename "$f")"
mkdir -p "$out"
file "$f" | tee "$out/file.txt"
exiftool -a -u -g1 "$f" > "$out/exif.txt"
strings -n 6 "$f" > "$out/strings.txt"
binwalk "$f" | tee "$out/binwalk.txt"
case "$(file -b --mime-type "$f")" in
image/png)
pngcheck -v "$f" > "$out/pngcheck.txt" 2>&1
zsteg -a "$f" > "$out/zsteg.txt" 2>&1 ;;
image/bmp|image/x-ms-bmp)
zsteg -a "$f" > "$out/zsteg.txt" 2>&1 ;;
image/jpeg|audio/x-wav|audio/wav)
steghide extract -sf "$f" -p "" -xf "$out/steghide_empty.bin" -f > "$out/steghide.txt" 2>&1 ;;
esac
grep -i -E "flag|ctf\{|pass|key" "$out"/*.txt
Run it with bash stegtriage.sh challenge.png. It doesn't replace the manual steps, but it quickly clears the easy cases.
Practice and resources
- picoCTF practice gym (Forensics category), Root-Me steganography challenges, and TryHackMe stego rooms
- My writeups that use these techniques: Too Dark (low-contrast image), Cut Short (PNG structure), Off The Record (DTMF audio), and Dead Drop (chained image stego)
- stego-toolkit: the tool list in its README is a good checklist
- CyberChef for decoding
- For memory images instead of files, see my Memory Forensics with Volatility 2 & 3 guide