GIF Steganography from First Principles

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.

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

GIF Header

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:

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:

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:

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()

GIFT Tool

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

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.

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

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

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

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---