zipパーサに関するメモ

仕事で zipファイルを展開せずバイナリのまま構造解析する可能性があったため, zipの仕様を調べていました. 仕様については Wikipedia(日本語版は今ひとつわからなかったところがあったので英語版をメインで見た方がよいと思います)等を参照してください. で, パース方法を調べると初めにファイル末尾にある End of central directory record(以下 EOCD)を探して, そこから Central directory file headerを探してごにょごにょするとあったのですが, EOCDには可変長なコメントフィールド(最大 0xffffバイト)があり, それを考慮した上で正しくパースするにはどうすればいいのだろうと思って, 各種言語の zipライブラリを調べてみました. これはそのメモです. 本記事は zip64の内容は含みません. ご注意ください

zip形式の仕様

Zip (file format) - Wikipedia

問題となると考えたケース

  • コメント部に別の zipを書き込んだ場合正しくパースできるのか
  • validなファイルを invalidと解釈しないか
  • 期待しないファイルを展開することはないか(今回未検証)

Go言語の archive/zip

zip.OpenReaderの大雑把な流れは以下のとおりです. 調べたのは Go 1.9の archive/zipになります.

  • EOCDを探す. 探すパターンが 2つあり, 末尾から 1024バイトの位置, 65 * 1024(=66560)バイトの位置から EOCDのマジックナンバを探す.
  • マジックナンバが見つかったら, マジックナンバを除いた位置から始まるバイト列で directoryEnd構造体(EOCDに対応)を初期化する
  • EOCDから Central directory file headerの先頭オフセットを取得し, 各ファイルの情報を zip.Fileに設定する
  • 各ファイルの情報を取得するとき, Central directory file headerのマジックナンバをチェックし, 不正だと zip.ErrFormatを返す

特にコメントを意識していないようです.

テスト zipの作成

test.zip(aaa.txtを保持)のコメント部に別の incomment.zip(test.txtを保持)を書き込みます. 期待としては, aaa.txtが展開/名前が取得されます. (zipに含まれる名前が適当すぎますが, それっぽい名前にしてしまうとバイト列が変わってしまうためはじめに作ったこスクリプトを利用しています)

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import zipfile

zip_file = 'test.zip'
incomment_file = 'incomment.zip'

for f in [zip_file, incomment_file]:
    if os.path.isfile(f):
        os.remove(f)

with open('test.txt', 'wb') as f:
    f.write(os.urandom(2048)) # 1024バイト以上の長さになるようにする.

incomment_zip = zipfile.ZipFile(incomment_file, mode='w')
incomment_zip.write('test.txt')
incomment_zip.close()

with open('aaa.txt', 'wb') as f:
    f.write(b'aaa')

zf = zipfile.ZipFile(zip_file, mode='w')
zf.write('aaa.txt')

with open(incomment_file, 'rb') as f:
    buf = f.read()

zf.comment = buf
zf.close()

作成した test.zipを archive/zipに与えてみる

以下のような zipに含まれるファイル一覧を表示する Goプログラムに test.zipを与えます.

package main

import (
    "archive/zip"
    "fmt"
)

func main() {
    r, err := zip.OpenReader("test.zip")
    if err != nil {
        fmt.Println(err)
        return
    }

    for _, f := range r.File {
        fmt.Println(f.Name)
    }
}

結果は以下のように validでないとエラーが返ります.

% go run main.go
zip: not a valid zip file

原因はコメント中の別 zipの Central directory file headerの offsetを元ファイルの先頭から調べたとき, Central directory file headerのマジックナンバではないためです. コメントに関する制約は特にないようなので上で作成した zipは validだと思うのですが, invalidな zipとして判断されてしまいました.

この問題を回避するためにコメント中の zipの Central directory file headerの offsetに元の zipのコメント位置の offsetを加算した値を書き込みます. 下記のカーソル位置から始まる 2byteが Central directory file headerの offsetなのでバイナリエディタ等を使って書き換えます. (上の例だと 0x0826を 0x0899に書き換えます)

f:id:syohex:20171004010648p:plain

再度コマンドを実行してみます. 思惑通りコメント中に書き込んだ zipの方を対象の zipとして認識してくれて text.txtが出力されました.

% go run main.go
test.txt

他の言語

中身までは見ていないですが, 試してみる. Goのような実装かもしれないので, 上の offset変更を適用した zipを喰わせます.

Python3(3.6.2)

import zipfile

zf = zipfile.ZipFile('test.zip', 'r')
print(zf.namelist())
% python3 test.py
['test.txt'] ## コメント中の zipを扱う

Perl(Archive::Zip 1.59)

use strict;
use warnings;

use Archive::Zip;

my $zip = Archive::Zip->new();
$zip->read('test.zip');
print $_, "\n" for $zip->memberNames();
% perl test.pl
test.txt ## コメント中の zipを扱う

unzipコマンド(6.00 Ubuntu版)

% unzip -l test.zip
Archive:  test.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     2048  2017-10-04 01:06   test.txt
---------                     -------
     2048                     1 file

Goのようにコメント中の zipが扱われてしまうものが多いようです. 手元で正しく動いたのは, macOSの Finder(Archive Utility)だけでした.

なおこれらのプログラムは zip中のファイル名だけでファイルの中身を誤って(??)取得させようとすると Central directory file headerの offsetもそれぞれ修正する必要があるかと思います. これは未検証なので, もしかするとうまくいかないかもしれません. 余裕があれば確認しようかと思います.

最後に

  • zipパーサにはコメントを考慮していないものがある.
  • そうなっていないのはやはり速度重視だから ?
    • ファイル末尾の min(コメントの最大長, ファイルサイズ)から状態(コメント中か否か等)を調べてマジックナンバを探索していけばよさそうなものだが..
  • 最初に探すべきデータブロックに可変長なフィールドがあるのはどうなのか.