DEV Community

loading...
Cover image for Feel like a secret agent: Hidden messages in images with steganography 🖼️🕵️‍♀️

Feel like a secret agent: Hidden messages in images with steganography 🖼️🕵️‍♀️

Pascal Thormeier
Passionate full stack web developer, he/him.
・5 min read

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:

A stock image of a computer with some CLI output

in this cat image:

A fluffy orange cat

Im 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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
})()
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Almost there, now I only need to save the resulting image:

const buffer = canvasTarget.toBuffer('image/png')
fs.writeFileSync(targetImagePath, buffer)
Enter fullscreen mode Exit fullscreen mode

And here's the result:

An image hidden in the cat image from above

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)
}
Enter fullscreen mode Exit fullscreen mode

If I do this for every single pixel, I get the original image again (plus a few artifacts):

Original image in lower quality

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!

Buy me a coffee button

Discussion (13)

Collapse
inhuofficial profile image
InHuOfficial

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 🦄!

Collapse
thormeier profile image
Pascal Thormeier Author

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 :)

Collapse
inhuofficial profile image
InHuOfficial • Edited

I’m British, self deprecation runs in my blood 🤣🤣

Steganography is really interesting...although I keep calling it stenography and that really confuses people 😜

Collapse
aleksandrhovhannisyan profile image
Aleksandr Hovhannisyan

Always enjoy reading your posts! Very cool stuff.

Collapse
thormeier profile image
Pascal Thormeier Author

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?

Collapse
aleksandrhovhannisyan profile image
Aleksandr Hovhannisyan

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!

Collapse
valeriavg profile image
Valeria

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!

Collapse
lukeshiru profile image
LUKE知る

This was actually really interesting! Thanks for sharing, Pascal! :D

Collapse
thormeier profile image
Pascal Thormeier Author

You're welcome, I'm happy that you liked it!

Collapse
jankapunkt profile image
Jan Küster

Great mix of high level and low level concepts!

Collapse
thormeier profile image
Pascal Thormeier Author

Thank you!

Collapse
darrentmorgan profile image
darrentmorgan

So awesome, great article!

Collapse
tankala profile image
Tankala Ashok

Simply Amazing. Loved it. My first comment in dev.to.