Skip to main content

Overview

Steganography is the practice of hiding information within other data in a way that is not easily detectable. For this assignment, you’ll implement LSB (Least Significant Bit) steganography to hide text messages in PNG images.

What is LSB steganography?

LSB steganography works by modifying the least significant bit of pixel data:

Minimal visual impact

Changing the LSB of a color value by ±1 is imperceptible

High capacity

One bit per pixel = width × height bits total

Simple encoding

Direct bit manipulation, no complex algorithms

Fragile

Any re-compression or editing destroys the message

Example

Original pixel (red channel): 11001011 (203) To encode a 0 bit: 11001010 (202) - changed from 203 to 202 To encode a 1 bit: 11001011 (203) - no change needed The difference is visually undetectable.

Two encoding methods

Your implementation must support two different encoding methods based on color type:

Method 1: Direct LSB (Non-palette images)

For color types 0, 2, 4, 6 (grayscale, RGB, and alpha variants):
  • Modify the LSB of the first channel of each pixel
  • Grayscale: LSB of gray value
  • RGB: LSB of red channel
  • Grayscale+Alpha: LSB of gray value
  • RGB+Alpha: LSB of red channel
Encoding process:
1

Get message bits

Convert message string to bits (8 bits per character + null terminator)
2

Decompress image data

Inflate all IDAT chunks to get raw pixel data
3

Unfilter scanlines

Remove PNG filtering (support all 5 filter types)
4

Modify LSBs

For each bit in the message, modify the LSB of the corresponding pixel’s first channel
5

Re-filter and compress

Apply filtering (can use type 0: None) and compress with zlib
6

Write output PNG

Construct valid PNG with modified IDAT chunks

Method 2: Palette index swapping (Palette images)

For color type 3 (indexed/palette images):
Directly modifying palette indices can cause visible artifacts. Instead, use identical-color pairs.
The clever solution:
  1. Find or create pairs of identical colors in the palette
  2. Map each pair: lower index = 0 bit, higher index = 1 bit
  3. Swap pixel indices between pairs to encode bits
  4. Result is visually identical (same RGB values)
Example palette:
Index 0: RGB(100, 150, 200)
Index 1: RGB(50, 75, 100)
Index 2: RGB(100, 150, 200)  ← Identical to index 0!
Pairs: (both are RGB(100, 150, 200)) To encode a 0: Use index 0 To encode a 1: Use index 2 Image looks identical! If no pairs exist: Duplicate existing palette entries (up to 256 max):
// Original palette has 100 colors
// Duplicate each color:
for (int i = 0; i < original_count && total < 256; i++) {
    palette[original_count + i] = palette[i]; // Copy RGB
    pair_map[i] = original_count + i;          // Record pair
}

API functions

png_encode_lsb()

Encode a secret message into a PNG:
int png_encode_lsb(const char *input_path, const char *output_path,
                   const char *secret);
input_path
const char*
required
Path to source PNG file
output_path
const char*
required
Path for output PNG with hidden message
secret
const char*
required
Message to hide (null-terminated string)
Returns: 0 on success, -1 on error

png_extract_lsb()

Extract a hidden message from a PNG:
int png_extract_lsb(const char *input_path, char *buffer, size_t max_len);
input_path
const char*
required
Path to PNG with hidden message
buffer
char*
required
Buffer to store extracted message
max_len
size_t
required
Maximum buffer size (including null terminator)
Returns: Length of extracted string (excluding null), or -1 on error

Implementation guide

Capacity calculation

Before encoding, verify the message fits:
// Get image dimensions
png_ihdr_t ihdr;
// ... extract IHDR ...

// Calculate capacity in bits
size_t capacity_bits = ihdr.width * ihdr.height;

// Calculate required bits (including null terminator)
size_t message_len = strlen(message);
size_t required_bits = (message_len + 1) * 8; // +1 for '\0'

if (required_bits > capacity_bits) {
    // Message too long!
    return -1;
}

Converting message to bits

void get_bit(const char *message, size_t bit_index, int *out) {
    size_t byte_index = bit_index / 8;
    size_t bit_offset = bit_index % 8;
    
    uint8_t byte = (uint8_t)message[byte_index];
    
    // Extract bit (MSB first)
    *out = (byte >> (7 - bit_offset)) & 1;
}

// Example usage:
const char *msg = "Hi";
// 'H' = 0x48 = 01001000
// 'i' = 0x69 = 01101001
// '\0' = 0x00 = 00000000

int bit;
get_bit(msg, 0, &bit); // bit = 0 (first bit of 'H')
get_bit(msg, 1, &bit); // bit = 1 (second bit of 'H')
get_bit(msg, 7, &bit); // bit = 0 (eighth bit of 'H')
get_bit(msg, 8, &bit); // bit = 0 (first bit of 'i')

Encoding bits (non-palette)

// For each bit in the message:
for (size_t i = 0; i < total_bits; i++) {
    // Calculate pixel position
    size_t y = i / ihdr.width;
    size_t x = i % ihdr.width;
    
    // Calculate scanline offset (skip filter byte!)
    size_t scanline_offset = y * (1 + ihdr.width * bytes_per_pixel);
    size_t pixel_offset = scanline_offset + 1 + x * bytes_per_pixel;
    
    // Get bit to encode
    int bit;
    get_bit(message, i, &bit);
    
    // Modify LSB of first channel
    if (bit) {
        pixel_data[pixel_offset] |= 0x01;  // Set LSB to 1
    } else {
        pixel_data[pixel_offset] &= 0xFE;  // Set LSB to 0
    }
}
Critical: Never modify the filter byte at the start of each scanline!Scanline structure: [filter_byte][pixel_0][pixel_1]...[pixel_width-1]

Encoding bits (palette)

// Build pair mapping first
int pair_map[256]; // pair_map[i] = index of pair for index i
// ... build pair_map ...

// For each bit in the message:
for (size_t i = 0; i < total_bits; i++) {
    size_t y = i / ihdr.width;
    size_t x = i % ihdr.width;
    
    size_t scanline_offset = y * (1 + ihdr.width);
    size_t pixel_offset = scanline_offset + 1 + x;
    
    uint8_t current_index = pixel_data[pixel_offset];
    
    // Skip if this index has no pair
    if (pair_map[current_index] == -1) {
        continue; // or return error
    }
    
    int bit;
    get_bit(message, i, &bit);
    
    // Get the pair indices
    uint8_t idx0 = current_index;
    uint8_t idx1 = pair_map[current_index];
    
    // Ensure idx0 < idx1
    if (idx0 > idx1) {
        uint8_t temp = idx0;
        idx0 = idx1;
        idx1 = temp;
    }
    
    // 0 bit = lower index, 1 bit = higher index
    pixel_data[pixel_offset] = bit ? idx1 : idx0;
}

Decoding bits

Extraction is the reverse process:
char buffer[1024];
size_t bits_read = 0;
size_t bytes_written = 0;
uint8_t current_byte = 0;

// Read until null terminator
for (size_t i = 0; i < capacity_bits; i++) {
    // Get pixel position and extract LSB
    size_t y = i / ihdr.width;
    size_t x = i % ihdr.width;
    size_t offset = y * (1 + ihdr.width * bpp) + 1 + x * bpp;
    
    int bit = pixel_data[offset] & 1;
    
    // Build byte (MSB first)
    current_byte = (current_byte << 1) | bit;
    bits_read++;
    
    if (bits_read == 8) {
        buffer[bytes_written++] = current_byte;
        
        // Check for null terminator
        if (current_byte == '\0') {
            return bytes_written - 1; // Don't count null
        }
        
        current_byte = 0;
        bits_read = 0;
    }
}

return -1; // No null terminator found

Constraints and requirements

Bit depth restriction

Only 8-bit images are supported (bit_depth == 8).
Reject images with other bit depths:
if (ihdr.bit_depth != 8) {
    return -1;
}

Filter byte handling

Structure of uncompressed image data:
Scanline 0: [filter_type_0][pixel_0][pixel_1]...[pixel_width-1]
Scanline 1: [filter_type_1][pixel_0][pixel_1]...[pixel_width-1]
...
Always skip the filter byte:
// WRONG: Treats filter byte as a pixel
for (size_t i = 0; i < width * height; i++) {
    pixel_data[i] ...
}

// CORRECT: Skips filter bytes
for (size_t y = 0; y < height; y++) {
    size_t scanline_start = y * (1 + width * bpp);
    for (size_t x = 0; x < width; x++) {
        size_t offset = scanline_start + 1 + x * bpp;
        pixel_data[offset] ...
    }
}

Compression requirements

When writing the output PNG:
// Use PNG-compatible zlib settings
size_t compressed_size;
uint8_t *compressed = util_deflate_data_png(
    filtered_data, data_size, &compressed_size
);
The util_deflate_data_png() function uses windowBits=15 for PNG compatibility.

Testing steganography

Basic test

# Encode a message
$ bin/png -f tests/data/image.png -e "Hello, World!" -o encoded.png
Message encoded successfully to encoded.png

# Decode the message
$ bin/png -f encoded.png -d
Hidden message: Hello, World!

Verify visual identity

# The images should look identical
$ open tests/data/image.png
$ open encoded.png

# But have different file sizes (slightly)
$ ls -lh tests/data/image.png encoded.png

Maximum capacity test

// For a 320x320 image:
// Capacity = 320 * 320 = 102,400 bits = 12,800 bytes

char huge_message[12799]; // 12,799 + null = 12,800
memset(huge_message, 'A', 12798);
huge_message[12798] = '\0';

int result = png_encode_lsb("image.png", "out.png", huge_message);
assert(result == 0); // Should succeed

char too_big[12800];
memset(too_big, 'A', 12799);
too_big[12799] = '\0';

result = png_encode_lsb("image.png", "out.png", too_big);
assert(result == -1); // Should fail (too long)

Security considerations

This steganography method is not secure against:
  • Statistical analysis
  • Re-compression or format conversion
  • Intentional steganalysis
  • Cropping or resizing
It’s suitable for:
  • Demonstrating the concept
  • Casual hiding of messages
  • Educational purposes
Not suitable for:
  • Actual secret communication
  • Security-critical applications

Next steps

Image overlay

Learn image compositing

Steg API

Review steganography API

Build docs developers (and LLMs) love