// Self-Guided Student Workbook

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.

🟢 Beginner 7 Labs ~6–8 hrs total Kali Linux · Python 3

// 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.

The Core Idea

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.

⚡ One-liner bootstrap — run this first

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.

Required tools — Kali install reference
ToolKali install commandUsed in
steghidesudo apt install steghideLab 4
exiftoolsudo apt install libimage-exiftool-perlLab 5
mat2sudo apt install mat2Lab 5
audacitysudo apt install audacityLab 3
xxdPre-installed on Kali (part of vim-common)Lab 6
filePre-installed on KaliLab 6
Python 3 + Pillow + NumPyVia 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.

Verify everything installed correctly
# 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')"
LAB 01

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.

The Mathematics

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.

Worked Example — By Hand

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).
Step-by-step: Encoding a message

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!')
Step-by-step: Decoding the message

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')
Step-by-step: Visualising the difference
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

  1. Capacity check: For a 1920×1080 RGB image, how many characters can you hide using 1-bit LSB encoding? Show your working.
  2. Two-bit LSB: Modify encode.py to use the two least significant bits of each channel. How does the maximum pixel difference change? Can you still see it?
  3. Security gap: The encoder above has no password. A classmate with a copy of decode.py can extract your message. Describe (in words) one change to the encoding scheme that would prevent this.
  4. Challenge: Share your stego.png with a classmate. Can they recover the message without seeing your code?
LAB 02

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.

The Key Characters

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.

Encoder

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)
Decoder
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())
Detection: spot zero-width characters

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

  1. Paste test: Copy the contents of stego.txt and paste it into a Word document, then a Discord message, then a Gmail compose window. Does the formatting survive? Can you still decode it?
  2. Find the limit: What is the maximum number of characters you can hide in a cover text of 50 words?
  3. Detection script: Write a Python script that scans every .txt file in a folder and reports any that contain zero-width characters.
  4. Challenge: Share a stego text with a classmate in a Teams or Slack message. Challenge them to find it using the detection script above.
LAB 03

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.

How it works

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.

Part A — Reveal an existing hidden image (Audacity)
  • 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.
Part B — Generate your own spectrogram message (Python)

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

  1. Listen first: Play hidden_audio.wav before 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.
  2. Frequency window: Explain why hiding data at high frequencies (above 10 kHz) is less suspicious than hiding it at 1–4 kHz.
  3. Detection: If a colleague emailed you this WAV file as a voice recording, what would make you suspicious enough to open it in Audacity?
  4. Challenge: Generate a WAV file containing your initials in the spectrogram. Share with a classmate — can they read the letters?
LAB 04

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.

How Steghide works

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.

Embedding a file
  • 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
Extracting the file
# 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.

What happens without the password?
# 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

  1. Capacity: Run steghide info photo.jpg before embedding. What is the maximum payload size reported? How does this compare to the LSB capacity you calculated in Lab 1?
  2. 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?
  3. Detection attempt: Run file stego.jpg and exiftool stego.jpg. Does anything look unusual compared to a clean JPEG? Why or why not?
  4. Challenge: Embed a file inside an audio WAV using steghide. The -cf flag accepts WAV files too.
LAB 05

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 structure

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.

Reading EXIF data with ExifTool
# 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
Writing a hidden message into EXIF
# 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
Encoding base64 payloads in EXIF fields

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
Stripping EXIF data (privacy / defence)
# 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

  1. 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?
  2. 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.
  3. Capacity: The EXIF Comment field has a practical limit of about 65,535 bytes. How many pages of A4 text is that?
  4. 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.
LAB 06

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.

Magic bytes — what every format looks like
FormatMagic bytes (hex)ASCII representation
JPEGFF D8 FFÿØÿ
PNG89 50 4E 47 0D 0A 1A 0A.PNG....
ZIP/DOCX/XLSX50 4B 03 04PK..
PDF25 50 44 46%PDF
ELF (Linux binary)7F 45 4C 46.ELF
Windows EXE/DLL4D 5AMZ
MP349 44 33 or FF FBID3
Part A — Disguise a ZIP file as a JPEG
  • 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)
Part B — Inspect any file's magic bytes in Python
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')
Part C — JPEG with a ZIP appended (polyglot file)

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

  1. 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.
  2. Format research: Find the magic bytes for GIF, 7-Zip, MP4, and OGG. Add them to the SIGNATURES dictionary above.
  3. 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?
  4. 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.
LAB 07

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.

Historical context

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.

Three classic null cipher schemes
SchemeRuleExample extract
First-letterFirst letter of each wordHello And Thank You → HATY
nth-letterThe Nth letter of each wordEvery 3rd letter of each word
AcrosticFirst letter of each sentence or lineEach sentence starts with a key letter
Part A — Write a null cipher by hand

Write an innocent paragraph whose first letter of each word spells out a short message. For example, encode CALL ME:

Carefully Arranging Lunch Leaves More Excitement.

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.

Part B — Automate extraction in Python

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

  1. Compose it: Write a paragraph encoding STEG IS FUN using first-letter-of-each-word. It must read as a plausible everyday message (a weather update, a work email, a holiday postcard).
  2. 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.
  3. 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.
  4. 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.

ToolLabsKey commandsInstall
Python 3 + Pillow + NumPy1, 2, 3, 6, 7python3 script.pyvenv + pip install Pillow numpy scipy
Steghide4embed / extract -cf -sf -psudo apt install steghide
ExifTool5exiftool -Tag=val file
exiftool -all= file
sudo apt install libimage-exiftool-perl
xxd / file6xxd f | head · file fPre-installed on Kali
Audacity3Track dropdown → Spectrogramsudo apt install audacity
mat25mat2 file.jpgsudo apt install mat2
Quick command cheat-sheet
# ── 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

Skills achieved — Beginner tier complete ✓
  • ✅ 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
Intermediate Tier — Labs 8–14: Detection & Breaking

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.

Full learning pathway
◉ 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