Skip to content

Image Steganography — C source

Hide a secret message inside a PNG image or extract a hidden message from one. Uses least-significant-bit encoding with optional AES encryption.

This is the C implementation — the same logic the interactive tool runs, in a shareable, citable form.

/*
 * steganography — hide and recover a message in the least-significant bits of
 *                 RGBA pixel data (LSB steganography).
 *
 * Language: C (C11, standard library only)
 * Source:   CosmoDev polyglot showcase port of the Steganography tool, ported
 *           from src/lib/steganography.ts (the canonical TypeScript
 *           implementation).
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * Scope: this snippet is the LSB encode/decode math — the part that is pure
 * arithmetic over a pixel buffer. The reference's two browser-bound halves are
 * deliberately out of scope: Canvas/ImageData acquisition (the caller here
 * hands in a plain RGBA byte array) and the optional AES-256-GCM body
 * encryption (SubtleCrypto). The wire format's encryption flag is still parsed
 * and reported, so an encrypted image is diagnosed rather than mis-decoded —
 * see STEGO_ERR_ENCRYPTION_UNSUPPORTED for where a crypto backend would plug in.
 *
 * Wire format (the "payload" hidden in the pixels):
 *   4-byte big-endian header, then the body. The header's top bit is an
 *   encryption flag (1 = body is salt+IV+AES-GCM ciphertext, 0 = body is raw
 *   UTF-8); the low 31 bits are the body length in bytes. The flag is what
 *   makes the "password required" / "not password-protected" diagnoses exact.
 *
 * Payload bits are written MSB-first, one per R/G/B channel in raster order
 * (Alpha is never touched): bit i lands in pixel i/3, channel i%3.
 * Capacity = width * height * 3 / 8 payload bytes, header included.
 *
 * C has no exceptions, so every path the reference throws on returns a
 * StegoStatus instead; stego_status_message() maps each one to the reference's
 * exact user-facing string.
 *
 * Build: cc -std=c11 steganography.c
 */

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ---------------------------------------------------------------- constants --- */

#define SALT_BYTES 16
#define IV_BYTES 12
#define GCM_TAG_BYTES 16

/** Salt + IV + GCM tag overhead added to the body when a password is used. */
#define ENCRYPTION_OVERHEAD_BYTES (SALT_BYTES + IV_BYTES + GCM_TAG_BYTES)

/** The 4-byte length header is also stored in the pixels, so it costs capacity. */
#define HEADER_BYTES 4

/** The header's top bit: set when the body is encrypted. */
#define ENCRYPTED_FLAG 0x80000000u
#define LENGTH_MASK 0x7fffffffu

/* ------------------------------------------------------------------- types --- */

/**
 * Minimal RGBA pixel buffer — the C stand-in for the browser's ImageData.
 * `data` holds width*height*4 bytes in R,G,B,A order, raster order.
 */
typedef struct {
    size_t width;
    size_t height;
    uint8_t *data;
    size_t len;
} StegoImage;

/** Every failure the reference throws on, as a return code. */
typedef enum {
    STEGO_OK = 0,
    STEGO_ERR_DIMENSIONS,
    STEGO_ERR_EMPTY_PASSWORD,
    STEGO_ERR_TOO_LONG,
    STEGO_ERR_NOT_FOUND,
    STEGO_ERR_NOT_ENCRYPTED,
    STEGO_ERR_PASSWORD_REQUIRED,
    STEGO_ERR_ENCRYPTION_UNSUPPORTED,
    STEGO_ERR_BUFFER_TOO_SMALL
} StegoStatus;

/** The reference's error strings, verbatim where they are not parameterised. */
const char *stego_status_message(StegoStatus s)
{
    switch (s) {
    case STEGO_OK:
        return "ok";
    case STEGO_ERR_DIMENSIONS:
        return "Width and height must be positive integers";
    case STEGO_ERR_EMPTY_PASSWORD:
        return "Password must not be empty";
    case STEGO_ERR_TOO_LONG:
        return "Message too long for this image";
    case STEGO_ERR_NOT_FOUND:
        return "No hidden message found in this image";
    case STEGO_ERR_NOT_ENCRYPTED:
        return "This message is not password-protected - extract without a password";
    case STEGO_ERR_PASSWORD_REQUIRED:
        return "This image contains an encrypted message - a password is required";
    case STEGO_ERR_ENCRYPTION_UNSUPPORTED:
        return "Encrypted payloads need an AES-256-GCM backend (out of scope here)";
    case STEGO_ERR_BUFFER_TOO_SMALL:
        return "Output buffer too small";
    default:
        return "Unknown error";
    }
}

/* ---------------------------------------------------------------- capacity --- */

/**
 * Max payload bytes (header + body) an image of this size can carry.
 * Fails on a degenerate size, matching the reference's throw.
 */
StegoStatus calculate_capacity(size_t width, size_t height, size_t *out)
{
    if (width == 0 || height == 0) return STEGO_ERR_DIMENSIONS;
    *out = (width * height * 3) / 8;
    return STEGO_OK;
}

/** Largest message body an image of this size can hold, in bytes. */
size_t max_message_bytes(size_t width, size_t height, bool encrypted)
{
    size_t capacity = 0;
    if (calculate_capacity(width, height, &capacity) != STEGO_OK) return 0;
    size_t overhead = HEADER_BYTES + (encrypted ? ENCRYPTION_OVERHEAD_BYTES : 0);
    return capacity > overhead ? capacity - overhead : 0;
}

/* -------------------------------------------------------------- bit plumbing --- */

/**
 * Write `payload` into the LSBs of the R/G/B channels of `data`, in place.
 * Bit i of the payload (MSB-first) lands in pixel i/3, channel i%3; the alpha
 * channel is never touched.
 */
static void embed_bits(uint8_t *data, size_t data_len,
                       const uint8_t *payload, size_t payload_len)
{
    size_t total_bits = payload_len * 8;
    for (size_t i = 0; i < total_bits; i++) {
        uint8_t byte = payload[i >> 3];
        uint8_t bit = (uint8_t)((byte >> (7 - (i & 7))) & 1);
        size_t idx = (i / 3) * 4 + (i % 3);
        if (idx >= data_len) return; /* capacity was checked by the caller */
        data[idx] = (uint8_t)((data[idx] & 0xfe) | bit);
    }
}

/** Read `count` payload bytes back out of the R/G/B LSBs, from `offset_bytes`. */
static void extract_bits(const uint8_t *data, size_t data_len,
                         size_t offset_bytes, uint8_t *out, size_t count)
{
    memset(out, 0, count);
    size_t start_bit = offset_bytes * 8;
    for (size_t i = 0; i < count * 8; i++) {
        size_t bit_index = start_bit + i;
        size_t idx = (bit_index / 3) * 4 + (bit_index % 3);
        if (idx >= data_len) return;
        uint8_t bit = (uint8_t)(data[idx] & 1);
        out[i >> 3] = (uint8_t)(out[i >> 3] | (bit << (7 - (i & 7))));
    }
}

/* ------------------------------------------------------------ UTF-8 checking --- */

/*
 * The reference decodes a plaintext body with TextDecoder({ fatal: true }) and
 * reports "no hidden message" when that throws — i.e. random pixels that happen
 * to parse as a plausible header still fail here. This is that same check:
 * strict UTF-8, rejecting overlong forms, surrogates and out-of-range scalars.
 */
static bool is_valid_utf8(const uint8_t *s, size_t n)
{
    size_t i = 0;
    while (i < n) {
        uint8_t c = s[i];
        size_t extra;
        uint32_t cp;

        if (c < 0x80) { i++; continue; }
        else if ((c & 0xe0) == 0xc0) { extra = 1; cp = c & 0x1fu; }
        else if ((c & 0xf0) == 0xe0) { extra = 2; cp = c & 0x0fu; }
        else if ((c & 0xf8) == 0xf0) { extra = 3; cp = c & 0x07u; }
        else return false;

        if (i + extra >= n + 0 && i + extra > n - 1) return false;
        for (size_t k = 1; k <= extra; k++) {
            uint8_t cc = s[i + k];
            if ((cc & 0xc0) != 0x80) return false;
            cp = (cp << 6) | (cc & 0x3fu);
        }
        /* Reject overlong encodings, UTF-16 surrogates and > U+10FFFF. */
        if (extra == 1 && cp < 0x80) return false;
        if (extra == 2 && cp < 0x800) return false;
        if (extra == 3 && cp < 0x10000) return false;
        if (cp > 0x10ffff) return false;
        if (cp >= 0xd800 && cp <= 0xdfff) return false;
        i += extra + 1;
    }
    return true;
}

/* --------------------------------------------------------------- public API --- */

/**
 * Hide `message` in the LSBs of `image`'s pixels, in place. `image->data` is
 * modified; copy it first if the original must survive (the TS version returns a
 * fresh buffer because JS callers expect immutability).
 *
 * The body is stored as raw UTF-8 — this snippet writes plaintext payloads only,
 * so the header's encryption flag is always 0.
 */
StegoStatus stego_hide_message(StegoImage *image, const char *message)
{
    if (image == NULL || message == NULL) return STEGO_ERR_DIMENSIONS;

    size_t capacity = 0;
    StegoStatus st = calculate_capacity(image->width, image->height, &capacity);
    if (st != STEGO_OK) return st;

    size_t body_len = strlen(message);
    size_t payload_len = HEADER_BYTES + body_len;
    if (payload_len > capacity) return STEGO_ERR_TOO_LONG;

    uint8_t *payload = malloc(payload_len);
    if (payload == NULL) return STEGO_ERR_BUFFER_TOO_SMALL;

    uint32_t header = (uint32_t)body_len & LENGTH_MASK; /* flag clear: plaintext */
    payload[0] = (uint8_t)(header >> 24);
    payload[1] = (uint8_t)(header >> 16);
    payload[2] = (uint8_t)(header >> 8);
    payload[3] = (uint8_t)header;
    memcpy(payload + HEADER_BYTES, message, body_len);

    embed_bits(image->data, image->len, payload, payload_len);
    free(payload);
    return STEGO_OK;
}

/**
 * Read the hidden message out of `image`'s pixels into `out` (NUL-terminated).
 * `have_password` reports whether the caller supplied one, so the encrypted /
 * plaintext mismatch cases return the same diagnosis as the reference.
 */
StegoStatus stego_extract_message(const StegoImage *image, bool have_password,
                                  char *out, size_t cap, size_t *out_len)
{
    if (image == NULL || out == NULL || cap == 0) return STEGO_ERR_BUFFER_TOO_SMALL;

    size_t capacity = 0;
    StegoStatus st = calculate_capacity(image->width, image->height, &capacity);
    if (st != STEGO_OK) return st;

    uint8_t head[HEADER_BYTES];
    extract_bits(image->data, image->len, 0, head, HEADER_BYTES);
    uint32_t header = ((uint32_t)head[0] << 24) | ((uint32_t)head[1] << 16) |
                      ((uint32_t)head[2] << 8) | (uint32_t)head[3];
    bool encrypted = (header & ENCRYPTED_FLAG) != 0;
    size_t length = header & LENGTH_MASK;

    if (length == 0 && !encrypted) {
        out[0] = '\0';
        if (out_len != NULL) *out_len = 0;
        return STEGO_OK;
    }

    size_t floor_len = encrypted ? (size_t)ENCRYPTION_OVERHEAD_BYTES : 1;
    if (HEADER_BYTES + length > capacity || length < floor_len) {
        return STEGO_ERR_NOT_FOUND;
    }

    if (encrypted) {
        /* Body is salt || IV || AES-256-GCM ciphertext+tag. Decrypting it needs
         * PBKDF2-SHA256 (100k iterations) and AES-GCM from a crypto backend —
         * the reference gets both from SubtleCrypto. */
        if (!have_password) return STEGO_ERR_PASSWORD_REQUIRED;
        return STEGO_ERR_ENCRYPTION_UNSUPPORTED;
    }
    if (have_password) return STEGO_ERR_NOT_ENCRYPTED;

    if (length + 1 > cap) return STEGO_ERR_BUFFER_TOO_SMALL;
    extract_bits(image->data, image->len, HEADER_BYTES, (uint8_t *)out, length);
    if (!is_valid_utf8((const uint8_t *)out, length)) return STEGO_ERR_NOT_FOUND;

    out[length] = '\0';
    if (out_len != NULL) *out_len = length;
    return STEGO_OK;
}

/* -------------------------------------------------------------------- demo --- */

static StegoImage make_image(size_t w, size_t h, uint8_t fill)
{
    StegoImage img = { w, h, NULL, w * h * 4 };
    img.data = malloc(img.len);
    if (img.data != NULL) memset(img.data, fill, img.len);
    return img;
}

int main(void)
{
    /* A 64x64 image carries floor(64*64*3/8) = 1536 payload bytes. */
    size_t capacity = 0;
    calculate_capacity(64, 64, &capacity);
    printf("64x64 capacity      : %zu payload bytes\n", capacity);
    printf("  max plaintext body: %zu bytes\n", max_message_bytes(64, 64, false));
    printf("  max encrypted body: %zu bytes\n\n", max_message_bytes(64, 64, true));

    StegoImage img = make_image(64, 64, 0x7f);
    const char *secret = "Meet at the docks — bring the manifest. \xE2\x9C\x93";

    StegoStatus st = stego_hide_message(&img, secret);
    printf("hide  : %s\n", stego_status_message(st));

    char recovered[2048];
    size_t got = 0;
    st = stego_extract_message(&img, false, recovered, sizeof recovered, &got);
    printf("extract: %s\n", stego_status_message(st));
    printf("  %zu bytes: \"%s\"\n", got, recovered);
    printf("  round-trip: %s\n\n", strcmp(recovered, secret) == 0 ? "exact" : "DIFFERS");

    /* Handing a password to a plaintext payload is its own diagnosis. */
    st = stego_extract_message(&img, true, recovered, sizeof recovered, &got);
    printf("extract with password: %s\n", stego_status_message(st));

    /* Untouched pixels carry no payload: the header reads as a huge length. */
    StegoImage clean = make_image(8, 8, 0xff);
    st = stego_extract_message(&clean, false, recovered, sizeof recovered, &got);
    printf("empty image          : %s\n", stego_status_message(st));

    /* A message past capacity is refused before any pixel is touched. */
    StegoImage tiny = make_image(4, 4, 0x00);
    st = stego_hide_message(&tiny, "this will not fit in sixteen pixels at all");
    printf("oversized message    : %s\n", stego_status_message(st));

    free(img.data);
    free(clean.data);
    free(tiny.data);
    return EXIT_SUCCESS;
}

Also available in 9 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →