【実務・中級編】 Mass Assignment(大量割り当て)による属性改ざん – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

おい、ちょっと手を止めてこっちを向いてくれ。

先日、とあるクライアントの新規API基盤のペネトレーションテストを完了したんだが、またやらかしていたよ。最新のフロントエンドフレームワークを使い倒し、認証にはモダンなJWT(JSON Web Token)を採用し、通信はすべてTLSで暗号化されている。一見すると「要塞」のようなAPIサーバーだ。

だが、ログイン後のプロフィール更新エンドポイントに対して、Burp Suiteからほんの数文字、JSONのパラメータを付け足しただけで、一般ユーザーが瞬時にデータベース上の管理者フラグ (is_admin: true) を書き換えてシステムを乗っ取ることができた。

原因は、お前たちも日々の開発でやりがちな Mass Assignment(大量割り当て) の脆弱性だ。

フレームワークが便利になりすぎて、リクエストボディをそのままORM(オブジェクト関係マッピング)やデータベースのモデルに放り込む実装が横行している。今日は、この攻撃がなぜこれほど簡単にシステムを崩壊させるのか、そして現場でどうやって完全にねじ伏せるのかを、俺の経験を交えて叩き込んでやる。心して聞け。

—

1. Mass Assignment(大量割り当て)のメカニズムと脅威

Mass Assignmentとは、ユーザーからのHTTPリクエストに含まれるパラメータ(キーと値のペア)を、開発者が意図しない形で内部のデータモデルやデータベースのオブジェクトに一括してバインドしてしまうことに起因する脆弱性だ。

攻撃者が狙う「隠しパラメータ」の盲点

例えば、ユーザーが自分の名前やメールアドレスを更新するための画面を考えてみよう。ブラウザから送信されるフォームデータやJSONは、通常以下のようなものだ。

{
  "name": "山田 太郎",
  "email": "yamada@example.com"
}

サーバー側のコードが、このリクエストデータ(req.body や $_POST など)を精査せず、そのままユーザーモデルの更新メソッドに渡してしまう設計になっているとどうなるか? 攻撃者はAPIリクエストをインターセプトし、以下のように勝手にパラメータを「マージ」して送りつける。

{
  "name": "山田 太郎",
  "email": "yamada@example.com",
  "is_admin": true,
  "role": "super_user",
  "account_balance": 999999
}

開発者は「フロントエンドの画面には is_admin を変更する入力欄(UI)を用意していないから大丈夫」とタカをくくっている。だが、HTTPリクエストなどブラウザの向こう側でいくらでも偽装できる。APIはフロントエンドの都合など知ったことかと言わんばかりに、送られてきたキーをそのまま受け入れてしまうのだ。これがMass Assignmentの恐ろしいところだよ。

—

2. 【PoC】実録:APIリクエスト改ざんの手口

実際のペネトレーションテストで俺たちがどのようにこの脆弱性を突いているか、その一端を見せておこう。

ターゲットは、ECサイトの会員情報更新API (PUT /api/v1/users/me) だ。

正常なリクエスト

PUT /api/v1/users/me HTTP/1.1
Host: api.target-system.local
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
Content-Type: application/json

{
  "first_name": "次郎",
  "last_name": "セキュリティ"
}

このリクエストに対するサーバーのレスポンスは 200 OK で、更新されたユーザー情報が返ってくる。

攻撃者による改ざんリクエスト(PoC)

ここに、開発者が本来触らせたくなかった内部フラグである role や status をインジェクションする。

PUT /api/v1/users/me HTTP/1.1
Host: api.target-system.local
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
Content-Type: application/json

{
  "first_name": "次郎",
  "last_name": "セキュリティ",
  "role": "admin",
  "status": "active",
  "email_verified": true
}

もし、サーバーサイドでホワイトリスト検証が行われておらず、渡されたJSONオブジェクトをそのままデータベースの永続化レイヤー(例: SequelizeやEloquent、Hibernateなど)にダイレクトにブッ込む実装になっていれば、データベース内のレコードは書き換わる。直後のAPIリクエストで管理者用のエンドポイント(/api/v1/admin/...)を叩いてみると、いとも簡単に管理画面のデータが露わになるというわけだ。笑えないだろう?

—

3. 完全防御のための実装サンプルコード

「じゃあ、どうやって防ぐんだ?」という話だな。
対策の基本原則はシンプルだ。「ユーザーから送られてきた入力を全信頼してはならない。受け入れるキーを明示的に絞り込め(ホワイトリスト方式)」。

主要な言語・フレームワークにおける、セキュアな実装サンプルを置いておく。明日からのコードレビューでこれらが守られているか徹底的にチェックしてくれ。

A. Node.js (Express + Mongoose) の場合

MongooseやExpressでありがちなのが、req.body をそのまま User.update() に渡すパターンだ。以下のように、更新を許可するフィールドを明示的にピックアップ(Destructuring)しろ。

/**
 * セキュアなユーザープロフィール更新コントローラー (Node.js / Express)
 */
async function updateProfile(req, res) {
  try {
    const userId = req.user.id; // 認証トークンから取得した信頼できるユーザーID

    // 【重要】ユーザーが勝手に追加できないよう、変更を許可するプロパティを明示的に限定する(ホワイトリスト抽出)
    const { first_name, last_name, bio } = req.body;

    // 許可されたフィールドだけで更新オブジェクトを構築
    const allowedUpdates = {};
    if (first_name !== undefined) allowedUpdates.first_name = first_name;
    if (last_name !== undefined) allowedUpdates.last_name = last_name;
    if (bio !== undefined) allowedUpdates.bio = bio;

    // データベースの更新(is_adminやroleなどの機密フィールドは絶対に巻き込まれない)
    const updatedUser = await User.findByIdAndUpdate(
      userId,
      { $set: allowedUpdates },
      { new: true, runValidators: true }
    ).select('-password -role -is_admin'); // レスポンスからも機密情報を除外

    return res.status(200).json({
      status: 'success',
      data: updatedUser
    });

  } catch (error) {
    console.error('Profile update error:', error);
    return res.status(500).json({ error: 'Internal Server Error' });
  }
}

B. Python (FastAPI + Pydantic) の場合

FastAPIを使っているならラッキーだ。Pydanticのモデル(スキーマ)を適切に分離することで、Mass Assignmentを構造的に防ぐことができる。

from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from typing import Optional

app = FastAPI()

# --- スキーマの分離 ---

# 1. ユーザーが入力・送信できるデータを定義するスキーマ(入力用)
class UserUpdateInput(BaseModel):
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    bio: Optional[str] = None
    # ここに `is_admin` や `role` を定義しては絶対にダメ!

    class Config:
        # スキーマに定義されていない余計なフィールドが含まれている場合はバリデーションエラーにする
        extra = "forbid"

# 2. クライアントに返却するデータを定義するスキーマ(出力用)
class UserResponse(BaseModel):
    id: int
    first_name: str
    last_name: str
    email: EmailStr
    is_admin: bool  # 読み取り専用として出力には含めてもよい

    class Config:
        orm_mode = True

@app.put("/api/v1/users/me", response_model=UserResponse)
def update_user_profile(
    payload: UserUpdateInput, 
    current_user = Depends(get_current_active_user)
):
    """
    ユーザー自身のプロフィールを更新するエンドポイント
    """
    # payloadには定義されたフィールドしか存在せず、extra="forbid" により不正なキーは弾かれる
    update_data = payload.dict(exclude_unset=True)

    for key, value in update_data.items():
        setattr(current_user, key, value)
    
    # データベースに保存
    current_user.save()

    return current_user

C. PHP (Laravel / Eloquent ORM) の場合

LaravelのEloquentでは、モデル側で $fillable または $guarded を正しく設定することが命綱になる。しかし、リクエスト全体を User::update($request->all()) のように一発で処理するのはアンチパターンだ。フォームリクエストやバリデーションを厳格に挟もう。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Requests\UpdateProfileRequest;
use Illuminate\Support\Facades\Auth;

class UserController extends Controller
{
    /**
     * プロフィール更新処理
     * 
     * @param UpdateProfileRequest $request バリデーション済みのリクエスト
     */
    public function update(UpdateProfileRequest $request)
    {
        $user = Auth::user();

        // safe()->only() を使って、バリデーション通過済みの安全なキーのみを抽出
        $validatedData = $request->safe()->only([
            'first_name', 
            'last_name', 
            'bio'
        ]);

        // 明示的に許可されたデータのみでモデルを更新
        $user->update($validatedData);

        return response()->json([
            'message' => 'プロフィールを更新しました。',
            'data' => $user
        ], 200);
    }
}

あわせて、モデル側(app/Models/User.php など)でも以下のようにガードを固めておくことだ。

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    // 方法A: 代入を許可するフィールドをホワイトリストで明示する(推奨)
    protected $fillable = [
        'first_name',
        'last_name',
        'bio',
    ];

    // 方法B: 代入を絶対に禁止するフィールドをブラックリストで指定する
    // protected $guarded = [
    //     'id',
    //     'is_admin',
    //     'role',
    //     'email_verified_at',
    // ];
}

—

4. セキュリティチーフからの総括

Mass Assignmentは、コードの行数としては数文字の油断から生まれる。しかし、ひとたび突かれればシステム全体の権限管理が崩壊し、企業の信用を失墜させる致命傷になり得る脆弱性だ。

フレームワークが提供する「便利で簡単な一括データ保存機能(Model::create($request->all()) や Object.assign() など)」は、セキュリティの文脈においては「毒薬」になり得るということを肝に銘じておいてほしい。

明日出社したら、自分たちのチームが書いたAPIのエンドポイントで、リクエストボディがそのままデータベースのモデルに直結していないか、今一度コードレビューを行え。疑わしきは即座に修正する。それが、プロのエンジニアの仕事だ。頼んだぞ。

コメント

タイトルとURLをコピーしました