PNGあぶり出し
Introduction
インターネットに溢れている画像ファイルには、適当なテキストを隠しておくことができる。 PNG画像のバイナリをいじりながら、画像にメッセージを埋め込んでみる。
PNGの仕様
PNG(Portable Network Graphics)は可逆な画像圧縮フォーマットである。 仕様の詳細はこちら。 PNGは8バイトのシグネチャといくつかのチャンクから構成されている。
チャンクにはいくつかの種類があるが、どのチャンクもチャンクサイズ、チャンクの種類、実際のデータ、チャンクのCRCを持っている。 チャンクの種類は画像の幅や高さを含むIHDRや、画像の終端を示すIENDといったものがある。 CRCはデータ破損などを検出するためのものだが、詳しい説明は割愛する。
チャンクを覗く
実際に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()
は与えられたフォーマットに沿ってアンパックしてくれる。
>
はビッグエンディアン、I
、B
はそれぞれ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())
このようにして下の画像にテキスト(サンプルコードとは異なる)を埋め込んだので、ダウンロードしてメッセージを読んでもらえると嬉しい。
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}')