Steganography with Python: Hiding Data in Images
Steganography is the art of concealing information within another medium so it's undetectable to the naked eye. Unlike encryption (which scrambles data), steganography makes data invisible. Python provides excellent tools for image steganography: Pillow, stegano, stepic, and invisible-watermark.
⚠️ Ethical use: This guide is for educational purposes and legitimate security research. Steganography has valid applications: digital watermarking, private communications, copyright protection, and forensic investigation.
Core Concept: LSB (Least Significant Bit)
The most common technique modifies the least significant bit of each color channel in each pixel. An RGB pixel has 3 channels of 8 bits → 3 bits available per pixel with no perceptible visual change.
Original value: 11001010 (202 decimal, red channel)
LSB = 0
With hidden message:
If message bit = 1: 11001011 (203) — 0.4% difference
If message bit = 0: 11001010 (202) — no change
A 1920×1080 image can store:
- 1920 × 1080 × 3 channels = 6,220,800 bits = 777,600 bytes ≈ 759 KB of hidden data
Installation
pip install Pillow stegano stepic
pip install invisible-watermark # optional, for robust watermarks
LSB Implementation from Scratch with Pillow
from PIL import Image
import numpy as np
def text_to_bits(text):
"""Convert UTF-8 text to a list of bits."""
bits = []
for byte in text.encode('utf-8'):
bits.extend([int(b) for b in format(byte, '08b')])
return bits
def bits_to_text(bits):
"""Convert a list of bits back to UTF-8 text."""
chars = []
for i in range(0, len(bits), 8):
byte = bits[i:i+8]
if len(byte) == 8:
chars.append(chr(int(''.join(map(str, byte)), 2)))
return ''.join(chars)
def hide_message(input_image, message, output_image):
"""Hide a message in an image using LSB steganography."""
img = Image.open(input_image).convert('RGB')
data = np.array(img)
# Add end-of-message marker
full_message = message + '\x00\x00\x00'
bits = text_to_bits(full_message)
if len(bits) > data.size:
raise ValueError(f"Message too long ({len(bits)} bits, max {data.size})")
flat = data.flatten().astype(np.uint8)
for i, bit in enumerate(bits):
flat[i] = (flat[i] & 0xFE) | bit # force LSB
modified = flat.reshape(data.shape)
Image.fromarray(modified, 'RGB').save(output_image, 'PNG')
print(f"Message hidden ({len(bits)} bits) in {output_image}")
def extract_message(image_with_message):
"""Extract the hidden message from a steganographic image."""
img = Image.open(image_with_message).convert('RGB')
data = np.array(img).flatten()
bits = [d & 1 for d in data]
msg_bits = []
for i in range(0, len(bits) - 23, 8):
byte_bits = bits[i:i+8]
msg_bits.extend(byte_bits)
if len(msg_bits) >= 24 and all(b == 0 for b in msg_bits[-24:]):
msg_bits = msg_bits[:-24]
break
return bits_to_text(msg_bits)
# Usage
hide_message("beach_photo.png", "Secret: meet at 8pm", "photo_with_data.png")
message = extract_message("photo_with_data.png")
print(f"Extracted: {message}")
Using the stegano Library
stegano provides a clean high-level API:
from stegano import lsb
# Hide a message
secret = lsb.hide("original.png", "This is my secret message")
secret.save("image_with_secret.png")
# Reveal the message
message = lsb.reveal("image_with_secret.png")
print(f"Found: {message}")
Pseudo-random Distribution (More Secure)
Instead of sequential bit placement (easier to detect), stegano distributes bits using a pseudorandom generator:
from stegano.lsbset import hide, reveal
from stegano.lsbset import generators
# Use pseudorandom distribution with ACKERMANN generator
secret = hide(
"photo.png",
"Randomly distributed secret",
generators.ACKERMANN
)
secret.save("secure_photo.png")
# Reveal (must use same generator)
message = reveal("secure_photo.png", generators.ACKERMANN)
print(message)
Available generators: DIAGONAL, HILBERT_CURVE, ACKERMANN, FIBONACCI
Hiding Entire Files (Not Just Text)
from PIL import Image
import numpy as np
import struct
def hide_file(input_image, secret_file, output_image):
with open(secret_file, 'rb') as f:
file_data = f.read()
# Header: 4-byte big-endian file size
header = struct.pack('>I', len(file_data))
payload = header + file_data
img = Image.open(input_image).convert('RGB')
array = np.array(img)
bits = []
for byte in payload:
bits.extend([int(b) for b in format(byte, '08b')])
flat = array.flatten().astype(np.uint8)
if len(bits) > len(flat):
raise ValueError("Image too small for this file")
for i, bit in enumerate(bits):
flat[i] = (flat[i] & 0xFE) | bit
Image.fromarray(flat.reshape(array.shape), 'RGB').save(output_image, 'PNG')
print(f"File '{secret_file}' ({len(file_data):,} bytes) hidden in {output_image}")
def extract_file(image_with_data, output_file):
img = Image.open(image_with_data).convert('RGB')
flat = np.array(img).flatten()
bits = [d & 1 for d in flat]
# Read 32-bit header
size = int(''.join(map(str, bits[:32])), 2)
# Read file data
file_bits = bits[32: 32 + size * 8]
file_bytes = bytearray()
for i in range(0, len(file_bits), 8):
file_bytes.append(int(''.join(map(str, file_bits[i:i+8])), 2))
with open(output_file, 'wb') as f:
f.write(file_bytes)
print(f"Extracted: {output_file} ({size:,} bytes)")
# Hide a PDF inside a landscape photo
hide_file("landscape.png", "classified_report.pdf", "landscape_with_doc.png")
extract_file("landscape_with_doc.png", "recovered_report.pdf")
Combining Steganography with AES Encryption
Two-layer security: encrypt the message, then hide the ciphertext:
from cryptography.fernet import Fernet
from stegano import lsb
import base64
def generate_key():
return Fernet.generate_key()
def encrypt_and_hide(image, message, key, output_image):
f = Fernet(key)
encrypted = f.encrypt(message.encode('utf-8'))
b64 = base64.b64encode(encrypted).decode('ascii')
hidden = lsb.hide(image, b64)
hidden.save(output_image)
print("Message encrypted and hidden")
def extract_and_decrypt(image, key):
b64 = lsb.reveal(image)
if not b64:
print("No hidden message found")
return None
encrypted = base64.b64decode(b64.encode('ascii'))
f = Fernet(key)
return f.decrypt(encrypted).decode('utf-8')
# Usage
key = generate_key()
print(f"Key (save this!): {key.decode()}")
encrypt_and_hide("photo.png", "Ultra secret payload", key, "secure_photo.png")
recovered = extract_and_decrypt("secure_photo.png", key)
print(f"Recovered: {recovered}")
Robust Digital Watermarking
For copyright protection, use robust watermarking that survives compression:
from imwatermark import WatermarkEncoder, WatermarkDecoder
import cv2
# Embed watermark
encoder = WatermarkEncoder()
encoder.set_watermark('bytes', b'Copyright2026KaijuConverter')
img = cv2.imread('original.png')
watermarked = encoder.encode(img, 'rivaGan')
cv2.imwrite('watermarked.png', watermarked)
# Decode watermark
decoder = WatermarkDecoder('bytes', 200) # 200 bits
test = cv2.imread('watermarked.png')
payload = decoder.decode(test, 'rivaGan')
print(f"Watermark: {payload}")
Steganalysis: Detecting Hidden Data
Check if an image contains hidden content:
from PIL import Image
import numpy as np
def analyze_lsb(image_path):
"""Statistical analysis of LSBs to detect steganography."""
img = Image.open(image_path).convert('RGB')
data = np.array(img)
for channel, name in enumerate(['R', 'G', 'B']):
lsbs = data[:, :, channel] & 1
mean = lsbs.mean()
variance = lsbs.var()
print(f"Channel {name}: LSB mean = {mean:.4f} (expected ~0.5 if natural), variance = {variance:.4f}")
if abs(mean - 0.5) < 0.02:
print(f" → Possible hidden data (suspiciously uniform distribution)")
else:
print(f" → Normal image distribution")
def print_capacity(image_path):
img = Image.open(image_path)
w, h = img.size
channels = 3 if img.mode == 'RGB' else 4
bits = w * h * channels
print(f"{w}×{h} {img.mode}: LSB capacity = {bits // 8 / 1024:.1f} KB")
analyze_lsb("suspicious_image.png")
print_capacity("suspicious_image.png")
Best Practices
Image format — critical:
- ✅ PNG — lossless, preserves LSBs exactly
- ✅ BMP — uncompressed, ideal for LSB
- ✅ TIFF — lossless with correct settings
- ❌ JPEG — lossy compression destroys LSBs → corrupted data
- ❌ WebP (lossy) — same problem as JPEG
Payload size:
- Keep payload below 10% of total capacity for stealth
- A 4K photo (3840×2160) can hide ~3 MB without perceptible change
Recommended security stack:
- Encrypt with AES-256 (Fernet / cryptography library)
- Distribute bits with pseudorandom generator (stegano ACKERMANN)
- Save as PNG with stripped EXIF metadata
# Strip EXIF metadata before sharing
img = Image.open("photo_with_data.png")
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
clean.save("clean_photo.png")
Additional Resource
For converting images between PNG, BMP, TIFF, WebP, and JPG without any coding, use KaijuConverter — free, fast, and no registration required.
Related conversions
Frequent conversions across the catalogue: