GIF Steganography from First Principles (2023)

2 hours ago 1

DTM

Sep 10, 2023 26 min read

GIF Steganography from First Principles Photo by Michael Maasen / Unsplash

Inspiration

Adam's blog on PNG steganography inspired me to start a project in a similar vein but focused on GIF files instead. Although I have previously written several .NET-based steganography tools previously, I have typically relied on existing libraries for the steganography part without delving into the fundamentals. It was great to step through the file format with scrappy code examples before refactoring it into a more complete solution, making it easier to understand and add the steganography in. Once you understand the file format it becomes clear just how many opportunities to hide data are there.

Why a GIF file?

I was intrigued by the possibility of hiding information across multiple frames. Additionally, animated GIF files are generally large files. By the end of this blog, you will be able to hide data across multiple frames of an animation which means that you can store more data with less visual artefacts.

How is a GIF Structured?

Before diving into the steganography part, it's crucial to understand the anatomy of a GIF file. A GIF (Graphics Interchange Format) is a bitmap image format that supports animations.

After looking at a bunch of different GIFs I made this simplified diagram showing a rough structure for an animated GIF.

gif_blog_image_1

You can read more in the latest spec here. We will step the most important parts below.

The GIF header tells you which version of the GIF standard the file uses. The two main versions are GIF87a and GIF89a.

Here is an example header:

gif_blog_image_2

Logical Screen Descriptor

Think of the logical screen descriptor as the stage where your GIF will be displayed. It sets the size of the "stage" by specifying the screen width and height. It also tells you the background color of the stage.

struct LogicalScreenDescriptor { uint16_t width; // Logical Screen Width (2 bytes) uint16_t height; // Logical Screen Height (2 bytes) uint8_t packed; // Packed Fields (1 byte) uint8_t backgroundColor; // Background Color Index (1 byte) uint8_t pixelAspectRatio; // Pixel Aspect Ratio (1 byte) };

Here’s is some Python code to read the logical screen descriptor for a GIF file:

def read_logical_screen_descriptor(file_path): with open(file_path, 'rb') as f: # Skip the GIF header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor screen_width = int.from_bytes(f.read(2), 'little') screen_height = int.from_bytes(f.read(2), 'little') packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0b10000000) >> 7 color_res = (packed_fields & 0b01110000) >> 4 sort_flag = (packed_fields & 0b00001000) >> 3 gct_size = packed_fields & 0b00000111 bg_color_index = int.from_bytes(f.read(1), 'little') pixel_aspect_ratio = int.from_bytes(f.read(1), 'little') print(f"Logical Screen Width: {screen_width}") print(f"Logical Screen Height: {screen_height}") print(f"Global Color Table Flag: {gct_flag}") print(f"Color Resolution: {color_res}") print(f"Sort Flag: {sort_flag}") print(f"Size of Global Color Table: {gct_size}") print(f"Background Color Index: {bg_color_index}") print(f"Pixel Aspect Ratio: {pixel_aspect_ratio}")

Ok let’s grab a GIF and give this a try:

wget https://media.giphy.com/media/s51QoNAmM6dkWcSC0P/giphy.gif

Running the above function:

>>> read_logical_screen_descriptor('giphy.gif') Logical Screen Width: 480 Logical Screen Height: 450 Global Color Table Flag: 1 Color Resolution: 7 Sort Flag: 0 Size of Global Color Table: 7 Background Color Index: 255 Pixel Aspect Ratio: 0

Global Color Table

The Global Color Table is a table of RGB values that represent the overall color palette for the GIF. Below is the 256 colors extracted from the downloaded GIF - this was rendered in HTML generated with the below code which parses out the global color table:

gif_blog_image_3

gif_blog_image_4

import struct def read_global_color_table(file_path): with open(file_path, 'rb') as f: # Skip GIF Header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor screen_width, screen_height = struct.unpack("<HH", f.read(4)) packed_fields = struct.unpack("<B", f.read(1))[0] bg_color_index = struct.unpack("<B", f.read(1))[0] pixel_aspect_ratio = struct.unpack("<B", f.read(1))[0] # Check if Global Color Table exists (bit 7 of packed_fields) gct_flag = (packed_fields & 0b10000000) >> 7 if gct_flag: # Determine the size of the Global Color Table gct_size_bits = packed_fields & 0b00000111 gct_size = 2 ** (gct_size_bits + 1) print(f"Global Color Table Size: {gct_size} colors") # Read Global Color Table gct_data = f.read(3 * gct_size) # Each color is 3 bytes (RGB) # Create a list of RGB triplets gct_colors = [(gct_data[i], gct_data[i + 1], gct_data[i + 2]) for i in range(0, len(gct_data), 3)] # Print or use Global Color Table as needed for i, color in enumerate(gct_colors): print(f"Color {i}: R={color[0]} G={color[1]} B={color[2]}") return gct_colors else: print("No Global Color Table present.") return [] def rgb_to_hex(r, g, b): return "#{:02x}{:02x}{:02x}".format(r, g, b) colors = read_global_color_table('giphy.gif') with open("colours.html", "w") as fh: i = 0 fh.write("<div style=\"width: 512px; word-wrap: break-word\">") for color in colors: html_code = rgb_to_hex(color[0], color[1], color[2]) fh.write(f"<font color={html_code}><b>{i}</b></font>&nbsp;") i += 1 fh.write("</div>")

Running this for our GIF gives:

Global Color Table Size: 256 colors Color 0: R=17 G=8 B=7 Color 1: R=19 G=15 B=21 Color 2: R=25 G=10 B=7 Color 3: R=25 G=16 B=22 Color 4: R=26 G=20 B=20 Color 5: R=26 G=20 B=26 Color 6: R=29 G=23 B=30 Color 7: R=30 G=17 B=15 Color 8: R=30 G=20 B=25 Color 9: R=34 G=27 B=32 Color 10: R=36 G=25 B=24 --snip--

GIF Data

The main data section of a GIF typically starts with an Application Extension block:

Application Extension

struct GIFApplicationExtension { uint8_t extensionIntroducer; // Should be 0x21 uint8_t applicationLabel; // Should be 0xFF uint8_t blockSize; // Typically 11 uint8_t appData[APP_EXT_BLOCK_SIZE]; // Application Identifier and Authentication Code }; struct GIFDataSubBlock { uint8_t size; // 0 <= size <= 255 uint8_t* data; // Dynamic array to hold data };

Here’s a Python function scan through and find and read the Application Extension:

def read_application_extension(file_path): with open(file_path, 'rb') as f: # Skip the GIF header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor (7 bytes) f.read(4) # Skip screen width and height packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0x80) >> 7 gct_size = packed_fields & 0x07 f.read(2) # Skip background color index and pixel aspect ratio # Skip Global Color Table if it exists if gct_flag: gct_length = 3 * (2 ** (gct_size + 1)) f.read(gct_length) # Loop through the blocks to find the first Application Extension while True: block_type = f.read(1) if not block_type: raise EOFError("Reached end of file without finding an Application Extension.") if block_type == b'\x21': # Extension Introducer next_byte = f.read(1) if next_byte == b'\xFF': # Application Extension Label # Process Application Extension block_size = int.from_bytes(f.read(1), 'little') if block_size == 11: # Typically 11 for Application Extension app_identifier = f.read(8).decode('ascii') app_auth_code = f.read(3).hex() print(f"Block Size: {block_size}") print(f"Application Identifier: {app_identifier}") print(f"Application Authentication Code: {app_auth_code}") # Reading and displaying sub-blocks while True: sub_block_size = int.from_bytes(f.read(1), 'little') if sub_block_size == 0: break else: sub_block_data = f.read(sub_block_size) print(f"Sub-block Size: {sub_block_size}") print(f"Sub-block Data: {sub_block_data.hex()}") break else: # Skip other types of extensions extension_length = int.from_bytes(f.read(1), 'little') f.read(extension_length)

For our GIF from Giphy we get:

Block Size: 11 Application Identifier: NETSCAPE Application Authentication Code: 322e30 Sub-block Size: 3 Sub-block Data: 010000

The last two bytes indicate how many times the GIF file should loop up to 65,535 times. 0 indicates unlimited looping.

Per Frame

Image Descriptor

This is like a snapshot of each scene or frame in your GIF. It tells you where the top-left corner of the image starts and how big the image is.

struct GIFImageDescriptor { uint16_t left_position; // X position of image in the logical screen uint16_t top_position; // Y position of image in the logical screen uint16_t width; // Width of the image uint16_t height; // Height of the image uint8_t packed_field; // Packed fields that indicate various settings, such as local color table presence // bit 0: Local Color Table Flag (1 = Local Color Table Present) // bit 1: Interlace Flag // bit 2: Sort Flag // bit 3-4: Reserved // bit 5-7: Size of Local Color Table (if present) }

Let’s loop until we find the first Image Descriptor:

def read_gif_image_descriptor(file_path): with open(file_path, 'rb') as f: # Skip the GIF header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor (7 bytes) f.read(4) # Skip screen width and height packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0x80) >> 7 gct_size = packed_fields & 0x07 f.read(2) # Skip background color index and pixel aspect ratio # Skip Global Color Table if it exists if gct_flag: gct_length = 3 * (2 ** (gct_size + 1)) # 3 bytes for each color f.read(gct_length) # Loop through the blocks to find the first Image Descriptor while True: block_type = f.read(1) if not block_type: raise EOFError("Reached end of file without finding an image descriptor.") if block_type == b'\x2C': # Read and process Image Descriptor (9 bytes) left_position = int.from_bytes(f.read(2), 'little') top_position = int.from_bytes(f.read(2), 'little') width = int.from_bytes(f.read(2), 'little') height = int.from_bytes(f.read(2), 'little') packed_field = int.from_bytes(f.read(1), 'little') local_color_table_flag = (packed_field & 0x80) >> 7 interlace_flag = (packed_field & 0x40) >> 6 sort_flag = (packed_field & 0x20) >> 5 reserved = (packed_field & 0x18) >> 3 local_color_table_size = packed_field & 0x07 print(f"Left Position: {left_position}") print(f"Top Position: {top_position}") print(f"Image Width: {width}") print(f"Image Height: {height}") print(f"Local Color Table Flag: {local_color_table_flag}") print(f"Interlace Flag: {interlace_flag}") print(f"Sort Flag: {sort_flag}") print(f"Reserved: {reserved}") print(f"Size of Local Color Table: {local_color_table_size}") break

Running this on our GIF we get for the first frame:

Left Position: 0 Top Position: 0 Image Width: 480 Image Height: 450 Local Color Table Flag: 0 Interlace Flag: 0 Sort Flag: 0 Reserved: 0 Size of Local Color Table: 0

Local Color Table

The Local Color Table (LCT) allows each frame to have its own color palette. Otherwise each frame will use the Global Color Table. We will skip past this for now as it looks the same as the GCT and will be parsed if the LCT flag is set in the Image Descriptor.

Graphics Control Extension

This is the director of your GIF movie. It tells each frame how long to stay on screen.

struct GraphicsControlExtension { uint8_t block_size; // Block Size: Fixed as 4 bytes (should be 0x04) uint8_t packed; // Packed Field: Various flags uint16_t delay_time; // Delay Time: Time for this frame in hundredths of a second uint8_t transparent_color; // Transparent Color Index uint8_t block_terminator; // Block Terminator: Fixed as 0 (0x00) };

Now let’s scan for the graphics control extension in Python:

def read_graphics_control_extension(file_path): with open(file_path, 'rb') as f: # Skip the GIF header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor (7 bytes) f.read(4) # Skip screen width and height packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0x80) >> 7 gct_size = packed_fields & 0x07 f.read(2) # Skip background color index and pixel aspect ratio # Skip Global Color Table if it exists if gct_flag: gct_length = 3 * (2 ** (gct_size + 1)) f.read(gct_length) # Loop through the blocks to find the first Graphics Control Extension while True: block_type = f.read(1) if not block_type: raise EOFError("Reached end of file without finding a Graphics Control Extension.") if block_type == b'\x21': # Extension Introducer next_byte = f.read(1) if next_byte == b'\xF9': # Graphic Control Label # Process Graphics Control Extension block_size = int.from_bytes(f.read(1), 'little') packed_fields = int.from_bytes(f.read(1), 'little') disposal_method = (packed_fields & 0b00011100) >> 2 user_input_flag = (packed_fields & 0b00000010) >> 1 transparent_color_flag = packed_fields & 0b00000001 delay_time = int.from_bytes(f.read(2), 'little') transparent_color_index = int.from_bytes(f.read(1), 'little') block_terminator = int.from_bytes(f.read(1), 'little') print(f"Block Size: {block_size}") print(f"Disposal Method: {disposal_method}") print(f"User Input Flag: {user_input_flag}") print(f"Transparent Color Flag: {transparent_color_flag}") print(f"Delay Time: {delay_time}") print(f"Transparent Color Index: {transparent_color_index}") print(f"Block Terminator: {block_terminator}") break else: # Skip other types of extensions extension_length = int.from_bytes(f.read(1), 'little') f.read(extension_length)

Running this on our GIF we get:

Block Size: 4 Disposal Method: 1 User Input Flag: 0 Transparent Color Flag: 1 Delay Time: 7 Transparent Color Index: 255 Block Terminator: 0

Image Data

This containts the actual picture data for each frame, usually compressed to save space.

struct ImageData{ uint8_t LZWMinimumCodeSize; // The minimum LZW code size }; struct SubBlock{ uint8_t size; // 0 <= size <= 255 uint8_t* data; // Dynamic array to hold data } ;

To scan for and dump image data let’s use this Python function:

def read_image_data(file_path): with open(file_path, 'rb') as f: # Skip the GIF header (6 bytes: GIF87a or GIF89a) f.read(6) # Read Logical Screen Descriptor (7 bytes) f.read(4) # Skip screen width and height packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0x80) >> 7 gct_size = packed_fields & 0x07 f.read(2) # Skip background color index and pixel aspect ratio # Skip Global Color Table if it exists if gct_flag: gct_length = 3 * (2 ** (gct_size + 1)) f.read(gct_length) # Loop through the blocks to find the first Image Descriptor while True: block_type = f.read(1) if not block_type: raise EOFError("Reached end of file without finding an Image Descriptor.") if block_type == b'\x2C': # Image Descriptor # Skip the Image Descriptor and focus on Image Data f.read(9) # Skip the next 9 bytes # Read the LZW Minimum Code Size LZW_min_code_size = int.from_bytes(f.read(1), 'little') print(f"LZW Minimum Code Size: {LZW_min_code_size}") # Reading and displaying sub-blocks while True: sub_block_size = int.from_bytes(f.read(1), 'little') if sub_block_size == 0: break else: sub_block_data = f.read(sub_block_size) print(f"Sub-block Size: {sub_block_size}") print(f"Sub-block Data: {sub_block_data.hex()}") break elif block_type == b'\x21': # Extension Introducer # Skip other types of extensions f.read(1) # Read the label extension_length = int.from_bytes(f.read(1), 'little') f.read(extension_length) while True: sub_block_size = int.from_bytes(f.read(1), 'little') if sub_block_size == 0: break else: f.read(sub_block_size)

We get the following (truncated for brevity):

LZW Minimum Code Size: 8 Sub-block Size: 255 Sub-block Data: 0021b0d9c46a132f579776a4d89022058a872d5a9c904163860a1327326ad478b1a38a152041ce1839b286491d4692e8a8926a1dbf973063ca9c49b3a6cd9b3863f6a3b793674d9f3d830a054a3427cda247911a5d9a54a9d3a750a3ea1bda542ad5ab56874edd2aafabd7aff6c28a1d4bb6ac59b068cdee5bcbb6edbab770e3ba9d4bf79dddbb78dde985b7f7aebc04ed041274b509e19286881f9a904871468c8d90217f9c1c72068b91974ba6ac32a825d3cfa03f631dad9574d6a2a64fab5e4d34356bd45c63bb362dbbb66dd8b6d3aaddcd9b6e6fb2f5820bcf3bbc38f1e3c8fb2a5fee17b0e04ebc0a5f7ae322f1431412279abc88b17b648e94c387 Sub-block Size: 255 Sub-block Data: 34d9a44a931a4d068d8b1abafdcb9df77ace9c2d541fb9dbf45febdfcfff3557dca5e517207e030ea8db816af9b38f820cfaf6db837519275772143267215e6139371074d2258203620ea140824430a8301277dda5e85d461e7d74c245218994847926cd618c4bfe40e59e68f16d958f3e3f0a58a09044f667e4910016595b924a32991b82504228e56f125668e59558daf55760686cd20961ae2cd2c8611634741d0a1849f4d1082ab66982089179f4e29c275ca6430d2c88500532f6f013568eeced58557c3df6a8536b4d268aa8a248367a24818b1209e9935156ea606f0d4e59e5a69c667921725b3e77902b08bda11043253834029a18a1c8e6abafb6 Sub-block Size: 255 Sub-block Data: f95d9c75d6908449490ca20da0bc062ae87c3d197ae87b8c166baca3c8f63769a4f42d4ba9a59a460b21851376eae9a77c6919aa199b74cbcb22618e99429929942022092330e66aac1f8cd0aebb70aaf85dbc27e830c77922d430883a7ff6aae3aff4e443aca1c2126bf0b14e229cecc28d3a8bf0b395422bedc4d6567bedc51886da2587a48aa94675a89a8b2e9b159d300208b0c2fa2ebbf1c2e972cb75563147127526a1c936dc68a3cecee38cb30ea000e7049f8f441fac70c24733acf4d20f1b18b1c453661a6dc554639ced711906c6ad41602264c6a9a88a28620b37a87402ca29bb9b769bf1b6c982bd55d480420c4f58620a33d248934ade3b5f ..........

Let’s validate we are on the right track here and dump some frames.

from PIL import Image import numpy as np from collections import deque def lzw_decode(min_code_size, compressed): clear_code = 1 << min_code_size eoi_code = clear_code + 1 next_code = eoi_code + 1 current_code_size = min_code_size + 1 dictionary = {i: [i] for i in range(clear_code)} dictionary[clear_code] = [] dictionary[eoi_code] = None def read_bits(bit_count, bit_pos, data): value = 0 for i in range(bit_count): byte_pos = (bit_pos + i) // 8 bit_offset = (bit_pos + i) % 8 if data[byte_pos] & (1 << bit_offset): value |= 1 << i return value bit_pos = 0 data_length = len(compressed) * 8 output = deque() current_code = None while bit_pos + current_code_size <= data_length: code = read_bits(current_code_size, bit_pos, compressed) bit_pos += current_code_size if code == clear_code: current_code_size = min_code_size + 1 next_code = eoi_code + 1 dictionary = {i: [i] for i in range(clear_code)} dictionary[clear_code] = [] dictionary[eoi_code] = None current_code = None elif code == eoi_code: break else: if code in dictionary: entry = dictionary[code] elif code == next_code: entry = dictionary[current_code] + [dictionary[current_code][0]] else: raise ValueError(f"Invalid code: {code}") output.extend(entry) if current_code is not None: dictionary[next_code] = dictionary[current_code] + [entry[0]] next_code += 1 if next_code >= (1 << current_code_size): if current_code_size < 12: current_code_size += 1 current_code = code return list(output) def read_and_dump_frames(file_path): frame_counter = 0 global_frame_data = None # To hold the entire frame canvas with open(file_path, 'rb') as f: f.read(6) # Skip the GIF header global_width = int.from_bytes(f.read(2), 'little') global_height = int.from_bytes(f.read(2), 'little') packed_fields = int.from_bytes(f.read(1), 'little') gct_flag = (packed_fields & 0x80) >> 7 gct_size = packed_fields & 0x07 f.read(2) # Skip remaining fields if gct_flag: gct_length = 3 * (2 ** (gct_size + 1)) global_color_table = np.array(list(f.read(gct_length))).reshape(-1, 3) # Initialize global_frame_data to zeros global_frame_data = np.zeros((global_height, global_width, 3), dtype=np.uint8) while True: block_type = f.read(1) if not block_type: print("Reached end of file.") break if block_type == b'\x2C': # Image Descriptor left_position = int.from_bytes(f.read(2), 'little') top_position = int.from_bytes(f.read(2), 'little') width = int.from_bytes(f.read(2), 'little') height = int.from_bytes(f.read(2), 'little') packed_field = int.from_bytes(f.read(1), 'little') interlace_flag = (packed_field & 0x40) >> 6 local_color_table_flag = (packed_field & 0x80) >> 7 if local_color_table_flag: lct_size = packed_field & 0x07 lct_length = 3 * (2 ** (lct_size + 1)) local_color_table = np.array(list(f.read(lct_length))).reshape(-1, 3) else: local_color_table = global_color_table LZW_min_code_size = int.from_bytes(f.read(1), 'little') compressed_data = bytearray() while True: sub_block_size = int.from_bytes(f.read(1), 'little') if sub_block_size == 0: break compressed_data += f.read(sub_block_size) decoded_data = lzw_decode(LZW_min_code_size, compressed_data) frame_data = np.zeros((height, width, 3), dtype=np.uint8) if interlace_flag: interlace_order = [] interlace_order.extend(range(0, height, 8)) interlace_order.extend(range(4, height, 8)) interlace_order.extend(range(2, height, 4)) interlace_order.extend(range(1, height, 2)) reordered_data = [None] * height for i, row in enumerate(interlace_order): start_index = row * width end_index = start_index + width reordered_data[row] = decoded_data[start_index:end_index] decoded_data = [pixel for row_data in reordered_data if row_data is not None for pixel in row_data] for i, pixel in enumerate(decoded_data): row = i // width col = i % width frame_data[row, col] = local_color_table[pixel] # Overlay the new frame_data onto the global frame global_frame_data[top_position:top_position+height, left_position:left_position+width] = frame_data frame_img = Image.fromarray(global_frame_data.astype('uint8'), 'RGB') frame_img.save(f'frame_{frame_counter}.png') frame_counter += 1 elif block_type == b'\x21': # Extension f.read(1) # Extension function code extension_length = int.from_bytes(f.read(1), 'little') f.read(extension_length) # Skip extension data while True: sub_block_size = int.from_bytes(f.read(1), 'little') if sub_block_size == 0: break f.read(sub_block_size) read_and_dump_frames("giphy.gif.1")

For this we had to handle the LZW compression before writing to a image format. We can see that we successfully get frames out:

gif_blog_image_5

Trailer

The GIF trailer is a single-byte block containing the hexadecimal value 0x3B. In ASCII, this corresponds to a semicolon (;).

Refactoring

At this point, I spent a bunch of time taking all of the code snippets above and making an overall python class. This brings them all together before diving into implementing the logic for hiding and recovering data.

Hiding Data

A common strategy used to hide data in images is called Least Significant Bit (LSB) steganography. In simple terms, it replaces the "least important" bits of the image with the data to be hidden.

def lsb_encode(self, frame_data, byte_array): for byte_index, byte_val in enumerate(byte_array): for bit_index in range(8): pixel_index = byte_index * 8 + bit_index frame_data[pixel_index] &= 0xFE # Clear the least significant bit frame_data[pixel_index] |= (byte_val >> bit_index) & 1 # Set the least significant bit return frame_data

The general logic for hiding data in a frame is as follows:

  • Take the uncompressed frame data.
  • Encode the hidden data (plus a magic signature to validate/detect the end later) into this uncompressed frame data.
  • LZW compress the new frame data.
  • Chunk the compressed data and generate the sub blocks containing the new frame data.
  • Replace these sub blocks in the output GIF file

The relevant code snippet is shown below:

uncompressed_frame = self.lzw_decode(min_code_size, data) if self.hide: if self.frames < len(self.blobs) and len(self.blobs[self.frames] + self.magic_code) > (len(uncompressed_frame) / 8): print("Warning: Blob to be hidden was too big, skipping") hidden_frame = uncompressed_frame elif self.frames < len(self.blobs): if len(self.blobs[self.frames]) > 0: blob_to_hide = self.blobs[self.frames] + self.magic_code hidden_frame = self.lsb_encode(uncompressed_frame, blob_to_hide) else: hidden_frame = uncompressed_frame else: hidden_frame = uncompressed_frame hidden_frame_compressed = self.lzw_encode(min_code_size, hidden_frame) new_sub_blocks = self.generate_sub_blocks(hidden_frame_compressed) for block in new_sub_blocks: self.buffer.extend(block.sub_block_size.to_bytes(1, 'little')) self.buffer.extend(block.sub_block_data) self.append_read_to_buffer = True

Recovering Data

To recover data we just take the uncompressed frame data and run the following LSB decode function. I check for a magic signature as a way to validate things have gone to plan and to denote the end of the data we want.

def lsb_decode(self, frame_data): num_bytes = len(frame_data) // 8 decoded_bytes = bytearray() for byte_index in range(num_bytes): decoded_byte = 0 for bit_index in range(8): pixel_index = byte_index * 8 + bit_index decoded_byte |= (frame_data[pixel_index] & 1) << bit_index # Extract the least significant bit decoded_bytes.append(decoded_byte) if self.magic_code in decoded_bytes: return decoded_bytes.split(self.magic_code)[0] else: return bytearray()

We now have a Python library that implements all of the basic logic needed. Let's use a wrapper tool that makes it easy to play around with this stuff.

It has the following modes:

  • hide - hide multiple files across multiple frames of a GIF
  • recover - recover multiple files from multiple frames of a GIF
  • spread - hide a single file across all of the frames of a GIF
  • gather - recover a single file that is hidden across all frames of a GIF
  • analyze - analyze a GIF and dump all the frames to PNG files

You can access the code at:
https://github.com/dtmsecurity/gift

python3 gift-cli.py usage: gift-cli.py [-h] [--source SOURCE] [--dest DEST] {hide,recover,analyze,spread,gather} filenames [filenames ...] gift-cli.py: error: the following arguments are required: mode, filenames

Let’s start playing - We are going to hide a text file and a jpg in a GIF:

python3 gift-cli.py --source giphy.gif --dest output.gif hide hello.txt meme.jpg Hiding files in giphy.gif and writing to output.gif We will hide: hello.txt We will hide: meme.jpg Doing magic... Done...now writing to output.gif

gif_blog_image_6

gif_blog_image_7

Despite the payloads only being introduced to the first two frames, due to the nature of GIF files you can still see the artefacts of the encoding in the following frames. For this example, it’s pretty cool to visualise things at least. By choosing how much data to hide and targeting particular frames or spreading the data we can minimise any visible differences easily.

Below is the first two frames dumped, as you can see the small text file in Frame 1 is barely noticeable. The meme in frame 2 shows a bit more.

gif_blog_image_8

gif_blog_image_9

Let’s recover the files we hid:

python3 gift-cli.py --source output.gif recover recovered_hello.txt recovered_meme.jpg Recovering files from output.gif Recovering recovered_hello.txt Recovering recovered_meme.jpg % shasum hello.txt recovered_hello.txt 22596363b3de40b06f981fb85d82312e8c0ed511 hello.txt 22596363b3de40b06f981fb85d82312e8c0ed511 recovered_hello.txt % shasum meme.jpg recovered_meme.jpg a1838d4cb7cd2311dae420ec6bc8688e56dc5414 meme.jpg a1838d4cb7cd2311dae420ec6bc8688e56dc5414 recovered_meme.jpg

gif_blog_image_10

Awesome! We successful got the original files back. Now I wanted to be able to spread a single file across all frames to reduce the visual impact. For this we use the ‘spread’ feature of the tool.

python3 gift-cli.py --source giphy.gif --dest output.gif spread meme.jpg Hiding file across frames of giphy.gif and writing to output.gif We will hide: meme.jpg We have split meme.jpg into 118 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 47 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Chunk of size 46 Doing magic... Done...now writing to output.gif

gif_blog_image_11

You can now barely see the artefacts of encoding! Let’s use the ‘gather’ feature to recover our file.

python3 gift-cli.py --source output.gif gather recovered_meme.jpg Recovering files from output.gif Recovering recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 47 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg Writing recovered blob of size 46 to recovered_meme.jpg % shasum meme.jpg a1838d4cb7cd2311dae420ec6bc8688e56dc5414 meme.jpg % shasum recovered_meme.jpg a1838d4cb7cd2311dae420ec6bc8688e56dc5414 recovered_meme.jpg

gif_blog_image_12

The final feature to introduce is the analyze function. This returns information on a GIF as well as dumping the frames:

python3 gift-cli.py analyze output.gif --- GIF INFO --- header = GIF89a frames = 118 --- LOGICAL SCREEN DESCRIPTOR --- screen_width = 500 screen_height = 375 packed_fields = 246 gct_flag = 1 color_res = 7 sort_flag = 0 gct_size = 6 bg_color_index = 57 pixel_aspect_ratio = 0 --- GLOBAL COLOR TABLE --- gct_size = 128 gct_colors = [(21, 163, 221), (6, 3, 16), (15, 141, 200), (2, 101, 151), (56, 173, 230), (37, 202, 252), (5, 36, 87), (36, 217, 255), (39, 74, 122), (21, 202, 250), (42, 118, 170), (2, 63, 124), (41, 100, 148), (1, 87, 151), (2, 104, 168), (1, 84, 135), (52, 202, 253), (2, 126, 185), (11, 124, 207), (2, 117, 171), (56, 217, 254), (2, 70, 146), (31, 232, 255), (32, 56, 103), (23, 122, 185), (24, 84, 134), (22, 103, 162), (22, 87, 151), (20, 118, 169), (21, 185, 241), (21, 68, 145), (37, 183, 242), (2, 184, 240), (49, 143, 197), (55, 190, 244), (83, 38, 69), (28, 87, 165), (10, 178, 226), (38, 185, 221), (37, 212, 243), (52, 210, 241), (71, 200, 252), (20, 214, 245), (12, 237, 255), (21, 218, 255), (5, 104, 186), (21, 103, 188), (0, 214, 244), (6, 86, 171), (3, 202, 247), (5, 219, 254), (66, 100, 140), (72, 192, 187), (12, 248, 230), (49, 224, 210), (122, 164, 195), (78, 21, 30), (116, 20, 28), (80, 4, 8), (108, 34, 44), (146, 20, 30), (145, 34, 42), (153, 9, 15), (178, 20, 23), (196, 17, 26), (115, 61, 100), (107, 88, 134), (30, 205, 151), (42, 161, 75), (109, 84, 32), (207, 46, 47), (146, 142, 67), (212, 71, 74), (255, 255, 255), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)] --- APPLICATION EXTENSIONS --- block_size = 11 app_identifier = NETSCAPE app_auth_code = 322e30 sub_block_size: 3 sub_block_data: b'\x01\x00\x00' sub_block_size: 60 sub_block_data: b'?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpm' sub_block_size: 101 sub_block_data: b'ta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 6.0-c002 79.164460, 2020/05/12-16:04:17 ">' sub_block_size: 32 sub_block_data: b'<rdf:RDF xmlns:rdf="http://www.w' sub_block_size: 51 sub_block_data: b'.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description r' sub_block_size: 100 sub_block_data: b'f:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xm' sub_block_size: 108 sub_block_data: b'ns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmp:CreatorTool="Adobe Photoshop 21.2 (Macintosh)"' sub_block_size: 32 sub_block_data: b'xmpMM:InstanceID="xmp.iid:2A4A11' sub_block_size: 55 sub_block_data: b'951C011EB8B3DEEE1F100462C" xmpMM:DocumentID="xmp.did:2A' sub_block_size: 52 sub_block_data: b'A117A51C011EB8B3DEEE1F100462C"> <xmpMM:DerivedFrom s' sub_block_size: 116 sub_block_data: b'Ref:instanceID="xmp.iid:2A4A117751C011EB8B3DEEE1F100462C" stRef:documentID="xmp.did:2A4A117851C011EB8B3DEEE1F100462C' sub_block_size: 34 sub_block_data: b'/> </rdf:Description> </rdf:RDF> <' sub_block_size: 47 sub_block_data: b'x:xmpmeta> <?xpacket end="r"?>\x01\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8\xf7\xf6\xf5\xf4\xf3\xf2\xf1\xf0' sub_block_size: 239 sub_block_data: b'\xee\xed\xec\xeb\xea\xe9\xe8\xe7\xe6\xe5\xe4\xe3\xe2\xe1\xe0\xdf\xde\xdd\xdc\xdb\xda\xd9\xd8\xd7\xd6\xd5\xd4\xd3\xd2\xd1\xd0\xcf\xce\xcd\xcc\xcb\xca\xc9\xc8\xc7\xc6\xc5\xc4\xc3\xc2\xc1\xc0\xbf\xbe\xbd\xbc\xbb\xba\xb9\xb8\xb7\xb6\xb5\xb4\xb3\xb2\xb1\xb0\xaf\xae\xad\xac\xab\xaa\xa9\xa8\xa7\xa6\xa5\xa4\xa3\xa2\xa1\xa0\x9f\x9e\x9d\x9c\x9b\x9a\x99\x98\x97\x96\x95\x94\x93\x92\x91\x90\x8f\x8e\x8d\x8c\x8b\x8a\x89\x88\x87\x86\x85\x84\x83\x82\x81\x80\x7f~}|{zyxwvutsrqponmlkjihgfedcba`_^]\\[ZYXWVUTSRQPONMLKJIHGFEDCBA@?>=<;:9876543210/.-,+*)(\'&%$#"! \x1f\x1e\x1d\x1c\x1b\x1a\x19\x18\x17\x16\x15\x14\x13\x12\x11\x10\x0f\x0e\r\x0c\x0b\n\t\x08\x07\x06\x05\x04\x03\x02\x01\x00' ---snip--- IMAGE DESCRIPTORS --- left_position = 0 top_position = 0 width = 500 height = 375 local_color_table_flag = 0 interlace_flag = 0 sort_flag = 0 reserved = 0 local_color_table_size = 0 local_color_table = [(21, 163, 221), (6, 3, 16), (15, 141, 200), (2, 101, 151), (56, 173, 230), (37, 202, 252), (5, 36, 87), (36, 217, 255), (39, 74, 122), (21, 202, 250), (42, 118, 170), (2, 63, 124), (41, 100, 148), (1, 87, 151), (2, 104, 168), (1, 84, 135), (52, 202, 253), (2, 126, 185), (11, 124, 207), (2, 117, 171), (56, 217, 254), (2, 70, 146), (31, 232, 255), (32, 56, 103), (23, 122, 185), (24, 84, 134), (22, 103, 162), (22, 87, 151), (20, 118, 169), (21, 185, 241), (21, 68, 145), (37, 183, 242), (2, 184, 240), (49, 143, 197), (55, 190, 244), (83, 38, 69), (28, 87, 165), (10, 178, 226), (38, 185, 221), (37, 212, 243), (52, 210, 241), (71, 200, 252), (20, 214, 245), (12, 237, 255), (21, 218, 255), (5, 104, 186), (21, 103, 188), (0, 214, 244), (6, 86, 171), (3, 202, 247), (5, 219, 254), (66, 100, 140), (72, 192, 187), (12, 248, 230), (49, 224, 210), (122, 164, 195), (78, 21, 30), (116, 20, 28), (80, 4, 8), (108, 34, 44), (146, 20, 30), (145, 34, 42), (153, 9, 15), (178, 20, 23), (196, 17, 26), (115, 61, 100), (107, 88, 134), (30, 205, 151), (42, 161, 75), (109, 84, 32), (207, 46, 47), (146, 142, 67), (212, 71, 74), (255, 255, 255), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)] ---snip--- DUMP FRAMES --- writing frame_0.png writing frame_1.png writing frame_2.png writing frame_3.png writing frame_4.png writing frame_5.png writing frame_6.png writing frame_7.png writing frame_8.png writing frame_9.png writing frame_10.png ---snip---
Read Entire Article