|
#!/usr/bin/env python3 |
|
|
|
import struct |
|
import zlib |
|
from collections import defaultdict, namedtuple |
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes |
|
from cryptography.hazmat.backends import default_backend |
|
|
|
|
|
CRYPTO_BACKEND = default_backend() |
|
|
|
|
|
def ecb_encrypt(data, key): |
|
if len(key) <= 16: key += b'\x00' * (16 - len(key)) |
|
elif len(key) <= 32: key += b'\x00' * (32 - len(key)) |
|
else: key = key[:32] |
|
|
|
if len(data) < 16: data += '\x00' * (16 - (len(data) % 16)) |
|
|
|
cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=CRYPTO_BACKEND) |
|
encryptor = cipher.encryptor() |
|
|
|
tail_len = len(data) % 16 |
|
data, tail = data[:-tail_len], data[-tail_len:] |
|
|
|
ciphertext = encryptor.update(data) |
|
ciphertext, penultimate_block = ciphertext[:-16], ciphertext[-16:] |
|
final_block = encryptor.update(tail + penultimate_block[:-tail_len]) |
|
|
|
return ciphertext + final_block + penultimate_block[-tail_len:] |
|
|
|
|
|
PNG_HEADER = b'\x89PNG\r\n\x1a\n' |
|
|
|
|
|
class Chunk(namedtuple('Chunk', ('name', 'data', 'length', 'crc'))): |
|
def __new__(cls, name, data, length=None, crc=None): |
|
if length is None: length = len(data) |
|
if crc is None: crc = zlib.crc32(name + data) & 0xffffffff |
|
|
|
return super().__new__(cls, name, data, length, crc) |
|
|
|
|
|
def parse_png(file): |
|
if file.read(8) != PNG_HEADER: |
|
raise Exception("Missing PNG header") |
|
|
|
chunks = [] |
|
|
|
while file.peek(1) != b'': |
|
length = struct.unpack('!I', file.read(4))[0] |
|
name = file.read(4) |
|
data = file.read(length) |
|
crc = struct.unpack('!I', file.read(4))[0] |
|
|
|
chunks.append(Chunk( |
|
name, |
|
data, |
|
length, |
|
crc, |
|
)) |
|
|
|
return chunks |
|
|
|
|
|
def build_png(chunks): |
|
return PNG_HEADER + b''.join( |
|
struct.pack( |
|
f'!I4s{c.length}sI', |
|
c.length, c.name, c.data, c.crc, |
|
) |
|
for c in chunks |
|
) |
|
|
|
|
|
def find_duplicate_blocks(chunks): |
|
blocks = defaultdict(int) |
|
total_blocks = 0 |
|
|
|
for chunk in chunks: |
|
if chunk.name == b'IDAT': |
|
for i in range(0, len(chunk.data), 16): |
|
blocks[chunk.data[i:i + 16]] += 1 |
|
total_blocks += 1 |
|
|
|
|
|
blocks = { |
|
block: count - 1 |
|
for block, count in blocks.items() |
|
if count > 1 |
|
} |
|
|
|
return total_blocks, sorted(blocks.items(), key=lambda bc: (bc[1], bc[0])) |
|
|
|
|
|
def print_duplicate_blocks(title, chunks, print_chunks=False): |
|
total_blocks, duplicates = find_duplicate_blocks(chunks) |
|
|
|
print(title) |
|
if print_chunks: |
|
for block, count in duplicates: print(f'\t{block.hex()}: {count: >10}') |
|
print(f'\tTotal Blocks: {total_blocks: >10}') |
|
print( |
|
f'\tTotal Duplicates: ' |
|
+ f'{sum(count for block, count in duplicates): >10}' |
|
) |
|
|
|
|
|
if __name__ == '__main__': |
|
import argparse |
|
import sys |
|
from pathlib import Path |
|
|
|
parser = argparse.ArgumentParser( |
|
prog='pngen', |
|
description="makes ECB PNGuins", |
|
) |
|
parser.add_argument( |
|
'file', |
|
nargs='?', |
|
type=argparse.FileType('rb'), |
|
default=sys.stdin.buffer, |
|
) |
|
parser.add_argument('-k', '--key', default=b'\x00' * 16) |
|
parser.add_argument( |
|
'-o', |
|
'--out', |
|
nargs='?', |
|
type=argparse.FileType('wb'), |
|
default=sys.stdout.buffer, |
|
) |
|
parser.add_argument( |
|
'--uncompressed', |
|
help="encrypt uncompressed data", |
|
action='store_true', |
|
) |
|
parser.add_argument( |
|
'--duplicates', |
|
help="print information about duplicate blocks", |
|
action='store_true', |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
if args.file.raw == sys.stdin.buffer.raw and sys.stdin.isatty(): |
|
parser.print_help() |
|
sys.exit() |
|
|
|
chunks = parse_png(args.file) |
|
|
|
chunks = [ |
|
Chunk(chunk.name, zlib.decompress(chunk.data)) |
|
if chunk.name == b'IDAT' |
|
else chunk |
|
for chunk in chunks |
|
] |
|
|
|
if args.duplicates and args.out.raw != sys.stdout.buffer.raw: |
|
print_duplicate_blocks('Duplicate Plaintext Blocks:', chunks) |
|
|
|
key = args.key if type(args.key) is bytes else args.key.encode() |
|
|
|
chunks = [ |
|
Chunk( |
|
chunk.name, |
|
ecb_encrypt( |
|
chunk.data if args.uncompressed else zlib.compress(chunk.data, 9), |
|
key, |
|
), |
|
) |
|
if chunk.name == b'IDAT' |
|
else chunk |
|
for chunk in chunks |
|
] |
|
|
|
if args.duplicates and args.out.raw != sys.stdout.buffer.raw: |
|
style = ( |
|
'Uncompressed and Encrypted' |
|
if args.uncompressed |
|
else 'Compressed then Encrypted' |
|
) |
|
print_duplicate_blocks( |
|
f'Duplicate Ciphertext ({style}) Blocks:', |
|
chunks |
|
) |
|
|
|
# Compress again even the "Compressed then Encrypted" data: This is to allow the PNG |
|
# to still render *something* so that the differences can be visualized, as renderers |
|
# are expecting zlib'd data. We don't adjust the dimensions of the image, and PNG's |
|
# IDAT format isn't just a straight run of pixels, so this'll still choke various |
|
# renderers in various ways. |
|
args.out.write(build_png([ |
|
Chunk( |
|
chunk.name, |
|
zlib.compress(chunk.data, 9), |
|
) |
|
if chunk.name == b'IDAT' |
|
else chunk |
|
for chunk in chunks |
|
])) |