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:


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
PDF 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:

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

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


Step 12: Documents and other carriers

PDF

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