detamamoruのブログ

興味を持ったことや勉強したことに関して記事を書きます。主に低レイヤー寄りの記事を公開。

Steganography(ステガノグラフィ)入門

出田 守です。 情報セキュリティに興味を持ち、独学で勉強中です。 主にPythonを使用します。時々勉強のためにCを使用することもあります。

目的

CTFの問題を解いていると、Steganographyに出会いました。名前は聞いたことあるけど、詳しくは知らなかったので調べました。調べた内容の説明後、実装までが今回の目的です。

Steganographyとは

Steganographyとはあるデータを他のデータに埋め込み秘匿してしまう技術の一種です。埋め込まれるデータをvesselやdummyと呼ばれるそうです。データとしては画像の他、音声データ、動画データなども対象になるようです。 従来のSteganography技術には以下があるようです。

  1. LSBステガノグラフィ
  2. 特定周波数ステガノグラフィ
  3. サンプリング誤差ステガノグラフィ

ただし、これらの技術のデータ埋め込み容量は小さいそうです。一方BPCS-Steganographyではデータ埋め込み容量は約50%にもなるようです。 今回は特に画像を対象にBPCS-Steganographyについて学んでみます。

参照: BPCS-Steganography の原理

BPCS-Steganography

BPCS-SteganographyはSteganography技術の一種です。ビットプレーン分解と人間の視覚情報認識能力を利用した手法のようです。

ビットプレーン分解

1情報のうちに複数のビット情報を持つものを1ビット毎に分解すること。例えば24色画像の場合1ピクセルあたり224色あります。224色の内訳はRED、GREEN、BLUEそれぞれ28色で構成されています。つまり282828色ということです。ビットプレーンは抽出した特定のビットのことを言います。ここで例えばREDの28の最上位ビットをMSBといい、最下位ビットをLSBといいます。
ここでビットプレーン分解を行うプログラムを実装してみます。

import numpy as np

from PIL import Image

def read_image_as_numpy(path):
    img = Image.open(path) # read img
    arr = np.array(img)    # convert to numpy array
return arr

def to_binary(arr):
    if len(arr.shape)==3: # 3 channels (R, G, B)
        bits = np.unpackbits(arr, axis=2) # [[[128,128,128]...]...] => [[[100000001000000010000000]...]...]
        bits = bits.reshape(bits.shape[:2]+(arr.shape[2], 8,)) # divide each color (h, w, c*8) => (h, w, c, 8)
        return bits
    elif len(arr.shape)==2: # 2 channels gray-scale
        bits = np.unpackbits(arr, axis=1)
        bits = bits.reshape(bits.shape[:1]+(arr.shape[1], 8,)) # (h, w*8) => (h, w, 8)
        return bits
    elif len(arr.shape)==1:
        org = arr.shape             # original shape
        arr = np.unpackbits(arr)
        arr = arr.reshape(org+(8,)) # (original shape*bits) => (original shape, 8)
        return arr
    else:
        print("Unsupported shape of image")
        exit(1)
    return None

def to_image(arr):
    if len(arr.shape)==4: # for 3 channels (R, G, B)
        arr = arr.reshape(arr.shape[:2]+(arr.shape[2]*arr.shape[3],)) # (h, w, c, 8) => (h, w, c*8)
        arr = np.packbits(arr, axis=2)
        return arr
    elif len(arr.shape)==3: # for 2 channels gray-scale
        arr = arr.reshape(arr.shape[:1]+(arr.shape[1]*arr.shape[2],)) # (h, w, 8) => (h, w*8)
        arr = np.packbits(arr, axis=1)
        return arr
    else:
        print("Unsupported shape of image")
        exit(1)
    return None

def extract_bitplane(arr, color, bit):
    if len(arr.shape)==4: # RGB
        bitplane = arr[:,:,color,bit] # bitplane shape is (h, w) 2d array
    elif len(arr.shape)==3: # gray-scale
        bitplane = arr[:,:,bit] # bitplane shape is (h, w) 2d array
    else:
        print("Unsupported shape of image")
        exit(1)
    return bitplane
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<4:
    print("USAGE: {0} <PATH> <COLOR> <BIT>".format(sys.argv[0]))
    print("    PATH:  image path")
    print("    COLOR: GRAY=-1, RED=0, GREEN=1, BLUE=2")
    print("    BIT  : 0~7 (0:MSB, 7:LSB)")
    exit(1)
    
PATH  = sys.argv[1]
COLOR = int(sys.argv[2])
BIT   = int(sys.argv[3])

def merge_bitplane_to_image(bitplane, arr, color):
    arr = bp.to_image(arr)
    img = np.zeros(arr.shape)
    img[:,:,color] = bitplane
    return img

arr = bp.read_image_as_numpy(PATH)
if len(arr.shape)<2 or len(arr.shape)>3:
    print("Unsupported shape of image")
    exit(1)
arr = bp.to_binary(arr) # arr.shape = (h, w, 3(color), 8(byte)) or (h, w, 8(byte))
# arr = bp.to_image(arr)  # arr.shape = (h, w, 3) or (h, w)
bitplane = bp.extract_bitplane(arr, COLOR, BIT)
bitplane[bitplane>0] = 255
if COLOR!=-1 and len(arr.shape)==4:
    arr = merge_bitplane_to_image(bitplane, arr, COLOR)
else:
    arr = bitplane
Image.fromarray(np.uint8(arr)).show()           # show image
# Image.fromarray(np.uint8(arr)).save("test.png") # save image
$ python show_specific_bits.py sample.jpg 0 7 

第一引数に画像パス、第二引数に抽出カラー、第三引数に抽出ビットです。

結果が以下になりました。

f:id:detamamoru:20181214111709p:plain
下位から7bit目
f:id:detamamoru:20181214111838p:plain
下位から4bit目
f:id:detamamoru:20181214111928p:plain
下位から0bit目

下位ビットにいくにつれて元画像が分からなくなっていますね。

視覚情報認識能力

上記のビットプレーン分解を見ると、LSBよりMSBのほうが、より元画像に近く見えます。このことは人間にとって重要な情報はMSBに近いビットさえあれば元画像として認識可能ということです。

PBCとCGC

画像にデータを埋め込む際、PBC(Pure Binary Code)よりもCGC(Canonical Gray Code)の方が埋め込み後の画像の変化が少ないそうです。
PBCからCGCへの変換は以下の参照ページを参照してください。
では、PBCからCGCとCGCからPBCの変換を行うプログラムを実装してみます。

...
def pbc_to_cgc(arr):
    """
    ref:http://datahide.org/BPCSj/pbc-vs-cgc-j.html
    """
    if len(arr.shape)==3 and arr.shape[2]==3:
        print("Please first convert to binary. i.e. arr = to_binary(arr)")
        exit(1)
    cgc = arr.copy()
    if len(cgc.shape)==4: # RGB
        for i in range(1, 8):
            cgc[:,:,:,i] = np.logical_xor(arr[:,:,:,i-1], arr[:,:,:,i]) # gi = bi-1^bi
    elif len(cgc.shape)==3: # gray-scale
        for i in range(1, 8):
            cgc[:,:,i] = np.logical_xor(arr[:,:,i-1], arr[:,:,i]) # gi = bi-1^bi
    else:
        print("Unsupported shape of image")
        exit(1)
    return cgc

def cgc_to_pbc(arr):
    """
    ref:http://datahide.org/BPCSj/pbc-vs-cgc-j.html
    """
    if len(arr.shape)==3 and arr.shape[2]==3:
        print("Please first convert to binary. i.e. arr = to_binary(arr)")
        exit(1)
    pbc = arr.copy()
    if len(pbc.shape)==4: # RGB
        for i in range(1, 8):
            pbc[:,:,:,i] = np.logical_xor(arr[:,:,:,i], pbc[:,:,:,i-1]) # bi = gi^bi-1
    elif len(pbc.shape)==3: # gray-scale
        for i in range(1, 8):
            pbc[:,:,i] = np.logical_xor(arr[:,:,i], pbc[:,:,i-1]) # bi = gi^bi-1
    else:
        print("Unsupported shape of image")
        exit(1)
    return pbc
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<2:
    print("USAGE: {0} <PATH>".format(sys.argv[0]))
    print("    PATH:  image path")
    exit(1)
    
PATH  = sys.argv[1]

arr = bp.read_image_as_numpy(PATH)
if len(arr.shape)<2 or len(arr.shape)>3:
    print("Unsupported shape of image")
    exit(1)
arr = bp.to_binary(arr)
arr = bp.pbc_to_cgc(arr)
# arr = bp.cgc_to_pbc(arr)
arr = bp.to_image(arr)
Image.fromarray(np.uint8(arr)).show()             # show image
# Image.fromarray(np.uint8(arr)).save("test.png") # save image

試した結果が以下です。味のある感じになりましたね笑

f:id:detamamoru:20181224102752p:plain
pbc->cgc
ちなみに元画像と同じですがcgcからpbcにした結果も載せておきます。
f:id:detamamoru:20181224103017p:plain
cgc->pbc

参照: Two Binary Number Coding Systems

複雑さ

埋め込まれる画像が埋め込まれる対象として適しているかを示す尺度のようです。下記参照ページでは白黒の境界の合計値にその画像での最大境界数を割った割合を複雑さとしています。この複雑さは画像全体としても局所的にも適用できます。まずは、画像全体のそれぞれのbitplaneでの複雑さを出力してみます。

...
def get_block_as_iter(bitplane, blocksize):
    n_rows, n_cols = bitplane.shape
    for y in range(0, n_rows, blocksize[0]):
        for x in range(0, n_cols, blocksize[1]):
            block = bitplane[y:y+blocksize[0], x:x+blocksize[1]]
            if block.shape==blocksize:
                yield x, y, block
    return None

def complexity(bitplane):
    n_rows, n_cols = bitplane.shape # bitplane shape is (h, w) 2d array
    max_cpx        = ((n_rows-1)*n_cols)+((n_cols-1)*n_rows) # max complexity
    k              = 0
    xor  = np.logical_xor(bitplane[1:], bitplane[:-1])       # row complexity
    k   += len(xor[xor==True])
    xor  = np.logical_xor(bitplane[:,1:], bitplane[:,:-1])   # col complexity
    k   += len(xor[xor==True])
    return k/max_cpx*1.0
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<4:
    print("USAGE: {0} <PATH> <COLOR> <BIT>".format(sys.argv[0]))
    print("    PATH:  image path")
    print("    COLOR: GRAY=-1, RED=0, GREEN=1, BLUE=2")
    print("    BIT  : 0~7 (0:MSB, 7:LSB)")
    exit(1)

PATH  = sys.argv[1]
COLOR = int(sys.argv[2])
BIT   = int(sys.argv[3])

arr = bp.read_image_as_numpy(PATH)
if len(arr.shape)<2 or len(arr.shape)>3:
    print("Unsupported shape of image")
    exit(1)
# arr = bp.pbc_to_cgc(arr)
arr = bp.to_binary(arr)

# image complexity
bitplane = bp.extract_bitplane(arr, COLOR, BIT) # extract bitplane as binary, shape is (h, w)
cpx      = bp.complexity(bitplane)
print("image complexity =", cpx)

# block complexity
for i, (x, y, block) in enumerate(bp.get_block_as_iter(bitplane, (8,8))):
    cpx = bp.complexity(block)
    print("block[{0}] complexity =".format(i), cpx)

MSBに近いと複雑さはないです。LSBは0.5付近の複雑さを多く持っています。

埋込みデータ

秘密データは決められたサイズに小ブロック化します。もしそのブロックの複雑さが閾値以下なら、白黒データ(1から始まる縞々データ)とXORさせて複雑にします。秘密データ抽出のために、そのブロックをXORしたかどうかをコンジュゲーションマップとしてどこかに記録しておく必要があります。ということは埋め込む際に何かしらフォーマットを決めておくと良さそうです。

ではまず、秘密データを小ブロック化して、白黒データをXORするプログラムを実装してみます。なお、今回は秘密データとしてASCII文字列メッセージを対象とします。

I am hiding. Please do not search.\n
...
def padding(arr, blocksize):
    block_row,block_col = blocksize
    n_row = len(arr)
    lack_row = block_row-n_row%block_row
    if lack_row>0:
        wb = create_wb((lack_row, 8)) # ascii only,so 8bit. wb is [[1,0,1,0,1,0,1,0]*lack_row]
        arr = np.concatenate((arr, np.packbits(wb))) # concatenate arr and packed wb
    return arr

def read_message_as_numpy(path, blocksize):
    fp  = open(path, "r")
    msg = fp.read()
    msg = np.array([ord(c) for c in msg], dtype=np.uint8)
    msg = np.concatenate((msg, SHINOBI))
    msg = padding(msg, blocksize)
    fp.close()
    return msg

def create_wb(size):
    if size[1]==1:
        return np.array([[1]*size[0]])
    wb = np.tile([1, 0], (size[0],size[1]))[:size[0], :size[1]]
    if len(wb[1::2])>0:
        wb[1::2] = 1-wb[1::2] # invert only odd rows
    return wb

def complication(block):
    wb = create_wb(block.shape)
    return np.uint8(np.logical_xor(block, wb))
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<2:
    print("USAGE: {0} <PATH>".format(sys.argv[0]))
    print("    PATH:  secret file path")
    exit(1)
    
PATH = sys.argv[1]

blocksize = (8,8)
ath       = 0.45 # complexity threshold

arr  = bp.read_message_as_numpy(PATH, blocksize)
arr  = bp.to_binary(arr)
for x, y, block in bp.get_block_as_iter(arr, blocksize):
    cpx = bp.complexity(block)
    print(block)
    print(block.shape)
    print("original complexity  =", cpx)
    if cpx<ath:
        block = bp.complication(block)
        print("converted complexity =", bp.complexity(block))
    print("="*30)

秘密データの終わりに特定の文字を仕込みました。
各ブロックの複雑さを見てみると、0.45以下のものを白黒データでXORしてあげると、複雑さが増しているのが分かります。

データ埋込み

いよいよデータを埋め込む準備ができました。 埋め込みはREDのLSBのビットプレーンから順番に各ブロックを走査し、複雑さが閾値以上のブロックと秘密データのブロックを入れ替えます。
なお、埋め込み後の画像出力はPNGです。JPGだと埋め込んだデータが壊れていためです。おそらく原因は非可逆圧縮だからでしょうか。
コンジュゲーションマップは秘密データの各ブロックがXORしたかを0または1で表すことにしました。これの埋め込みは秘密データの各ブロックの埋め込み前に埋め込まれるデータブロックの複雑さに関係なく埋め込まれます。注意として、コンジュゲーションマップは複雑さが閾値以下でも複雑化していません。

def secret_blocks(arr, blocksize, ath):
    # create secret blocks
    secret_blocks = []
    conj_map      = np.array([0]*int(arr.shape[0]/arr.shape[1]), dtype=np.uint8)
    for i, (x, y, block) in enumerate(get_block_as_iter(arr, blocksize)):
        cpx = complexity(block)
        if cpx<ath:
            block       = complication(block)
            conj_map[i] = 1
        secret_blocks.append(block)
    # create conjugation map (NOTE: not complication)
    conj_map = conjugation_map(conj_map, blocksize)
    return secret_blocks, conj_map

def encode(arr, secret_blocks, conj_map, blocksize, ath):
    COLOR = 1
    if len(arr.shape)==4: # RGB
        COLOR = 3
    elif len(arr.shape)==3: # gray-scale
        arr = arr.reshape(arr.shape[:2]+(1, arr.shape[2],))
        COLOR = 1
    else:
        print("Unsupported shape of image")
        exit(1)
    secret_blocks = secret_blocks[:]
    conj_map      = conj_map[:]
    for color in range(COLOR):
        for bit in (7, -1, -1):
            bitplane = extract_bitplane(arr, color, bit)
            for i, (x, y, block) in enumerate(get_block_as_iter(bitplane, blocksize)):
                if conj_map:
                    bitplane[y:y+blocksize[0], x:x+blocksize[1]] = conj_map.pop(0)
                elif complexity(block)>ath:
                    bitplane[y:y+blocksize[0], x:x+blocksize[1]] = secret_blocks.pop(0)
                if not secret_blocks:
                    break
            arr[:,:,color,bit] = bitplane
            if not secret_blocks:
                if arr.shape[2]==1: # gray-scale
                    arr = arr.reshape(arr.shape[:2]+(arr.shape[3],))
                return arr
    return None
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<3:
    print("USAGE: {0} <IPATH> <SPATH>".format(sys.argv[0]))
    print("    IPATH:  image path")
    print("    SPATH:  secret file path")
    exit(1)
    
IPATH = sys.argv[1]
SPATH = sys.argv[2]

blocksize = (8,8)
ath       = 0.45 # complexity threshold

# prepare secret blocks
arr = bp.read_message_as_numpy(SPATH, blocksize)
arr = bp.to_binary(arr)
secret_blocks, conj_map = bp.secret_blocks(arr, blocksize, ath)

# encode
arr = bp.read_image_as_numpy(IPATH)
arr = bp.to_binary(arr)
arr = bp.encode(arr, secret_blocks, conj_map, blocksize, ath)
arr = bp.to_image(arr)
Image.fromarray(np.uint8(arr)).show()
Image.fromarray(np.uint8(arr)).save("images/encoded.png")

元画像が以下です。

f:id:detamamoru:20181227193004j:plain
元画像
そして、埋込み後の結果は以下です。
f:id:detamamoru:20181228104333p:plain
埋込み後
全く違いが分かりませんね!
ついでに、MSBのビットプレーンにも埋め込んでみました。
f:id:detamamoru:20181228104402p:plain
MSB埋込み後
帽子付近にちょこちょこっとノイズが入っていますね。

データ抽出

一応埋め込めましたが、実際に取り出せないとただの落書きになってしまうので、埋め込んだデータを抽出してみます。

...
def decode_block(block):
    decoded = np.packbits(block).tolist()
    decoded = "".join([chr(c) for c in decoded])
    return decoded

def decode(arr, blocksize, ath):
    COLOR = 1
    if len(arr.shape)==4: # RGB
        COLOR = 3
    elif len(arr.shape)==3: # gray-scale
        arr = arr.reshape(arr.shape[:2]+(1, arr.shape[2],))
        COLOR = 1
    else:
        print("Unsupported shape of image")
        exit(1)
    conj     = True
    conj_map = []
    decoded  = ""
    shinobi  = "".join([chr(c) for c in SHINOBI])
    tail     = "".join([chr(c) for c in TAIL])
    for color in range(COLOR):
        for bit in (7, -1, -1):
            bitplane = extract_bitplane(arr, color, bit)
            for x, y, block in get_block_as_iter(bitplane, blocksize):
                if conj:
                    decoded += decode_block(block)
                    if tail in decoded:
                        conj_map = [ord(c) for c in decoded[:decoded.index(tail)]]
                        conj     = False
                        decoded  = ""
                elif complexity(block)>ath:
                    if conj_map and conj_map.pop(0):
                        decoded += decode_block(complication(block))
                    else:
                        decoded += decode_block(block)
                    if shinobi in decoded:
                        return decoded[:decoded.index(shinobi)]
    return None
import sys
import numpy as np
import bpcs  as bp

from PIL import Image

if len(sys.argv)<2:
    print("USAGE: {0} <PATH>".format(sys.argv[0]))
    print("    PATH:  encoded image path")
    exit(1)
    
PATH = sys.argv[1]

blocksize = (8,8)
ath       = 0.45 # complexity threshold

arr = bp.read_image_as_numpy(PATH)
arr = bp.to_binary(arr)
decoded = bp.decode(arr, blocksize, ath)
print("[decoded code]")
print(decoded)

githubに公開

完全版を公開しました。

github.com

参照: http://www.datahide.org/BPCSe/Articles/Ref-6.SPIE98.pdf