1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

攻撃者はAPIを見つけたあと何をするのか — そして、あなたは何を直すべきか〜バグバウンティで認可不備(P1/P2)を報告してきた視点で書く、バックエンド実装防御ガイド〜

1
Last updated at Posted at 2026-07-14

はじめに

前回の記事では、

攻撃者はブラウザを開いて最初の5分で何を見るのか

というテーマで、フロントエンド(Next.js)から取得できる情報を追いました。そこで最後に見つかるのが API です。

では攻撃者は、APIを見つけたあと何を考えるのか。

結論から言うと、派手な攻撃はほとんど使いません。「当たり前に実装されているはず」の認可・検証・制限を、一つずつ疑って確認していくだけです。そして、その"当たり前"が抜けている箇所を突きます。

私が普段バグバウンティで報告している脆弱性も、大半がこの種の「基本の抜け」です。特別なテクニックではなく、認可のチェックが1行足りない、それだけで成立します。

この記事では前回同様、

「攻撃者はここを見る」→「だから、あなたはこう直す」

の順で、バックエンドで守るべきポイントをコード付きで見ていきます。実際のシステムはフロントとバックで言語が違うことが多いので、ここではバックエンドを Python / FastAPI で書きます。考え方自体はどの言語でも同じです。

※この記事は防御を目的とした内容です。


Step1. 「APIが見えること」は問題ではない

前回のとおり、Networkタブを開けばAPIは丸見えです。

GET    /api/users/123
PUT    /api/users/123
DELETE /api/admin/users/123

初心者はここで「URLがバレた」と気にしますが、攻撃者は気にしません。URLは隠せない前提だからです。

攻撃者が確認するのは、URLが見えるかどうかではなく、次の3つです。

  1. 他人のデータにアクセスできないか(オブジェクトの認可)
  2. 触ってはいけない項目を書き換えられないか(プロパティの認可)
  3. 本来使えない機能を呼べないか(機能の認可)

これはOWASP API Security Top 10 の BOLA(API1)/BOPLA(API3)/BFLA(API5) に対応する、API脆弱性の中核です。これらは自動スキャナでは見つけにくく、コードを読まないと分からない認可ロジックの不備です。裏を返せば、実装する側が意識すれば確実に潰せます。

以降、この3層を順に見ていきます。


Step2. BOLA:IDを書き換えて、他人のデータを取る

攻撃者はここを見る

最初に試すのは、これ以上ないほど単純な操作です。

GET /api/users/123   ←  自分
GET /api/users/124   ←  124に変えてみる

124 で他人の情報が返ってきたら、その時点で BOLA(Broken Object Level Authorization)。OWASP API1、最も多く踏まれている認可不備です。

重要なのは、認証は突破されていないことです。攻撃者は正規ユーザーとして正しくログインしています。壊れているのは「このユーザーは、このオブジェクトを見てよいか」という認可の側です。

  • 認証(Authentication)= あなたは誰か
  • 認可(Authorization)= あなたはそれをしてよいか

✅ 直すべきこと

「ログインしているか」ではなく「このオブジェクトの所有者か」を毎回検証します。FastAPIなら認証済みユーザーを Depends で受け取り、対象と突き合わせます。

from fastapi import Depends, HTTPException, status

@router.get("/api/users/{user_id}", response_model=UserOut)
async def get_user(
    user_id: int,
    current_user: User = Depends(get_current_user),  # 認証
):
    target = await users.get(user_id)
    if target is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    # ❌ return target                      ← これがBOLA
    # ✅ 「リクエスト元 == 所有者」を検証する
    if target.id != current_user.id and not current_user.is_admin:
        # 存在を隠すため 403 ではなく 404 を返す設計もある
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    return target

所有者チェックが複数のエンドポイントに散らばるなら、依存関数に切り出して使い回すと抜け漏れが減ります。

async def get_owned_user(
    user_id: int,
    current_user: User = Depends(get_current_user),
) -> User:
    target = await users.get(user_id)
    if target is None or (target.id != current_user.id and not current_user.is_admin):
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return target

補足:ID採番を連番からUUIDにするのは推測(enumeration)を難しくする"緩和"であって、認可の代わりにはなりません。他人のUUIDを知った瞬間にアクセスできるなら、それは認可が無いのと同じです。


Step3. BOPLA / Mass Assignment:role を紛れ込ませて権限昇格する

攻撃者はここを見る

プロフィール更新APIがあるとします。

PUT /api/users/123
{ "name": "sei", "email": "sei@example.com" }

攻撃者はここに、本来更新できないはずの項目をこっそり足します。

PUT /api/users/123
{ "name": "sei", "email": "sei@example.com", "role": "admin" }

これが通れば、自分を管理者に昇格できてしまいます。これが Mass Assignment(OWASP API3 BOPLA)。BOLAが「オブジェクトごと」の認可漏れなら、こちらはオブジェクト内のプロパティ単位の認可漏れです。

✅ 直すべきこと

FastAPIの強みがここで効きます。受け取るPydanticスキーマに書いていない項目は、そもそも入り込めません。つまりスキーマ自体が「更新を許可する項目のホワイトリスト」になります。

from pydantic import BaseModel, ConfigDict, EmailStr

class UserUpdate(BaseModel):
    # 更新を許可する項目だけを定義する。role はここに無い。
    name: str
    email: EmailStr

    # 想定外のキーが来たら弾く(明示的に拒否)
    model_config = ConfigDict(extra="forbid")
@router.put("/api/users/{user_id}", response_model=UserOut)
async def update_user(
    payload: UserUpdate,                       # role は受け取れない
    target: User = Depends(get_owned_user),    # Step2の所有者チェックを再利用
):
    await users.update(target.id, payload.model_dump())
    return await users.get(target.id)

ポイントは、リクエストを丸ごとモデルに流し込まないこと。request の生データや dict をそのまま update に渡す実装は、role の混入を許します。「入力スキーマ」と「DBモデル」を分け、権限に関わる項目(roleis_adminplanprice など)は入力スキーマに一切載せないのが原則です。

同時に、返す側も response_model=UserOut で項目を絞れば、パスワードハッシュや内部フラグの漏洩(BOPLAのもう半分=Excessive Data Exposure)も同じ仕組みで防げます。

class UserOut(BaseModel):
    id: int
    name: str
    email: EmailStr
    # password_hash や is_admin は返さない

Step4. BFLA:管理者向け機能を、一般ユーザーが直接呼ぶ

攻撃者はここを見る

前回のフロント編で見たとおり、UIでボタンを隠していても意味がありません。

{user.role === "admin" && <DeleteButton />}

攻撃者はUIを無視して、管理APIを直接叩きます。

DELETE /api/admin/users/123

このエンドポイントに認可チェックが無ければ、一般ユーザーでも実行できてしまう。これが BFLA(Broken Function Level Authorization、OWASP API5)。BOLAが「他人のデータ」なら、BFLAは「本来使えないはずの機能」を使えてしまう問題です。

✅ 直すべきこと

管理系には、依存関数で明示的な権限チェックを付けます。ルーター単位でまとめて掛ければ、管理エンドポイントの付け忘れを防げます。

def require_admin(current_user: User = Depends(get_current_user)) -> User:
    if not current_user.is_admin:
        raise HTTPException(status.HTTP_403_FORBIDDEN)
    return current_user

# 管理ルーターは丸ごと権限で囲う
admin_router = APIRouter(
    prefix="/api/admin",
    dependencies=[Depends(require_admin)],  # 全エンドポイントに適用
)

@admin_router.delete("/users/{user_id}")
async def delete_user(user_id: int):
    await users.delete(user_id)
    return {"ok": True}

前回のフロント編と用語を揃えておくと、ステータスコードの使い分けはこうです。

  • 未ログイン → 401 Unauthorized(認証が無い)
  • ログイン済みだが権限なし → 403 Forbidden(認可で弾く)

「管理エンドポイントを、匿名・一般ユーザー・他人・管理者の4パターンで叩いて、403であるべき所が200になっていないか」を確認する——これは攻撃者が必ずやるチェックであり、そのまま自分の受け入れテストにできます。


Step5. エラーメッセージ:500は攻撃者への"自己紹介"

攻撃者はここを見る

攻撃者は500エラーを歓迎します。詳細が返れば環境が読めるからです。

sqlalchemy.exc.ProgrammingError: column "xxx" does not exist
File "/app/api/users.py", line 42, in get_user
Python 3.12 / FastAPI 0.11x

フレームワーク・バージョン・DB・ファイルパスが分かれば、次に狙う既知の脆弱性を絞り込めます。

✅ 直すべきこと

本番では、例外の中身を利用者に返さないこと。FastAPIは既定ではトレースバックをクライアントに出しませんが、debug=True での起動や、str(exc) をレスポンスに載せる自前ハンドラで漏れることがあります。汎用メッセージだけを返し、詳細はサーバーログに送ります。

import logging
from fastapi import Request
from fastapi.responses import JSONResponse

logger = logging.getLogger("app")

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    # 詳細はログにだけ残す(クライアントには返さない)
    logger.exception("unhandled error at %s %s", request.method, request.url.path)
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal Server Error"},  # 汎用メッセージ
    )

本番起動時は debug=True--reload を使わないこと、リバースプロキシ側の詳細エラーページも無効化することを確認します。


Step6. レート制限:ログインAPIは何回でも叩けるか

攻撃者はここを見る

ログインエンドポイントを見つけたら、何回叩いても弾かれないかを確認します。

POST /api/login   ← 何百回でも送れる?

制限が無ければ、パスワードの総当たり(ブルートフォース)やクレデンシャルスタッフィングが成立します。

✅ 直すべきこと

FastAPIは標準にレート制限を持たないので、slowapi などでIP単位の制限を掛けます。

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@router.post("/api/login")
@limiter.limit("5/minute")           # IPあたり1分5回、超過で429
async def login(request: Request, form: LoginIn):
    ...

ただしIP単位だけだと、IPを分散されると回避されます。ログインは「アカウント単位」でも失敗回数を数え、閾値を超えたら一時ロックやCAPTCHAを挟むのが有効です(IPはslowapi、アカウントはキャッシュで別カウント、の二段構え)。

# アカウント単位の失敗カウント(Redis等のキャッシュを利用)
async def check_account_lock(email: str):
    fails = await cache.get(f"login_fail:{email}") or 0
    if int(fails) >= 5:
        raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS)

# ログイン失敗時にインクリメント、成功時にリセットする

Step7. 入力値:クライアントが送る数字を信じない

攻撃者はここを見る

購入APIにこんなパラメータがあるとします。

POST /api/orders
{ "product_id": 10, "quantity": 2, "price": 3000 }

攻撃者は想定外の値を試します。

{ "product_id": 10, "quantity": 2, "price": 1 }      ← 値段を1円に
{ "product_id": 10, "quantity": -5, "price": 3000 }  ← 数量をマイナスに

フロントの入力制限は、HTTPリクエストを直接組み立てれば簡単に無視できます。

✅ 直すべきこと

バリデーションはバックエンドで必須。Pydanticなら型と制約で宣言的に書けます。そして最も重要なのは、金額のような重要な値はクライアントから受け取らず、サーバーで再計算することです。

from pydantic import BaseModel, Field

class OrderIn(BaseModel):
    product_id: int
    quantity: int = Field(gt=0, le=100)   # 1〜100に制約
    # price はクライアントから受け取らない

@router.post("/api/orders")
async def create_order(payload: OrderIn, current_user: User = Depends(get_current_user)):
    product = await products.get(payload.product_id)
    if product is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    # ✅ 金額はサーバー側の正データから計算する
    total = product.price * payload.quantity

    await orders.create(user_id=current_user.id, product_id=product.id,
                        quantity=payload.quantity, total=total)

「クライアントが送ってきた合計金額を信じる」実装は、EC系で最も典型的な事故です。信頼できるのはサーバーが持つデータだけ、を原則にします。


Step8. ファイルアップロード:その画像、本当に画像ですか?

ここは扱いを軽くしがちですが、成立すると被害が最も大きいため厚めに書きます。

攻撃者はここを見る

プロフィール画像アップロードを見つけたら、拡張子・Content-Type・中身を差し替えて、画像に見せかけた実行ファイルを通せないか試します。

POST /api/avatar
Content-Type: multipart/form-data

filename="avatar.php"        ← 拡張子を偽装
Content-Type: image/png      ← ヘッダは詐称
<?php system($_GET['c']); ?> ← 中身はスクリプト

もしこれが公開ディレクトリに元の名前のまま保存され、かつ実行できてしまえば、/uploads/avatar.php?c=... で**サーバー上で任意コマンド実行(RCE)**につながります。フロントで accept="image/*" を指定していても、リクエストは自由に組めるので無意味です。

✅ 直すべきこと

「拡張子とContent-Typeのチェック」だけでは不十分です。それらは詐称できる前提で、中身を実際に検証し、万一おかしなファイルでも実行させない設計にします。

import uuid
from pathlib import Path
from fastapi import UploadFile, HTTPException, status
from PIL import Image, UnidentifiedImageError

ALLOWED = {"image/jpeg", "image/png", "image/webp"}
# 公開ディレクトリの外に保存する
UPLOAD_DIR = Path("/var/app/storage/avatars")

@router.post("/api/avatar")
async def upload_avatar(file: UploadFile, current_user: User = Depends(get_current_user)):
    # 1. Content-Type は一次フィルタ(信用はしない)
    if file.content_type not in ALLOWED:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid type")

    data = await file.read()
    if len(data) > 2 * 1024 * 1024:          # 2MB上限
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "too large")

    # 2. 中身を画像として検証(本当に画像か)
    try:
        Image.open(io.BytesIO(data)).verify()
    except UnidentifiedImageError:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "not an image")

    # 3. ファイル名は自前で再生成(avatar.php を残さない)
    dest = UPLOAD_DIR / f"{uuid.uuid4().hex}.png"
    dest.write_bytes(data)

    await users.update(current_user.id, {"avatar_path": str(dest)})

押さえるべきは4点です。

  1. 中身を検証する(Pillowで画像として開けるか)。拡張子・Content-Typeは詐称前提。
  2. 公開ディレクトリの外に保存するstatic/ に直接置かない)。
  3. ファイル名を再生成するavatar.php のまま残さず、UUID+固定拡張子に)。
  4. 保存先でスクリプトを実行させない(アップロード先でPHP等が実行されないようサーバー設定を確認)。

表示はディレクトリ直リンクではなく、認可を通したエンドポイント経由で配信すると、Step2のBOLA対策とも接続できます。


Step9. ログと検知:破られた「あと」に気づけるか

攻撃者が"嫌う"こと

攻撃者にとって最もやりにくいのは、試行が記録され、気づかれる環境です。逆に、認可拒否や連続失敗が誰にも見られていなければ、時間をかけて安全に探索できます。

✅ 直すべきこと

防御(防ぐ)だけでなく**検知(気づく)**もセキュリティの一部です。最低限、次のイベントは追跡できるようにします。

  • ログイン成功/失敗(誰が・いつ・どのIPから)
  • 認可拒否(403)——正規ユーザーが権限外を叩いた記録は、攻撃の初期兆候
  • 管理操作(誰が何を削除・変更したか)
logger.warning(
    "authorization denied",
    extra={"user_id": current_user.id,
           "route": f"{request.method} {request.url.path}",
           "ip": request.client.host},
)

そして同じくらい重要なのが、ログに残してはいけないものです。

  • パスワード、トークン、セッションID
  • クレジットカード番号などの機微情報

これらをうっかり出力すると、ログ自体が新たな漏洩源になります。リクエストボディをまるごとログに吐く実装は特に危険なので、機微項目はマスクしてから記録します。


まとめ:バックエンドで直すべきことリスト

攻撃者の思考をなぞると、やるべきことは基本の積み重ねに収束します。

認可(ここが本丸・OWASP API1/3/5)

  • オブジェクト単位で所有者を検証しているか(BOLA)
  • role 等の項目を入力スキーマに載せていないか(Mass Assignment / BOPLA)
  • 管理機能にサーバー側の権限チェックがあるか(BFLA)
  • 未ログインは401、権限なしは403で弾いているか

入力・値の信頼

  • 金額など重要な値をサーバーで再計算しているか
  • Pydanticで入力を検証し、余分なキーを拒否しているか

濫用・情報漏洩の防止

  • ログインにレート制限(IP+アカウント)があるか
  • 本番でトレースバックをクライアントに返していないか
  • アップロードは公開ディレクトリ外・再生成名・実行不可か

検知

  • 認可拒否・ログイン失敗・管理操作を記録しているか
  • ログにパスワードやトークンを残していないか

おわりに

フロント編では「ブラウザから見える情報」を、今回はその先の「APIは本当に守られているか」を見てきました。

実際のペネトレーションテストやCTF、そしてバグバウンティでも、特別な攻撃から始まることはほとんどありません。まずAPIの挙動を観察し、「認証はあるか」「認可は適切か」「想定外の入力を受け入れないか」という基本を、一つずつ確認していくだけです。

だからこそ、守る側も同じ順番で基本を潰していけば、多くの攻撃は成立しなくなります。派手なテクニックよりも、認可・検証・制限・検知の積み重ねが、システムの安全性を決めます。

この記事が、ご自身のAPIを「もし最初の5分で叩かれたら」という目で見直すきっかけになれば幸いです。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?