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.
But first: What's steganography anyways?
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.
Wait, what? Significant, insignificant?
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 an image with JS
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.
Calculating the new color
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.
And how do I restore the hidden image?
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!
Top comments (16)
First article that I understood about steganography, very well written and explained.
I wish you had written a few weeks ago as I wrote an article on how a dumbass like me can do steganography....but I just used the alpha channel as my little brain couldnβt understand any bit flipping etc!
dev.to/inhuofficial/i-fit-the-whol...
Not trying to self promote just though you might find it interesting.
As with most of your articles have a β€οΈ and a π¦!
So glad you liked it! Don't be so harsh on yourself, the idea of using the alpha channel is actually really good. There's a multitude of different approaches to steganography, you could in theory even hide a plain old bit string that is divided into packets of 3 bits each. Or hide a sound file. Or hide an image in a sound file! Hiding an entire game in an image's alpha channel makes it even less obvious that something's hidden :)
Quilting has always appealed to me. Especially back when I was watching James Bond. But at some point, I realized that I needed to look at reality, and it was time to learn a profession. And I opted for phlebotomy. After all, phlebotomy is one of the cheapest career certifications you can get. I want to ensure that the patients are not stressed out after the training. The cost of a training course for this profession has many variables. But usually, the training costs $300 to $700 and can be completed online or in person. Still, I think there are similarities to steganography in this profession. So I haven't gotten very far from that.
That's an interesting insight! I personally know nothing of the challenges there, what are the similarities?
Iβm British, self deprecation runs in my blood π€£π€£
Steganography is really interesting...although I keep calling it stenography and that really confuses people π
Great idea and a very well written article!
I'll definitely try out hiding text messages in the two least significant bits (a decent image should fit my whole evil genius master plan muahaha)
Thank you!
Always enjoy reading your posts! Very cool stuff.
So amazing to hear, thank you! I always try to create content that people actually enjoy. Are there specific topics you'd like to read more about?
Nope, no topics in particular. I like how original/unique your posts are (like the guitar SVG post you did a while back). It's refreshing to read those kinds of niche/creative tutorials!
You're welcome, I'm happy that you liked it!
Great mix of high level and low level concepts!
Thank you!
Simply Amazing. Loved it. My first comment in dev.to.
Wow, thank you so much, so glad you liked it!
So awesome, great article!
Thank you very much! I'm always trying hard to write good stuff :)