はちのす日記

備忘録とかチラ裏とか...

思い出のjpg画像を復元しようとしたけど えっちな画像しか出てこなかった

発端

大した話ではありません。
あまりこういった話に馴染みがないひと向け、Windowsユーザー向け。
この間、SDカードに入れていた写真データがふっ飛んでしまいました。とりわけ重要なものではないですが(そもそも一生とっておきたいデータなんてありませんが)、下田とか竹原とかまで18切符で遊びに行ったときの写真があったので、復元を試みました。その時の作業記録です。
以下の順で書きました。

作業目的

SDカード(64GB)からjpg画像の復元(当時iPhone5で撮影したもの)

FTKimagerでファイルの確認

FTKimagerは、証拠保全に便利かつ簡易なフォレンジック(※1)ツールです。イメージファイル(※2)の取得や、記録媒体のファイルを確認することができます。 削除データに対応するエントリ(※3)に「削除したデータであることの印」が付いているだけなら、このツールを使うだけでことで、削除されたデータを復元することが可能です。 FTKimagerは以下のサイトからダウンロードできます。ただし、メールアドレスの登録が必要です。 http://accessdata.com/product-download

  • ※1 フォレンジック
    「法医学的な」とか「法廷の」とかといった意味です。「鑑定」ってことです。
  • ※2 イメージファイル
    記録領域をそっくりそのまま取り出したファイルのことです。 DVDのイメージファイルをとれば、それはDVDそのものをPCの中に入れたようなものです。 「イメージファイルを取得」というのは、要はリッピングです。
  • ※3 エントリ
    そのデータに関するデータ(ファイル名やサイズ、作成日時、データ本体の場所など)を記録している部分のことです。

f:id:ub_k138:20170212232028p:plain FTKimagerの画面。左上ではフォルダーをツリー状に表示しています。右上ではフォルダーに含まれるファイルを表示しています。右下ではファイルの中身を「browse」「text」「hex」の3通りの方法で見ることができます。左下も便利なんですが割愛します。

f:id:ub_k138:20170212232039j:plain ファイル名の頭に付いている赤枠で囲ったようなマーク(「×」のマーク)は、そのファイルが削除されたファイルであることを示しています。

なお、上記の画像は実際にSDカードで作業したときのものではありません。

残念ながらこのツールでは復元できなかったので次に移ります。

FTKimagerによる未割当領域の抽出

SDカードから未割当領域を引っこ抜きます。未割当領域とは、ファイルシステム(※4)上、まだ使用されていない記録領域のことです。システム上では現在使用されていませんが、かつては使用されていたかもしれない部分です。 FTKimagerでは、未割当領域は「unallocated space」と表示されています(そのまんまです)。これを全部取り出して、ローカル上に保存します。

  • ※4 ファイルシステム
    デジタルの世界は0か1を羅列したものなので、それがどういったものであるのか理解するためのルールが必要です。ファイルシステムは、ファイルを記録する際のルールです。

f:id:ub_k138:20170213233442p:plain FTKimagerで確認できる未割当領域。右の部分に並ぶファイル(数字の羅列が名称となっているもの)一つひとつが未割当領域を分割したものになっています。右クリックでエクスポート可能です。

取り出した未割当領域は1つのファイルではなかったので結合しました。未割当領域の各ファイルを同一のフォルダに入れて、コマンドプロンプト上でそのフォルダに移動し、
copy /b * unallocatesd.img
とコマンドを投入しました。各ファイルの0と1の羅列をファイルごとにそのまま並べて、1つのファイルにするイメージです。
26GBのサイズになりました。
(読み込み時に結局分割したので、この作業はあまり意味がなかったかもしれないです…。)

マジックナンバーをもとに掘り出し

jpg形式のファイルは、自身がjpgであることを示す必要があります。ファイルの先頭にjpgであることの標識がなければ、0か1かのただの羅列をどう読めばいいのかわかりません。どう読めば良いのか教えてくれる標識がマジックナンバーです(「シグネチャ」と言ったり、「ヘッダー」と言ったりすることもあります)。
jpgの場合、ファイルの先頭は16進数表記で「FF D8 FF」です(今回はこれで…)。
同様に、ファイルの終わりを示す標識もあり、jpgの場合は「FF D9」です(「フッター」と言います)。
上記の点を踏まえて、抽出した未割当領域から、jpgの始まりの標識から終わりの標識までを切り出すプログラムを作りました。

# -*- coding: utf-8 -*-

import re

class jpg_type()
    def __init__(self):
        self.ext = 'jpg'
        self.header = b'\xff\xd8\xff'
        self.footer = b'\xff\xd9'
        self.buf = b''
        self.buf_len = 0
        self.buf_loc = 0
        # 検索を省略するためのサイズ
        self.size_min = 1572864    # 1.5MB
        # 検索をうち切るためのサイズ
        self.size_max = 5242880    # 5MB
    def buf_length(self):
        self.buf_len = len(self.buf)


def carve(fragment, store, ftype, loc):
    '''
    入力されたオブジェクトの型に対応するファイルを見つけ次第保存する
    途中で切れてしまった場合を考慮して、当該オブジェクトにバッファーを残す
    以下では、フッターがmin_addrからmax_addrまでの間にあると想定
    '''
    offset = 0
    f_out_name = ''
    while True:
        print('ofset =', offset)
        # bufが空の場合、先にヘッダーのサーチを行う
        if ftype.buf_len == 0:
            h_obj = re.search(ftype.header, fragment[offset:])
            if h_obj is None:
                break
            h_addr = h_obj.start() + offset
            min_addr = h_addr + ftype.size_min
            max_addr = h_addr + ftype.size_max
        # bufが空でない場合,fractionの先頭1アドレス目を便宜上のヘッダーアドレス(h_addr)とする
        else:
            h_obj = 'dummy'   
            h_addr = 0
            min_addr = 0
            max_addr = ftype.size_max - ftype.buf_len
        # フッターを検索(後述のf_addrはフッターの末尾のアドレス)        
        f_obj = re.search(ftype.footer, fragment[min_addr:max_addr])
        # フッターが見つからない場合は、bufを初期化する
        # ただし、フラグメントの途中で切れた場合は、当該領域をbufに格納する
        if f_obj is None:
            if len(fragment[h_addr:]) < ftype.size_max:                
                ftype.buf += fragment[h_addr:]
                ftype.buf_length()
                break
            else:         
                ftype.buf = b''
                # ヘッダーを見つけた位置から再度新しいヘッダーを探す
                offset = h_addr + len(ftype.header)
                continue
        # フッターがあった場合は、bufに詰めて保存する
        f_addr = f_obj.end() + min_addr      
        ftype.buf += fragment[h_addr:f_addr]
        f_out_name = store + '//' + '{}_{}.{}'.format(loc+h_addr-ftype.buf_len, loc+f_addr, ftype.ext)
        with open(f_out_name, 'wb') as f_out:
            f_out.write(ftype.buf)
            f_out.flush()
            print(f_out_name)
        # buf関連の値の初期化
        ftype.buf = b''
        ftype.buf_len = 0
        offset = f_addr

def main():
    file = 'unallocated.img'
    store = 'CarvedJPG'
    chunk = 104857600    #100MB
    c = 0
    # jpg以外のファイル形式もいっしょに検索できるようにリスト化
    ftype_list =[jpg_type()]
    with open(file, 'rb') as f:
        while True:
            # chunkサイズごとにファイルを読み込む
            fragment = f.read(chunk)
            if not fragment:
                break
            for ftype in ftype_list:
                carve(fragment, store, ftype, chunk * c)
            c += 1        
            print('chunk {} is finished!! ({} MB)'.format( c, c*chunk/1048576))

if __name__ == '__main__':
    main()

プログラマではないので糞コードでも悪しからず…。
iPhone5で撮った写真が復元の目的なので、残っていたjpg画像のサイズからおおよそのサイズに目途をつけました。最低サイズを1.5MB、最大サイズを(余裕をもたせて)5MBと設定し、jpgの始まりの標識「FF D8 FF」を見つけたらそこから1.5MBぶんとんで終わりの標識「FF D9」を探します。終わりの標識「FF D9」を5MBまで探しても見つからなかった場合は、検索を打ち切り、始まりの標識「FF D8 FF」まで戻ります。その後、再度「FF D8 FF」を探します。
問題は、Pythonでファイルを読み込む際、read()で読もうとすると、一気に読んでしまうことです。抽出した未割当領域は26Gあったのでメモリが足りません。そこで、100MBずつ読み込んで上記の処理を行うことにしました。

結果

欲しいjpg画像はなかったです。えっちな絵だけは大量に復元できました(20GB近く)。
今回は、jpg形式のファイルの切り出し方を単純なものにしましたが、クラスタだとかjpgの仕様だとかを反映すれば、もっと精度は上がります。
でも、無いものは無いんだから、頑張ってもしょーがない。

こういう切り出し作業は結構難しい。等今回使用した終わりの標識「FF D9」なんて26GBもあればいくらでも出てきてしまう…(工夫すべきところではありますが)。

うまく切り出せていないと、こんな感じになったりします。しかしながら、ファイルを開けているのでマシな方です。 f:id:ub_k138:20170213233543j:plain

まぁ、わざわざこんな事しなくても、ググればツールがいっぱい出てくるんじゃないかと思います。