Hidden in Plain Sight
An Introduction to
Steganography
Seven hands-on labs taking you from the mathematics of hidden bits to historical spy ciphers — with full Python code, tool commands, and worked examples you can run yourself.
// Introduction
What steganography is, how it differs from cryptography, and why it matters.
Cryptography scrambles a message so it cannot be read without a key. Anyone who intercepts it knows a secret is being sent — they just can't read it. Steganography takes a different approach: it hides the message so that no-one even suspects it exists.
The word comes from the Greek steganos (covered) and graphia (writing). Ancient practitioners tattooed messages on shaved slave scalps and waited for the hair to grow back. In the Second World War, the Nazis used microdots — photographs shrunk to the size of a full stop. Today, steganography is used by human-rights activists to evade censorship, by malware authors to exfiltrate data, and by digital forensics investigators who need to find it.
Every digital file has more capacity than its visible content requires. A 24-bit colour image stores 8 bits per channel per pixel — but the human eye cannot distinguish a colour that differs only in its lowest bit. That spare capacity is the hiding place. The art of steganography is exploiting it without leaving a detectable trace.
In these labs you are learning both how to hide data and how to recognise that data is being hidden. Both skills are essential in a defensive security role.
// Lab Environment Setup
Optimised for Kali Linux. Run the bootstrap script once, then proceed through the labs in order.
Open a terminal in Kali and paste the entire block below. It installs every tool and Python package needed for all 7 labs.
# ── System packages (apt) ──────────────────────────────────────── sudo apt update && sudo apt install -y \ steghide \ libimage-exiftool-perl \ audacity \ mat2 \ xxd \ file \ zip \ python3-pip \ python3-venv # ── Python packages ────────────────────────────────────────────── # Kali 2023.1+ uses Python 3.11 with PEP 668 (externally managed). # Use a venv — DO NOT use --break-system-packages in a lab environment. python3 -m venv ~/steg-env source ~/steg-env/bin/activate pip install --upgrade pip pip install Pillow numpy scipy mutagen # ── Working directory ──────────────────────────────────────────── mkdir -p ~/steg-labs/{lab1,lab2,lab3,lab4,lab5,lab6,lab7} cd ~/steg-labs echo "✓ All tools installed. Venv active. Working directory: ~/steg-labs"
Every new terminal session: reactivate the venv before running any Python lab script: source ~/steg-env/bin/activate. Your prompt will show (steg-env) when it is active. Without this, import PIL will fail.
| Tool | Kali install command | Used in |
|---|---|---|
steghide | sudo apt install steghide | Lab 4 |
exiftool | sudo apt install libimage-exiftool-perl | Lab 5 |
mat2 | sudo apt install mat2 | Lab 5 |
audacity | sudo apt install audacity | Lab 3 |
xxd | Pre-installed on Kali (part of vim-common) | Lab 6 |
file | Pre-installed on Kali | Lab 6 |
| Python 3 + Pillow + NumPy | Via venv (see bootstrap above) | Labs 1–3, 6–7 |
Why apt for mat2, not pip? On Kali, pip install mat2 fails or installs into a broken path due to PEP 668. The apt package is maintained by Debian/Kali and installs correctly system-wide with all native dependencies.
# Check all tools respond steghide --version exiftool -ver mat2 --version audacity --version xxd --version 2>&1 | head -1 file --version | head -1 # Check Python packages inside the venv source ~/steg-env/bin/activate python3 -c "import PIL, numpy, scipy; print('✓ Python packages OK')"
LSB Image Steganography — Manual Bit Manipulation
You will hide a short message inside a PNG image by replacing the least significant bit (LSB) of each pixel's colour value. First you will do the maths by hand, then implement an encoder and decoder in Python, then compare the original and modified images.
Each pixel in a 24-bit RGB image stores three numbers (R, G, B) each in the range 0–255, represented as 8 binary bits. The most significant bit (MSB, leftmost) contributes 128 to the value. The least significant bit (LSB, rightmost) contributes only 1. Flipping the LSB changes the colour value by at most ±1 — imperceptible to the human eye, but enough to hide one bit of secret data per channel.
Before running any Python script in this lab (and every lab), make sure your venv is active: source ~/steg-env/bin/activate. Your prompt should show (steg-env). Then cd ~/steg-labs/lab1.
Suppose you want to hide the letter A (ASCII 65 = binary 01000001) in a single pixel with RGB values (200, 130, 75).
Original pixel channels in binary: R = 200 → 11001000 G = 130 → 10000010 B = 75 → 01001011 Message bits to hide: 01000001 (letter 'A') Replace the LSB of each channel with a message bit: R: 1100100[0] → replace with bit 0 → 11001000 = 200 (unchanged) G: 1000001[0] → replace with bit 1 → 10000011 = 131 (changed by 1) B: 0100101[1] → replace with bit 0 → 01001010 = 74 (changed by 1) Next 5 bits hidden in next 5 channel values of following pixels. Result: pixel (200, 131, 74) — visually identical to (200, 130, 75).
Save the following as lab1/encode.py.
from PIL import Image import numpy as np def encode_message(input_path, output_path, message): """Hide 'message' in the LSB of each RGB channel.""" img = Image.open(input_path).convert('RGB') pixels = np.array(img, dtype=np.uint8) # Encode message length (4 bytes) + message + null terminator msg_bytes = message.encode('utf-8') + b'\x00' msg_bits = [] for byte in msg_bytes: for i in range(7, -1, -1): # MSB → LSB msg_bits.append((byte >> i) & 1) flat = pixels.flatten() # 1D array of all channel values if len(msg_bits) > len(flat): raise ValueError("Message too long for this image.") for i, bit in enumerate(msg_bits): flat[i] = (flat[i] & 0b11111110) | bit # clear LSB, set to bit stego = Image.fromarray(flat.reshape(pixels.shape)) stego.save(output_path, 'PNG') print(f"Encoded {len(msg_bits)} bits into {output_path}") # ── Run it ─────────────────────────────────────────────────────── encode_message('original.png', 'stego.png', 'Hello, hidden world!')
Save as lab1/decode.py.
from PIL import Image import numpy as np def decode_message(stego_path): """Extract the message hidden in the LSB of the stego image.""" img = Image.open(stego_path).convert('RGB') flat = np.array(img).flatten() bits = [int(v) & 1 for v in flat] # extract LSB of every channel message = [] for byte_start in range(0, len(bits) - 7, 8): byte_bits = bits[byte_start : byte_start + 8] value = 0 for b in byte_bits: value = (value << 1) | b if value == 0: # null terminator found break message.append(chr(value)) print("Decoded message:", ''.join(message)) decode_message('stego.png')
from PIL import Image, ImageChops import numpy as np original = Image.open('original.png').convert('RGB') stego = Image.open('stego.png').convert('RGB') diff = ImageChops.difference(original, stego) diff_arr = np.array(diff) # Amplify by 128 so single-bit changes become visible amplified = Image.fromarray(np.clip(diff_arr * 128, 0, 255).astype(np.uint8)) amplified.save('diff_amplified.png') print(f"Max pixel difference: {diff_arr.max()} (should be ≤ 1)") print(f"Changed pixels: {(diff_arr > 0).sum()}")
Open original.png, stego.png, and diff_amplified.png side-by-side. The first two should look identical. The amplified diff reveals exactly which pixels were changed — they form a pattern at the top-left of the image, where the message bits were written.
Always use PNG, never JPEG, for LSB steganography. JPEG compression re-encodes pixel values and will destroy your hidden bits. PNG is lossless.
✦ Exercises
- Capacity check: For a 1920×1080 RGB image, how many characters can you hide using 1-bit LSB encoding? Show your working.
- Two-bit LSB: Modify
encode.pyto use the two least significant bits of each channel. How does the maximum pixel difference change? Can you still see it? - Security gap: The encoder above has no password. A classmate with a copy of
decode.pycan extract your message. Describe (in words) one change to the encoding scheme that would prevent this. - Challenge: Share your
stego.pngwith a classmate. Can they recover the message without seeing your code?
Text Steganography — Zero-Width Characters
Unicode contains invisible characters that take up no visual space. You will use two of them to encode binary data inside ordinary-looking text. The result can be pasted into an email, a Word document, or a tweet — and it will look completely normal.
U+200B Zero-Width Space (ZWSP) — represents a binary 0.
U+200C Zero-Width Non-Joiner (ZWNJ) — represents a binary 1.
To hide a byte, insert eight of these characters (in order, MSB first) between the visible characters of the cover text. The reader sees nothing unusual; a steganalysis tool scanning for these Unicode codepoints reveals the pattern immediately.
Save as lab2/encode_text.py.
def encode_text(cover_text, secret): ZWSP = '\u200B' # bit 0 ZWNJ = '\u200C' # bit 1 # Convert secret to binary string bits = ''.join(f'{byte:08b}' for byte in secret.encode('utf-8')) # Build invisible payload string payload = ''.join(ZWSP if b == '0' else ZWNJ for b in bits) # Insert after first word of cover text words = cover_text.split(' ', 1) if len(words) < 2: return cover_text + payload return words[0] + payload + ' ' + words[1] cover = "The weather today is sunny and warm. Have a great afternoon." secret = "MEET AT NOON" result = encode_text(cover, secret) print("Stego text written to stego.txt") with open('stego.txt', 'w', encoding='utf-8') as f: f.write(result)
def decode_text(stego_text): ZWSP = '\u200B' ZWNJ = '\u200C' bits = '' for ch in stego_text: if ch == ZWSP: bits += '0' elif ch == ZWNJ: bits += '1' if not bits: print("No hidden data found.") return # Reassemble bytes message = '' for i in range(0, len(bits) - 7, 8): byte = bits[i:i+8] message += chr(int(byte, 2)) print("Hidden message:", message) with open('stego.txt', encoding='utf-8') as f: decode_text(f.read())
To see the invisible characters in a terminal:
# Hex dump of the stego text (Linux/Mac) cat stego.txt | xxd | head -20 # Look for: e2 80 8b (ZWSP) and e2 80 8c (ZWNJ) in the hex output # These sequences should not appear in normal plain text.
In a Python script:
with open('stego.txt', encoding='utf-8') as f: text = f.read() count_zwsp = text.count('\u200B') count_zwnj = text.count('\u200C') print(f"ZWSP: {count_zwsp}, ZWNJ: {count_zwnj}") if count_zwsp + count_zwnj > 0: print("⚠ Zero-width characters detected — possible hidden data!")
✦ Exercises
- Paste test: Copy the contents of
stego.txtand paste it into a Word document, then a Discord message, then a Gmail compose window. Does the formatting survive? Can you still decode it? - Find the limit: What is the maximum number of characters you can hide in a cover text of 50 words?
- Detection script: Write a Python script that scans every
.txtfile in a folder and reports any that contain zero-width characters. - Challenge: Share a stego text with a classmate in a Teams or Slack message. Challenge them to find it using the detection script above.
Audio Steganography — Spectrogram Images
A spectrogram represents sound as an image: the X-axis is time, the Y-axis is frequency, and the brightness represents amplitude. Artists and hackers alike have discovered that you can draw images in the frequency domain of an audio file — invisible to the ear, but revealed the instant you open the spectrogram view.
By generating sine waves at precise frequencies and at precise moments in time, you can cause specific cells in the spectrogram grid to light up. String enough of these together and you can draw letters, images, or QR codes. The audio sounds like faint static or high-pitched noise to anyone who listens — completely innocuous.
- Download a WAV file known to contain a spectrogram image. A classic example: search "aphex twin windowlicker spectrogram" — the final track contains a face in the spectrogram. For your lab, use any WAV file your instructor provides.
- Open Audacity. Go to File → Import → Audio and open the WAV file.
- In the track header (left side of the waveform), click the dropdown arrow next to the track name.
- Select Spectrogram. The view changes from a waveform to a colour frequency plot.
- Scroll to the end of the track. If an image has been hidden in the high-frequency range, it will appear here. The default spectrogram may not show high frequencies — adjust: click the dropdown again → Spectrogram Settings → set Maximum Frequency to 22050 Hz.
- Note what you see, and take a screenshot for your lab report.
Save as lab3/spectrogram_encode.py. This generates a WAV file where white pixels in an image are encoded as tones.
import numpy as np from PIL import Image import wave, struct def image_to_spectrogram_wav(image_path, output_wav, duration=5.0, sample_rate=44100, freq_min=1000, freq_max=8000): img = Image.open(image_path).convert('L') # greyscale img = img.resize((200, 100)) # 200 time cols × 100 freq rows pixels = np.array(img) / 255.0 n_samples = int(duration * sample_rate) audio = np.zeros(n_samples) n_cols, n_rows = pixels.shape[1], pixels.shape[0] col_samples = n_samples // n_cols for col in range(n_cols): start = col * col_samples end = start + col_samples t = np.linspace(0, col_samples / sample_rate, col_samples, endpoint=False) for row in range(n_rows): amp = pixels[n_rows - 1 - row, col] # flip: low row = low freq if amp > 0.1: freq = freq_min + (freq_max - freq_min) * row / n_rows audio[start:end] += amp * 0.3 * np.sin(2 * np.pi * freq * t) # Normalise and write WAV audio = np.clip(audio / np.max(np.abs(audio) + 1e-9), -1, 1) audio_int = (audio * 32767).astype(np.int16) with wave.open(output_wav, 'w') as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(audio_int.tobytes()) print(f"Wrote {output_wav}") # Create a small test image (white text on black background) then: image_to_spectrogram_wav('message.png', 'hidden_audio.wav')
Create message.png by opening MS Paint (or GIMP), setting the background to black, and typing white text. Keep the image small — 200×100 pixels works well. Then run the script and open hidden_audio.wav in Audacity's spectrogram view.
✦ Exercises
- Listen first: Play
hidden_audio.wavbefore looking at the spectrogram. Describe what it sounds like. Then switch to spectrogram view. Describe the difference between what you hear and what you see. - Frequency window: Explain why hiding data at high frequencies (above 10 kHz) is less suspicious than hiding it at 1–4 kHz.
- Detection: If a colleague emailed you this WAV file as a voice recording, what would make you suspicious enough to open it in Audacity?
- Challenge: Generate a WAV file containing your initials in the spectrogram. Share with a classmate — can they read the letters?
Image Steganography with Steghide
Steghide is a professional-grade command-line tool that embeds files inside JPEG, BMP, WAV, or AU files using a passphrase. The hidden data is compressed, encrypted (Rijndael-128 by default), and spread throughout the carrier using a pseudorandom scheme — making detection much harder than simple LSB.
Steghide compresses and encrypts your payload, then uses the passphrase to seed a pseudorandom number generator that determines which pixels (or audio samples) carry each hidden bit. Without the password, an attacker cannot reconstruct the distribution pattern and cannot extract the data.
- Obtain any JPEG photo —
photo.jpg. Create a text file to hide:echo "Secret: lab access code 4729" > secret.txt - Embed the file:
steghide embed -cf photo.jpg -sf stego.jpg -p "my_passphrase" # -cf cover file (original) # -sf stego file (output) # -p passphrase
- Confirm the embed worked — check the file info:
steghide info stego.jpg
- Compare file sizes:
ls -lh photo.jpg stego.jpg # The stego file will be slightly different in size
# Extract to current directory steghide extract -sf stego.jpg -p "my_passphrase" # Verify the file came out intact cat secret.txt
Share stego.jpg with a classmate. Give them the passphrase separately (over a different channel). They should be able to extract the file successfully.
# Try extracting without the correct passphrase steghide extract -sf stego.jpg -p "wrong_password" # Steghide will either fail silently or produce garbage — note the result. # Even knowing steganography was used, without the key the data is inaccessible. # This illustrates the combination of steganography + cryptography.
Steghide does not alter file extension or EXIF data — only the pixel data. Opening the stego JPEG in any image viewer looks completely normal. This is what makes it effective for covert communication.
✦ Exercises
- Capacity: Run
steghide info photo.jpgbefore embedding. What is the maximum payload size reported? How does this compare to the LSB capacity you calculated in Lab 1? - Channel security: You embed data in an image and post it publicly on a website. A colleague extracts it using the passphrase. Draw a simple diagram showing the communication model. Which part of this system is the weakest link?
- Detection attempt: Run
file stego.jpgandexiftool stego.jpg. Does anything look unusual compared to a clean JPEG? Why or why not? - Challenge: Embed a file inside an audio WAV using steghide. The
-cfflag accepts WAV files too.
Metadata Steganography — EXIF Data
Every photo taken on a digital camera or smartphone contains hidden metadata in the EXIF (Exchangeable Image File Format) standard. This includes GPS coordinates, the camera model, shutter speed, and dozens of other fields — most of which can be freely written to. It's a ready-made hiding place that almost nobody checks.
EXIF data lives in a special header block at the start of a JPEG file. Fields like Artist, Copyright, Comment, ImageDescription, and UserComment accept arbitrary text. An attacker — or a legitimate user wanting privacy — can write anything into these fields without affecting how the image looks or renders.
# Read all metadata from a JPEG exiftool photo.jpg # Read specific fields only exiftool -Comment -Artist -GPSLatitude -GPSLongitude photo.jpg # Read ALL fields, including obscure ones exiftool -a -u -g1 photo.jpg
# Write a message to the Comment field exiftool -Comment="Meeting confirmed: Tuesday 14:00, Gate 7" photo.jpg # Multiple fields exiftool -Artist="John Smith" \ -Copyright="© 2024" \ -Comment="PAYLOAD: dXNlcjpwYXNzd29yZA==" \ photo.jpg # Verify it was written exiftool -Comment photo.jpg
For binary data, encode it as base64 first so it survives the text field without corruption:
# Encode a secret file as base64 base64 secret.txt > secret.b64 # Store in EXIF Comment exiftool -Comment="$(cat secret.b64)" photo.jpg # ── On the receiving end ── exiftool -Comment -b photo.jpg | base64 -d > recovered_secret.txt cat recovered_secret.txt
# Remove ALL metadata from an image exiftool -all= photo.jpg # Or with mat2 — installed via apt on Kali (NOT pip) mat2 photo.jpg
Many organisations strip EXIF data from all images before publishing them on the web — both for privacy (removing GPS coordinates) and to prevent covert channels.
✦ Exercises
- Metadata hunt: Take a photo with your phone and email it to yourself. Open it on your computer and run
exiftool. What metadata is present? Does it include GPS coordinates? What privacy risks does this create? - Detection policy: You work in a SOC and want to prevent EXIF-based data exfiltration. Describe a network-level control that could detect employees exfiltrating data via EXIF fields in images uploaded to external services.
- Capacity: The EXIF Comment field has a practical limit of about 65,535 bytes. How many pages of A4 text is that?
- Challenge: Embed a base64-encoded text file in the EXIF Comment field of a JPEG, share it with a classmate, and have them extract and decode it successfully.
File Extension and Magic Bytes
A file's extension (.jpg, .pdf) is just a name. The actual format is identified by the first few bytes of the file — known as the magic bytes or file signature. You will explore how to disguise files by renaming their extensions and manipulating their headers, and how to detect this.
| Format | Magic bytes (hex) | ASCII representation |
|---|---|---|
| JPEG | FF D8 FF | ÿØÿ |
| PNG | 89 50 4E 47 0D 0A 1A 0A | .PNG.... |
| ZIP/DOCX/XLSX | 50 4B 03 04 | PK.. |
25 50 44 46 | %PDF | |
| ELF (Linux binary) | 7F 45 4C 46 | .ELF |
| Windows EXE/DLL | 4D 5A | MZ |
| MP3 | 49 44 33 or FF FB | ID3 |
- Create a ZIP file:
zip secret_archive.zip secret.txt - Rename it:
cp secret_archive.zip disguised.jpg - Try opening it:
eog disguised.jpg— the image viewer will refuse to open it, or crash. - Detect the real type:
file disguised.jpg # Output: disguised.jpg: Zip archive data, at least v2.0 to extract # The 'file' command reads the magic bytes, ignoring the extension.
- Inspect in hex:
xxd disguised.jpg | head -3 # You will see: 50 4b 03 04 (PK.. — ZIP magic bytes) # NOT ff d8 ff (JPEG magic bytes)
def identify_file(filepath): SIGNATURES = { b'\xff\xd8\xff' : 'JPEG image', b'\x89PNG\r\n\x1a\n' : 'PNG image', b'PK\x03\x04' : 'ZIP archive (could be DOCX/XLSX/JAR)', b'%PDF' : 'PDF document', b'MZ' : 'Windows executable (EXE/DLL)', b'\x7fELF' : 'Linux ELF binary', b'ID3' : 'MP3 audio', b'RIFF' : 'WAV/AVI', } with open(filepath, 'rb') as f: header = f.read(16) print(f"\nFile: {filepath}") print(f"Extension claims: {filepath.rsplit('.',1)[-1].upper()}") print(f"First 16 bytes (hex): {header.hex()}") for sig, name in SIGNATURES.items(): if header.startswith(sig): print(f"Actual type: {name} ✓") return print("Actual type: Unknown / no matching signature") identify_file('disguised.jpg') identify_file('photo.jpg')
A polyglot file is a file that is simultaneously valid in two formats.
# Append a ZIP to the end of a JPEG — both formats stay valid cat photo.jpg secret_archive.zip > polyglot.jpg # Opens normally as a JPEG in image viewers # But also works as a ZIP: unzip polyglot.jpg # ZIP readers scan from the END of the file for the ZIP central directory # JPEG readers scan from the START — they ignore everything after the image data
This technique is used in real malware to bypass file-type filters. An email security gateway that only checks the first magic bytes will pass this as a harmless JPEG — but it contains a full ZIP archive.
✦ Exercises
- Bulk scanner: Write a Python script that scans all files in a folder and flags any where the magic bytes do not match the file extension.
- Format research: Find the magic bytes for GIF, 7-Zip, MP4, and OGG. Add them to the
SIGNATURESdictionary above. - Security bypass: Explain how a polyglot file could be used to bypass a web application that only allows image uploads. What should the developer check instead of the file extension?
- Challenge: Create a DOCX file (which is just a ZIP), rename it to
.jpg, and trick a classmate into opening it by sending it via email. Record whether their mail client warns them.
Null Cipher — Classical Steganography
Before digital computers, spies hid messages in plain text. A null cipher encodes a secret by selecting specific characters from a larger innocent text — for example, the first letter of each word, every fifth letter, or the last letter of alternating sentences. You will write and detect null ciphers, then automate extraction in Python.
During the First World War, a German prisoner wrote home: "Apparently neutral's protest is thoroughly discounted and ignored. Isman hard hit. Blockade issue affects pretext for embargo on by-products, ejecting suets and vegetable oils." The first letter of each word reads: PERSHING SAILS FROM NY JUNE 1. Real null ciphers were far more sophisticated than this example — but the principle is identical.
| Scheme | Rule | Example extract |
|---|---|---|
| First-letter | First letter of each word | Hello And Thank You → HATY |
| nth-letter | The Nth letter of each word | Every 3rd letter of each word |
| Acrostic | First letter of each sentence or line | Each sentence starts with a key letter |
Write an innocent paragraph whose first letter of each word spells out a short message. For example, encode CALL ME:
Now write your own. Encode the message STEG IS FUN as a paragraph of at least 3 sentences. The first letter of each word should spell it out. It must read naturally.
Save as lab7/null_cipher.py.
import re def extract_first_letters(text): words = re.findall(r"[A-Za-z]+", text) return ''.join(w[0].upper() for w in words) def extract_nth_letter(text, n=3): words = re.findall(r"[A-Za-z]+", text) return ''.join(w[n-1].upper() for w in words if len(w) >= n) def extract_acrostic(text): sentences = re.split(r'(?<=[.!?])\s+', text.strip()) return ''.join(s[0].upper() for s in sentences if s) def extract_every_nth_char(text, n=5): letters = [c.upper() for c in text if c.isalpha()] return ''.join(letters[i] for i in range(n-1, len(letters), n)) # WWI example — first letters of each word spell the secret wwi = ("Apparently neutral's protest is thoroughly discounted and ignored. " "Isman hard hit. Blockade issue affects pretext for embargo on " "by-products, ejecting suets and vegetable oils.") print("First letters:", extract_first_letters(wwi)) print("Every 5th char:", extract_every_nth_char(wwi, 5)) # Acrostic poem poem = ("Shadows fall across the evening sky. " "Echoes travel far beyond the hills. " "Candles flicker in the winter wind. " "Rain will come before the morning light. " "Each dawn begins a chapter fresh and new. " "Tomorrow waits with patience for us all.") print("Acrostic:", extract_acrostic(poem)) def brute_force(text): print("\n── Brute-force extraction ──") print("First letter of each word:", extract_first_letters(text)) print("Acrostic (per sentence): ", extract_acrostic(text)) for n in [2, 3, 4, 5]: print(f"Every {n}th char: ", extract_every_nth_char(text, n)) print(f" {n}th letter of each word: ", extract_nth_letter(text, n))
✦ Exercises
- Compose it: Write a paragraph encoding
STEG IS FUNusing first-letter-of-each-word. It must read as a plausible everyday message (a weather update, a work email, a holiday postcard). - Brute-force: Given an unknown text, you suspect a null cipher but don't know the scheme. Use
brute_force()to try all methods. Record which output looks like real language. - Historical research: Find one real historical example of steganography (not the WWI example above). Write 3–4 sentences explaining how it worked and when it was discovered.
- Challenge: Exchange null cipher messages with a classmate using different schemes without telling each other which one. Use the brute-force script to decode their message.
// Tool Reference
Every tool used in Labs 1–7 with install commands and key flags.
| Tool | Labs | Key commands | Install |
|---|---|---|---|
| Python 3 + Pillow + NumPy | 1, 2, 3, 6, 7 | python3 script.py | venv + pip install Pillow numpy scipy |
| Steghide | 4 | embed / extract -cf -sf -p | sudo apt install steghide |
| ExifTool | 5 | exiftool -Tag=val fileexiftool -all= file | sudo apt install libimage-exiftool-perl |
| xxd / file | 6 | xxd f | head · file f | Pre-installed on Kali |
| Audacity | 3 | Track dropdown → Spectrogram | sudo apt install audacity |
| mat2 | 5 | mat2 file.jpg | sudo apt install mat2 |
# ── Activate venv first (every new terminal session) ──────────── source ~/steg-env/bin/activate cd ~/steg-labs # Lab 1 — LSB encode / decode python3 encode.py python3 decode.py # Lab 2 — Zero-width character detection python3 -c "t=open('stego.txt').read(); print(t.count('\u200B'), t.count('\u200C'))" # Lab 3 — Spectrogram WAV python3 spectrogram_encode.py # Then open hidden_audio.wav in Audacity → Spectrogram view # Lab 4 — Steghide steghide embed -cf cover.jpg -sf stego.jpg -p "passphrase" steghide extract -sf stego.jpg -p "passphrase" steghide info stego.jpg # Lab 5 — ExifTool exiftool photo.jpg # read all exiftool -Comment="hidden text" photo.jpg # write exiftool -all= photo.jpg # strip all metadata # Lab 6 — Magic bytes file suspicious.jpg xxd suspicious.jpg | head -4 python3 identify_file.py suspicious.jpg # Lab 7 — Null cipher python3 null_cipher.py
// What You've Learned — and What's Next
- ✅ Explain steganography and distinguish it from cryptography
- ✅ Hide data in images via LSB bit manipulation — and prove it mathematically
- ✅ Use invisible Unicode characters to conceal messages in plain text
- ✅ Draw images inside audio files using the frequency domain (spectrogram)
- ✅ Embed and extract password-protected payloads from JPEGs with Steghide
- ✅ Read and write hidden data in EXIF image metadata
- ✅ Detect disguised files using magic byte analysis and hex inspection
- ✅ Write and decode null ciphers — connecting modern techniques to their history
The next tier flips your perspective from maker to analyst. You will run chi-square statistical attacks to detect LSB steganography in images you didn't create, analyse DCT coefficients inside JPEGs, build covert TCP/IP network channels with Scapy, extract hidden objects from PDFs, and work through real CTF steganography challenges from PicoCTF.
◉ Labs 1–7 BEGINNER ← You are here ✓ Understand how each hiding medium works. Build encoders and decoders from scratch. Use professional tools. ○ Labs 8–14 INTERMEDIATE Chi-square attacks · StegExpose · DCT coefficient analysis Network covert channels · PDF steganalysis · CTF challenges ○ Labs 15–21 EXPERT F5 algorithm · Adaptive steganography (WOW) DNS tunnelling · C2 over stego channels · Build a DLP detection pipeline
Recommended bridge exercise: Take your Lab 1 stego image and run it through StegExpose (a Java-based steganalysis tool). Can it detect your hidden message automatically? Adjust the number of hidden bits and observe when detection fails. That's your first taste of the intermediate tier.
Steganography Beginner Workbook · Labs 1–7 · Kali Linux Edition · For educational use in a supervised lab environment only