18
Feel like a secret agent: Hidden messages in images with steganography ๐ผ๏ธ๐ต๏ธโโ๏ธ
James Bond, Ethan Hunt, Napoleon Solo - secret agents working in disguise, sending secret messages to their employer and other agents. Let's be honest, secret agents are cool. At least in the movies and books. They get awesome gadgets, hunt down villains, get to visit fancy clubs with fancy clothes. And at the end of they day, they save the world. When I was a kid, I would've loved to be a secret agent.
In this post, I'm going to show you a technique that might well be used by secret agents to hide images within other images: Steganography.
Steganography could be something invented by the famous engineer Q of MI6 in "James Bond" movies, but it's actually much older! Hiding messages or images from eyes that shouldn't see them was a thing since the ancient times already.
According to Wikipedia, in 440 BC, Herodotus, an ancient Greek writer, once shaved the head of one of his most loyal servants to write a message on their bald head and sent the servant to the recipient once their hair grew back.
We're not going to shave anyone today, let alone hide messages on each others heads. Instead, we're hiding an image in another image.
To do this, we get rid of insignificant parts of the colors of one image and replace it with the significant parts of the colors of another image.
To understand what that means, we first need to know how colors work, for example, in PNG. Web devs might be familiar with the hex notations of colors, such as #f60053
, or #16ee8a
. A hex color consists of four different parts:
- A
#
as a prefix - Two hex digits for red
- Two hex digits for green
- Two hex digits for blue
Since the values can go from 00
to FF
for each color, this means it's going from 0
to 255
in decimal. In binary, it would go from 00000000
to 11111111
.
Binary works very similar to decimal: The further left a single digit is, the higher it's value. The "significance" of a bit therefore increases, the further left it is.
For example: 11111111
is almost twice as large as 01111111
, 11111110
on the other hand is only slightly smaller. A human eye most likely won't notice the difference betweeen #FFFFFF
and #FEFEFE
. It will notice the difference between #FFFFFF
and #7F7F7F
, though.
Let's hide this stock image:
in this cat image:
I'm going to write a little Node script to hide an image in another. This means my script needs to take three arguments:
- The main image
- The hidden image
- The destination
Let's code this out first:
const args = process.argv.slice(2)
const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]
// Usage:
// node hide-image.js ./cat.png ./hidden.png ./target.png
So far so good. Now I'll install image-size to get the size of the main image and canvas for node to inspect the images and generate a new image.
First, let's find out the dimensions of the main image and the secret image and create canvasses for both of them. I'll also create a canvas for the output image:
const imageSize = require('image-size')
const { createCanvas, loadImage } = require('canvas')
const args = process.argv.slice(2)
const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]
const sizeMain = imageSize(mainImagePath)
const sizeHidden = imageSize(hiddenImagePath)
const canvasMain = createCanvas(sizeMain.width, sizeMain.height)
const canvasHidden = createCanvas(sizeHidden.width, sizeHidden.height)
const canvasTarget = createCanvas(sizeMain.width, sizeMain.height)
const contextMain = canvasMain.getContext('2d')
const contextHidden = canvasHidden.getContext('2d')
const contextTarget = canvasTarget.getContext('2d')
Next, I need to load both images into their respective canvasses. Since these methods return promises, I put the rest of the code in an immediately invoked function expression that allows for async/await:
;(async () => {
const mainImage = await loadImage(mainImagePath)
contextMain.drawImage(mainImage, 0, 0, sizeMain.width, sizeMain.height)
const hiddenImage = await loadImage(hiddenImagePath)
contextHidden.drawImage(hiddenImage, 0, 0, sizeHidden.width, sizeHidden.height)
})()
Next, I iterate over every single pixel of the images and get their color values:
for (let x = 0; x < sizeHidden.width; x++) {
for (let y = 0; y < sizeHidden.height; y++) {
const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)
}
}
With these values, I can now calculate the "combined" color of every pixel that I'm going to draw into the target image.
I said something about significant bits earlier. To actually calculate the color, let me illustrate this a bit further.
Let's say, I want to combine the red parts of colors A and B. I'll represent their bits (8bit) as follows:
A7 A6 A5 A4 A3 A2 A1 A0 (color A)
B7 B6 B5 B4 B3 B2 B1 B0 (color B)
To hide the color B in the color A, I replace the first (right most), lets say, 3 bits of A with the last (left most) bits of B. The resulting bit pattern would look like this:
A7 A6 A5 A4 A3 B7 B6 B5
This means, I lose some information of both colors, but the combined color will not look much different than the color B itself.
Let's code this:
const combineColors = (a, b) => {
const aBinary = a.toString(2).padStart(8, '0')
const bBinary = b.toString(2).padStart(8, '0')
return parseInt('' +
aBinary[0] +
aBinary[1] +
aBinary[2] +
aBinary[3] +
aBinary[4] +
bBinary[0] +
bBinary[1] +
bBinary[2],
2)
}
I can now use that function in the pixel loop:
const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)
const combinedColor = [
combineColors(colorMain[0], colorHidden[0]),
combineColors(colorMain[1], colorHidden[1]),
combineColors(colorMain[2], colorHidden[2]),
]
contextTarget.fillStyle = `rgb(${combinedColor[0]}, ${combinedColor[1]}, ${combinedColor[2]})`
contextTarget.fillRect(x, y, 1, 1)
Almost there, now I only need to save the resulting image:
const buffer = canvasTarget.toBuffer('image/png')
fs.writeFileSync(targetImagePath, buffer)
And here's the result:
Depending on your screen settings, you might see the pattern of the hidden image in the top half of the image. Usually, you would use an image that obfuscates the hidden image more.
To extract the hidden image, all that's necessary is to read out the last 3 bits of each pixel and make them the most significant bits again:
const extractColor = c => {
const cBinary = c.toString(2).padStart(8, '0')
return parseInt('' +
cBinary[5] +
cBinary[6] +
cBinary[7] +
'00000',
2)
}
If I do this for every single pixel, I get the original image again (plus a few artifacts):
Now you can feel like a real secret agent by hiding images and sending hidden messages to other secret agents!
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a โค๏ธ or a ๐ฆ! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, buy me a coffee โ or follow me on Twitter ๐ฆ! You can also support me directly via Paypal!
18