コース目次 / 第2章

ピクセルの、最下位の1ビット — steganography

画像のピクセル値の最下位ビット(LSB)にフラグをビット単位で埋め込みます。見た目には気づけないこと、PNGのIDATを自前で解凍してビットを読み出せば復元できることを確認します。

第2章 / 全5章目安 約15分この章のゴール: LSBステガノグラフィの原理を理解し、自作画像から埋め込まれたビット列を復元できるようになる

carvingは「ファイルの外側」に隠しました。この章は、「値そのものの中」に隠します。

LSB(最下位ビット)とは

1ピクセルのRGB値は、それぞれ0〜255(8ビット)です。最下位の1ビットを書き換えても、値は最大でも1しか変わりません。(例: 220 → 221)。人の目にはほぼ区別がつかない変化に、1ビットの情報を運ばせる——これがLSBステガノグラフィの原理です。

フラグを、ピクセルに埋め込む

make_stego.py として保存します。フラグの長さ(4バイト)+本体を、ピクセルごとに1ビットずつ埋め込みます。

import struct, zlib, random

def chunk(tag, data):
    return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data))

def make_stego_png(path, width, height, secret: bytes):
    payload = struct.pack('>I', len(secret)) + secret  # 先頭4バイトに長さ
    bits = []
    for byte in payload:
        for i in range(7, -1, -1):
            bits.append((byte >> i) & 1)

    rnd = random.Random(1337)  # 再現用の乱数(暗号用途ではない、単なる背景画像生成)
    pixels = bytearray(rnd.randint(0, 255) for _ in range(width * height * 3))
    for i, bit in enumerate(bits):
        pixels[i] = (pixels[i] & 0xFE) | bit  # 最下位ビットだけ書き換える

    raw = bytearray()
    for y in range(height):
        raw.append(0)  # フィルタバイト
        raw += pixels[y*width*3:(y+1)*width*3]

    sig = b'\x89PNG\r\n\x1a\n'
    ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
    idat = chunk(b'IDAT', zlib.compress(bytes(raw)))
    iend = chunk(b'IEND', b'')
    with open(path, 'wb') as f:
        f.write(sig + ihdr + idat + iend)

make_stego_png('stego.png', 32, 32, b'flag{lsb_hides_in_plain_sight}')
python3 make_stego.py
file stego.png
stego.png: PNG image data, 32 x 32, 8-bit/color RGB, non-interlaced

fileは普通のPNGとしか言いません。binwalkでスキャンしても、ファイル形式としては何も異常がないので、carvingのようには検出できません。「見た目は正常な画像だが、ピクセル値そのものに手がかりがある」——このパターンが、LSBステガノグラフィの見抜きにくさです。

PNGを、自分で解凍して読む

extract_stego.py として保存します。PNGのチャンク構造を自分でたどり、IDATをzlibで解凍して、生のピクセル値からLSBを読み出します。

import struct, zlib

data = open('stego.png', 'rb').read()
offset = 8  # PNG署名をスキップ
idat_data = b''
width = height = None
while offset < len(data):
    length = struct.unpack('>I', data[offset:offset+4])[0]
    tag = data[offset+4:offset+8]
    body = data[offset+8:offset+8+length]
    if tag == b'IHDR':
        width, height = struct.unpack('>II', body[:8])
    if tag == b'IDAT':
        idat_data += body
    offset += 8 + length + 4
    if tag == b'IEND':
        break

raw = zlib.decompress(idat_data)
stride = width * 3 + 1
pixels = bytearray()
for y in range(height):
    pixels += raw[y*stride+1 : y*stride+1+width*3]

def read_bits(pixels, nbits, start=0):
    val = 0
    for i in range(nbits):
        val = (val << 1) | (pixels[start+i] & 1)
    return val

secret_len = read_bits(pixels, 32)
out = bytearray()
byte = bits = 0
for i in range(32, 32 + secret_len * 8):
    byte = (byte << 1) | (pixels[i] & 1)
    bits += 1
    if bits == 8:
        out.append(byte)
        byte = bits = 0

print(out.decode())
python3 extract_stego.py
flag{lsb_hides_in_plain_sight}

復元できました。 PNGの構造(チャンク→zlib圧縮された生ピクセル)さえ分かれば、専用ツールなしでもLSBを読めます。実務のステガノグラフィ検出ツール(zstegなど)も、原理はこれと同じ——ピクセル値の下位ビットに、統計的な偏りや規則性がないかを調べています。

こうなっていればOK

卒業まであと2章です。

この章はまだ完了していません。