How to build lsb steganography challenges: hide flags in carrier images
- Step 1Choose a lossless carrier with headroom — Upload a PNG or BMP via the dropzone (the picker accepts
.jpg,.jpeg,.png,.gif,.bmp,.webp). For low detectability, pick a large carrier so a short flag uses a tiny fraction of the LSB budget. Avoid a JPEG source — its LSBs are pre-disturbed by compression, which can confuse solvers' statistical tools. - Step 2Format the flag in 'Message to hide' — Type the flag into the
Message to hidetextarea (placeholderHidden text payload…). Keep it in the Latin-1 range — typical flag formats likeCTF{lsb_h1d3s_1n_pl41n_s1ght}are pure ASCII and round-trip perfectly. Avoid emoji or CJK in the flag; code points above 255 corrupt the bit stream. - Step 3Decide whether to add a crypto stage — For a multi-stage challenge, first encrypt the flag with aes-256-encryptor and paste the base64 ciphertext as the message. Solvers must then extract the LSB payload *and* decrypt it. Remember the ciphertext is longer than the plaintext — size the carrier accordingly.
- Step 4Encode the carrier — The encoder packs
flag + "\0"into the R/G/B LSBs, thenputImageDataandtoBlobproduce a lossless PNG named<name>-stego.png. If the (rare) over-capacity case hits, it throwsMessage too large for this carrier image. Use a larger image.before writing anything. - Step 5Playtest by extracting the flag yourself — Before shipping, run the stego PNG through steganography-decoder to confirm it returns the exact flag (it stops at the NUL, capped at 100,000 chars). Optionally check the entropy-analyzer to see how detectable your embed ratio is.
- Step 6Distribute the PNG losslessly — Host the
-stego.pngsomewhere that serves it byte-identically — a static file server or a download link, not a platform that re-compresses or converts images. Verify the downloaded copy still decodes to the flag, because a re-encoding CDN will quietly break the challenge.
Embed ratio and difficulty for a ~30-character flag
Detectability scales with the fraction of the LSB budget you use. Capacity is pixels × 3 bits; a ~30-char flag needs ~248 bits (31 bytes × 8). The smaller the used fraction, the harder casual and statistical detection becomes.
| Carrier size | LSB budget (bits) | Bits used by flag | Fraction used | Approx. difficulty |
|---|---|---|---|---|
| 256 × 256 | 196,608 | ~248 | ~0.13% | Easy — visible to basic steganalysis |
| 512 × 512 | 786,432 | ~248 | ~0.03% | Easy–medium |
| 1024 × 1024 | 3,145,728 | ~248 | ~0.008% | Medium |
| 1920 × 1080 | 6,220,800 | ~248 | ~0.004% | Medium–hard |
| 3840 × 2160 (4K) | 24,883,200 | ~248 | ~0.001% | Hard — minimal statistical footprint |
What you can and can't tune as an author
The encoder exposes one option (message). Difficulty comes from choices around it, not from configuring the embedding — there is no channel, bit-plane, seed, or strength control.
| Difficulty lever | Available? | How |
|---|---|---|
| Embed ratio (payload vs. carrier) | Yes | Choose carrier size relative to flag length |
| Crypto layer | Yes | Encrypt the flag first with aes-256-encryptor |
| Encoding obfuscation | Yes | Base64 or otherwise transform the flag before embedding |
| Channel selection (e.g. only blue) | No | Fixed to R, G, B together |
| Bit-plane / depth | No | Fixed to the least-significant bit |
| Embedding seed / scatter pattern | No | Sequential from the first pixel |
Hard constraints that break challenges if ignored
These are non-negotiable behaviours of the encode/decode pair. Designing around them keeps your challenge solvable.
| Constraint | Value | Consequence if ignored |
|---|---|---|
| Output format | Lossless PNG only | A lossy re-encode erases the flag |
| Character range | Latin-1 / 0–255 (charCodeAt(0)) | Emoji/CJK in the flag corrupt the payload |
| End marker | NUL byte appended | A literal NUL in the flag truncates it |
| Decoder read cap | 100,000 characters | Flags buried past 100k won't be read |
| Embedding order | Sequential from pixel 0 | Solvers expect a standard sequential LSB read |
Cookbook
Challenge-authoring recipes that produce solvable, appropriately-difficult LSB stego puzzles. Every example reflects the real encoder: sequential RGB-LSB embedding, Latin-1 packing, NUL terminator, lossless PNG output, and the decoder's 100,000-character read cap.
Beginner challenge: flag in a small carrier
An easy intro challenge. A short flag in a 512×512 image is recoverable with any LSB tool and even hinted by elevated noise — perfect for a 'intro to steganography' track.
Carrier: cat.png (512x512 -> 98,303 char capacity)
Message: CTF{w3lc0m3_t0_lsb}
Encode -> cat-stego.png
Solver runs steganography-decoder:
-> CTF{w3lc0m3_t0_lsb}Stealthy challenge: tiny flag in a 4K wallpaper
Raise difficulty without any extra crypto by minimising the embed ratio. A ~30-char flag in a 4K carrier uses ~0.001% of the LSB budget — invisible to the eye and a minimal statistical footprint.
Carrier: wallpaper-4k.png (3840x2160 -> ~3,110,399 char cap)
Message: CTF{n33dl3_in_4k_h4yst4ck}
Encode -> wallpaper-4k-stego.png
~99.999% of the LSB budget is untouched; solvers must know
to run an LSB extractor rather than just 'strings'.Multi-stage: stego then crypto
Chain two domains. Encrypt the flag first, embed the ciphertext; solvers must extract the LSB payload and then decrypt it with a passphrase you hint at elsewhere in the challenge.
Step 1 — aes-256-encryptor (encrypt):
flag: CTF{lsb_then_aes_gcm}
passphrase: hinted in the challenge prompt
-> base64: U2FsdGVkX1+...==
Step 2 — steganography-encoder:
Message: U2FsdGVkX1+...==
Carrier: scene.png -> scene-stego.png
Solver: extract LSB ciphertext -> decrypt -> flag.Playtest before shipping
Always verify the round-trip and sanity-check detectability before release. Extract with the decoder, and check entropy to judge how obvious the embed is.
1) steganography-decoder on scene-stego.png -> exact flag/ciphertext returned at the NUL terminator? OK 2) entropy-analyzer on scene-stego.png -> compare against the clean carrier's entropy profile -> if it spikes, lower the embed ratio (bigger carrier)
The classic broken challenge to avoid
The single most common authoring mistake: the carrier is re-compressed somewhere between encode and solver. The flag is gone and the challenge is unsolvable for everyone.
Encode -> scene-stego.png (flag intact) Upload to a platform that auto-converts images to JPEG/WebP Solver downloads scene-stego.jpg: steganography-decoder -> (no LSB-encoded message detected) Fix: serve the PNG byte-identically and re-test the hosted copy.
Edge cases and what actually happens
Hosting platform re-compresses the carrier
Challenge brokenIf the stego PNG is served through anything that re-encodes images (many CDNs, social/chat platforms, image-optimising hosts), the LSBs are rewritten and the flag is destroyed before solvers ever see it. Always host the PNG byte-identically and re-download-and-decode the hosted copy to confirm the flag survived.
Flag contains emoji or non-Latin characters
Corruption riskThe encoder packs each character with charCodeAt(0) as one byte. A flag containing emoji, CJK, or other code points above 255 overflows and desynchronises the bit stream, so solvers extract garbage. Keep flags in the ASCII/Latin-1 range — standard CTF{...} formats already are.
JPEG used as the carrier source
RiskyYou can upload a JPEG (Canvas decodes it), and the output PNG is still lossless — but the JPEG's compression already perturbed the LSBs, which can muddy a solver's statistical analysis or make the challenge behave unexpectedly. Start from a clean PNG/BMP carrier for predictable difficulty.
Flag exceeds carrier capacity
RejectedFlags are short, so this is rare, but if a long encrypted/base64 payload exceeds (chars + 1) × 8 ≤ pixels × 3, the encoder throws Message too large for this carrier image. Use a larger image. and writes no file. Pick a larger carrier.
Payload buried beyond 100,000 characters
Decoder-limitedThe steganography-decoder stops reading at 100,000 characters. If you pad the carrier with junk and place the flag after the 100,000-character mark expecting solvers to read further, a standard decode will never reach it. Keep the flag within the first 100,000 characters of the payload.
Expecting solvers to read only the blue channel
Won't matchThe encoder always writes R, G, and B together, sequentially from the first pixel — there is no single-channel or scatter mode. A challenge premise that assumes a blue-only or seeded embedding won't match what this tool produces; design hints around the actual sequential RGB-LSB scheme.
Empty flag field
RejectedIf the Message to hide field is blank, the processor throws Enter a message to encode. and produces no file. There's nothing to hide — enter the flag.
Animated GIF or multi-frame WebP carrier
First frame onlyCanvas drawImage flattens an animated carrier to its first frame, and the output is a single static PNG. If your challenge relied on animation or per-frame data, that's lost. Use a still image carrier.
Flag contains a literal NUL byte
Truncated on decodeA NUL (code 0) inside the flag is indistinguishable from the appended terminator, so the decoder stops there and solvers get a truncated flag. Flag formats never contain NULs in practice, but if you transform the flag, strip any NULs before encoding.
High embed ratio gives the challenge away
Too easyFilling a large fraction of a small carrier's LSB budget leaves an obvious statistical signature that basic steganalysis (or even visual noise inspection) flags immediately. If you want the challenge to require knowing it's LSB stego, keep the embed ratio low by using a much larger carrier — playtest with entropy-analyzer.
Frequently asked questions
Will any standard LSB solver recover my flag?
Yes. The encoder uses the conventional scheme — sequential embedding into the R, G, B least-significant bits with a NUL terminator — so any standard LSB extractor, including steganography-decoder, recovers the flag without a custom tool. That's what makes the challenge fair: solvers apply known techniques.
How do I control the difficulty?
Through the choices around the embed, not the embed itself. Lower the embed ratio (short flag, large carrier) to reduce detectability; add a crypto stage by encrypting the flag with aes-256-encryptor first; or transform the flag (base64, etc.) so solvers must decode after extracting. You cannot select channels, bit-planes, or a scatter seed — those are fixed.
Why did my flag disappear after I uploaded the challenge?
Almost certainly the host re-compressed or converted the image. LSB flags only survive in a byte-identical lossless PNG; a CDN or platform that re-encodes to JPEG/WebP rewrites every least-significant bit and erases the flag. Serve the PNG losslessly and re-download-and-decode the hosted file to confirm before releasing.
What flag formats are safe?
Anything in the ASCII/Latin-1 range (0–255), which covers every standard format like CTF{...}, flag{...}, and picoCTF{...}. The encoder packs each character with charCodeAt(0) as a single byte, so emoji or CJK in the flag overflow and corrupt the payload. Stick to ASCII.
Can I make a multi-stage stego-then-crypto challenge?
Yes — a popular pattern. Encrypt the flag with aes-256-encryptor (AES-GCM-256, PBKDF2 100k), embed the base64 ciphertext, and hint the passphrase elsewhere. Solvers extract the LSB payload, then decrypt it. Size the carrier for the longer ciphertext, and confirm the full chain round-trips during playtesting.
How do I keep the embedding from being too obvious?
Minimise the fraction of the LSB budget you use: put a short flag in a large carrier. A ~30-character flag in a 4K image uses about 0.001% of the available bits, leaving almost no statistical footprint. Playtest with entropy-analyzer — if the stego file's entropy spikes versus the clean carrier, go bigger.
Is the embedding deterministic so I can regenerate the file?
Yes. For a given carrier and message the encode is deterministic — same pixels, same bits, same NUL terminator — so re-running produces the same stego PNG. That makes it easy to regenerate the challenge artefact in a build pipeline. (If you add an encryption stage, note AES-GCM uses a random salt/IV, so the ciphertext changes each run.)
Does the flag get uploaded anywhere while I build the challenge?
No. The encode runs entirely in your browser via Canvas — the carrier and flag are read with getImageData, packed, and written with toBlob, all in the page. An unreleased flag never transits a server. The tool is browser-only with no server API path.
What's the maximum payload I can bury?
Capacity is floor(pixels × 3 ÷ 8) − 1 characters, so a 1024×1024 carrier holds ~393,215. But the decoder only reads the first 100,000 characters, so a fair challenge keeps the flag (and any wrapper) within that range. Don't hide a flag past the 100,000-character mark expecting a standard decode to reach it.
Can I hint that the data is in a specific channel?
Not accurately, because the encoder writes all three RGB channels together, sequentially from the first pixel — there's no single-channel or seeded mode. Design your hints around the real scheme ("check the least-significant bits") rather than implying a blue-only or scattered embedding that this tool doesn't produce.
Should I encrypt the flag or just hide it?
Depends on the tier. For an intro challenge, plain LSB hiding is fine and teaches the technique. For a harder, multi-domain challenge, encrypt first with aes-256-encryptor so solvers must extract and then break crypto. Either way, never rely on hiding alone if the flag must stay secret — LSB stores it as plaintext bits.
How do I sanity-check a challenge before release?
Run the stego PNG through steganography-decoder to confirm it returns the exact flag at the NUL terminator, then re-test the *hosted* copy after upload to catch any re-compression. Optionally use entropy-analyzer to gauge how detectable the embedding is and adjust the carrier size.
Privacy first
Every JAD Security operation runs entirely in your browser. Files, passwords, and PGP private keys never leave your device — verified by zero outbound network requests during processing.