PNGあぶり出し

Introduction

インターネットに溢れている画像ファイルには、適当なテキストを隠しておくことができる。 PNG画像のバイナリをいじりながら、画像にメッセージを埋め込んでみる。

PNGの仕様

PNG(Portable Network Graphics)は可逆な画像圧縮フォーマットである。 仕様の詳細はこちらPNGは8バイトのシグネチャといくつかのチャンクから構成されている。

チャンクにはいくつかの種類があるが、どのチャンクもチャンクサイズ、チャンクの種類、実際のデータ、チャンクのCRCを持っている。 チャンクの種類は画像の幅や高さを含むIHDRや、画像の終端を示すIENDといったものがある。 CRCはデータ破損などを検出するためのものだが、詳しい説明は割愛する。 f:id:ken_tunc:20180922010117p:plain

チャンクを覗く

実際にPythonを使ってPNG画像からチャンクを見てみよう。 画像はいつものを使わせていただく。 まずチャンクを表すクラスを定義する。 Python3.7で追加されたData Classesが便利なので、これを使う。 Data Classes__init__()などを自動的に生成してくれるので大変使い勝手が良い。 変数の型も宣言すると補完が効いたりして気持ちがいいので、今回は積極的に使っていく。

from dataclasses import dataclass

@dataclass
class Chunk:
    length: int
    type: str
    data: bytes

次に、与えられたファイルのチャンクのジェネレータを返す関数を定義する。 f.read(size)メソッドでsizeバイトを読み込むことができる。 また、PNGはビッグエンディアンであることにも注意する。

def read_chunks(fname: str):
    with open(fname, 'rb') as f:
        # シグネチャを出力
        print(f'signature: {f.read(8)}')

        while True:
            length = int.from_bytes(f.read(4), 'big')
            type = f.read(4).decode()
            data = f.read(length)
            yield Chunk(length, type, data)

            # 画像の終端ならば終了
            if type == 'IEND':
                break

            # CRCの4バイトをスキップ
            f.seek(4, 1)


for chunk in read_chunks('Lenna_text.png'):
    print(f'chunk {chunk.type} ({chunk.length} bytes)')

これを実行すると、次のような出力が得られる。

signature: b'\x89PNG\r\n\x1a\n'
chunk IHDR (13 bytes)
chunk sRGB (1 bytes)
chunk IDAT (473761 bytes)
chunk IEND (0 bytes)

PNGシグネチャ\x89PNG\r\n\x1a\nであり、IHDRというチャンクには13バイトのデータがあることがわかる。 IHDRチャンクには画像の幅や高さ(4バイト)、ビット深度やカラータイプ(1バイト)などが含まれているので、これを出力してみよう。 1度に複数のバイト列を解釈するにはstructモジュールが便利だ。

import struct

for chunk in read_chunks('Lenna_text.png'):
    print(f'chunk {chunk.type} ({chunk.length} bytes)')

    if chunk.type == 'IHDR':
        width, height, bit_depth, color_type = \
            struct.unpack_from('>IIBB', chunk.data)
        print(f'width: {width}, height: {height}, bit depth: {bit_depth}, '
              f'color type: {color_type}')

struct.unpack_from()は与えられたフォーマットに沿ってアンパックしてくれる。 >はビッグエンディアンIBはそれぞれunsigned int(4バイト)、unsigned char(1バイト)を表す。 これを実行すると次のように出力される。 

signature: b'\x89PNG\r\n\x1a\n'
chunk IHDR (13 bytes)
width: 512, height: 512, bit depth: 8, color type: 2
chunk sRGB (1 bytes)
chunk IDAT (473761 bytes)
chunk IEND (0 bytes)

画像の高さと幅が512pxであることがわかる。 その他細かい仕様は各自調べてみて欲しい。

秘密のテキスト

次はPNG画像にテキスト情報を埋め込む。 PNGには補助チャンクとしてtEXtチャンクがあり、テキストを保持することができる。 tEXtチャンクはキーワードとテキストをnull文字で結合したものを格納できる。 まず、先ほどのChunkクラスを継承したTextChunkクラスを作る。

@dataclass
class TextChunk(Chunk):

    def __init__(self, keyword: str, text: str):
        data_str = keyword + '\0' + text
        self.data = data_str.encode()
        self.length = len(self.data)
        self.type = 'tEXt'

このクラスの__init__()は自動生成されるものではなく、新たに定義されたものが呼び出される。 次にChunkをファイルに書き出す際にバイト列に変換する必要があるため、to_bytes()メソッドを定義する。

from binascii import crc32

@dataclass
class Chunk:
    # 省略

    def to_bytes(self) -> bytes:
        # それぞれをバイト列に変換
        length = self.length.to_bytes(4, 'big')
        type = self.type.encode()
        data = self.data
        crc = crc32(type + data).to_bytes(4, 'big')

        return length + type + data + crc

CRCはチャンクの種類とデータを用いて計算される。 今回は組込みのbinasciiモジュールを使って済ませてしまった。 最後に、これらのクラスを用いて、新しいPNGファイルを書き出す。 tEXtチャンクはIHDRチャンクとIENDチャンクの間にあればどこでもよい。

keyword = 'comment'
text = 'nanrakano message'
text_chunk = TextChunk(keyword, text)

# 元画像のチャンクを取得
chunks = list(read_chunks('Lenna.png'))

with open('Lenna_text.png', 'wb') as f:
    # シグネチャを出力
    f.write(b'\x89PNG\r\n\x1a\n')
    # IHDRチャンクを出力
    f.write(chunks[0].to_bytes())
    # tEXtチャンクを出力
    f.write(text_chunk.to_bytes())
    # 残りのチャンクを出力
    for chunk in chunks[1:]:
        f.write(chunk.to_bytes())

このようにして下の画像にテキスト(サンプルコードとは異なる)を埋め込んだので、ダウンロードしてメッセージを読んでもらえると嬉しい。 f:id:ken_tunc:20180926151901p:plain

tEXtチャンクのテキストを読むために少しだけコードを追加する。

for chunk in read_chunks('Lenna.png'):
    print(f'chunk {chunk.type} ({chunk.length} bytes)')

    if chunk.type == 'IHDR':
        # 省略

    elif chunk.type == 'tEXt':
        keyword, text = chunk.data.decode().split('\0')
        print(f'{keyword}: {text}')