Introduction
Imagine you have a secret note that you want to pass to a friend without anyone else noticing. Instead of hiding the note in your hand or pocket, you decide to hide it in a drawing or a photo. That’s basically what steganography is. Hiding a message within something else so that only the sender and the intended recipient know it’s there.
The specific steganography topic covered is LSB Steganography in Python. The process can be done like this. Take a secret message, turn it into bits, and then swap out the least significant bits of the pixels in the image with the bits of your secret message. To anyone else, the picture looks just the same. This software will encode and decode plaintext messages into PNG files.
The Software: tuckerlsb
The original tucker.py simply concatenated files together to demonstrate Steganography by File Appending.
tuckerlsb.py is a simple implementation of LSB steganography in Python for PNG files. A user will provide an input image, an output image where the message will be contained, and the secret message to be saved within the output file. It also works in reverse. You can take an input image which may contain a secret, then reveal this secret message.
Sample Usage
PS C:\tools> .\tuckerlsb.exe
usage: tuckerlsb.exe [-h] --input INPUT_IMAGE [--output OUTPUT_IMAGE] [--message USER_MESSAGE] [--getmessage]
tuckerlsb.exe: error: the following arguments are required: --input/-i
PS C:\tools> .\tuckerlsb.exe --input .\demo.png --output steg.png --message "Hello my friend!"
[+] Saving image to: steg.png
PS C:\tools> .\tuckerlsb.exe --input .\steg.png --getmessage
Hello my friend!
The Code
This first piece of code converts a text message into its binary representation, a process which transforms each character of the message into a sequence of 0s and 1s.
The for
loop goes through the message character by character. For each character in the message
, the loop performs the following steps below:
binary_string = ''
for char in message:
binary_char = format(ord(char), '08b')
binary_string += binary_char
return binary_string
The ord
function is used to find the ASCII value of the character. ASCII is a standard that assigns numbers to different characters. For example, the ASCII value of ‘A’ is 65, ‘B’ is 66, and so on.
The ASCII value is then converted to its binary equivalent using the format
function. The '08b'
format specifier tells the function to format the number as binary, padded with zeros to make it 8 digits long. This is because in ASCII, each character is represented by an 8-bit binary number (1 byte).
The next snippet of code does the opposite of the previous one; it converts a binary string back into a human-readable text message. This code takes a string of binary data (composed of 0s and 1s) and decodes it back into its original text form, character by character, by interpreting each group of 8 binary digits as one ASCII character.
for i in range(0, len(binary_message), 8):
byte = binary_message[i:i + 8]
text += chr(int(byte, 2))
return text
The range
function starts from 0, goes up to the length of the binary message (len(binary_message)
), stepping 8 units at a time. This means it processes one character of the original message in each iteration since, in ASCII encoding, one character is represented by 8 bits.
Inside the loop, byte = binary_message[i:i + 8]
slices out 8 bits from the binary message starting from index i
. This 8-bit segment (or byte) represents one character from the original message in binary form.
The binary byte is then converted back to a numerical value using int(byte, 2)
, with the 2 indicating that the number should be interpreted as a base-2 (binary) number. This numerical value corresponds to the ASCII value of the character. The chr
function then converts this ASCII value back into the corresponding character.
This code starts by converting the message into a binary form (a series of 0s and 1s) and then alters the pixels of an image to encode this binary message directly into the image itself. Each bit of the binary message is hidden by tweaking the least significant bit (the last bit) of a color component in a pixel. Since these changes are so minor, they are virtually invisible to the naked eye, and the image appears unchanged.
image = Image.open(self.input_image)
binary_message = self.text_to_binary(self.message) + self.delimeter
pixels = image.load()
message_index = 0
for row in range(image.size[0]):
for column in range(image.size[1]):
if message_index < len(binary_message):
pixel = list(pixels[row, column])
bit_to_write = int(binary_message[message_index])
pixel[0] = (pixel[0] & ~1) | bit_to_write
pixels[column, row] = tuple(pixel)
message_index += 1
else:
break
else:
continue
break
print(f'[+] Saving image to: {self.output_image}')
image.save(self.output_image)
The code loops through each pixel in the image, hiding a bit of the message in the pixels until the entire message is embedded. Once the message is fully hidden within the image, it saves this modified image to a new file. This technique allows secret messages to be embedded in plain sight without detection as the image alterations are too subtle for the human eye.
With this next code, the opposite is performed. This code checks the very last bit of a color component (RGB) of each pixel to collect a series of 0s and 1s. These 0s and 1s are pieced together to form a binary message. It keeps collecting these bits until it finds the delimiter that identifies the end of the hidden message.
image = Image.open(input_image)
pixels = image.load()
binary_message = ''
for row in range(image.size[0]):
for column in range(image.size[1]):
pixel = pixels[column, row]
least_significant_bit = pixel[0] & 1
binary_message += str(least_significant_bit)
if binary_message.endswith(self.delimeter):
binary_message = binary_message[:-16] # Remove the delimiter
return self.binary_to_text(binary_message)
return "ERROR"
Once this pattern is found, the code removes this end marker and translates the binary message back into text, revealing the secret message from earlier.
This text is then printed to stdout.