knqyf263's blog

自分のためのメモとして残しておくためのブログ。

spliceを使って高速・省メモリでGzipからZIPを作る

良い話を含むので概要の最初だけでも読んでもらえると幸いです。この話が実用的かと言うと多分全然実用的ではないので理解しても仕方ないかなと言う気がします。

概要

先日Dirty PipeというLinuxカーネル脆弱性が公表されました。

dirtypipe.cm4all.com

この脆弱性の原理自体も面白いのですが、その前に報告者の組織で行っているGzipとZIPの処理で引っかかったのでまず先にそちらの理解に努めました。ちなみにアホなのでDirty Pipe関連で不明点を報告者に直接ぶつけたら丁寧に回答してくれました。話題になっていて問い合わせも殺到しているはずなのに返信がいつも数時間以内でありがたい限りです。疑問が解消してスッキリして最高な気分になれたので、失礼かもみたいな気持ちを一度捨てて無理を承知で連絡をとってみると良いかもしれません。忙しければスルーされるだけなので、そこまで時間を取ってしまうこともないと思います。そういったことで関係が生まれて何かに繋がるかもしれません。ちなみに自分もOSSのメンテナをやっていて知らない人からメールがちょくちょく来ますが面倒な時はただスルーしてますし、特に嫌な気持ちにはなりません。

話を戻すと、まずは上記ブログ内に以下の記述がありました。

Via HTTP, all access logs of a month can be downloaded as a single .gz file. Using a trick (which involves Z_SYNC_FLUSH), we can just concatenate all gzipped daily log files without having to decompress and recompress them, which means this HTTP request consumes nearly no CPU.

こちらは簡単です。まず報告者の組織で提供しているサービスでは、Webサーバのログを日にちごとにgzipで保存していて、一ヶ月単位で丸ごと落とすことも可能になっています。gzipは仕様的にgzip同士を連結しても正しいgzipファイルとなるので、それを活用して日にちごとのgzipファイルを連結して1ヶ月分のgzipファイルとしているということだと思います。以下のようなイメージです。

$ cat 20220301.log.gz 20220302.log.gz 20220303.log.gz > 202203.log.gz

この辺りは以前以下のブログで説明したので興味あれば見て下さい。

knqyf263.hatenablog.com

次に、

Memory bandwidth is saved by employing the splice() system call to feed data directly from the hard disk into the HTTP connection, without passing the kernel/userspace boundary (“zero-copy”).

こちらの説明も簡単です。 splice(2) システムコールを使うことでユーザ空間にいちいちコピーせずともgzipファイルの連結が可能ということです。spliceについての説明は調べれば出てくるので詳細は省きますが、ファイル等からカーネル空間のページキャッシュに読み込み、それを直接パイプに渡せます。ファイルごとの処理が特に必要ではなく順番に連結するだけなので、これは難しくないと思います。

man7.org

問題は次です。

Windows users can’t handle .gz files, but everybody can extract ZIP files. A ZIP file is just a container for .gz files, so we could use the same method to generate ZIP files on-the-fly; all we needed to do was send a ZIP header first, then concatenate all .gz file contents as usual, followed by the central directory (another kind of header).

gzipWindowsで使えないから代わりにzipにしているということですが、zipは単に複数gzipファイルの入れ物でしかないからgzipの連結と同様にzipファイルも作れると言っています。まずzipヘッダを作り、次にgzipファイルを連結し、最後にcentral directoryヘッダを付ければ良いと言っています。

正直かなり混乱しました。確かにzipとgzipはどちらも圧縮アルゴリズムとしてDeflateが使えます。そのため圧縮したファイルそのものは同じになる可能性がありますが、仕様としては全然別物なのでヘッダやフッタのフォーマットも異なります。zipはアーカイバとしての機能もありますしgzipは圧縮のみです。もちろん一度展開すれば変換は容易ですが、CRCとかもあるのにgzipファイルの中身をユーザ空間のメモリに載せずただ連結するだけで行けるもんなの?!というのが疑問でした。

あとで分かりましたが、ここを理解しないと脆弱性の細かい理解も難しいです。

ファイルフォーマット

まず最初にgzipとzipのフォーマットについて軽く見ていきますが、ググると大量のドキュメントやブログがあるのでそちらを見てもらったほうが確実かもしれません。

gzip

RFC1952 を見るとフォーマットは以下のようになっています。gzipの仕様に詳しくなりたいわけではないのでオプショナルなヘッダは省略しています。

+---+---+---+---+---+---+---+---+---+---+
|ID1|ID2|CM |FLG|     MTIME     |XFL|OS | (more-->)
+---+---+---+---+---+---+---+---+---+---+

+=======================+
|...compressed blocks...| (more-->)
+=======================+

0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
|     CRC32     |     ISIZE     |
+---+---+---+---+---+---+---+---+

大きく分けて4つのパートがあります。

  • 10-byteのヘッダ
  • オプショナルな拡張ヘッダ(上の図では省略)
  • DEFLATEで圧縮されたファイル本体
  • 8-byteのフッタ(trailer)

10-byteのヘッダ

+---+---+---+---+---+---+---+---+---+---+
|ID1|ID2|CM |FLG|     MTIME     |XFL|OS | (more-->)
+---+---+---+---+---+---+---+---+---+---+

まずはgzipであることを示すために2バイトのマジックバイトが来ます(0x1f 0x8b)。次に圧縮方法(CM, Compression Method)が来ます。CMが8の場合はDeflateを意味します。gzipでは通常Deflateが利用されます。そして次にフラグですが、この値によって拡張ヘッダの有無が決まります。フラグの各ビットの意味は以下のとおりです。

bit 0   FTEXT
bit 1   FHCRC
bit 2   FEXTRA
bit 3   FNAME
bit 4   FCOMMENT
bit 5   reserved
bit 6   reserved
bit 7   reserved

この辺は任意なので気にせずで良いですが、FNAMEはファイル名が入っているので普通にコマンドラインからgzip作ると埋められると思います。

ヘッダの残りは圧縮されている元ファイルの最終更新時刻や拡張フラグやOSなどが入ります。

拡張ヘッダ

上のフラグによって決まります。例えばFNAME がセットされていれば、元ファイルの名前が存在しゼロバイトによって終端されます。詳細はRFCが分かりやすいです。

ファイル本体

上で指定された圧縮アルゴリズムによって圧縮されたブロックです。

フッタ(trailer)

gzipのtrailerは8バイトで、CRC-32とサイズが4バイトずつ含まれています。

0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
|     CRC32     |     ISIZE     |
+---+---+---+---+---+---+---+---+

zip

まずは大雑把に内容を掴むため、Wikipediaの図を引用します。

f:id:knqyf263:20220310064828p:plain
https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88) より引用

各ファイルの前にヘッダ(ローカルヘッダ)と、索引用途のヘッダ(セントラルディレクトリ)で構成されていることが分かります。ファイルエントリ自体は各ローカルヘッダのあとに置かれています。ヘッダについてもう少し細かく言うと

  • ローカルファイルヘッダ
  • Data descriptor
  • セントラルディレクトリエントリ
  • セントラルディレクトリの終端レコード

の4つがあります。複数のファイルとセントラルディレクトリエントリを入れることが出来て、次のようなレイアウトになります。

f:id:knqyf263:20220310090242p:plain

以下で1つずつ見ていきます。

ちなみに以下を参照していますが、zipのRFCとかはないのでしたっけ...?その辺の歴史に詳しくないので誰か詳しい人が教えてくれたら追記します。 https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT

ローカルファイルヘッダ

これは上でも述べたように各ファイルの前につくヘッダで、以下のようになっています。

local file header signature     4 bytes  (0x04034b50)
version needed to extract       2 bytes
general purpose bit flag        2 bytes
compression method              2 bytes
last mod file time              2 bytes
last mod file date              2 bytes
crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes
file name length                2 bytes
extra field length              2 bytes

file name (variable size)
extra field (variable size)

この中でまず重要なのはcompression methodで、圧縮メソッドが指定できます。デフォルトでは8で、Deflateになります。zipではかなり多くの圧縮メソッドが利用可能です。ただ、0が非圧縮で8がDeflateであることを覚えておけばとりあえず良さそうです。zipは圧縮せずにアーカイバとして使うことも可能で、その場合は0になります。ファイルサイズが小さかったりで圧縮率が悪い場合も勝手に0になるようです。

また、CRC-32のフィールドや圧縮サイズ、非圧縮サイズなどもあります。そして汎用目的のビットフラグについてWikipediaの説明を引用しておきます。

汎用目的のビットフラグフィールドの3ビット目がセットされている場合、ヘッダの書き込み時にはCRC-32とファイルサイズが不明である。ローカルヘッダのCRC-32とファイルサイズのフィールドにはゼロが書き込まれ、CRC-32とファイルサイズは圧縮データの後ろに12バイトのデータとして追加される。(オプションの4バイトのシグネチャが前に付く場合もある。)

つまりCRC-32はファイル本体のあとにも置くことが出来るということです。これはdata descriptorと呼ばれる領域ですが、自分はこのヘッダの存在を知らず、これを知ってから色々腑に落ちた感じがあります。

Data descriptor

この領域は上で説明したようにオプショナルですが、存在する場合はCRC-32とサイズが入っています。

crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes

なので実際には

  • Local file header
  • File data
  • Data descriptor

の3つで1セットです。

セントラルディレクトリエントリ

以下のようになっています。ローカルファイルヘッダとかなり似た感じです。何で同じ値を複数持ってるのか疑問ですが、冗長性の確保のためとWikiには書いていました。ただ仕様の方ではそういった記述が見つけられなかったので未だに疑問です。

central file header signature   4 bytes  (0x02014b50)
version made by                 2 bytes
version needed to extract       2 bytes
general purpose bit flag        2 bytes
compression method              2 bytes
last mod file time              2 bytes
last mod file date              2 bytes
crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes
file name length                2 bytes
extra field length              2 bytes
file comment length             2 bytes
disk number start               2 bytes
internal file attributes        2 bytes
external file attributes        4 bytes
relative offset of local header 4 bytes

file name (variable size)
extra field (variable size)
file comment (variable size)

ここで重要なのはローカルファイルヘッダの相対オフセットです。これが上述したローカルファイルヘッダへの参照となります。つまりセントラルディレクトリエントリを見つければ実際のファイルにも飛べるということになります。

セントラルディレクトリの終端レコード

全てのセントラルディレクトリエントリの後に、ZIPファイルの終わりを表すセントラルディレクトリの終端レコードが続きます。

end of central dir signature    4 bytes  (0x06054b50)
number of this disk             2 bytes
number of the disk with the
start of the central directory  2 bytes
total number of entries in the
central directory on this disk  2 bytes
total number of entries in
the central directory           2 bytes
size of the central directory   4 bytes
offset of start of central
directory with respect to
the starting disk number        4 bytes
.ZIP file comment length        2 bytes
.ZIP file comment       (variable size)

セントラルディレクトリへのオフセットが保存されています。展開時は、まず初めにセントラルディレクトリの終端レコードのシグネチャを探し、次に各セントラルレコードを探索し、ローカルファイルヘッダを探索するという処理の流れになります。

gzipからzipへの変換

ここまでgzipとzipのヘッダを見てきましたが、共通点が多くあったことがわかると思います。特に、どちらも圧縮メソッドとしてDeflateが利用可能な点、CRC-32を含む点は重要です。gzipはシンプルで、ヘッダ内で重要なのはファイル名と最終更新時刻ぐらいな気がします(暴論)。そしてtrailerにCRC-32とサイズが含まれています。これらの情報を流用すれば再計算なしにzipのローカルヘッダやセントラルヘッダが作れそうです。拡張ヘッダなどの諸々は一旦無視して、とりあえずzipファイルとして正しいフォーマットのものを作ることを目指します。

gzipをストリームに処理していきたいので、

  1. gzipヘッダの処理
  2. gzipファイル本体の処理
  3. gzip trailerの処理

の3つに分けて説明していきます。

gzipヘッダの処理

まずヘッダからファイル名と最終更新時刻を取り出します。次にzipのローカルファイルヘッダを作ります。compression methodにはDeflateを指定し、最終更新時刻もgzipから取り出したものを入れます。しかし、この時点ではCRC-32や非圧縮サイズは持っていません。そこで、上述したように汎用目的のビットフラグフィールドの3ビット目をセットし、CRC-32やcompressed/uncompressed sizeなどは全て0を埋めておきます。実際の値はdata descriptorに入れます。

あとはファイル名やファイル名の長さを入れればローカルファイルヘッダは完成です。後々セントラルディレクトリエントリでも同じ情報が必要になるのでこれらの情報は取っておきます。

ちなみにCRC-32はgzip trailerを読めば取得可能なので、一旦フッタを読んでseekして先頭に戻せば一応この時点でもCRC-32のフィールドを埋めることが出来ます。ただファイル自体が大きい場合はseekすら惜しいということでストリーム処理したかったのではないかと推測しています。そういった場合にはやはりdata descriptorを使うのが有用そうです。

簡略したイメージ図は以下です。この時作ったローカルファイルヘッダのオフセットがセントラルディレクトリの終端レコード作成時に必要なのでそれも取っておきます。

f:id:knqyf263:20220310082442p:plain

gzipファイル本体の処理

ここは同じDeflateで圧縮されているのでそのままでOKです。実際適当にzipとgzipを作って比べると本体部分が共通しています。

$ echo fooooooooooo > hello.txt
$ zip hello hello.txt
  adding: hello.txt (deflated 54%)
$ od -Ax -tx1z hello.zip
000000 50 4b 03 04 14 00 00 00 08 00 88 09 6a 54 38 e5  >PK..........jT8<
000010 35 2f 06 00 00 00 0d 00 00 00 09 00 1c 00 68 65  >5/............he<
000020 6c 6c 6f 2e 74 78 74 55 54 09 00 03 50 34 29 62  >llo.txtUT...P4)b<
000030 51 34 29 62 75 78 0b 00 01 04 f5 01 00 00 04 14  >Q4)bux.........<
000040 00 00 00 4b cb 47 00 2e 00 50 4b 01 02 1e 03 14  >...KG...PK.....<
000050 00 00 00 08 00 88 09 6a 54 38 e5 35 2f 06 00 00  >.......jT85/...<
000060 00 0d 00 00 00 09 00 18 00 00 00 00 00 01 00 00  >................<
000070 00 a4 81 00 00 00 00 68 65 6c 6c 6f 2e 74 78 74  >......hello.txt<
000080 55 54 05 00 03 50 34 29 62 75 78 0b 00 01 04 f5  >UT...P4)bux....<
000090 01 00 00 04 14 00 00 00 50 4b 05 06 00 00 00 00  >........PK......<
0000a0 01 00 01 00 4f 00 00 00 49 00 00 00 00 00        >....O...I.....<
0000ae
$ gzip hello.txt
$ od -Ax -tx1z hello.txt.gz
000000 1f 8b 08 08 50 34 29 62 00 03 68 65 6c 6c 6f 2e  >....P4)b..hello.<
000010 74 78 74 00 4b cb 47 00 2e 00 38 e5 35 2f 0d 00  >txt.KG...85/..<
000020 00 00                                            >..<
000022

gzipはファイル名のあとに本体が来ているので 4b cb 47 から始まるところが本体ですが、上のzipでも共通していることが分かります。compression methodも共に8(Deflate)になっています。

ということで単にgzipファイルの本体部分をそのままzipに転用すればOKなので以下のようになります。spliceを使う場合は先頭のヘッダサイズをオフセットに指定してスキップし、最後trailerの8バイトを残してzipに流し込めば良いです。つまりこのファイルの内容をユーザ空間のメモリに載せる必要なしに処理可能です。

f:id:knqyf263:20220310081714p:plain

この時、spliceの戻り値から圧縮サイズが分かります。

gzip trailerの処理

gzip trailerからCRC-32と非圧縮サイズを取り出します。そしてそれらをdata descriptorに入れます。

f:id:knqyf263:20220310082329p:plain

ここまででファイルの1セットが完成です。あとはヘッダ処理時に取っておいたファイル名や最終変更時間、そして今取得したCRC-32やサイズを使ってセントラルディレクトリエントリを作ります。

f:id:knqyf263:20220310082544p:plain

セントラルディレクトリエントリが完成したら、計算しておいたオフセットを使ってセントラルディレクトリの終端レコードを作れば完成です。

複数gzipファイルの連結

gzipファイルが複数あっても同じです。gzipファイルをストリームに処理してローカルファイルヘッダ、ファイル本体、data descriptorの1セットを作ります。セントラルディレクトリエントリはファイルごとに作ったものをメモリ上で連結して保存しておいて、最後にzipファイルの末尾に置くだけです。

PoC

机上の空論なのではないかと不安だったので作ってみました。

github.com

内部でspliceを使っているのでパイプを使う必要があります。

$ go run main.go hello.txt.gz | cat - > hello.zip

これで作ったzipはきちんとunzipコマンドで正しく解凍できました。とりあえず確かめたかっただけなので拡張ヘッダはスキップしてますし(何なら最終更新時刻もサボった)ソースコードもゴリ押しですが、理解は深まったので良しとします。

報告者にソースコードを送りつけてこういうことだよね?と確認したので、細かい違いはあると思いますが大筋は間違ってないと思います。Linuxカーネルに特別詳しいわけでもないので、実はこの実装だと省メモリになってないよ、とかあったら教えて下さい。ただ報告者がやっていたのは少なくともこういうことで正しいはずです。

ちなみにDirty Pipeの脆弱性においてはspliceで最後8バイトは(trailerなので)パイプに送っていないというのがとても重要です。

まとめ

複数のgzipファイルをspliceを使いつつストリームに処理して高速かつ省メモリでzipファイルが作れることを確かめました。どっちのフォーマットも割と共通しているところが多いのでこういった事が可能なんだなと感心しました。元のブログではgzipファイルをそのまま連結しているという書き方でしたが、実際にはDeflate圧縮された部分を取り出して連結している、が正しい表現かな気がします。本筋から外れるので説明を簡略したのかなと思います。

しかしDirty Pipeの解説を書くはずが自分は一体何を...