Skip to content

# Steganography Laboratory

Look at a carrier's individual bit planes, get a statistical verdict on whether data is hidden in them, and embed or extract a payload yourself. Carriers: PNG, BMP, WAV/PCM audio and JPEG, and which one a file is comes from its first bytes rather than its name.

Rendered when the site was built, by running the same core the browser runs. The carrier is synthetic: a banded scene with modelled sensor noise, 320×240 pixels. It is shown four ways: clean, and carrying 28791 bytes in bit plane 0 of all three channels.

## The verdict, on the same image four ways

χ² pair test, the same carrier clean and carrying
Image and channel | χ² | Degrees of freedom | Probability of embedding | Verdict
--- | --- | --- | --- | ---
Clean, Red | 324.329 | 55 | 0‰ | NO EVIDENCE
Clean, Green | 315.826 | 56 | 0‰ | NO EVIDENCE
Clean, Blue | 314.457 | 56 | 0‰ | NO EVIDENCE
Carrying random bytes, Red | 27.561 | 55 | 999‰ | DETECTED
Carrying random bytes, Green | 25.684 | 56 | 1000‰ | DETECTED
Carrying random bytes, Blue | 31.250 | 56 | 997‰ | DETECTED
Carrying plain text, Red | 526.067 | 55 | 0‰ | NO EVIDENCE
Carrying plain text, Green | 471.104 | 56 | 0‰ | NO EVIDENCE
Carrying plain text, Blue | 465.181 | 56 | 0‰ | NO EVIDENCE
Carrying encrypted text, Red | 30.999 | 55 | 996‰ | DETECTED
Carrying encrypted text, Green | 27.098 | 56 | 1000‰ | DETECTED
Carrying encrypted text, Blue | 21.366 | 56 | 1000‰ | DETECTED

None of those four images looks different from the others. The statistic collapses anyway: the clean carrier reads [ NO EVIDENCE ] at 0‰ and the one carrying random bytes reads [ DETECTED ] at 1000‰.

### And then the surprising part

Row three is the same carrier carrying English text, and it reads [ NO EVIDENCE ] at 0‰, a miss. The χ² test works because uniform random bits send each sample to either member of its value pair equally often, which is what equalises the pair counts. ASCII is about 446‰ ones, not 500‰, so plain text leaves the pairs lopsided in the same direction a photograph's already are.

Row four is that identical text encrypted with a passphrase first, and it reads [ DETECTED ] at 1000‰. Ciphertext bits are uniform. Encrypting what you hide makes the hiding easier to detect, not harder. concealment and confidentiality pull in opposite directions, and that is the thing worth walking away knowing. (The published answer to the blind spot is RS / sample-pair analysis, which estimates the embedding rate from pixel-pair transitions rather than from the histogram. It is not built here.)

### What the verdict cannot do

Two detectors run here, and neither is the whole truth. The χ² pair test fires only near a full fill: a payload under about a quarter of the carrier's capacity leaves the whole-frame pairs alone and reads NO EVIDENCE, so a clean χ² verdict is not an empty image. It is also blind to a biased payload, which is why RS / sample-pair analysis runs beside it: RS reads sample-group correlation, not the histogram, and estimates the embedding rate on plain text the pair test misses. Neither is proof: equal value pairs are not proof of a payload (a colour ramp, a test card, a posterised or resampled image and anything drawn rather than photographed all flatten the pairs by themselves), and RS shows a small non-zero rate on a busy or noisy clean channel. Check the tile grid, read the progressive sweep, and try `extract`.

### And the one thing it can do with certainty

Anything this tool hides opens with the four-byte marker `PMS1` in the low bits, followed by a flag byte and a big-endian length, in the clear, even when the payload itself is sealed. Anyone who knows this tool finds its output with one string match, which is what the line above does. Steganography is concealment, not security.

Run against the four images above, that check reads: clean, No `PMS1` frame under any plan this tool writes (1–4 planes × every channel subset). Another tool's payload, or a payload written with a plan outside that set, would not show here.; carrying random bytes, A `PMS1` payload frame is present: 1 plane × rgb, 28791 byte body, in the clear. This is certainty, not a probability: the marker is a literal four-byte string, and `extract` will read the payload out.. The image carrying plain text is the one the χ² test misses entirely, and the marker finds it anyway: A `PMS1` payload frame is present: 1 plane × rgb, 28759 byte body, in the clear. This is certainty, not a probability: the marker is a literal four-byte string, and `extract` will read the payload out..

## The bit planes

Each block below is one bit of the red channel, drawn as text: `@` where most of that patch has the bit set, a space where most of it does not. Plane 7 is the picture. Plane 0 is sensor noise, and the noise looks the same whether or not it is carrying, which is exactly why the number above is the finding and the picture is not.

### Plane 7 (the most significant bit), clean

    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

### Plane 0 (the least significant bit), clean

    +++++#+#+++#++++++#++#++++++#+##+##+++++++++++++++++++++++#+++++
    ++#++#+##+.++++#+++##+++++++#++++++.#+++.++++.++++++++#++++++#++
    +++#+#+++#+++++++++++#+++++++..++++#++++++#+++.++#++#++#++++++++
    ++++++++.++#+++#+#+#+.+++.++++++++.+++++++++++++++++++++++++++++
    +++++++#+++++++++++++++++.#+.++++++++++++++++#+++.+#.++++#+#++++
    +++.++++++++++++++#++#++++.+++++++++++.##++++++++++.++#+#++++.++
    .#++++++++++++++++#.+++++#+++.#+++++.+#+++++++++++.+++#+++#+++++
    ++##++++++++#+#+++++++++.++#+++.+++++++++++#+++++#+#++++++.#++++
    #++++++##+.++++++++++#++++++#+++++++++++++++++++++#+++++++++++++
    +++++.+++++++++++++++++++#+++++.#+++.+++++++++.+++++++++++++#+++
    ++#++#++++++#+#+++++..+#+++++++++.++.++++++++++#++++++#++++++#++
    #+++++#+.+++#++.++#+++++.#+++++++++++++#++#++#.+++++++++#+++++++
    ++++++++++#.+++++++++++++++++++++.#++++++++++++++++++.+++++.++++
    +.+.+++++++++++.+++++#+++#+++++.++++++.++++++#+++++++++++#++++++
    ++#+++++++.++++++++++.+++#++#+.+++++++++++.++++++#++++++++++++++
    +++++#+++++#++++++++++++++++++#.++++++#++++++++##+++++++++++++++
    +#++++++++++++++++++.++#++++#++++#+++++#++++#+++++++++++++++++++
    +#+++++++++++.++++++++++++++++++#.++++++.++++#++.+++#+#++#+#+#++
    +++.++++++++++++#++++.++++.++#+++#++++++++.++++++++#+++.++###+++
    +++#+++++++.+++++++++++++.+#++++++#+#++#++#++++++++++++++++#++#+
    +++++++.+++++++#++##++++#++++#++++++++++++++#++.++++++#++#+++++#
    +++++++.+#++#+.++++#++#+++++++++++++++#+++#.+++++++..#++++.++++#
    +.+++#+++++.+++++++++++#++++++.++#+++++++++++++++++.+++.#+++.+++
    #++++++#+++#++.++++++++.++++#.+++++++++++++.++++#+++#+++++.+++++

### Plane 0, carrying a payload

    +++++++++++++++++++++++++++++++++.++++++++++++++++++++++++++#+++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    +++.++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    +++++++++++++++++#++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++#+++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++#+++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    +++++++++.++++++++++++++++++++++++++#+++++++++++++++++++++++++++
    ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

## How the verdict is graded

The verdict is always stated in words, never carried by colour alone. These four rows are the grader's own output.

Verdict bands
Verdict | Example probability | What it means
--- | --- | ---
NO EVIDENCE | 0‰ | The value pairs are as unequal as a photograph's. Nothing here looks like LSB embedding.
WEAK | 500‰ | The value pairs are slightly flatter than a natural histogram. That happens in smooth or heavily processed images too, so on its own it is not a finding.
LIKELY | 800‰ | The value pairs are notably flatter than a natural histogram, which is the direction LSB embedding pushes them. A smooth gradient, a posterised or resampled image and a synthetic render all push them the same way, so this is a reason to look closer rather than a finding.
DETECTED | 1000‰ | The value pairs are equal to within sampling noise. That is what writing random bits into the low bit does, and it is also what a colour ramp, a test card, a posterised or resampled image, or anything drawn rather than photographed does on its own. Read it as a finding only once you know the image is a photograph.

## How much fits

One bit per plane per channel per pixel. This 320×240 carrier holds:

Carrying capacity by plane count
Plan | Bits addressed | Payload bytes (header deducted)
--- | --- | ---
1 plane × 3 channels | 230400 | 28791
2 planes × 3 channels | 460800 | 57591
3 planes × 3 channels | 691200 | 86391
4 planes × 3 channels | 921600 | 115191

## What the hiding costs the carrier

Two numbers, because they measure different things and disagree about which damage matters. PSNR totals the error energy over the whole file: it cannot tell damage spread across every window from the same amount piled into one corner, and above about 40 dB a difference is invisible on a screen, which is the whole reason low-bit hiding works. SSIM compares brightness, contrast and structure inside each 8×8 window and takes the mean, so it does see where the damage landed. Every figure in this table was measured when the page was built.

PSNR and SSIM, measured on real embeddings
Carrier and payload | Samples changed | PSNR | SSIM (1.000000 is unchanged)
--- | --- | --- | ---
Picture, carrying random bytes | 115404 | 51.13 dB | 0.993204
Picture, carrying plain text | 114645 | 51.16 dB | 0.993368
Picture, carrying encrypted text | 115188 | 51.14 dB | 0.993251
Picture, random bytes over all 4 planes | 215998 | 31.89 dB | 0.666538
Recording, carrying a payload in bit plane 0 | 16513 | 51.11 dB | 0.996643

SSIM compares the two pictures window by window, taking brightness, contrast and structure together, where PSNR only totals the error. 1.000000 is unchanged.

The last row is sound, and that changes what the number means. For sound this is a structural similarity of the carrying bytes, not a measure of how the recording sounds: it says how far the low byte of each sample moved, window by window. Audio quality is not what it reports.

## The same carrier, in more than one file format

A carrier format is a codec, not a second detector. The four rows below were produced when this page was built by writing the two images above out as a PNG and as a BMP, reading them back through the real decoders, and running the χ² test on whatever came back. The file sizes are very different. The verdicts are identical, because after the decoder there is one set of samples and one set of detectors.

The verdict does not depend on the file format
Image and format | File size | Probability of embedding | Verdict
--- | --- | --- | ---
Clean, PNG | 208581 bytes | 0‰ | NO EVIDENCE
Clean, BMP | 230454 bytes | 0‰ | NO EVIDENCE
Carrying random bytes, PNG | 209653 bytes | 1000‰ | DETECTED
Carrying random bytes, BMP | 230454 bytes | 1000‰ | DETECTED

## Sound, through the same detectors

A WAV is not a picture, so it is not pretended to be one. The payload goes into the low byte of every sample (the whole sample at 8 bits, byte 0 of the pair at 16), and it is exactly those bytes the χ² test, the RS estimator and the bit-plane view read. Laid out with time along a row, a recording is a grid of 8-bit samples, which is the only thing any of those detectors ever wanted.

The three rows below were read at build time out of recordings this repo did not write: a modelled 8-bit stereo recording, the same recording with a payload written into bit plane 0 by the script that made it, and a clean 16-bit one.

The same detectors, over sound
Recording | File size | Payload marker | Probability of embedding | Verdict
--- | --- | --- | --- | ---
A clean 8-bit recording | 32812 bytes | none | 0‰ | NO EVIDENCE
The same recording, carrying a payload | 32812 bytes | found | 1000‰ | DETECTED
A clean 16-bit recording | 53292 bytes | none | 1000‰ | DETECTED

Read the third row. That recording is clean, and the pair test says DETECTED anyway. This is the honest limit of the carrier rather than a bug in the detector: at 16 bits the carrying byte is the low half of a sample, and a signal at any ordinary level moves by far more than one byte between samples, so that byte is already close to random before anybody hides anything in it. On 16-bit audio the payload marker is the fact and the probability is not, which is why the marker has a column of its own above. 8-bit audio has no such problem: there the sample is the carrying byte, and the first two rows behave exactly as the pictures do.

### The fourth carrier is lossy, and that changes what a carrier is

Every carrier above stores samples, and the low bit of a stored sample is a bit somebody can choose. A JPEG stores none. It stores quantised frequency coefficients, 64 per 8×8 block, and the pixels you see are worked out from them when the file opens. So the low bit of a JPEG pixel is not a place, it is a result: it moves whenever anything upstream of it moves, and writing to it and saving would run the transform again and throw the change away.

What that leaves is a codec whose useful surface is the coefficients themselves. The figures below were measured when this page was built, by decoding the fixture and writing it back out again.

A baseline JPEG, decoded and re-encoded at build time
What | Measured
--- | ---
Picture | 128×96, 4:2:0
Blocks of 8×8 | 288 across 3 components
Quantised coefficients | 18432, of which 578 are non-zero above the flat term
File in, file out | 1308 bytes in, 1308 bytes out, identical byte for byte

#### Hiding in one: JSteg, in the coefficients

The payload goes into the low bit of the magnitude of every coefficient above the flat term whose value is 2 or more away from zero, in the order the file stores them, keeping the sign. Coefficients that are 0, 1 or −1 are skipped: writing into a zero would create a coefficient rather than hide a bit, and a 1 cannot lose its low bit without disappearing. Skipping all three is what makes the rule reversible, because a coefficient that could carry a bit still can afterwards.

So capacity is a property of the picture rather than of the file size, and it is counted for the file in front of you before anything is written. Everything below was measured when this page was built, by hiding a payload in the fixture and reading it back out.

JSteg over a real JPEG, measured at build time
What | Measured
--- | ---
Method | JSteg, which is what a JPEG carrier uses here
Capacity, counted | 16128 AC coefficients over the picture, 1193 zero and 2355 plus or minus one skipped, 12580 can carry a bit, which is 1563 payload bytes
One payload, in and out | 58 bytes in, 536 coefficients written, 260 of them changed, 13681 bytes out, and 58 bytes read back
What it did to the picture | PSNR 63.61 dB, SSIM 0.999570
Clean file, pair test | χ² 76.468 over 36 pairs, 35 degrees of freedom: 0‰, NO EVIDENCE
Same file, filled, pair test | χ² 12.715 over 36 pairs: 1000‰, DETECTED

The detector that applies to a JPEG reads its coefficients, and the two rows above are the whole claim: the clean file's value pairs are as unequal as a photograph's, and the same file with a payload in it has pairs that are equal to within sampling noise. One detector runs on a JPEG, and it is not the whole truth. The coefficient pair test compares the counts of coefficient values (2,3), (4,5), (6,7) and so on: a photograph's fall away from each other and JSteg flattens them. It fires only near a full fill, so a short payload in a big picture reads NO EVIDENCE. It is blind to a payload whose bits are lopsided, because plain text writes more zeros than ones and leaves the pairs lopsided too; that is the same blind spot the pixel test has, and sealing the payload with a passphrase makes its bits uniform and the carrier easy to see. It also has the opposite failure: a picture saved as a JPEG several times over, or one drawn rather than photographed, flattens its own pairs with nothing hidden in it. The pixel statistics stay withheld here, because a JPEG's pixels are the output of a transform rather than stored samples. The marker check is the one certainty: try `extract`.

The pixel statistics stay withheld, on purpose. No sample-domain probability is shown for a JPEG carrier, and that is the honest answer rather than a missing feature. The χ² pair test, RS analysis and the tile profile all ask whether the low bits of the file's stored samples look chosen. A JPEG has no stored samples: its pixels are worked out from frequency coefficients when it is opened, so their low bits are the rounding of that arithmetic, with the 8×8 block grid running through it. A clean JPEG reads a probability in the hundreds of per mille on the pair test carrying nothing at all, and the tile profile reads the codec's own block grid as structure. A number there would say "something is in this file" about a file with nothing in it. The statistic that does apply to a JPEG reads its coefficients, and it is the one reported here: the coefficient pair test, which is the published attack on JSteg. It is not an attack on F5, the other method a JPEG offers, and that is said beside the number rather than left to be assumed.

#### The other way into one: F5, matrix encoding with shrinkage

F5 writes into the same coefficients and does three things differently. It visits them in a shuffled order your passphrase keys, so a short payload spreads over the whole picture instead of sitting in one corner. It packs several bits into a group of coefficients and changes at most one of them, so a small payload moves very few values. And it never raises a value: it steps the size down towards zero by one. That last rule is the whole point, and it has a cost. A value of 1 that has to change becomes 0, which means it can no longer carry anything, so those bits are written again further along. That is shrinkage, and handling it on both sides is what the row below is about: the reader simply skips zeros, and the writer leaves the picture in exactly the state that makes skipping zeros correct.

Everything below was measured when this page was built, by hiding a payload with the real embedder and reading it back out.

F5 over a real JPEG, measured at build time
What | Measured
--- | ---
Method | F5, the other method a JPEG carrier offers, chosen with the control on this page or with `--method f5`
Capacity, counted | 16128 AC coefficients over the picture, 1193 zero and therefore not carriers, 14935 that can carry a bit of which 2355 are plus or minus one, which is 1562 payload bytes guaranteed and 1865 frame bytes if nothing shrinks
One payload, in and out | 69 bytes in at k = 7 (127 coefficients to a code word), 11456 coefficients used over 12363 positions of the shuffled walk, 111 of them changed, 18 shrank, 13678 bytes out, and 69 bytes read back
Shrinkage, forced | the same embedder over a coarsely quantised carrier whose coefficients are 746 per mille plus or minus one: 922 of them were driven to zero and re-embedded around, and the 70 byte payload still came back in full
What it did to the picture | PSNR 63.88 dB, SSIM 0.999866
Clean file, pair test | χ² 76.468 over 36 pairs: 0‰, NO EVIDENCE
Same file, filled with F5, pair test | χ² 168.817 over 36 pairs: 0‰, NO EVIDENCE
F5 verdict | NOT MEASURED. No probability is reported for F5, and that is the honest answer rather than a missing feature: see below.
The trace it does leave | zero coefficients 1193 before and 2367 after (73‰ to 146‰), non-zero 14935 before and 13761 after, which is one of each per coefficient driven to zero

Read the last three rows together, because they are the honest result of this stage. The pair test is the published attack on JSteg, and on an F5 carrier filled to capacity it reads exactly what the clean file reads. That is not a weak detector: it is a detector that cannot apply. This probability is the attack on JSteg and it does not apply to F5: stepping a value down towards zero scales a falling histogram without changing its shape, so a file full of F5 reads the same here as a clean one.

So what would find it, and why this page does not run it. No probability is reported for F5, and that is an honest answer rather than a missing feature. The coefficient histogram test on this page is the published attack on JSteg: it works because JSteg substitutes the low bit of a value and so drives the counts of 2 and 3, 4 and 5, and so on together. F5 does not substitute anything. It steps a value down towards zero, which multiplies every bin of a falling histogram by the same amount and leaves its shape alone, so the test is blind to it by construction and no tuning would change that. What F5 does leave is a count: more zero coefficients, fewer non-zero ones, one of each per value it drove to zero. Reading a count needs to know what the count should have been, and the published way to find that out is to decompress the picture, crop it so the block grid moves, and compress it again to estimate the original histogram. This tool has no JPEG compressor on purpose, because one would let a page save a lossy carrier and quietly destroy a payload, so it cannot build that estimate and does not pretend to. What is reported instead is the description: the histogram, the zero count and the count of values that are one away from zero. The certain check is still the marker, and it still works unless a passphrase keyed the shuffle.

Choosing between the two. Two methods can hide a payload in a JPEG, and they trade differently: JSteg is simpler and this tool's own histogram test finds it, while F5 changes fewer coefficients, spreads them over the whole picture and is not found by that test, which is exactly what it was designed for.

And one thing a passphrase changes here that it changes nowhere else. One thing is different about F5 and it is worth knowing before you use it. Every carrier this tool writes opens with the same four-byte marker, and `analyze` finds it. That is the honest disclosure on every other method. F5 shuffles the coefficients using your passphrase, so with a passphrase the marker is still there but it is somewhere only that passphrase can point at: this tool's own analysis will not find it without it, and neither will anybody else's. With no passphrase the shuffle is the public one written into this page's source, and the marker can be found exactly as it can on any other carrier. So a passphrase here hides more than the payload's contents, and losing it loses the payload for good.

Carriers: PNG, BMP, WAV and JPEG. Which one a file is comes from its first bytes, not its extension. All four can be hidden in, by two different methods, and the surface says which one it is using. In a PNG, BMP and WAV the payload goes into the low bits of the stored samples, so it survives being saved as that kind of file again and does not survive being saved as a lossy one. A JPEG has no stored samples, so the payload goes into its frequency coefficients instead, by either of two methods you pick between: JSteg or F5. Their capacity depends on the picture rather than the file size, and re-saving the picture anywhere else deletes the payload either way.

- PNG. PNG is compressed and lossless: an ordinary-looking file, and the payload survives because nothing is thrown away. Re-saving it in another program usually keeps the low bits, but only while that program writes the same 8-bit layout without resampling.
- BMP. BMP is uncompressed: every sample is stored raw, so the low bits cannot be lost to re-compression and the file size is pure arithmetic. The cost is that size. A multi-megabyte BMP of a small picture is itself something a reader notices.
- WAV. WAV is uncompressed sound: every sample is stored raw, so the low bits survive the save and the file size is the length of the recording. The cost is that a lossy codec throws them all away. Saving this as MP3, AAC or Opus afterwards deletes the payload, and so does anything that resamples it.
- JPEG. JPEG is lossy, and that changes what a carrier even is. It does not store samples: it stores frequency coefficients, and the pixels are worked out from them when the file is opened. So there is no stored low bit to write into, and the low bit of a pixel you can see is a result rather than a place. The coefficients themselves are a real place to hide, and that is where a payload goes here, by one of two methods you choose between: JSteg, in the low bit of each coefficient that can carry one, or F5, which steps values down towards zero in a shuffled order and is the one the histogram test does not find. What it costs is that capacity depends on the picture rather than the file size, and that re-saving the file in another program re-quantises the coefficients and deletes the payload.

A greyscale carrier is the one asymmetry among the pictures: BMP's greyscale form is palette-indexed, which this codec declines to read because analysing palette indices as intensities would print a number that looks exactly like an answer. So a one-channel carrier is saved as a PNG, and the tool says so rather than writing a file it could not open again. Sound is a bigger asymmetry: a recording cannot be saved as a picture at all, and asking for that is a sentence rather than a file.

Two tools work on the same bytes from the other end: File Header Forensics tells you what a file you were handed really is before you look inside it, and File Hasher & Checksum Verifier shows that hiding a payload changes the file's digest even when it changes nothing you can see.

About this lab4 paragraphs

Bit plane 0 of a photograph looks like noise. Bit plane 0 of a photograph carrying a hidden payload also looks like noise, to the eye. The χ² test does not have eyes: writing random bits into the low bit makes the counts of the value pairs `(0,1)`, `(2,3)` … `(254,255)` equal, and a photograph's are not. Hiding data in an image is invisible per pixel and obvious in aggregate, and that is the thing this tool exists to show.

Sound is a carrier too, and it is not a picture: in a WAV the payload goes into the low byte of every sample, so a bit plane here is a timeline rather than an image. The detectors are the same ones, reading the same kind of bytes.

The verdict is a statement about the statistics, not about anybody's intent, and it runs both ways. Near-uniform bytes written into the low bit are what the pair test is built to catch; English text is not, and the same text sealed with a passphrase is uniform again, which is why the worked example below carries all three. A clean reading means this test found nothing, not that nothing is there, and a loud one on a heavily processed image can be the processing.

Offline, `stego-cli analyze <carrier>` prints the same report for any of them, and `stego-cli hide --format bmp` converts on the way out, inside a family: sound cannot become a picture.

Use it locally This lab has a native command line twin. Build `stego-cli` from the site's source with:

    cargo build --release --bin stego-cli

Source and licence terms