Steganography hides data inside other data, most commonly a message or file embedded inside an image so the modification is invisible to the eye. Unlike encryption, which makes data unreadable but obviously present, steganography aims to make the existence of hidden data unnoticeable in the first place. This makes it a recurring theme in CTF challenges and a real technique used in covert data exfiltration.
This guide walks through a full command-line workflow for investigating an image suspected of containing hidden data: from basic file identification through LSB extraction, and the pitfalls that trip people up along the way, particularly around false positives and format-specific tooling limitations.
Platform note: Examples assume macOS or Linux with a standard shell. Package manager commands are given for both where they differ; some tools (
zsteg) are Ruby gems and install identically on both.
Minimal Toolkit Setup
# macOS (Homebrew)
brew install exiftool binwalk
# Linux (Debian/Ubuntu)
sudo apt install exiftool binwalk
# zsteg (Ruby gem, cross-platform, no compiled dependencies)
gem install zsteg
steghide is deliberately left out of this list; more on why in the JPEG section below.
Why Steganography Analysis Is Different From Other Forensics
Most forensic work starts with a hypothesis about where evidence lives: a log file, a deleted inode, a network capture. Steganography analysis starts with the opposite problem. You usually don't know whether anything is hidden at all, and if it is, you don't know which of several possible techniques was used to hide it.
This means the workflow is necessarily a checklist of increasingly invasive checks, run roughly in order of how much a technique costs to try and how likely it is to catch something:
- Cheap checks that catch careless hiding (trailing data, metadata fields)
- Signature-based checks that catch embedded files (
binwalk) - Bit-level checks that catch true steganographic embedding (LSB analysis)
- Format-specific checks that need dedicated tools (JPEG DCT-domain embedding)
Skipping straight to the most sophisticated tool is usually a mistake. A flag hidden in an EXIF comment field will not show up in an LSB scan, and running that scan first wastes time on a much harder analysis when a five-second exiftool call would have found it.
Step 1: Identify the File Type
Never trust a file extension. file reads the actual header bytes and reports the real format:
file suspicious.jpg
suspicious.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 768x512, components 3
This matters for two reasons. First, some challenges deliberately mislabel the extension. Second, and more importantly for the rest of this guide, the format determines which tools are actually usable; PNG and JPEG behave very differently under LSB analysis, for reasons covered later.
Step 2: Check for Appended Data
Every image format has a defined end marker. JPEG ends with the two-byte sequence FF D9. PNG ends with an IEND chunk. Anything after that marker is not part of the image and was appended deliberately, whether by a steganography tool or by hand.
xxd suspicious.jpg | tail -50
Look at whether the last bytes shown are ff d9 at the very end of the output, or whether there is more data following. A quick programmatic check is more reliable than eyeballing a hex dump:
python3 -c "
data = open('suspicious.jpg', 'rb').read()
i = data.rfind(b'\xff\xd9')
print('EOF marker at:', i, 'of', len(data), 'bytes total')
if i + 2 < len(data):
print('Trailing data found:', data[i+2:i+200])
"
If i + 2 equals the total file length, there is nothing appended. If it doesn't, you likely have a second file concatenated onto the first, which binwalk (step 4) will usually identify more precisely.
Step 3: Check Metadata
Metadata fields are a common but shallow hiding spot. EXIF, IPTC, and XMP all support free-text fields that a hiding tool or a careless challenge author might repurpose.
exiftool suspicious.jpg
Pay particular attention to Comment, UserComment, XPComment, Copyright, and any custom or unusually named field. A standard camera or editor-generated ICC profile block (ProfileDescription: sRGB, ProfileCopyright: Google Inc., and so on) is boilerplate present in nearly every JPEG and is not itself meaningful; it only becomes relevant if a field within it has been altered from its expected value.
To extract binary data hidden in a metadata field:
exiftool -b -Comment suspicious.jpg > comment_data.bin
file comment_data.bin
Step 4: Scan for Embedded Files
binwalk scans the file byte by byte, looking for magic-number signatures of other known file formats appearing anywhere in the data, not just at the start.
binwalk suspicious.jpg
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------
0 0x0 JPEG image, total size: 45947 bytes
A single entry matching the file's own size and format means nothing else was found. A second entry at a nonzero offset means another file signature was detected inside or after the first, and it's worth extracting:
binwalk -e suspicious.jpg
This produces an _suspicious.jpg.extracted/ directory. Check what landed inside:
file _suspicious.jpg.extracted/*
Note that binwalk declines to auto-extract a JPEG signature found inside another JPEG-like file by default, since JPEG has no clean end-of-file guarantee the way many other formats do; you may need to slice the file manually at the reported offset if this happens.
Step 5: LSB Analysis with zsteg
Least-significant-bit (LSB) steganography hides data by slightly altering the lowest bits of pixel color values, changes small enough to be visually imperceptible but recoverable if you know which bits, which channels, and in which order to read them.
zsteg automates checking many combinations of these parameters against PNG and BMP files:
zsteg suspicious.png
imagedata .. file: shared library
b1,g,lsb,xy .. text: "EjA{X TT"
b3,b,msb,xy .. file: OpenPGP Public Key
Important limitation: zsteg reliably supports PNG and BMP only. Its JPEG handling depends on an underlying library (zpng) whose JPEG support is incomplete; running it against a JPEG typically crashes with an error like:
undefined method `alpha_used?' for <SOF0 ...>:ZPNG::JPEG::SOF0 (NoMethodError)
This is a library bug, not a statement about JPEG steganography being impossible; JPEG absolutely can carry hidden data, just not via the pixel-domain LSB techniques zsteg checks for, and not via a tool built around PNG's internals. JPEG-specific tools are covered separately below.
Step 6: The Full Scan, and Why Most of Its Output Is Noise
The default zsteg run checks a limited, commonly used set of bit planes and channel orders. A thorough pass checks all of them:
zsteg -a suspicious.png
On any nontrivial image, this produces dozens to hundreds of result lines. Almost all of them are false positives, and understanding why is essential to using this output correctly.
Each result line represents one specific interpretation of the image's bit data: a bit plane (b1 through b8), a channel or channel combination (r, g, b, rgb, bgr), a bit order (lsb, msb), a scan direction (xy, yx, and their capitalized and mirrored variants), and sometimes a prime flag. That's several hundred distinct combinations for a single image. When you extract raw pixel bits under that many different interpretations, some of them will, purely by chance, produce byte sequences that happen to match the first few bytes of some known file signature, or a short run of printable ASCII.
This is the same phenomenon as flipping a coin enough times to see a run of ten heads in a row. It isn't a sign that the coin is unfair; it's what enough trials will eventually produce. In one real scan of a heavily-tested image, over a dozen result lines reported file: OpenPGP Public Key or file: OpenPGP Secret Key under totally different bit/channel combinations. Real embedded PGP keys don't appear a dozen times under unrelated extraction parameters; a genuine signature match at one specific, correct configuration does.
Two heuristics separate genuine hits from noise:
- Length and coherence. A false positive text match is almost always short (under 15-20 characters) and non-language-like. A real hit tends to be long enough to read as actual words or structured data.
- Content-validated matches beat signature-only matches.
zstegcan go one step further than checking magic bytes; for some formats, most notably zlib, it actually attempts decompression and shows you a preview of the result. A line like:
b2,rgb,lsb,yx .. zlib: data="Title: Nineteen eighty-four\r\nAuthor: George Orwell...", offset=57, size=146
is a fundamentally stronger signal than a bare file: line, because the tool didn't just see a matching header, it successfully decompressed real, coherent, human-readable content. A collision that both matches a zlib header by chance and decompresses into grammatical English is astronomically less likely than a bare four-byte signature match.
Sample image used for this example: 1984-stegafied-Part1.png
When triaging a long zsteg -a output, sort mentally by this hierarchy: content-validated matches first, then long/coherent text matches, then everything else as noise to be ignored, not chased.
Step 7: Extracting the Real Payload
Once a specific channel spec is identified, extract just that combination:
zsteg -e "b2,rgb,lsb,yx" suspicious.png > extracted.bin
file extracted.bin
If zsteg reported the payload as a zlib: match, it decompresses automatically during extraction in most cases. Check the result:
cat extracted.bin
If this prints readable text, you're done. If it prints binary noise, the extraction likely captured the raw compressed stream rather than the decompressed content, which is common when the zlib stream doesn't begin at byte 0 of the extracted range.
Step 8: Handling an Offset Mismatch
The scan output often reports an offset= value alongside a zlib: match. This is the number of bytes into the raw extracted bit stream where the actual zlib header begins; anything before it is other embedded data or padding that happened to precede the real payload in that bit plane.
Manual decompression needs to account for this:
python3 -c "
import zlib
data = open('extracted.bin', 'rb').read()
print(zlib.decompress(data[57:]).decode())
"
If the reported offset doesn't line up exactly, which can happen due to how the scanning tool counts bytes versus how extraction slices them, brute-force a small window around it:
python3 -c "
import zlib
data = open('extracted.bin', 'rb').read()
for off in range(0, 100):
try:
result = zlib.decompress(data[off:])
print(off, '->', result.decode())
break
except Exception:
pass
"
This tries every offset in the range until one produces a valid zlib stream, which is far faster than reasoning about it by hand.
JPEG-Specific Steganography
JPEG's lossy compression is the reason pixel-domain LSB tools don't work reliably against it; naive bit-flipping in decoded pixel values gets destroyed the moment the image is re-encoded. Tools built for JPEG instead operate in the DCT (discrete cosine transform) coefficient domain, embedding data in a way that survives the compression process. steghide, jsteg, F5, and outguess are the classic tools in this category.
steghide is the most commonly encountered of these in CTF contexts, but it's also the most difficult to install on modern macOS without a package manager, since it depends on libmcrypt, libjpeg, and mhash, all of which are old C libraries that don't compile cleanly against current Clang and SDK versions without patches. If Homebrew or MacPorts isn't available, practical alternatives include:
- OpenStego, a Java-based tool distributed as a runnable
.jar, avoiding native compilation entirely. - stegoveritas, a pip-installable Python tool that runs multiple non-steghide checks (LSB planes, color plane extraction, metadata, file carving) in one pass.
- A container or VM with a Linux distribution's package manager, where
apt install steghideworks without any of the macOS toolchain issues.
Once installed, basic usage is:
steghide extract -sf suspicious.jpg
steghide prompts for a passphrase; an empty passphrase is worth trying first. If the passphrase is unknown, stegseek performs the same extraction against a wordlist far faster than steghide's own brute-force mode:
stegseek suspicious.jpg /path/to/wordlist.txt
Practical Investigation Scenario: Full Walkthrough
Putting the whole workflow together, against a single suspect image:
file suspicious.pngto confirm formatxxd suspicious.png | tail -50to check for appended data afterIENDexiftool suspicious.pngto check metadata fieldsbinwalk -e suspicious.pngto check for embedded file signatureszsteg suspicious.pngfor a default LSB passzsteg -a suspicious.pngfor an exhaustive pass if the default pass finds nothing- Filter the exhaustive output for content-validated matches (
zlib:,file:with plausible length) rather than short text fragments zsteg -e "to pull the identified payload" suspicious.png > extracted.bin file extracted.binto determine whether further decoding is needed- Decompress, decode, or decrypt as appropriate, accounting for any reported byte offset
Next Steps
This covers image-based LSB and metadata steganography, which accounts for the large majority of CTF-style challenges. A few adjacent areas worth studying next:
- Audio steganography: WAV and other uncompressed audio formats support LSB techniques directly analogous to image LSB embedding.
steghidealso supports WAV and AU natively. - Frequency-domain and transform-domain techniques: Beyond JPEG's DCT-based tools, wavelet-domain and other transform-domain embedding methods exist and require correspondingly specialized detection tools.
- Statistical steganalysis: Rather than searching for a specific known embedding technique, statistical steganalysis looks for anomalies in the statistical distribution of pixel or coefficient values that suggest something was altered, even without recovering the payload directly. This is a much deeper field and the basis of most academic steganalysis research.
- Automated multi-tool suites:
stegoveritasand similar tools chain many of these individual checks (metadata, binwalk, zsteg, color plane extraction) into a single pass, which is convenient once you understand what each underlying check is actually doing and, more importantly, what its false positive rate looks like.
Conclusion
Image steganography analysis is less about any single powerful tool and more about running a series of increasingly specific checks in a sensible order, and, critically, correctly separating genuine hits from the noise that inevitably appears once you start checking hundreds of bit-plane and channel combinations at once. exiftool and binwalk catch careless or shallow hiding in seconds. zsteg catches genuine LSB embedding in PNG and BMP, provided the results are read with appropriate skepticism. JPEG requires a different toolchain entirely, one built around the DCT domain rather than raw pixels.
None of these tools require deep specialization to use correctly; the skill is almost entirely in the triage, knowing which of a hundred reported matches is worth a second look; and in that regard, this overlaps closely with general CLI forensics, and the same instinct for signal versus noise transfers directly between the two.