01st May, 2017

Hiding messages inside images in Python

A program that takes messages and stores them using the pixels of an image, written in Python 3.5.

This program takes text messages and stores them inside images by manipulating the RGB channels of pixels. You can find this project on my GitHub by clicking here.

Most colour images use 24 bit RGB to store and display the colour values for every pixel - each pixel has three 8 bit channels (one for red, one for blue, and one for green) meaning each channel is capable of representing a value between 0 and 255, and each individual pixel has a total of 24 bits. If we converted a 24 bit pixel to denary we would see a value like 255, 255, 255 (which is absolute white) or 0, 0, 0 (which is absolute black). Similarly, if we converted a pixel to hexadecimal we would see a value like 0xFFFFFF (which is also absolute white) or 0x000000 (which is also absolute black). Generally, this is how colours are represented and applied in markup languages such as CSS, and in photo manipulation programs like Photoshop and GIMP.

How this program works

This program works by taking a text message and mapping each character to its corresponding numerical value in the built-in custom character set - each character becomes a 3 digit number. Then, each 3 digit character is split into 3 individual digits - these digits are then inserted into the R, G, and B channels of a pixel; only the least significant digit of each channel is changed - this way the pixel stays close to its original value, meaning the change in colour is indistinguishable. Using this method, only one pixel is required to store a character, and the pixel stays close to its original value, meaning there are no visual distortions which could give away the message.

This program can both write messages to images and extract and read messages from images; simply pass either "write" or "read" as a command line argument when executing the main.py script. This program will dictate which image should be inputted into the program and used for writing messages to, and what to name the output file based on the options set in the config file.

When extracting and reading a message from an image the program needs a key so that it knows when to stop reading pixels. The key is the number returned after writing a message to an image - it is simply the length of the message. If you wanted to you could use the total number of pixels in the image as the key - the program will read every pixel and will return a bunch of random characters.

To write a message to an image, set the name of the image in the config.txt file and then execute the main.py script with the "write" argument - type your message and then press enter. You will then be given a "length key" - this key is required when extracting and reading the message from the image.

To extract and read a message from an image, set the name of the image file you want to read in the config.txt file and then execute the main.py script with the "read" argument - enter the key you were given when writing the message and then press enter. The message will then be returned and printed to the console.


Features

There are several built in features which make the process of inserting messages into images easier and more secure.

  1. - Can write messages to images and extract and read messages from images.
  2. - Custom character set.
  3. - Built in ROT13 enciphering and deciphering.
  4. - Options can be set via a config file.
  5. - Config and character set file are formatted in JSON.
  6. - Detects whether an image contains enough pixels to store inputted message.

The character set

Why use a custom character set? There are two main reasons for using a custom character set over an existing character set such as ASCII: 1. because not all denary ASCII codes are 3 digits long - meaning they can’t be evenly divided into the R, G, B channels for each pixel. This is not an issue when writing messages to images, but makes reading messages back from images impossible since there is no way of distinguishing between a 1, 2, or 3 digit long character code - meaning, for example, 97 (a) could be read as 973. And 2. because character codes can only contain numbers from 0 to 5 - this is because we are changing the least significant digit of each R, G, B denary value; if, for example, the denary value for R was 255, and our character code was 97, changing the last 5 of 255 to 9 would result in R having the value 259 - which is impossible and cannot be stored using 8 bit binary integers. So a character set with 3 digit long denary character codes which only use the numbers from 0 to 5 is required to store one character per pixel efficiently and effectively.

When you first start the program (if a charset.txt file doesn't already exist) the config.py script will generate the default character set file. It is absolutely possible to modify and extend this character set file to your liking, but it is important that you follow the two rules described above in order for messages to be inserted and extracted from images correctly. Both the charset.txt and config.txt files are formatted in JSON - so when modifying either of the two files it is important that you follow the JSON syntax rules.

Example images

Here are two example images - the first being the original image, and the second being the image containing a hidden message.


Input example image

This image contains no message.

Input example image

Output example image

This image contains the message "Hello, World! This is a secret message.".

Output example image

Example usage

Here is an example of how you would use the program to write and read messages to and from images.


config.txt

When writing a message to an image the program will look for an image file named "input-image.png", will output to an image file called "output-image.png", and will not use ROT13 to encipher the message. When extracting and reading a message from an image the program will look for an image file named "output-message.png" and will not use ROT13 to decipher the message.

  
{
  "input_file": "input-image.png",
  "output_file": "output-image.png",
  "read_file": "output-image.png",
  "rot13_mode": false
}
  

Writing a message to an image

After executing the main.py script with the "write" argument you are asked to enter your message. After typing your message and pressing enter a success message is returned along with a "key length" - this is required when reading the message so that the program knows when to stop reading pixels.

  
python3.5 main.py write
Enter message: Hello, World! This is a secret message.
New image saved successfully. Your key length is 39.
  

Extracting and reading a message from an image

After executing the main.py script with the "read" argument you are asked to enter the key length - this is the number returned after writing the message to the image (the length of the message). The number is required so that the program knows when to stop reading pixels.

  
python3.5 main.py read
Enter key: 39
Hello, World! This is a secret message.
  

Source code

main.py

  
from sys import argv
from config import create_config, create_charset, get_file, check_file
from image import get_image, input_message, output_message, save_image
from cipher import rot13


def write():
    # if config file doesn't exist
    if not check_file("config.txt"):
        # create config file
        create_config()
    # if character set file doesn't exist
    if not check_file("charset.txt"):
        # create character set file
        create_charset()
    # get and unpack config file
    input_file = get_file("config.txt")['input_file']
    output_file = get_file("config.txt")['output_file']
    rot13_mode = get_file("config.txt")['rot13_mode']
    # get and unpack character set file
    charset = get_file("charset.txt")
    # get and unpack image
    file = get_image(input_file)['file']
    length = get_image(input_file)['length']
    height = get_image(input_file)['height']
    # ask user for message and save as list
    message = list(input("Enter message: "))
    # if rot13 mode is enabled in config file encipher message
    if rot13_mode:
        # produce a key based on character set
        key = [char for char in charset]
        # cipher message
        message = rot13(''.join(message), key, 13, 1)
    # check if image has enough pixels to store message
    if len(message) > length * height:
        # if insufficient pixels return error
        return "Image {0} pixels long is too short to store message with {1} characters. At least {2} pixels " \
               "required.".format(length * height, len(message), len(message))
    # set file to new file data and save new image by passing to save_image function
    file = input_message(file, message, charset)
    save_image(file, output_file, length, height)
    return "New image saved successfully. Your key length is {0}.".format(len(message))


def read():
    # if config file doesn't exist
    if not check_file("config.txt"):
        # create config file
        create_config()
    # if character set file doesn't exist
    if not check_file("charset.txt"):
        # create character set file
        create_charset()
    # get and unpack config file
    read_file = get_file("config.txt")['read_file']
    rot13_mode = get_file("config.txt")['rot13_mode']
    # get and unpack character set file
    charset = get_file("charset.txt")
    # get and unpack image
    file = get_image(read_file)['file']
    # ask user for key
    key = int(input("Enter key: "))
    message = output_message(file, key, charset)
    # if rot13 mode is enabled in config file decipher message
    if rot13_mode:
        # produce a key based on character set
        key = [char for char in charset]
        # cipher message
        return ''.join(rot13(message, key, 13, -1))
    # otherwise just return message
    return message


def main(mode):
    # start write mode
    if mode == "write":
        return write()
    # start read mode
    elif mode == "read":
        return read()
    # return error
    else:
        return "Invalid mode."

if __name__ == "__main__":
    # get mode
    mode = argv[1]
    # start main function with mode
    print(main(mode))
  

image.py

  
from PIL import Image


def get_image(path):
    # set variable file to image file
    file = Image.open(path) ## change this so it loads from path
    # set length and height
    length, height = file.size
    # recreate file variable as list of tuples containing rgb values / pixels of image
    file = list(file.getdata())
    # return list of pixels and image dimensions
    return {
        'file': file,
        'length': length,
        'height': height
    }


def input_message(file, message, charset):
    # set pointer to 0
    pointer = 0
    # while the pointer is less than the length of the message
    while pointer < len(message):
        # get the character set value for current char and split number into list
        chars = list(charset[message[pointer]])
        # for each number of each character set value
        for pos, value in enumerate(chars):
            # if the first number put at beginning
            if pos == 0:
                file[pointer] = (int(str(file[pointer][0])[:-1] + value), file[pointer][1], file[pointer][2])
            # if the second number put in middle
            elif pos == 1:
                file[pointer] = (file[pointer][0], int(str(file[pointer][1])[:-1] + value), file[pointer][2])
            # if last number put at end
            else:
                file[pointer] = (file[pointer][0], file[pointer][1], int(str(file[pointer][2])[:-1] + value))
        # increment pointer
        pointer +=1
    # return new image data
    return file


def output_message(file, key, charset):
    # initialise message list
    message = []
    # set pointer to position 0
    pointer = 0
    # while the pointer is less than the length of the message (key)
    while pointer < key:
        # set pixel to current tuple
        pixel = file[pointer]
        # initialise chars list
        chars = []
        # for r g b values in pixel tuple
        for value in pixel:
            # append the last value / least significant digit to chars
            chars.append(str(value)[-1])
        # join all chars together to form character set value
        char = ''.join(chars)
        # is the character set value is in the character set
        if char in charset.values():
            # append the corresponding character to the message list
            message.append(list(charset.keys())[list(charset.values()).index(char)])
        # increment the pointer
        pointer +=1
    # return message list as a string
    return ''.join(message)


def save_image(file, output_file, length, height):
    # create new image
    image = Image.new('RGB', (length, height))
    # insert image data
    image.putdata(file)
    # save image with output_file name from config
    image.save(output_file)
  

config.py

  
from os import path
from json import loads


def create_config():
    # create config file
    with open('config.txt', 'w+') as config:
        # write default values
        config.write("{\n"
                     "  \"input_file\": \"image.png\",\n"
                     "  \"output_file\": \"message-image.png\",\n"
                     "  \"read_file\": \"message-image.png\",\n"
                     "  \"rot13_mode\": false\n"
                     "}\n")
        # return success
        return 1


def create_charset():
    # create character set file
    with open('charset.txt', 'w+') as charset:
        # write default values
        charset.write("{\n"
                     "  \" \": \"000\",\n"
                     "  \"a\": \"001\",\n"
                     "  \"b\": \"002\",\n"
                     "  \"c\": \"003\",\n"
                     "  \"d\": \"004\",\n"
                     "  \"e\": \"005\",\n"
                     "  \"f\": \"010\",\n"
                     "  \"g\": \"011\",\n"
                     "  \"h\": \"012\",\n"
                     "  \"i\": \"013\",\n"
                     "  \"j\": \"014\",\n"
                     "  \"k\": \"015\",\n"
                     "  \"l\": \"020\",\n"
                     "  \"m\": \"021\",\n"
                     "  \"n\": \"022\",\n"
                     "  \"o\": \"023\",\n"
                     "  \"p\": \"024\",\n"
                     "  \"q\": \"025\",\n"
                     "  \"r\": \"030\",\n"
                     "  \"s\": \"031\",\n"
                     "  \"t\": \"032\",\n"
                     "  \"u\": \"033\",\n"
                     "  \"v\": \"034\",\n"
                     "  \"w\": \"035\",\n"
                     "  \"x\": \"040\",\n"
                     "  \"y\": \"041\",\n"
                     "  \"z\": \"042\",\n"
                     "  \"A\": \"043\",\n"
                     "  \"B\": \"044\",\n"
                     "  \"C\": \"045\",\n"
                     "  \"D\": \"050\",\n"
                     "  \"E\": \"051\",\n"
                     "  \"F\": \"052\",\n"
                     "  \"G\": \"053\",\n"
                     "  \"H\": \"054\",\n"
                     "  \"I\": \"055\",\n"
                     "  \"J\": \"100\",\n"
                     "  \"K\": \"101\",\n"
                     "  \"L\": \"102\",\n"
                     "  \"M\": \"103\",\n"
                     "  \"N\": \"104\",\n"
                     "  \"O\": \"105\",\n"
                     "  \"P\": \"110\",\n"
                     "  \"Q\": \"111\",\n"
                     "  \"R\": \"112\",\n"
                     "  \"S\": \"113\",\n"
                     "  \"T\": \"114\",\n"
                     "  \"U\": \"115\",\n"
                     "  \"V\": \"120\",\n"
                     "  \"W\": \"121\",\n"
                     "  \"X\": \"122\",\n"
                     "  \"Y\": \"123\",\n"
                     "  \"Z\": \"124\",\n"
                     "  \"0\": \"125\",\n"
                     "  \"1\": \"130\",\n"
                     "  \"2\": \"131\",\n"
                     "  \"3\": \"132\",\n"
                     "  \"4\": \"133\",\n"
                     "  \"5\": \"134\",\n"
                     "  \"6\": \"135\",\n"
                     "  \"7\": \"140\",\n"
                     "  \"8\": \"141\",\n"
                     "  \"9\": \"142\",\n"
                     "  \".\": \"143\",\n"
                     "  \",\": \"144\",\n"
                     "  \"!\": \"145\",\n"
                     "  \"?\": \"150\"\n"
                     "}\n")
        # return success
        return 1


def get_file(file):
    # open file
    with open(file, 'rb') as data:
        # set data to value of file
        data = loads(data.read())
        # return data
        return data


def check_file(file):
    # if file exists
    if path.exists(file):
        # return success
        return 1
    # otherwise return nothing
    return
  

cipher.py

  
def rot13(string, key, shift, mode):
    # initialise output list
    output = []
    for char in string:
        # encipher / decipher the character only if it is in the key
        if char in key:
            # the pointer is equal to the position of the character in the key plus the shift value multiplied by the mode
            pointer = key.index(char) + shift * mode
            # append the value at the position of the pointer modulus the length of the key (wrap around)
            output.append(key[pointer % len(key)])
        # if the character is not in the key just append it to the output list
        else:
            output.append(char)
    # return as a list
    return output
  

config.txt

  
{
  "input_file": "input-image.png",
  "output_file": "output-image.png",
  "read_file": "output-image.png",
  "rot13_mode": false
}
  

charset.txt

  
{
  " ": "000",
  "a": "001",
  "b": "002",
  "c": "003",
  "d": "004",
  "e": "005",
  "f": "010",
  "g": "011",
  "h": "012",
  "i": "013",
  "j": "014",
  "k": "015",
  "l": "020",
  "m": "021",
  "n": "022",
  "o": "023",
  "p": "024",
  "q": "025",
  "r": "030",
  "s": "031",
  "t": "032",
  "u": "033",
  "v": "034",
  "w": "035",
  "x": "040",
  "y": "041",
  "z": "042",
  "A": "043",
  "B": "044",
  "C": "045",
  "D": "050",
  "E": "051",
  "F": "052",
  "G": "053",
  "H": "054",
  "I": "055",
  "J": "100",
  "K": "101",
  "L": "102",
  "M": "103",
  "N": "104",
  "O": "105",
  "P": "110",
  "Q": "111",
  "R": "112",
  "S": "113",
  "T": "114",
  "U": "115",
  "V": "120",
  "W": "121",
  "X": "122",
  "Y": "123",
  "Z": "124",
  "0": "125",
  "1": "130",
  "2": "131",
  "3": "132",
  "4": "133",
  "5": "134",
  "6": "135",
  "7": "140",
  "8": "141",
  "9": "142",
  ".": "143",
  ",": "144",
  "!": "145",
  "?": "150"
}
  

Thank you for reading.

🦆🦆🦆


Leave a comment

Invalid or missing field(s).
Comment sent successfully, please wait for it to be approved.

This post has no comments