Title: Use steganography to hide messages in an image in Python
This example demonstrates a cryptographic technique called "steganography." It also shows how you can use a generator function that yields values one at a time when a more direct approach would be far more difficult.
Understanding Steganography
Steganography is the science of hiding information within other information. For example, a watermark "hides" an image on a piece of paper. If you look at most paper currency at a low angle or if you hold it up to a bright light, you can see a ghostly image inside the paper. When you look at the currency straight on in normal light, you cannot see the image. Because this example is so easy to understand, steganography is sometimes called "watermarking."
Another example is a correspondence where the last letter in each word spells out a secret message. Composing this sort of message can be fun. The more constraints you place on the correspondence, the more challenging it is to write something that satisfies the constraints and still contains the hidden message. For example, try writing a poem where the last letter of each word spells out the message. Or try writing a sonnet, which has particular rules for the poem's meter and rhyme.
Today steganography is sometimes used to hide copyright information in an image, movie, or audio file. The information is carefully encrypted and hidden so you cannot easily find it. Later, if I think you've stolen my movie file, I can pull the hidden copyright information out of it to prove it's mine and not yours.
This example encodes a message in an image. For each bit in the message, the program changes the least significant bit (LSB) in a pixel's red, green, or blue color component.
Stegifying Messages
The program uses three functions to stegify an image: stegify_message, text_to_bits, and stegify_bits.
stegify_message
The stegify_message function works at the highest level. You pass it the image and the message, and it returns an image with the message hidden inside.
def stegify_message(image, message, mark_pixels=False):
'''Embed the message in this image.'''
return stegify_bits(image, text_to_bits(message), mark_pixels)
This function calls text_to_bits (described next) to convert the message into a string of 0s and 1s. It passes the result to stegify_bits, which I'll describe shortly. (I'll talk about mark_pixels a bit later.)
text_to_bits
Here's the text_to_bits function.
def text_to_bits(text):
'''Convert the text into a string of 0s and 1s.'''
return ''.join(f'{byte:08b}'
for byte in text.encode('utf-8', errors='replace'))
This function calls text.encode to convert the text into a bytes object. A generator loops through the bytes and uses an f-string to convert them into 8-bit strings of 0s and 1s. Finally, the function uses ''.join to paste the byte strings into one long string of 0s and 1s.
stegify_bits
The following stegify_bits function is where the real magic happens.
def stegify_bits(image, bits, mark_pixels=False):
'''Embed the bits in this image.'''
# Make a copy so we don't mess up the original.
image = image.copy()
# Convert to RGB if necessary.
if image.mode != 'RGB':
image = image.convert('RGB')
# Add the length of the bits list to the front as a 32-bit integer.
num_bits = len(bits)
bits = f'{num_bits:032b}' + bits
num_bits = len(bits)
# Start procssing with bit 0.
i = 0
# Process the copy's pixels.
pixels = image.load()
for y in range(image.height):
for x in range(image.width):
# Get the pixel's RGB value.
(r, g, b) = pixels[x, y]
# Store the next 3 bits.
# Red.
bit = int(bits[i])
r = 255 if mark_pixels else (r & 0b11111110) | bit
i += 1
# Green.
if i < num_bits:
bit = int(bits[i])
g = 0 if mark_pixels else (g & 0b11111110) | bit
i += 1
# Blue.
if i < num_bits:
bit = int(bits[i])
b = 0 if mark_pixels else (b & 0b11111110) | bit
i += 1
# Update the pixel.
pixels[x, y] = (r, g, b)
# If we've run out of message, we're done.
if i >= num_bits:
return image
# If we've run out of pixels, we're done.
return image
The function performs a few startup tasks like making a copy of the image and converting it to RGB format if necessary.
When we're decoding the message, we need to know how many bits to pull out of the image. To make that possible, the function gets the length of the message bits and adds that number to the beginning of the message. That way, in the final encoded image, the first 32 bits give the length of the message that follows.
The function then calls the image's load method so it can access its pixels quickly. It then loops through the image's pixels by row and column.
For each pixel in the image, the code gets that pixel's red, green, and blue color components. It modifies the red color component to store the message's next bit.
If the mark_pixels parameter is True, it makes the red component 255, which means fully red.
If mark_pixels is False, the code masks the red component with the binary value 11111110 to clear its LSB. It then use | to set that bit to the message's next bit value.
(See, I told you I'd get to the mark_pixels parameter. The program uses that to tell whether the function should actually encode the message or whether it should mark the pixels that would store the message in red. You'll see how the program uses this a bit later.)
At this point (ignoring mark_pixels for a while), the LSB of the red component holds the message's next bit.
The code increments i to consider the message's next bit. If i is less than the message's length, the code uses similar steps to encode the message's next bit in the pixel's green component. This time if mark_pixels is True, the code sets the green component to 0.
The function repeats the same steps to set the blue component. (If mark_pixels is True, the pixel's components are set to red = 255, green = 0, blue = 0, so the pixel is bright red.)
Having adjusted the LSBs in the pixel's components, the code updates the pixel in the image.
When the runs out of message or uses all of the image's pixels, the function returns the new image. (If the image isn't big enough to hold the message, it will probably be impossible to decode the message because it will likely end in the middle of a Unicode character's bits. You could try to automatically shorten the message, but I'm just going to ignore this issue.)
Destegifying Messages
Destegifying an image to recover the hidden message also requires three functions: bits_to_text, bit_extractor, and destegify_image.
(Note that I made up the terms "stegify" and "destegify" so, if you use them in the cafeteria, the other kids will probably have no clue what you're talking about.)
bits_to_text
The following bits_to_text function converts a string of 0s and 1s back into a Unicode string.
def bits_to_text(bits):
'''Convert a string of 0s and 1s into text.'''
bytes = int(bits, 2).to_bytes(len(bits) // 8, byteorder='big')
return bytes.decode('utf-8')
This function uses int(bits, 2) to convert the bit string into a huge integer and then uses to_bytes to convert the huge integer into a bytes object. It uses decode to convert the bytes into a UTF-8 string and returns the result.
bit_extractor
The following bit_extractor function is a generator function. Each time you invoke it, it returns a single bit for the encoded message. You'll see how that works shortly.
def bit_extractor(image):
'''Extract bits from this image.'''
# Load the pixels.
pixels = image.load()
# Process the pixels.
for y in range(image.height):
for x in range(image.width):
# Get the pixel's RGB value.
(r, g, b) = pixels[x, y]
# Yield the next 3 bits.
yield str(r & 0b00000001)
yield str(g & 0b00000001)
yield str(b & 0b00000001)
This method calls image.load so it can easily work with the image's pixels. It then loops through the pixels.
For each pixel, it uses the mask 00000001 to extract the LSB from the pixel's red, green, and blue color components and yields each of those values.
This approach makes the function very simple. It just yields bits as long as it is invoked. Note that it doesn't stop after it has returned all of the message's bits. It's up to the calling code to know how many times it should extract a bit. If the program keeps asking the generator for more bits, this function will spit out whatever random values are in the pixels following the message bits.
destegify_image
The destegify_image function is the high-level function you call to destegify an image.
def destegify_image(image):
'''Extract the bits from this image.'''
# Get a bit extractor.
extractor = bit_extractor(image)
# Get the 32-bit stored message length.
bits = ''.join(next(extractor) for i in range(32))
num_bits = int(bits, 2)
# Get the message bits.
bits = ''.join(next(extractor) for i in range(num_bits))
# Convert the bits into bytes.
bytes = int(bits, 2).to_bytes(num_bits // 8, byteorder='big')
# Decode the Unicode and return the result.
return bytes.decode('utf-8')
The code first gets a bit_extractor for the image. It then uses a for loop to call next(extractor) 32 times to fetch 32 bits.
|
ASIDE: Here's where the bit extractor really pays off. If you tried to directly fetch 32 bits from the image, you would need to use the color components from 10 pixels plus the red and green components in the 11th pixel. Later, when you needed to fetch the message's bits, you would need to start at the 11th pixel's blue component.
That's all doable, but it's much easier to use the extractor which uses yield so the program doesn't need to keep track of where it should find the next bit. The extractor just returns bits and lets the main program retrieve them one at a time.
|
Having retrieved the first 32 bits, destegify_image uses ''.join to join the bits into a string and uses int to convert the bit string into an integer. That value is the length of the message bits that we added to the beginning of the message earlier in the stegify_bits function.
Next, the code repeats the previous steps to get the message bits. It calls next(extractor) once for each message bit and uses ''.join to glue them all together in a string of bits. It then uses int to convert the bit string into an integer and uses to_bytes to break the integer into bytes. Finally, it calls the bytes object's decode method to convert the bytes into a Unicode string and returns the result.
Using the Program
Use the File menu's Open command to load an image. Enter a message and click Encode to stegify the image and message. The program displays the results on its tabs.
- Original: This tab shows the original image.
- Encoded: This tab shows the stegified image containing the hidden message. If you switch back and forth between this image and the original, you won't be able to tell the difference.
- Marked: This tab shows the original image with the message bits colored red, as shown in the picture on the right.
- Decoded: This tab shows the destegified message.
Analyzing the Results
Because the program modifies the LSBs of the pixels' color components, the changes are tiny. If the message and pixels LSBs are random (they're not, but close enough for horseshoes and hand grenades), a message bit would be the same as the pixel's bit half of the time, making the changes even harder to notice.
If the image is a photograph, which never has large blocks of perfectly uniform color, it will be impossible to notice the change. Even if you use an image with large areas of solid color, you probably won't be able to tell the difference, although a program could if it was checking specifically for that. To be extra safe, use a photograph or add some shading so the image doesn't have blocks of perfectly uniform color.
The marked image above shows an example where I encoded the Gettysburg address plus three hat emojis (just to prove the method works with Unicode characters). You can see how little of the image is needed to encode the whole thing. The message is 1,455 characters long, which converts into 11,712 Unicode bits. Each pixel holds three bits of information, so we need 11,712 / 3 = 3,904 pixels. The image is 376 pixels wide, so the message occupies 3,904 / 376 ≈ 10.38 rows of pixels. This image could hold a LOT more data! (The image is from my book Build Your Own Python Action Arcade!)
The picture on the right shows the destegified message. You can see that the message with its three emoji hats has been decoded correctly. (Well, you can see the start of the message anyway.)
Using the Right File Format
Note that it is critical that you save the stegified image in a lossless format like BMP or PNG. If you save it in a lossy format like JPG or GIF, the compression algorithm will almost certainly change some of the message's pixels and that will make the message unrecoverable.
Conclusion
In my next post or two, I'll improve this program to make it more secure and easier to use in real life.
Download the example to experiment with it and to see additional details.
|