蛇ノ目の記

技術のことも。そうでないことも。

Pythonでステガノグラフィをやってみる

ステガノグラフィ is 何

情報を他の情報に埋め込む技術のこと

ステガノグラフィー - Wikipedia

ここでは画像に文字列を埋め込む文脈でのステガノグラフィをやっていく。

2012年頃から数年間に渡ってRedditを中心に盛り上がった目的不明の暗号『Cicada 3301』の解説動画を観ていて、画像自体をほぼ劣化させずに他の情報を埋め込めるのは面白そう、と感じ興味を持った。Cicada 3301そのものも今尚、謎に満ちていて面白い。

www.youtube.com

"ステガノグラフィ Python"とか"Steganography Python"で検索しても日本語の情報があまり出てこないこともあって自分でまとめることにした。

関係ないんだけど上に引用したゆっくり解説のシリーズは面白いものが多いので時間を溶かすのに最適。

ステガノグラフィの仕組み

文字列をasciiコードに変換し、asciiコードを2進数に置き換える。1Byte文字であれば8bitのbit列が得られる。このbit列を画像を構成する画素に隠していくことになる。

隠蔽のアプローチ

3通りのアプローチが考えられる。

  1. 各bit列を8画素に隠蔽 → 各画素のRGBどれかのチャネルに隠す
  2. 各bit列を4画素に隠蔽 → 各画素のRGBからどれか2つのチャネルに隠す
  3. 各bit列を3画素に隠蔽 → (R, G, B), (R, G, B), (R or G or B)という順で隠す

文字列の隠蔽に必要な解像度はそれぞれ、 文字数(nByte) × 8文字数(nByte) × 4, 文字数(nByte) × 3 となる。

  1. の方法が最も効率的に文字列を隠蔽できるが、端数の部分の隠蔽の処理が少し面倒(と感じた)。

このあたりは解説している記事によっても取るアプローチが異なるので一概にも言えないのかもしれない。

隠蔽のアルゴリズム

極めてシンプルで、元の画素への変化が少ないため元画像と情報が隠蔽された画像を見比べても人間の目では違いはわからない。

  • 対象のbitが0 であれば、隠蔽先の値を偶数にする
  • 対象のbitが 1 であれば、隠蔽先の値を奇数にする

以下の図では最もシンプルな1.の方法を図示した。元画像をcover file、情報を隠蔽した画像をstego fileと呼ぶ。

f:id:Nao_Y:20220317194641p:plain

Pythonでやってみる

用意するもの: 情報を隠蔽する画像ファイル, PIL

配列を使うので速度とメモリ効率を重視するのであればnumpyを使うのがよさそう。

from PIL import Image


MSG = "The quick brown fox jumps over the lazy dog."

PIXEL_LENGTH = len(MSG) * 8


def mod_pixel(bin, pixel):
    for idx, b in enumerate(bin):
        r_channel = pixel[idx][0]
        if int(b) == 0:
            # bin: 0 -> pixel: even
            if r_channel % 2 == 0:
                pixel[idx][0] = r_channel - 1
            else:
                pixel[idx][0] = r_channel
        elif int(b) == 1:
            # bin: 1 -> pixel: odd
            if r_channel % 2 > 0:
                pixel[idx][0] = r_channel - 1
            else:
                pixel[idx][0] = r_channel


bins = []
for c in MSG:
    bins.append(f"{ord(c):08b}")

cover_file = Image.open("base.jpg")
size = cover_file.size
target_px = [list(px) for idx, px in enumerate(iter(cover_file.getdata())) if idx < PIXEL_LENGTH]  # 情報を隠蔽する画素
base_px = [px for idx, px in enumerate(iter(cover_file.getdata())) if idx >= PIXEL_LENGTH]  # そのままにする画素

for idx, bin in enumerate(bins):
    target = target_px[idx * 8 : (idx + 1) * 8]
    mod_pixel(bin, target)

target_px.extend(base_px)
new_pixel = [tuple(pixel) for pixel in target_px]

# ref. https://stackoverflow.com/questions/29637191/python-pil-putdata-method-not-saving-the-right-data
stego_file = Image.new("RGB", size)
stego_file.putdata(new_pixel)
stego_file.save("stego.png")

ここではフォントのサンプルでよく使われる文を画像に隠蔽した。なお、この文のようなアルファベットすべてを使う文をパングラムと呼ぶらしい。

入力がjpg形式でも、出力は必ずpng形式にしなければならない。jpg形式は不可逆圧縮のため情報を隠蔽した画素の値が変化してしまう。

画像の比較

cover file

f:id:Nao_Y:20220317203104j:plain

stego file

f:id:Nao_Y:20220317203118p:plain

人間の目にとっては2つの画像はなんの変哲もない代々木ビルとドコモタワーが写っている写真で、特に違いは見受けられない。

しかし下の画像にはThe quick brown fox . . . という文字列が隠蔽されている。

(ネットで適当に取ってきた画像ではなく自分で撮ったもの。念の為)

Untitled

復号

stego fileの画素を左上から舐めていき、各画素のRチャネルが偶数であれば0、奇数であれば1と読んでいけばよい。

そうして得られた2進数の数列を8bitごとに区切ってasciiコードに変換して文字列に複合する。

(隠蔽時のアプローチがわかっていない場合は意味の読み取れる文字列が出てくるまで、それぞれのアプローチを試せばいいんだろうか)

参考資料

※ 下の記事では出力もjpg形式になっているが、隠蔽した情報が壊れる気がする。どうなんだろうか。