展開してから中を見るのでは、もう遅い
一言でいうと
持ち込み媒体の検疫は、アーカイブを展開する前にヘッダーの一覧だけを読んで、規模とリスクを判定することであり、展開したあとに見るのは、検査ではなく事故調査です。
なぜ必要なのか
エアギャップ環境には、ネットワークを通じて何かが入ってくることはありません。そのため、入ってくるものは、ほとんどすべて人が持ってきます。協力会社の担当者がリムーバブル媒体を持って正門を通り、持ち込み審査台に置く瞬間、その中にはたいてい圧縮ファイルが1つ入っています。パッチ、設定、依存ライブラリ、インストール案内が、ひとかたまりに束ねられています。
ここで審査者が最もよく行うのは、とりあえず展開することです。展開しないと中が見えないからです。ところが、アーカイブを展開する行為そのものが、すでにファイルシステムに触れる行為です。項目名が/etc/cron.d/agent-syncなら、その名前が指す場所に書き込もうとし、../../etc/profile.d/agent.shなら、展開したディレクトリの外に書き込もうとします。項目がシンボリックリンクなら、リンクが先に置かれ、そのあとに来る項目がリンクをたどって見当違いの場所に書き込まれます。特殊ファイルが混ざっていれば、デバイスノードができます。これらすべてが、「中を見よう」として打った1行のコマンドの中で起こります。
そして、圧縮爆弾があります。同じバイトが繰り返されたファイルは、圧縮率が数百倍にまで上がります。媒体に入っているファイルは16KBなのに展開すると4MBになり、その比率を少し大きくするだけで、審査台の一時ディスクが先に干上がります。ディスクが干上がると審査自体が中断され、中断された審査は、たいてい「とりあえず通して、あとで」で終わります。
そのため、順序を変えます。先に一覧を読み、判定し、そのあとで条件付きで展開します。
どう動くのか
tarは非常に単純な形式です。512バイトのヘッダー1つに、名前、サイズ、権限ビット、項目の種類、リンク先が書かれていて、そのあとに内容が付いています。この構造のおかげで、内容をディスクに書き込まなくてもわかることが多くあります。
- 規模。ヘッダーのサイズ値をすべて足すと、展開したときに何バイトになるかが出ます。Pythonのtarfileの
getmembers()が、このヘッダーをそのまま返してくれます。圧縮ファイルのサイズで割ると圧縮比が出て、しきい値を超えたら爆弾とみなします。しきい値そのものには、正解がありません。このラボは、100倍を仮定として使います。 - 危険な項目。名前がスラッシュで始まるか(絶対パス)、階層単位で数えたときに基準ディレクトリの外に出るか(上位パスへの脱出)、リンク先が外を指しているか、デバイスやFIFOのような特殊ファイルか、実行ビットが立っているか。5つとも、ヘッダーだけを見ればわかります。
- 形式の不一致。名前は
.csvなのに先頭2バイトが1f 8bならgzipで、.txtなのに7f E L Fなら実行ファイルです。extractfile()はストリームだけを開いてくれるので、先頭の数百バイトだけを読んで閉じられます。ここでgrepやheadで覗いてはいけません。NULが混ざったファイルでは、GNU grepは内容の代わりに「Binary file matches」の1行だけを出します。
判定が終わってはじめて展開します。Python 3.12からは、抽出にフィルターをかけられます。PEP 706がその背景を書いていて、要点は、extractall()の既定の動作が、アーカイブのメタデータをそのまま信じるものであり、それが2007年に報告された脆弱性(CVE-2007-4559)の場所だったということです。フィルターは3つです。fully_trustedは従来どおり、tarはGNU tarを真似、dataはUnix固有の機能を切り落として、最も狭く展開します。PEPは、既定値がPython 3.12と3.13では警告付きでfully_trustedのままで、3.14からdataになると書いています。
ここで注目すべき点が1つあります。dataフィルターは、絶対パスを拒否しません。PEPのtarフィルターの説明どおり、先頭のスラッシュを取ってから判定するので、/etc/cron.d/agent-syncは、エラーなしに、隔離ディレクトリの中のetc/cron.d/agent-syncとして入ってきます。ラボのイメージで実測した結果も同じでした。ですから、「フィルターを有効にしたから安全だ」と「この媒体に何が入っていた」は、別の文です。持ち込み判定書に残すべきなのは、後者です。
매체(tar.gz) ──▶ getmembers() ──▶ 규모·위험·압축비·형식 불일치 ──▶ 판정서
(풀지 않음) │
└─▶ data 필터로 항목별 판정 ──▶ 격리 디렉터리
거절 목록(사유 포함)
拒否も、一度にまとめなければなりません。extractall()にフィルターを渡すと、最初の拒否で例外が上がって止まり、そのあとに何がさらにあったかは見えません。項目ごとにdata_filter()を直接呼んで例外を受け取っておけば、拒否した項目と理由が一度に残ります。審査者が協力会社に差し戻すときに必要なのが、その一覧です。
現場での姿
判定書には根拠が付いていなければなりません。「危険のため差し戻し」とだけ書かれた判定書は、再検証できず、協力会社は何を直せばよいかわかりません。どのアーカイブのどのハッシュを、どのレポートとして読み、そのレポートからどの理由が出たかが、1行でつながっている必要があります。レポートを作り直したなら、判定書のハッシュも書き直します。この1行を忘れて審査を2回受けた話が、持ち込みバンドルのラボにも出てきます。
署名は検疫の代わりになりません。同じコースの持ち込みバンドルのラボで扱う署名検証は、「送り主が正しい」ことを教えてくれます。送り主が正しくても、その人が圧縮したディレクトリに、ビルドキャッシュ4MBと開発用のシンボリックリンクが付いてくることがあります。署名と構造検査は、順番に、両方行います。
媒体自体も管理の対象です。持ち込みが終わった媒体をどう扱うかは、技術ではなく手順の問題です。媒体の消去はNIST SP 800-88 Rev.1が、媒体保護系の統制はNIST SP 800-53 Rev.5が扱っています。このラボは、その手順を真似するのではなく、検疫記録が、その手順に渡せる形になっているかまでを見ます。
一度痛い目に遭ったこと。審査台でアーカイブを展開してみて、「大したものはない」と判断して通した媒体がありました。あとで見ると、その中にあったdocs/notes.txtが、テキストではありませんでした。名前が拡張子と合っているかを、人が目で見る方法はありません。それ以来、検疫器が先頭バイトを読みます。
次のラボですること
媒体2つを合成で作って、受付台帳から書き、展開せずに一覧だけを読んで規模を測り、危険な項目を5つに分け、圧縮比で爆弾を見分け、拡張子と実際の形式の不一致を探します。そのあとdataフィルターで項目ごとに判定して、隔離ディレクトリに展開し、拒否の一覧を残したうえで、根拠のハッシュが付いた持ち込み判定書を書きます。最後に、同じ手順を2つ目の媒体にそのまま回して、判定が覆るかを見ます。