Hiding Data Inside an Image Using Python

by jose in Circuits > Software

1847 Views, 4 Favorites, 0 Comments

Hiding Data Inside an Image Using Python

Cover.png

In this instructable, we are going to use Python with the Pillow library to conceal a file inside an image, this practice is called steganography, which consists of hiding a message (like a text file) inside another message (like an image), note that even though in this case we are putting a text file inside an image, we can put any type of data (even another image) as long as it's not too large

We will use google colab to run our code, so you will need a google account

I first heard of this in this video and i think it's worth looking at

Overview

imagePixels.png

Before we start with the code, let's define first some things

We will consider a digital image as a set of values, each value describing a color. in a grayscale image for example, we usually have a set of values that range from 0 to 255, where 0 represents black, 255 represents white, and the values in between represent different levels of gray, these values are arranged in a 2D plane as shown in the image, notice how when the value is closer to 0 the shade of gray is darker, and when it's closer to 255 it gets lighter.

With this, we can consider color images as 3 grayscale images, each of the grayscale images corresponding to the red, green and blue channels of the color image, as shown.

Now, how do we modify an image to add arbitrary data while keeping the image close to the original?

First we consider the value of a pixel, we know that it ranges from 0 to 255 and that's because each pixel value is an integer of 8 bits in size, so the minimum value in binary would be 0000 0000 (the 8 bits in 0) and the maximum value would be 1111 1111 (the 8 bits in 1), and for example a value of 135 would be 1000 0111

consider now that, we want to save 1 bit of data in a pixel, so what we do is change one of the 8 bits in the value of the pixel to the value that we want to save, but, which of the bits do we change so that the value of the pixel doesn't change a lot?

We can do an experiment, for example, let's say that the bit of data that we want to save is the number 0, and the pixel where we are going to save it has a value of 135 (1000 0111 in binary)

now, we have 8 options as there are 8 bits, but we are going to consider only the bits that are in bold because they are basically the two extremes, now suppose we save our 0 in the bit that's on the left, so we have

1000 0111 → 0000 0111

135 → 7

note that the new value is 7, which means changing this bit has a really big impact on the value of the pixel, so let's try the other option

changing the bit on the right, we have

1000 0111 → 1000 0110

135 → 134

note that the pixel value only changed by one, so if we want to modify pixel values to store arbitrary data, we should modify the bit on the right

The bit on the right is named the Least significant bit (LSB) and the bit on the left, the Most significant bit (MSB) appropriately so, changing the LSB doesn't cause much change on the value, and changing the MSB causes a lot of change on the value, and the impact that a bit has on the value of a pixel increases moving from the LSB to the MSB.

Overview

Artboard AB.png

We know how to save 1 bit of data in a pixel without changing its color too much; now to save multiple bytes of data, what we need is multiple pixels, we will need 8 pixels for each byte as we are saving 1 bit of data per pixel.

we have now a general procedure, consider dbits as a list containing the bits of the data we want to save in the image, and consider pixels as a simple 1-dimensional list containing the pixels in the image, and list[i] corresponds to the element with position i on the list list

What we want to do is set the value of the least significant bit of pixels[i] to the value of dbits[i] for each value of i from 0 to the length of dbits

Now an example: consider we want to save the text "hi" inside a 32x32 image, first, we find the representation of the text in numbers, using this tool we know that the letter h is represented with a value of 104 and the letter i is represented with a value of 105, we will need 16 pixels as each character in the text is 1 byte (8 bits) long, so we take the decimal representation of the characters, and find the binary representation, 104 → 0110 1000, and 104 → 0110 1001.

Now we start at the top left corner of the image, because generally that's the first pixel (pixel at coordinates (x,y) = (0,0), where x is the horizontal position of the pixel, and y is the vertical position)

We set the LSB of that pixel to the first bit in our data, and then we move to the next pixel (pixel at coordinates (1,0)), and set the LSB of that pixel to the second bit in our data, and we repeat the process again until all the data we want to write to the image has been written.

Note that after the pixel at coordinates (0,0) we move to the pixel at coordinates (1,0) meaning the pixel on its right, this is arbitrary, and we can move to the pixel at coordinates (0,1) instead, the order in which we write the data to the image is necessary to know only when we are extracting the data.

Retrieving the data is a simpler process, just go through each pixel and extract the last bit, then make groups of 8 with the bits to form the bytes and we are done.

Also, for color images the process is pretty much the same, except that we can consider the pixel coordinates to be (x,y,c) where c is the color channel

Now let's start with the code

Starting With the Code

kodim07.png

First, we need an image and some data, the image we will use is the one shown above, and the data is a text file, with the code below in google colab, we download the image and save it as image.png and we download the text file and save it as data.txt

!wget -O image.png "http://r0k.us/graphics/kodak/kodak/kodim07.png"
!wget -O data.txt "https://pastebin.com/raw/fvuinc16"

After that, we import the necessary libraries and define variables with the names of each file

from PIL import Image

imgName = "image.png"
imgSecretName = "imgWithSecret.png"

dataSecretName = "data.txt"
dataRecoverName = "dataOut.txt"<br>

imgSecretName holds the name of the image that we will create, it will have the data from data.txt hidden inside it

dataRecoverName holds the name of the data we extract from imgSecretName, it should be the same as data.txt

Putting Data Inside the Image

imgWithSecretLSB.png
imgWithSecretMSB.png

Getting bits from bytes

We are going to first define a function that takes a list of bytes, and returns a list of bits, this is probably not the best approach in terms of memory usage, but it makes things easier to work with

def getDataBits(data):
  bits = []
  for byte in data:
    for b in range(0,8):
      bits.append((byte >> b) & 0b1)
  
  return bits

in the function, we first define the empty list bits, and what we want to do is iterate through data (the list of bytes) and append every bit to bits

we first iterate through every byte in data and then we iterate through every bit in byte

to get a specific bit from a byte, we use some bitwise operations, more specifically, right shift ( >> ), and bitwise and ( & )

right shift works by literally shifting the places of the bits to the right by a certain amount

for example

we have the byte x = 0100 0000

the operation x >> 4 results in the number 0000 0100

notice that the bits in bold are appended to the left (they didn't exist before the operation), and they are always 0

bitwise and performs an and operation between two numbers bit by bit, and the way we are using this operation in this function is often called bit masking

Our approach to get byte[i] (the bit number i in the number byte), is to first move the bit we want, from the position i to the position 0 (we do a right shift by the position → byte >> i), and then we read the bit number 0 of that result by making every other bit 0 ( x & 1 ) (if we do x & 1, which is the same as x & 0000 0001, notice every bit is going to be 0 except the one at the position 0, and we know that if have a certain bit y, the operation y & 1 results in y)

After we get the bit we want, we use the method append() to add it to the list

Note that even though we call it a list of bits, if you check the type of an item in the list it will show it's a list of integers (int), however, we call it a list of bits simply because we know the values in the list can only be 0 or 1

Writing bits to the image

We define a function that takes an image (an image object from the Pillow library), and a list of bytes, this function modifies the image by hiding the data inside it

def putDataInsideImage(im, data):
  dataBits = getDataBits(data)

  maxX = im.size[0] # width of the image
  maxY = im.size[1] # height of the image
  maxC = len(im.getpixel((0,0))) # number of channels in the image

  x = 0 # x coordinate of the pixel
  y = 0 # y coordinate of the pixel
  c = 0 # color channel of the pixel

  for bit in dataBits:
    color = list(im.getpixel((x,y)))
    color[c] = (color[c] & (~0b1)) | bit;
    im.putpixel((x,y), tuple(color)) 

    c = c + 1 # we first iterate through the color channel
    if c >= maxC:
      c = 0
      x = x + 1 # then through the x coordinate
      if x >= maxX:
        x = 0
        y = y + 1 # and finally through the y coordinate
	if y >= maxY:
          print("Not enough pixels!")
          return

        # knowing the order in which we save the bits is important when recovering the data from the image

The first thing we do, is get the list of bits from the list of bytes by using the function we defined above, getDataBits

Then, we define variables that hold the width of the image, the height of the image and the number of channels of the image, we have to know them so we can iterate through all the pixels of the image while avoiding accessing a pixel in a coordinate that doesn't exist

we then define counters for each coordinate (x is the horizontal coodinate, y is the vertical coordinate and c is the color channel)

now we iterate through every bit in the list of bits

what we do is get the current pixel we are at based on the counters (x,y) using the method getpixel((coords)), and then again, using bitwise operations, we change the least significant bit of the value of the pixel in channel c to the bit we want to save, the bitwise or ( | ) work similarly to the bitwise and, except it performs the or operation bit by bit, and the bitwise not ( ~ ) flips every bit in the number (changes every 0 to a 1, and every 1 to a 0), our approach is first to set the LSB to 0 by using bit masking (~1 is equal to 1111 11110, so if we do the operation x & ~1, we set the LSB of x to 0) and then we do a bitwise or to set the LSB to the value of the bit we want to save

We update the pixel values in the image with the method putpixel((coords), (color))

Then we increment the counters and check if they are over the maximum value to reset them, notice that we first move through each color channel, then through every horizontal coordinate, and then through every vertical coordinate, we need this information when we are going to extract the data

Note: if the image doesn't have enough pixels to hold all the data, we exit the function and print a message to indicate that

Now, executing the next bit of code will open the image and show it on google colab

im = Image.open(imgName)
im

and executing the next bit will open the data file and read the contents (the bytes) and save it into a variable

with open(dataSecretName, "rb") as dataFile: # Note that we open in binary mode
  secretData = dataFile.read()

Now we can execute the function and save the image

putDataInsideImage(im, secretData)
im.save(imgSecretName)
im

The first image in this step shows the image with the data added to the LSB, in contrast, the second image is the image with the data added but to the MSB, notice how the top of the image changes a lot (also notice where the data ends)

Recovering Data From Image

dataOut.png

Getting bits from image

We first define a function that extracts the LSB of every value in the image

(notice that we don't know how long the data is, so we don't know how many bits to read from the image to get the hidden message, we will solve this problem later)

def getBitsFromImage(im):
  bits = []

  maxC = len(im.getpixel((0,0)))
  for y in range(0, im.size[1]):
    for x in range(0, im.size[0]):
      for c in range(0, maxC):
        bits.append(im.getpixel((x,y))(c) & 0b1)

  return bits

The function takes an image as an input, and outputs the list of bits corresponding to the LSB of every pixel value

we first define an empty list of bits, we are going to append every bit there

notice the order of the iterative loops, we first go through every value of c for a certain value of x and y, then go through every value of x for a value of y and then we change y this order is the same one as in the function used to write the data inside the image

we use the method getpixel((coord)) and we get the value of the channel c, then we use bit masking to get the LSB and we append the value of the bit to the list of bits

Note that this function will return every LSB in the image, but we saw in the last step that only a small part of the image contains the data, so we will get a lot of data after our hidden data that we don't need, so we will actually define this function a little different, by adding an argument with the maximum number of bits we want from the image in case we know the length of the data

def getBitsFromImage(im, maxBits = 0):
  bits = []

  maxC = len(im.getpixel((0,0)))
  for y in range(0, im.size[1]):
    for x in range(0, im.size[0]):
      for c in range(0, maxC):
        bits.append(im.getpixel((x,y))[c] & 0b1)
        if maxBits is not 0 and len(bits) >= maxBits:
          return bits

  return bits

if the argument is 0 we get all the bits, but if it's not 0 then the second argument determines the amount of bits to read

Getting bytes from bits

we define a function that takes a list of bits and returns a list of bytes

def bitsToBytes(dataBits):
  dataBytes = []
  for i in range(0, len(dataBits), 8):
    if i+7 >= len(dataBits):
      break
    
    currByte = 0
    for b in range(0, 8):
      currByte = currByte | (dataBits[i + b] << b)

    dataBytes.append(currByte)

  return(bytes(dataBytes))<br>

we define the empty list dataBytes where we are going to append every byte

we iterate through the list in steps of 8 because we take 8 elements every loop to make a byte, we do some checks to see if the next operation will fail and break the loop accordingly

then, we define the variable currByte, which is the current byte we are calculating, we then use bitwise operations to update every bit in currByte, notice the left shift ( << ) operation which works similarly to the right shift, what we do here is move the current bit to it's position and set it in the currByte variable

after currByte is ready, we append it to the list

we do return(bytes(dataBytes)) because dataBytes is a list, and we want to return an object of type bytes so we can save it directly to a file

Getting data from image

We define a function for convenience that simply uses the two functions defined above

def getDataFromImage(im):
  dataBits = getBitsFromImage(im)
  return bitsToBytes(dataBits)<br>

we first get every bit from the image, and then get the list of bytes

Now we open the image, read the secret data and save it in a file

imO = Image.open(imgSecretName)
with open(dataRecoverName, "wb") as dataFile: # Note that we open in binary mode
  dataFile.write(getDataFromImage(imO))<br>

We open the file dataOut.txt and notice that the start of the file is correct, but the actual data is followed by some random characters (as shown in the image for this step), we fix that in the next step

Improvement

dataOutCrop.png

Writing the data

To solve the problem we have of not knowing the size of the data when extracting it, we do a very simple thing.

When saving the data, we save the size of the data on the first bits of the image, and after those bits we save the actual data

so we define the function

import struct
def putDataInsideImageWithSize(im, data):
  data = list(data)
  data[0:0] = struct.pack("=L", len(data)) # insert 4 bytes at the start of the data that specify the ammount of data in number of bytes, the pack function takes the number and returns the bytes representing the number
  putDataInsideImage(im, bytes(data))<br>

which first prepends 4 bytes to the data which contain the length, and then calls putDataInsideImage

we use struct.pack() to get the 4 bytes that represent the ammount of data

the usage is the same as the other function

im = Image.open(imgName)
with open(dataSecretName, "rb") as dataFile: # Note that we open in binary mode
  secretData = dataFile.read()
putDataInsideImageWithSize(im, secretData)
im.save(imgSecretName)<br>

Reading the data

Now, we need to define a function that first reads the size of the data form the image, and then reads the actual data, so we define the function

def getDataFromImageWithSize(im):
  dataSizeBits = getBitsFromImage(im, 4*8)
  dataSize = struct.unpack("=L", bitsToBytes(dataSizeBits))[0]
  dataBits = getBitsFromImage(im, 4*8 + dataSize*8) 
  return bitsToBytes(dataBits)[4:]<br>

What we do is we first read 4*8 bits from the image (so 4 bytes), then we use struct.unpack() to get the number those 4 bytes are representing, and now, knowing the data size, we read again from the image, now we read 4*8 + dataSize*8, meaning the 4*8 bits of the data size plus the ammount of actual data in bits

We return only from the 5th bit to the last to ignore the 4 bytes that represent the data

The usage is the same as the other function

imO = Image.open(imgSecretName)
with open(dataRecoverName, "wb") as dataFile: # Note that we open in binary mode
  dataFile.write(getDataFromImageWithSize(imO))<br>

Notice that when we open dataOut.txt, the random data is not there, and the files data.txt and dataOut.txt are identical

The End

So we have implemented a way to put data inside images

im = Image.open(imgName)
with open(dataSecretName, "rb") as dataFile: # Note that we open in binary mode
  secretData = dataFile.read()
putDataInsideImageWithSize(im, secretData)
im.save(imgSecretName)

and to read the data from those images

imO = Image.open(imgSecretName)
with open(dataRecoverName, "wb") as dataFile: # Note that we open in binary mode
  dataFile.write(getDataFromImageWithSize(imO))

Notice that the data file is read and written in binary mode, so we can put any type of file inside the image as long as the image is big enough to hold all the data

Also notice that we can save two bits per pixel value instead of one if we want to save more data, as the second bit also causes very minimal changes in the value of the pixel, you can check the google colab notebook for information in implementing the version with 2 bits per pixel

Now some considerations. Even though we successfully achieved hiding the data and retrieving it, we didn't send it through a messaging service or uploaded it to a website to see the effects there might be on the hidden data on a real world usage scenario, truth is if the image is compressed in any way, there's a high chance that the hidden data is lost, however, it depends on the service used, for example by uploading the image (with the hidden data) to imgur and then downloading it in colab and extracting the data, we get dataOut.txt to be the same as data.txt, however by sending it through whatsapp and downloading it, we get an image with extension jfif (this format uses lossy compression) and trying to extract data results in a bunch of random characters.