escriptで珍妙なCLIツールを作った
Erlang Advent Calendar #17
前の記事は、binzumeさんの
次の記事は、sileさん
です。
先に断っておきますと、Erlangについての記事というよりは、「今まで構想に留まっていた実装がErlangで実現できちゃた」といった感じなので、Erlangについてあまり深掘りした話は出てこないと思います。
Topics
Convertible File Format
Erlangを触り初めて5ヶ月目の僕です。
以前(といってもいつだったか忘れましたが…)参加したとあるCTFの大会で、「与えられたPNG画像の拡張子を.zip
に書き換えて解凍すると中からフラグ画像が現れる」みたいな問題を見た時に、僕は非常に感動しました。
「どうなってんの、コレ??」
確かにPNGの画像として読み込めたはずのファイルが、拡張子を.zip
に書き換えただけでZIPアーカイブファイルとして解凍することができました。
そんな「エディタで編集したテキストファイルの拡張子を.pdf
で保存するとPDFファイルになる」みたいな事あり得るんかwみたいな気持ちだったんですが、バイナリエディタでそのファイルを読んだりPNGやZIPのRFCや仕様を読んだりしているうちに、うまくそれぞれのファイルフォーマットの特性を活かしてやるとどうやらジェネレータ作成の実現が可能かもしれない?という推測が頭のなかに浮かんできました。というか理論上確実に可能でした。
その推測をもとにErlang(escript)を使って実装したのが、
「拡張子を書き換えるだけでPNG/ZIPとして変換できるファイルジェネレーター」、通称
"ZIPNG"
です。ドヤ。
Demo
Why Erlang?
バイナリパターンマッチが滅茶苦茶有用!!!
これに尽きます。
PNGやZIPはファイルを任意の数のセクションに分割することができて、それらのセクションの長さは各セクションのヘッダやらシグネチャやらに値として保存されています。今回のジェネレータの実装にあたってファイルの解析と分割は必須の処理なのですが、各セクションからその値を取り出してやれば、Erlangのバイナリパターンマッチを用いてファイルのバイナリをうまくセクション毎に再帰的に分割してやることが可能なのです。しかも簡単。すごい。
最初にこの方法を見つけた時には目からウロコが落ちました。
How to hide each other
ZIP in PNG
PNGは非常にシンプルなバイナリの構造をしています。PNGを構成する各セクションはチャンク(Chunk)といい、全てのPNGファイルに含まれるチャンクはRFCによって定められた仕様に沿った規則正しい構造をとるようになっています。(逆に、この仕様に沿わないチャンクやその他の不正なデータを含むファイルはPNGファイルとして解釈されないように画像ビューワやエディタなどの実装は保証されているべきなんですね。)
PNGファイル先頭に共通で存在する8バイトのシグネチャを除いて、残りの部分は全て以下のようなチャンクとして分割することができます。
name | length(bytes) |
---|---|
DataLength | 4 |
Name | 4 |
Data | * |
CRC32 | 4 |
DataLengthには、その後に続くDataの部分のサイズが単位byteで格納されています。NameにはRFCの定義に従って付与されたチャンクの名前、CRC32にはNameとDataのバイナリから計算されたチェックサムの値が格納されています。
PNGには多種多様なチャンクが定義されており、ファイルの構成に必須なチャンクの他に補助チャンクやユーザー定義が可能なチャンクなども存在します。実はそれらの中にメタデータとして任意のバイナリやテキストをPNGファイルに含めることができるチャンクが存在するのです。
というわけで、ZIPファイルのバイナリをそのままチャンクのData部として、他のチャンクと同様にDataLengthやName、CRC32などを付与してファイル内に突っ込んであげればZIPファイル入りPNGができることが分かりました。
ZIPファイルを隠蔽したあとのPNGファイルは勿論サイズはその分大きくなりますが、隠蔽前と同様に正常に画像として表示することができます。
ちなみにPNGの再帰分割処理はわかりやすく簡略化するとこんな感じになってます(そのままだと多分動きません)。詳しい実装についてはリポジトリを見ていただけると助かります。
%% 基底部の定義 split_png(<<"">>) -> []; %% シグネチャを除いたバイナリの再帰分割処理 split_png(PNGbinary) -> <<DataLen:32, Name:32, Data:(DataLen * 8), CRC32:32, Rest/binary>> = PNGbinary, [<<DataLen:32, Name:32, Data:(DataLen * 8), CRC32:32>> | split_png(Rest)].
実は今回の実装ではPNGを分割して解析する必要はあまりなかったのですが、ファイルの正規性検査とZIPを含んだチャンクを正しい位置に確実に挿入できるようにするために分割処理を書くことになりました。
PNG in ZIP
こっちは少し難しいです。
ZIPもPNGと同様にキレイにデータを分割することができて、Deflateによって圧縮されたデータのバイナリとヘッダのようなものから構成されるようになっています。バイナリの構造はPNGと比べると少し複雑ですが、ちゃんとバックトラッキングすることなくワンパスで解析することができます。
ヘッダ内の各データの意味はウィキペディアなどを参照していただけると助かります(量が多すぎてスペースが足りない)。
ヘッダ名 | 説明 |
---|---|
ローカルヘッダ | 圧縮前・圧縮後のファイルに関するメタデータやファイル名・サイズなどが格納されており、後に圧縮されたデータ本体が続く |
セントラルディレクトリヘッダ | 対応する各ローカルヘッダと一致するデータに加えて、ZIPファイルが分割された場合のディスク番号やローカルヘッダとのオフセットが格納されている |
終端セントラルディレクトリヘッダ | ZIPファイル全体のディスク数(分割数)やオフセット、その他のメタデータなどが格納されている |
上記のようにヘッダには複数の種類があり、各ヘッダによって構造が違うので、先にヘッダのシグネチャを取り出してからパターンマッチにかけて再帰的に分割…という手法を取らざるを得ませんでした。更に、ヘッダ内のデータのバイナリがリトルエンディアンになっていたりオフセットの値が相対だったり絶対だったり、処理をうまく抽象化することが中々難しく実装したコードのZIP分割処理の部分はかなりuglyな感じになっています。
コードは少し汚くても、ZIPファイルはちゃんとキレイにヘッダ+データ毎に分割することができました。
次にZIPのバイナリにPNGのバイナリを正しく挿入することになるのですが、実はそのままデータをつなげたり突っ込んだりするだけではエラーが出ます。
$ cat sample1.png sample2.zip > out.zip $ unzip out.zip
適当にこんなことをやっていると、「オフセットが狂ってますよ」みたいな感じのメッセージが吐かれると思います。
というのも、上で示したようにセントラルディレクトリヘッダや終端セントラルディレクトリヘッダにはローカルヘッダとのオフセットやセントラルディレクトリとのオフセットが格納されているため、勝手に先頭に別のデータを追加してしまうと本来それらがあるべき位置がズレてしまうので正しいデータを検出することができなくなってしまうわけですね。
ZIPはPNGのようにアーカイブファイルを構成するものの一部として別のデータを含めることができないはずなので、先頭にPNGのデータを追加してオフセットが正しい位置を示すように修正するような方法をとることにします。PNGの場合と同様でほぼ疑似コードのような感じですが、以下のような感じでZIPファイルを解析・分割しながらオフセットをずらすことができます。(もちろんずらす分のオフセットは予め計算して一緒に関数の引数として渡してやる必要があります。)
%% 基底部の定義 split_into_dir(<<"">>, _) -> []; %% 再帰分割処理、不正なシグネチャを検出するとabortされるようになっている split_into_dir(ZipBin, OFFSET) -> <<Signature:32, Rest/binary>> = ZipBin, case Signature of ?LOCAL -> << Other1:112, Size:32, Other2:32, FNLen:16, EFLen:16, FileName:(FNLen * 8), ExtraField:(EXLen * 8), Data:(Size * 8), Rest2/binary >> = Rest, [<< Signature:32, Other1:112, Size:32, Other2:32, FNLen:16, EFLen:16, FileName:(FNLen * 8), ExtraField:(EFLen * 8), Data:(Size * 8) >> | split_into_dir(Rest2, OFFSET)]; ?CENTRAL -> << Other1:192, FNLen:16, EFLen:16, FCLen:16, Other2:64, LCOffset:32, FileName:(FNLen * 8), ExtraField:(EXLen * 8), FileComment:(FCLen * 8), Rest2/binary >> = Rest, << ShiftOffset:32 >> = add_offset(LCOffset, OFFSET), [<< Signature:32, Other1:192, FNLen:16, EFLen:16, FCLen:16, Other2:64, ShiftOffset:32, FileName:(FNLen * 8), ExtraField:(EFLen * 8), FileComment:(FCLen * 8) >> | split_into_dir(Rest2, OFFSET)]; ?EOCENTRAL -> << Other:96, CDOffset:32, FCLen:16, FileComment:(FCLen * 8), Rest2/binary >> = Rest, << ShiftOffset:32 >> = add_offset(CDOffset, OFFSET), [<< Signature:32, Other:96, ShiftOffset:32, FCLen:16, FileComment:(FCLen * 8) >> | split_into_dir(Rest2, OFFSET)]; _ -> io:format("illegal signature.~n") end. %% オフセットを再計算するための関数 add_offset(Binary, OFFSET) -> <<Value:32/little-unsigned-integer>> = <<Binary:32>>, <<Ret:32/little-unsigned-integer>> = << (Value + OFFSET):32>>, <<Ret:32>>.
冗長ですが、これ以上抽象化するのは難しそうです…。まあちゃんと動くので大丈夫。
以上が処理のメインとなるPNGのとZIPのバイナリ解析・分割用の関数についての解説でした。
Implementaion
関数の実装はOKなので、あとはメインとなる処理を考えるだけです。
注意するべきポイントは2つあって、
- PNGに含めるZIPはチャンクデータとしてそのまま挿入できるが、オフセットをずらすことになるのでCRC32の計算は後回しにする必要がある。
- ZIPの各ヘッダのオフセットは、PNGのデータ長からIENDのデータ長を差し引いたもの、更にそれに「ZIP用のチャンク」のチャンク名とデータ長を表すデータ分のデータ長を足した値になる。
というところです。
今回はZIPのバイナリにシグネチャとデータ長とCRC32チェックサムを付加してzTXTチャンク(zipped textをDataとして持つチャンク)とし、PNGファイルのIENDチャンクの直前に挿入することにします。しかしそのままではPNGに挿入したZIPのヘッダにあるオフセットがずれてしまうので、先にPNGのIENDチャンクを除いた分にzTXTチャンクのチャンク名とデータ長のデータ分のデータ長をZIPのヘッダのオフセットに足してあげる必要があります。それからZIPのCRC32を計算してPNGファイルに挿入することにします。
それらの処理順序などを踏まえた上での生成手順は以下のようになります。
- PNGをチャンク毎に分割する。
- ZIPとして格納するためのファイルをZIPする。
- 1の分割済みのPNGのIENDを除いたデータ長を計算し、その値にzTXTのチャンク名とデータ長のデータ分のデータ長を2のZIPのヘッダにあるオフセットに足す。
- 3でできたZIPにzTXTのチャンク名とデータ長とCRC32を計算して付加し、zTXTチャンクを作成する。
- 1で分割したPNGのIENDチャンク直前に4のzTXTチャンクを挿入する。
- ファイルをPNGファイルとして書き出す。
これで一つのファイルにPNGのバイナリとZIPのバイナリが混在した奇妙なファイルができあがります。
拡張子を.png
にすると、画像ビューワなどで正常にPNGフォーマットの画像ファイルとして表示することができます。もちろん中に含まれるZIPファイルの情報などは表示されません。
拡張子を.zip
にすると、unzipコマンドなどを用いて正常にZIPアーカイブファイルとして展開することができます。もちろんZIPのバイナリの前に繋がっているPNGファイルは出てきませんし、解凍するときにそれに関する情報なども表示されません。
Deflateで圧縮する処理はプログラムの中で行っているので、隠蔽するZIPファイルの中身は自由に決めることができます。ただ、PNGファイルやZIPするファイルが大きすぎる場合は検証していないのでそこら辺は自己責任でお願いします。
Summary
リポジトリへのリンクや参考資料を以下にリストアップしておきます。今回は完全に趣味の範囲でのプログラムでしたが、これをキッカケにErlangやPNG、ZIPに興味を持ってくれるような方がいればこの珍妙なCLIツールを作った甲斐があったというものです。
スターやPRなどお待ちしています。ちなみにツールの悪用は厳禁です。
RFC 2083 - PNG (Portable Network Graphics) Specification Version 1.0
APPNOTE - PKZIP/SecureZIP - PKWARE Support Site
Portable Network Graphics - Wikipedia
By the way…
正しいけど正しくない?
ZIPの分割関数についてなのですが、何故か仕様通りに実装すると動かない部分があって、オフセットを相対の値ではなくて絶対オフセットにしないとちゃんとファイルが解凍されないみたいなんですよね。
ウィキペディアやRFCにはオフセットとして相対の値が使われているとの記述がありますが、その通りに実装するとunzipコマンドやWindows標準のアーカイブマネージャーなどではエラーが出て解凍されません。現状オフセットには絶対オフセットを使うようにしており、unzipコマンドでは一切のエラー出力なしで解凍できるのですが、WindowsやmacOSのアーカイブマネージャでは正常に動作しないようです。エラーを見る限りセントラルディレクトリヘッダのオフセットが不正らしいのですが、そうであればunzipコマンドでも同様の警告文が出力されるはずなので謎は深まるばかりです。情報提供お待ちしています。
PNGのチャンク名の話
zTXTというチャンク名を見てもらえると分かりますが、アルファベット4文字で定義されているチャンク名は不自然に部分的に大文字になったり小文字になったりしています。実はチャンク名は、それぞれの文字が大文字か小文字かでチャンクの持つ特性を表すフラグような役割を果たすようになっているのです。
n文字目 | フラグ名 | 大文字 | 小文字 |
---|---|---|---|
1 | Ancillary Bit | 画像の構成に欠かせない必須チャンク | 補助チャンク、ユーザー定義チャンク |
2 | Private Bit | 国際標準によって定義されている、もしくはPNGの特別目的の公開チャンク一覧に登録されている | そうではないチャンク |
3 | Reserved Bit | 意味は未定義で、現状大文字であることが義務づけられている | - |
4 | Safe-to-copy Bit | ファイル内のデータの変更に対してチャンクの継続使用が不可能であることを表す | 継続使用が可能であることを表す |
Next
さて、明日の記事はsileさんです。
よろしくお願いします!