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

thormeier

Pascal Thormeier

Posted on June 19, 2021

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.

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

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

💖 💪 🙅 🚩
thormeier
Pascal Thormeier

Posted on June 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related