Image Hidden Message

February 2024

March 2024

Leisure

This tool can be used to “hide” arbitrary messages — be it UTF-8 encoded text messages, or binary data — inside PNG images. This is done by declaring certain bits of a pixel as “value”-bits. These bits, starting with the least significant bits of a pixel, are then modified to encode the relevant message.

On a very basic level data decoding looks like this:

Example: RGB8 Buffer
              R------R G------G B------B
              01234567 01234567 01234567 
Value Mask   |......XX ......XX ......XX
Image Mask   |X..X.X.X XX.XX.XX ..XXX.X.
Encoded Data |      .X       XX       X. 
             | 0b011110 = 0x1E = 30 

An image defines a header which is always a magic number, followed by a header length, header payload and a checksum. An image is only considered to have decodable data if the magic is set to 0x42 and the checksum matches the data payload.

The header payload itself defines encoded data length, data offset start and the value mask. Decoding the header is done with the bincode package.

The tool then reads the pixel buffer using the encoded value mask until the desired payload length is achieved.

I have written a similar tool a couple years back with Node.js.
I used this as an opportunity to rewrite the previous tool “correctly”, while also using it as an excuse to get a better understanding of Rust.

Usage

Encoding

You can encode a message by piping the secret via STDIN. You need to provide a path to a source image. You will get the image with the hidden message returned via STDOUT.

# ➜ ll
total 5.8M
-rw-r--r-- 1 waldi waldi 2.6M Mar  5 23:22 exampleImageNoData.png

# ➜ echo "such secret. much wow" | image-hidden-message encode ./exampleImageNoData.png > exampleImageWithNewSecret.png
Loaded image. Contains 1354 × 1005 = 1360770px
Channels: 4, Bytes per Channel: 1
Waiting for stdin to finish. If you are stuck here, you forgot to pipe a message. You can get a message in by:
- Piping a file or text, e.g. cat mySecret.tgz | ...
- Typing the message now, then sending EOF (usually Ctrl-D)
Alternatively, provide the message via the --message option
Ctrl-C to abort.
Message received and is 22 bytes long
len: 3268175...done

<!-- Created image has same dimensions and same Pixel Format -->
# ➜ file ./exampleImageNoData.png ./exampleImageWithNewSecret.png 
./exampleImageNoData.png:        PNG image data, 1354 x 1005, 8-bit/color RGBA, non-interlaced
./exampleImageWithNewSecret.png: PNG image data, 1354 x 1005, 8-bit/color RGBA, non-interlaced

Decoding

You can decode a message by piping the PNG Image via STDIN. The tool will then output the hidden message via stdout. In this instance, the HTTP/1.0 Spec is encoded as UTF-8. This could however also be binary data like a tar-ball.

# ➜ ll                                                        
total 5.8M
-rw-r--r-- 1 waldi waldi 2.6M Mar  5 23:22 exampleImageNoData.png
-rw-r--r-- 1 waldi waldi 3.2M Mar  8 01:07 exampleImageWithNewSecret.png

# ➜ cat exampleImageWithNewSecret.png | image-hidden-message decode > message.txt
Waiting for stdin to finish. If you are stuck here, you forgot to pipe a PNG file. You can fix this by
- Piping a PNG file, e.g. cat imgWithSecret.png | ...
Alternatively, provide the source via the --source option
Ctrl-C to abort.
Done.

# ➜ cat message.txt 
such secret. much wow

Stat

You can also try to decode a header inside the image with the help of the stat command.

Here we have the source image, without any hidden messages:

# cat exampleImageNoData.png | image-hidden-message stat 
Waiting for stdin to finish. If you are stuck here, you forgot to pipe a PNG file. You can fix this by
- Piping a PNG file, e.g. cat imgWithSecret.png | ...
Ctrl-C to abort.
Success: no
Reason: Tried to find a header in file. Magic was 0xbb, not 0x42

And here is the stat command with the Image that was created above:

# cat exampleImageWithNewSecret.png | image-hidden-message stat  
Waiting for stdin to finish. If you are stuck here, you forgot to pipe a PNG file. You can fix this by
- Piping a PNG file, e.g. cat imgWithSecret.png | ...
Ctrl-C to abort.
--------------------------
Success: yes
Pixel Offset: 1017960
Byte Length: 22
Data Mask: 0b0000000100000000000000000000000000000000000000000000000000000000
         :  |0      |8      |16     |24     |32     |40     |48     |56     |64

The Data Mask you see here is a right-0-padded binary mask. In this instance, only the first least significant bit of the first byte (=R channel) is used for data.