● 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

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →