[{"data":1,"prerenderedAt":499},["ShallowReactive",2],{"$f2ve2xv3hjzuqx":3},{"title":4,"date":5,"tags":6,"categories":10,"draft":12,"_id":13,"slug":14,"path":15,"document":16,"excerpt":23,"readingTimeMinutes":498},"Hiding the Bee Movie transcript with steganography","2026-05-26T00:00:00.000Z",[7,8,9],"python","steganography","web",[11],"programming",false,"content:blog:steganography-bee-movie","steganography-bee-movie","\u002Fblog\u002Fsteganography-bee-movie",{"frontmatter":17,"meta":18,"nodes":19},{},{},[20,24,28,31,38,57,60,64,71,74,82,86,89,95,98,102,105,136,143,158,162,165,183,188,192,195,216,223,474,481],[21,22,23],"p",{},"You probably didn't realize it, but the static noise visible on the home page of this website is not just noise. It's actually the entire script of the bee movie encoded with a technique called steganography.",[25,26,27],null,{},"more",[21,29,30],{},"The image is a lossless PNG with the RGB pixels encoding the bytes of a small payload:",[21,32,33],{},[34,35],"img",{"src":36,"alt":37},"\u002Fimages\u002Fnoise.png","Noise image containing the encoded Bee Movie script",[39,40,41,45,48,51,54],"ul",{},[42,43,44],"li",{},"a magic header",[42,46,47],{},"the original text size",[42,49,50],{},"the compressed text size",[42,52,53],{},"the zlib-compressed script",[42,55,56],{},"padding to fill a square image",[21,58,59],{},"Additionally, before writing the bytes to pixels, the payload is XORed against a deterministic SHA-256 byte stream to ensure we have an evenly distributed pattern of noise.",[61,62,63],"h2",{"id":8},"Steganography",[21,65,66,67,70],{},"The term for this kind of idea is ",[68,69,8],"strong",{},", and is defined as the practice of hiding information inside another medium so that the carrier looks ordinary. In other words, cryptography tries to make a message unreadable, while steganography tries to make the message easy to miss.",[21,72,73],{},"Classic examples include hiding text in the least significant bits of an image, placing data in audio noise, or using metadata fields.",[21,75,76,77,81],{},"This version is especially uninteresting as the entire image is generated from the payload, but you could imagine the Bee Movie being hidden in other images with very little detection. Now ",[78,79,80],"em",{},"that's"," interesting.",[61,83,85],{"id":84},"hiding-the-bee-movie-in-the-bee-movie","Hiding the Bee Movie in the Bee Movie",[21,87,88],{},"So let's do that too. Here's a regular bee image, converted to a lossless PNG, with the same Bee Movie script embedded into the least significant bits of its RGB channels:",[21,90,91],{},[34,92],{"src":93,"alt":94},"\u002Fimages\u002Fbee-movie-bee.png","Bee image containing the encoded Bee Movie script",[21,96,97],{},"The image is still 1200x630, and the hidden payload is 20,557 bytes after compressing the 49,474-byte script and adding a small header. Since the payload only changes the lowest bit of each color channel, the image looks essentially unchanged, but the script can be recovered as long as the file remains lossless.",[61,99,101],{"id":100},"encoder","Encoder",[21,103,104],{},"Here's the encoder:",[106,107,110],"pre",{"language":108,"class":109},"shell","shiki shiki-themes tokyo-night dark:tokyo-night",[111,112,114],"code",{"class":113},"language-shell",[115,116,119,123,127,130,133],"span",{"class":117,"style":118},"line","display: inline",[115,120,122],{"style":121},"color:#C0CAF5","uv",[115,124,126],{"style":125},"color:#9ECE6A"," run",[115,128,129],{"style":125}," encode.py",[115,131,132],{"style":125}," script.txt",[115,134,135],{"style":125}," noise.png",[106,137,139],{"language":7,"class":138},"shiki dark:tokyo-night",[111,140,142],{"class":141},"language-python","# encode.py\n\nimport argparse\nimport binascii\nimport hashlib\nimport math\nimport struct\nimport zlib\nfrom pathlib import Path\n\n\nMAGIC = b\"TXTIMG1\\0\"\nPNG_SIGNATURE = b\"\\x89PNG\\r\\n\\x1a\\n\"\nXOR_SEED = b\"txtimg1-static-noise-v1\"\n\n\ndef xor_stream(data: bytes) -> bytes:\n    masked = bytearray(len(data))\n    cursor = 0\n    counter = 0\n\n    while cursor \u003C len(data):\n        block = hashlib.sha256(XOR_SEED + struct.pack(\">Q\", counter)).digest()\n        take = min(len(block), len(data) - cursor)\n        for index in range(take):\n            masked[cursor + index] = data[cursor + index] ^ block[index]\n        cursor += take\n        counter += 1\n\n    return bytes(masked)\n\n\ndef png_chunk(chunk_type: bytes, data: bytes) -> bytes:\n    crc = binascii.crc32(chunk_type)\n    crc = binascii.crc32(data, crc) & 0xFFFFFFFF\n    return struct.pack(\">I\", len(data)) + chunk_type + data + struct.pack(\">I\", crc)\n\n\ndef write_rgb_png(path: Path, width: int, height: int, rgb: bytes) -> None:\n    row_size = width * 3\n    rows = [\n        b\"\\x00\" + rgb[row_start : row_start + row_size]\n        for row_start in range(0, len(rgb), row_size)\n    ]\n    ihdr = struct.pack(\">IIBBBBB\", width, height, 8, 2, 0, 0, 0)\n    png = (\n        PNG_SIGNATURE\n        + png_chunk(b\"IHDR\", ihdr)\n        + png_chunk(b\"IDAT\", zlib.compress(b\"\".join(rows), level=9))\n        + png_chunk(b\"IEND\", b\"\")\n    )\n    path.write_bytes(png)\n\n\ndef next_power_of_two(value: int) -> int:\n    return 1 \u003C\u003C (value - 1).bit_length()\n\n\ndef encode_text_to_png(input_txt: Path, output_png: Path) -> None:\n    raw = input_txt.read_bytes()\n    compressed = zlib.compress(raw, level=9)\n\n    payload = MAGIC + struct.pack(\">QQ\", len(raw), len(compressed)) + compressed\n    pixel_count = math.ceil(len(payload) \u002F 3)\n    side = next_power_of_two(math.ceil(math.sqrt(pixel_count)))\n    capacity = side * side * 3\n    padded = payload + bytes(capacity - len(payload))\n    static = xor_stream(padded)\n\n    output_png.parent.mkdir(parents=True, exist_ok=True)\n    write_rgb_png(output_png, side, side, static)\n\n    print(f\"Original:   {len(raw):,} bytes\")\n    print(f\"Compressed: {len(compressed):,} bytes\")\n    print(f\"PNG size:   {output_png.stat().st_size:,} bytes\")\n    print(f\"Image:      {side}x{side}\")\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"Encode a text file into a lossless static PNG image.\"\n    )\n    parser.add_argument(\"input_txt\", type=Path, help=\"Path to the source text file.\")\n    parser.add_argument(\"output_png\", type=Path, help=\"Path for the generated PNG.\")\n    args = parser.parse_args()\n\n    encode_text_to_png(args.input_txt, args.output_png)\n\n\nif __name__ == \"__main__\":\n    main()",[21,144,145,146,149,150,153,154,157],{},"The script writes a minimal RGB PNG directly with the PNG signature, ",[111,147,148],{},"IHDR",", ",[111,151,152],{},"IDAT",", and ",[111,155,156],{},"IEND"," chunks.",[61,159,161],{"id":160},"decoder","Decoder",[21,163,164],{},"And here's the decoder:",[106,166,167],{"language":108,"class":109},[111,168,169],{"class":113},[115,170,171,173,175,178,180],{"class":117,"style":118},[115,172,122],{"style":121},[115,174,126],{"style":125},[115,176,177],{"style":125}," decode.py",[115,179,135],{"style":125},[115,181,182],{"style":125}," decoded.txt",[106,184,185],{"language":7,"class":138},[111,186,187],{"class":141},"# decode.py\n\nimport argparse\nimport hashlib\nimport struct\nimport zlib\nfrom pathlib import Path\n\n\nMAGIC = b\"TXTIMG1\\0\"\nPNG_SIGNATURE = b\"\\x89PNG\\r\\n\\x1a\\n\"\nXOR_SEED = b\"txtimg1-static-noise-v1\"\n\n\ndef xor_stream(data: bytes) -> bytes:\n    masked = bytearray(len(data))\n    cursor = 0\n    counter = 0\n\n    while cursor \u003C len(data):\n        block = hashlib.sha256(XOR_SEED + struct.pack(\">Q\", counter)).digest()\n        take = min(len(block), len(data) - cursor)\n        for index in range(take):\n            masked[cursor + index] = data[cursor + index] ^ block[index]\n        cursor += take\n        counter += 1\n\n    return bytes(masked)\n\n\ndef paeth_predictor(left: int, above: int, upper_left: int) -> int:\n    predictor = left + above - upper_left\n    pa = abs(predictor - left)\n    pb = abs(predictor - above)\n    pc = abs(predictor - upper_left)\n    if pa \u003C= pb and pa \u003C= pc:\n        return left\n    if pb \u003C= pc:\n        return above\n    return upper_left\n\n\ndef read_rgb_png(path: Path) -> bytes:\n    blob = path.read_bytes()\n    if not blob.startswith(PNG_SIGNATURE):\n        raise ValueError(\"Not a PNG file.\")\n\n    offset = len(PNG_SIGNATURE)\n    width = height = bit_depth = color_type = interlace = None\n    idat_parts = []\n\n    while offset \u003C len(blob):\n        length = struct.unpack(\">I\", blob[offset : offset + 4])[0]\n        chunk_type = blob[offset + 4 : offset + 8]\n        data_start = offset + 8\n        data_end = data_start + length\n        chunk_data = blob[data_start:data_end]\n        offset = data_end + 4\n\n        if chunk_type == b\"IHDR\":\n            width, height, bit_depth, color_type, _, _, interlace = struct.unpack(\n                \">IIBBBBB\", chunk_data\n            )\n        elif chunk_type == b\"IDAT\":\n            idat_parts.append(chunk_data)\n        elif chunk_type == b\"IEND\":\n            break\n\n    if (bit_depth, color_type, interlace) != (8, 2, 0):\n        raise ValueError(\"Only non-interlaced 8-bit RGB PNG files are supported.\")\n\n    raw = zlib.decompress(b\"\".join(idat_parts))\n    bytes_per_pixel = 3\n    row_size = width * bytes_per_pixel\n    result = bytearray()\n    previous = bytearray(row_size)\n    cursor = 0\n\n    for _ in range(height):\n        filter_type = raw[cursor]\n        cursor += 1\n        row = bytearray(raw[cursor : cursor + row_size])\n        cursor += row_size\n\n        for index, value in enumerate(row):\n            left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0\n            above = previous[index]\n            upper_left = previous[index - bytes_per_pixel] if index >= bytes_per_pixel else 0\n\n            if filter_type == 1:\n                row[index] = (value + left) & 0xFF\n            elif filter_type == 2:\n                row[index] = (value + above) & 0xFF\n            elif filter_type == 3:\n                row[index] = (value + ((left + above) \u002F\u002F 2)) & 0xFF\n            elif filter_type == 4:\n                row[index] = (value + paeth_predictor(left, above, upper_left)) & 0xFF\n            elif filter_type != 0:\n                raise ValueError(f\"Unsupported PNG filter type: {filter_type}.\")\n\n        result.extend(row)\n        previous = row\n\n    return bytes(result)\n\n\ndef decode_png_to_text(input_png: Path, output_txt: Path) -> None:\n    data = xor_stream(read_rgb_png(input_png))\n\n    if data[:8] != MAGIC:\n        raise ValueError(\"Not a TXTIMG1 payload image.\")\n\n    original_size, compressed_size = struct.unpack(\">QQ\", data[8:24])\n    compressed = data[24 : 24 + compressed_size]\n    raw = zlib.decompress(compressed)\n\n    if len(raw) != original_size:\n        raise ValueError(\n            f\"Decoded size mismatch: expected {original_size}, got {len(raw)}.\"\n        )\n\n    output_txt.parent.mkdir(parents=True, exist_ok=True)\n    output_txt.write_bytes(raw)\n    print(f\"Decoded {len(raw):,} bytes to {output_txt}\")\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"Decode a TXTIMG1 PNG image back into a text file.\"\n    )\n    parser.add_argument(\"input_png\", type=Path, help=\"Path to the encoded PNG.\")\n    parser.add_argument(\"output_txt\", type=Path, help=\"Path for the decoded text file.\")\n    args = parser.parse_args()\n\n    decode_png_to_text(args.input_png, args.output_txt)\n\n\nif __name__ == \"__main__\":\n    main()",[61,189,191],{"id":190},"dont-believe-me","Don't believe me?",[21,193,194],{},"First, download the image:",[106,196,197],{"language":108,"class":109},[111,198,199],{"class":113},[115,200,201,204,208,211,214],{"class":117,"style":118},[115,202,203],{"style":121},"curl",[115,205,207],{"style":206},"color:#E0AF68"," -fsSL",[115,209,210],{"style":125}," https:\u002F\u002Fcmpadden.github.io\u002Fimages\u002Fnoise.png",[115,212,213],{"style":206}," -o",[115,215,135],{"style":125},[21,217,218,219,222],{},"Save the ",[111,220,221],{},"decode.py"," snippet, and then run the following:",[106,224,225],{"language":108,"class":109},[111,226,227,256,257,256,273,256,327,256,370,256,404,256,409,256,414,256,438,256,451,256,456,256,469],{"class":113},[115,228,229,232,235,237,239,241,244,248,251,254],{"class":117,"style":118},[115,230,231],{"style":121},"$",[115,233,234],{"style":125}," uv",[115,236,126],{"style":125},[115,238,177],{"style":125},[115,240,135],{"style":125},[115,242,243],{"style":125}," out.txt",[115,245,247],{"style":246},"color:#89DDFF"," &&",[115,249,250],{"style":121}," head",[115,252,253],{"style":206}," -n10",[115,255,243],{"style":125},"\n",[115,258,259,262,265,268,271],{"class":117,"style":118},[115,260,261],{"style":121},"Decoded",[115,263,264],{"style":125}," 49,474",[115,266,267],{"style":125}," bytes",[115,269,270],{"style":125}," to",[115,272,243],{"style":125},[115,274,275,278,280,283,286,289,292,295,298,301,304,307,310,313,316,319,322,324],{"class":117,"style":118},[115,276,277],{"style":121},"According",[115,279,270],{"style":125},[115,281,282],{"style":125}," all",[115,284,285],{"style":125}," known",[115,287,288],{"style":125}," laws",[115,290,291],{"style":125}," of",[115,293,294],{"style":125}," aviation,",[115,296,297],{"style":125}," there",[115,299,300],{"style":125}," is",[115,302,303],{"style":125}," no",[115,305,306],{"style":125}," way",[115,308,309],{"style":125}," a",[115,311,312],{"style":125}," bee",[115,314,315],{"style":125}," should",[115,317,318],{"style":125}," be",[115,320,321],{"style":125}," able",[115,323,270],{"style":125},[115,325,326],{"style":125}," fly.",[115,328,329,332,335,338,341,344,346,349,352,355,358,361,364,367],{"class":117,"style":118},[115,330,331],{"style":121},"Its",[115,333,334],{"style":125}," wings",[115,336,337],{"style":125}," are",[115,339,340],{"style":125}," too",[115,342,343],{"style":125}," small",[115,345,270],{"style":125},[115,347,348],{"style":125}," get",[115,350,351],{"style":125}," its",[115,353,354],{"style":125}," fat",[115,356,357],{"style":125}," little",[115,359,360],{"style":125}," body",[115,362,363],{"style":125}," off",[115,365,366],{"style":125}," the",[115,368,369],{"style":125}," ground.",[115,371,372,375,378,380,383,386,389,392,395,398,401],{"class":117,"style":118},[115,373,374],{"style":121},"The",[115,376,377],{"style":125}," bee,",[115,379,291],{"style":125},[115,381,382],{"style":125}," course,",[115,384,385],{"style":125}," flies",[115,387,388],{"style":125}," anyway",[115,390,391],{"style":125}," because",[115,393,394],{"style":125}," bees",[115,396,397],{"style":125}," don",[115,399,400],{"style":246},"'",[115,402,403],{"style":125},"t care what humans think is impossible.",[115,405,406],{"class":117,"style":118},[115,407,408],{"style":125},"Yellow, black. Yellow, black. Yellow, black. Yellow, black.",[115,410,411],{"class":117,"style":118},[115,412,413],{"style":125},"Ooh, black and yellow!",[115,415,416,419,421,424,427,430,433,435],{"class":117,"style":118},[115,417,418],{"style":125},"Let",[115,420,400],{"style":246},[115,422,423],{"style":125},"s",[115,425,426],{"style":125}," shake",[115,428,429],{"style":125}," it",[115,431,432],{"style":125}," up",[115,434,309],{"style":125},[115,436,437],{"style":125}," little.",[115,439,440,443,446,448],{"class":117,"style":118},[115,441,442],{"style":121},"Barry!",[115,444,445],{"style":125}," Breakfast",[115,447,300],{"style":125},[115,449,450],{"style":125}," ready!",[115,452,453],{"class":117,"style":118},[115,454,455],{"style":121},"Coming!",[115,457,458,461,464,466],{"class":117,"style":118},[115,459,460],{"style":121},"Hang",[115,462,463],{"style":125}," on",[115,465,309],{"style":125},[115,467,468],{"style":125}," second.",[115,470,471],{"class":117,"style":118},[115,472,473],{"style":121},"Hello?",[21,475,476,477,480],{},"To hide your own text file inside of a noisy image, save ",[111,478,479],{},"encode.py"," and then run:",[106,482,483],{"language":108,"class":109},[111,484,485],{"class":113},[115,486,487,489,491,493,496],{"class":117,"style":118},[115,488,122],{"style":121},[115,490,126],{"style":125},[115,492,129],{"style":125},[115,494,495],{"style":125}," my-super-secret-text.txt",[115,497,135],{"style":125},2,1790648870584]