●
CWE-22 · CVE-2007-4559
Python の tarfile.extractall のパストラバーサル(CVE-2007-4559):起こる仕組みと直し方
展開フィルターが導入される前の tarfile.extractall() は、各メンバーをアーカイブに記録されたパスへそのまま書き込んでいたため、.. を含む名前、絶対パス、リンクを使って展開先フォルダーの外にファイルを置けました。PEP 706 の filter="data" 引数でこれを防ぐ方法、展開先をユーザーの入力から決めてはいけない理由、追加すべきサイズ制限を Python の例で解説します。
わかりやすい解説 (ELI5)
引っ越し業者があなたの新居で箱を開け、ラベルに書かれた場所に荷物を置いていきます。「台所、それから玄関を出て、隣の家の寝室へ」というラベルも書かれたとおりに実行されます。作業員はラベルを読むだけで、その住所がまだあなたの部屋の中かどうかを確かめないからです。アーカイブも同じで、ファイルごとに自分の展開先パスを持っています。対策は、先にすべてのラベルを読み、部屋の外に出るものや、別の場所を指す案内板のような普通の箱ではないものを断る現場監督を置くことです。
主要な概念と専門用語
- CVE-2007-4559
- Python の
tarfileモジュールは、メンバー名が展開先ディレクトリの外に出ることを止めていませんでした。この問題は 2007 年から知られており、2022 年に Trellix は約 35 万のオープンソースプロジェクトが信頼できないアーカイブに対してextractall()を呼んでいると推定しました。 - `..` や絶対パスを含むメンバー名
../outside.txtや/etc/cron.d/jobという名前の tar エントリーは、展開先と結合すると展開先の外を指します。フィルターがなければextractall()はプロセスの権限でそのパスに書き込みます。- シンボリックリンクやハードリンクのメンバー
- アーカイブは、まず展開先の中に外を指すリンクを作り、そのリンク越しに通常のファイルを書き込むこともできます。名前だけを確認してもこれは防げないため、フィルターはリンク先も調べます。
- 展開フィルター(PEP 706)
filter="data"は、絶対パスの名前、..による脱出、展開先の外を指すリンク、デバイスファイルを拒否し、set-user-ID ビットとその他のユーザーの実行権限を外します。Python 3.12 以降と 3.8〜3.11 のセキュリティリリースで使え、Python 3.14 では既定になります。- リソースの上限
- フィルターはサイズを制限しません。小さな圧縮アーカイブが数百万のメンバーや何 GB ものデータに展開されることもあるため、展開前にメンバー数と合計サイズを数えます。
ステップ・バイ・ステップの攻撃フロー
ステップ 1
アプリがアップロードされたアーカイブを受け取る
データのインポート、プラグインのインストーラー、CI の成果物を扱う処理が、ユーザーから .tar.gz を受け取ります。
ステップ 2
メンバー名がフォルダーの外を指している
あるエントリーに記録されたパスが展開先より上へ抜けているか、絶対パスになっています。../outside.txt という名前のメンバーを含むテスト用アーカイブはこの場合を示しています。
ステップ 3
extractall が解決後のパスに書き込む
ファイルは名前が解決した場所に置かれ、展開先そのものもユーザーが渡した job_id から決まることがあります。
ステップ 4
アプリケーションのファイルが置き換えられる
上書きされた設定、テンプレート、スクリプトは、次にアプリケーションが読み込んだときに実行されます。こうしてパストラバーサルがコード実行につながります。
ソースコード比較:脆弱 vs 堅牢化
脆弱な実装
# imports.py: 各メンバーは記録された名前が指す場所に書き込まれる
import tarfile
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
# 展開先は呼び出し元が決め、フィルターもサイズ制限もない
def import_bundle(archive_path: str, job_id: str) -> Path:
dest = UPLOAD_ROOT / job_id
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path) as tar:
tar.extractall(dest)
return dest
堅牢化されたセキュアパッチ
# imports.py: data フィルター、サーバーが名付けたフォルダー、サイズ制限
import tarfile
import uuid
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024
# メンバー数やバイト数が多すぎるアーカイブは拒否する
def check_limits(tar: tarfile.TarFile) -> None:
members = tar.getmembers()
if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
raise ValueError("archive too large")
# filter='data' が ..、絶対パス、外部へのリンク、デバイスファイルを止める
def import_bundle(archive_path: str) -> Path:
dest = UPLOAD_ROOT / uuid.uuid4().hex
dest.mkdir(parents=True)
with tarfile.open(archive_path, mode="r:*") as tar:
check_limits(tar)
tar.extractall(dest, filter="data")
return dest
エンジニアリング&システム堅牢化チェックリスト
extractall()とextract()の呼び出しにはすべてfilter="data"を渡し、Bandit B202 や Semgrep ルールがfilter=のない呼び出しを見つけたら CI を失敗させる。- UUID などサーバーが名前を付けた新しいフォルダーに展開し、リクエストのデータから組み立てたパスには展開しない。
- 展開前にメンバー数と展開後の合計サイズを確認し、上限を超えるアーカイブは拒否する。
- 展開処理は、アプリケーションのコードや設定に書き込み権限のないユーザーで実行する。
../outside.txtと絶対パスのメンバーを含むアーカイブでtarfile.FilterErrorを期待する CI テストを追加する。