Sterra Security Tech Blog

株式会社ステラセキュリティの公式技術ブログ

「マイナス10個あげます」でアイテムが増えるゲーム ― 数値バリデーションの落とし穴2選

代表の小竹(aka tkmru)です。

本記事は、先日公開した 「1日1回限定のガチャ」を20連できてしまう ― ゲームAPIで頻出するTOCTOU脆弱性と対策 に続く、ゲームAPIの脆弱性シリーズの第2弾です。

tech-blog.sterrasec.com

元ネタは、2026年3月19日に開催された勉強会「TECH BATON in 東京 〜ゲームアンチチートに学ぶ セキュリティ設計と攻防の舞台裏〜」での登壇「10分で知る ゲームが『チートされる』仕組み 〜通信・API・メモリから見る攻撃と防御〜」です(スライドは Speaker Deck で公開しています)。

前回は「同時並列リクエストで回数制限をすり抜ける」というレースコンディションの話でしたが、今回は入力値のバリデーションの話です。取り上げるのは次の2つです。

  • マイナス値を受け付けてしまい、減らすはずのアイテムが増えてしまう
  • 巨大な値を受け付けてしまい、整数オーバーフローで所持数が壊れる

いずれもゲーム固有の問題ではなく、決済・ポイント・在庫管理など数量を扱う Web アプリケーション全般で起こりうる典型的なバグです。 「型は検証しているから大丈夫」「エスケープしているから大丈夫」と思っている箇所ほど穴になりがちなので、Webアプリ開発に携わる方にも参考にしていただければと思います。

落とし穴1:マイナスの数量を送るとアイテムが増える

想定シナリオ ― アイテム交換 API

次のような仕様のAPIを例に考えます。

  • エンドポイント: アイテムAをN個渡して、代わりにアイテムBを受け取る交換 API
  • サーバは「ユーザの所持数からN個引く」「アイテムBを1個付与する」という処理を行う

Rails のコントローラで書くと、次のような流れになります。

# app/controllers/exchange_controller.rb
def create
  give_item_id = params[:give_item_id]
  give_count   = params[:give_count]

  current_user.items[give_item_id] -= give_count   # 渡す分を減らす
  current_user.items["item_b"]     += 1            # 代わりのアイテムを付与
  current_user.save!
end

一見、問題なさそうに見えます。しかし、この実装は give_count に負の値を送られると破綻します。

攻撃方法 ― 負の数量を送るだけ

攻撃者は、数量パラメータだけを負の値に書き換えて送信します。

POST /api/exchange
Content-Type: application/json

{ "give_item_id": "wood", "give_count": -10 }

サーバ側の処理を展開すると次のようになります。

current_user.items["wood"] -= (-10)
  ↓
current_user.items["wood"] += 10   # 減らすはずが、逆に10個増える

引き算の右辺が負の数なので、結果として足し算になってしまうという話です。おまけに「交換したアイテム」は通常通り1個もらえるので、攻撃者は木材(減るどころか増える)と 新たに手に入るアイテムを両方ゲットできてしまいます。

なぜ見落とされるのか

このバグは「型チェックだけで安心してしまう」ことに起因します。

  • give_count は整数型である ✓
  • JSON のパースも通っている ✓
  • SQL インジェクションの対策もしている ✓

型・構文レベルのバリデーションは通っているため、静的解析ツールや一般的な脆弱性スキャナでも検出されません。 「値がルール上ありえる範囲に収まっているか」はアプリケーションロジックでしか判断できない領域です。

ゲーム以外でも、

  • 送金 API で送金額にマイナスを送ると逆方向に送金される
  • EC サイトの返品処理で「返品数量」にマイナスを送ると購入扱いになる
  • ポイント交換 API で「消費ポイント」にマイナスを送るとポイントが増える

といった事例が考えられます。

対策 ― 値の範囲バリデーションを徹底する

対策はシンプルで、入力値の範囲を検証することです。Rails の Strong Parameters はマスアサインメント時に許可するパラメータを明示する仕組みなので、値の範囲の検証は別途実装する必要があります。

# app/controllers/exchange_controller.rb
def create
  give_count = params.require(:give_count)
  raise BadRequest, "give_count must be positive"    if give_count <= 0
  raise BadRequest, "insufficient items"             if give_count > current_user.items[params[:give_item_id]]
  raise BadRequest, "exceeds max exchange count"     if give_count > MAX_EXCHANGE_PER_REQUEST
  # ...
end

あるいは ActiveModel のバリデーションに寄せる方法もあります。

ポイントは3つです。入力値が仕様上ありえる範囲に収まっているかは、アプリケーション側で検証することが大切です。

  1. 下限チェック> 0)― マイナス値とゼロを弾く
  2. 所持数チェック<= 所持数)― 手持ちを超える消費を弾く
  3. 上限チェック<= MAX)― 次節の整数オーバーフロー対策にもなる

落とし穴2:巨大な値で整数オーバーフローを起こす

マイナス値のチェックを入れれば万事解決、とはいきません。今度は「上限を超える巨大な値」のケースを考えます。

整数オーバーフローとは

コンピュータが扱う整数のうち、固定長で表現される型には、型ごとに最大値があります。代表的なのは次のとおりです。

最大値
32bit 符号付き整数(int32 2,147,483,647(約21億)
64bit 符号付き整数(int64 9,223,372,036,854,775,807(約922京)

C、Java、Go などの固定長整数型を使う言語や、MySQL、PostgreSQL などの DB の整数型では、型ごとに表現できる範囲が決まっています。範囲を超える値を扱った場合の挙動は環境によって異なり、負の値への折り返し、例外、範囲外エラー、警告付きの丸め・切り詰めなどが発生し得ます。これが整数オーバーフローです。

int32 の最大値:     2,147,483,647
        + 1:       -2,147,483,648   ← 言語によっては負の数に折り返す
                                      (例:Java の int など。環境によっては例外や範囲外エラーになる)

一方、Ruby の Integer は固定長整数ではなく、実用上は大きな整数を扱えます。値が大きくなると内部的に多倍長整数として扱われるため、Ruby のコード内で計算する限りは折り返しが起きません。Ruby で固定長 int としてのオーバーフローを検出したい場合は、Integer#bit_length を使えます。

if n.bit_length < 32
  # int32 の範囲内
else
  raise "overflow"
end

ところがゲームAPIの実装でオーバーフローが顔を出すのは、Ruby のコードの中ではなく、それと連携する DB やクライアントなど「固定長 int 前提のシステム」と接する部分です。次の節で具体的に見ていきます。

攻撃方法 ― 購入数 × 単価でオーバーフローを起こす

ゲームには「素材アイテムをまとめ買いする」「マーケットにアイテムを大量出品する」「ガチャを10連・100連まとめて引く」など、「数量 × 単価」で合計金額を計算する処理が数多く存在します。乗算は加算と違って、1回のリクエストで値が桁違いに大きくなりうるため、攻撃者にとってオーバーフローを狙いやすい処理です。

ここでは、ゲーム内マーケットで高級アイテムをまとめ買いするケースを考えます。

POST /api/market/buy
{ "item_id": "rare_crystal", "count": 100000 }

サーバ側では、マスタデータから単価を引いて合計金額を計算し、所持ゴールドから差し引きます。

# app/controllers/market_controller.rb
def buy
  item  = ItemMaster.find(params[:item_id])   # 単価はサーバ側で引く
  count = params[:count]

  total_price = item.unit_price * count       # ← ここでオーバーフローの可能性
  current_user.gold -= total_price
  current_user.items[item.id] += count
  current_user.save!
end

一見、ユーザが操作できるのは count だけで、単価はサーバ側のマスタデータから取ってきているため安全に見えます。しかし、その countunit_price と掛け合わされた結果、オーバーフローの余地が生まれます。

具体的に見てみます。rare_crystal の単価が30,000ゴールド、現在の所持ゴールドが10億あるユーザが、count: 100000(10万個)を送ったとします。

total_price = 30_000 * 100_000   # => 3_000_000_000(30億)

ここで重要なのは、Ruby の Integer は固定長整数ではなく、実用上は大きな整数を扱えるということです。30億という値は Ruby の中ではそのまま正しく扱われ、勝手に負の値に折り返したりはしません。

ではどこで問題になるかというと、この値を DB に保存する瞬間です。gold カラムが MySQL の INT(符号付き32bit、上限 約21億)で定義されていた場合、

current_user.gold -= total_price   # 10億 - 30億 = -20億
current_user.save!

-2_000_000_000INT の下限(約-21億)に近づき、もう少し大きな取引をされた途端に ActiveRecord::RangeError が発生してリクエストが500エラーで落ちます。Rails の API ドキュメントでも「DB に渡す値が範囲外の場合」に発生するエラーとして定義されています。unsigned を使っていれば、そもそも負の値を保存できずに同じく例外になります。

Ruby 側で計算済みの値を代入するのではなく、DB 上で直接演算するパターンも考えられます。たとえば次のような書き方を考えます。

# DB側で直接演算する例
User.where(id: current_user.id)
    .update_all(["gold = gold - ?", total_price])
# ↓ 実際に発行されるSQL
# UPDATE users SET gold = gold - 3000000000 WHERE id = ?

このとき、右辺の gold - 3000000000 は DB 側で実行されます。MySQL の INT(符号付き4バイト、-21474836482147483647)や PostgreSQL の integer 型では、許容範囲を超える値の保存はエラーになります。MySQL では設定によって桁落ちが起きることもあります。Ruby 側では何も問題が起きていないのに、DB側で計算が壊れるのがこのケースの特徴です。

攻撃者から見れば、正常系では絶対に到達しない巨大な値を1リクエストで作り出せること自体が問題です。サービス停止に追い込めるだけでなく、エラー処理の不備と組み合わさって所持金や所持アイテムの不整合を引き起こすことも考えられます。

レベルが上がると発動する時限爆弾

このバグの厄介な点は、ゲームの初期状態では起きないことです。登録したばかりのユーザは、

  • 所持ゴールドは数百〜数千
  • 高級アイテムを10万個も買える資金力はない
  • そもそも単価の高いアイテムがアンロックされていない

という状態なので、開発時のテストプレイでもまず発動しません。しかし運用を続けるうちに、

  • プレイヤーのレベルが上がる → 高レベル帯向けの高単価アイテムが出現する
  • インフレが進む → 所持ゴールドやアイテム価格が桁違いに膨らむ
  • 周年イベントやキャンペーン → 取引可能な数量の上限が一時的に緩和される
  • 新規実装のまとめ買い機能 → それまで1個ずつ買うしかなかったアイテムが大量購入可能に

といった変化が重なって、オーバーフローの条件が揃います。「今まで動いていたから大丈夫」という思い込みが一番危険です。

Webアプリ全般でも起こりうる

同じ構造の問題はゲーム以外でも珍しくありません。 「江南スタイル」が流行した際、YouTube の再生数カウンタが int32 の上限(約21億)に達する見込みとなり、int64 に拡張された話は有名です。

www.bbc.com

「数量 × 単価」「数量 × 期間」のような乗算が絡む合計値は、注意が必要な箇所だと覚えておきましょう。

対策 ― 上限チェックと型の見直し

① 入力値の上限チェック

MAX_BUY_COUNT = 10_000  # 1リクエストで購入できる上限

count = params.require(:count)
raise BadRequest, "count must be positive"            if count <= 0
raise BadRequest, "count must be <= #{MAX_BUY_COUNT}" if count > MAX_BUY_COUNT

「1リクエストで1万個まで」のような上限を設けることで、想定範囲を超える入力値によるリスクを下げられます。ただし、単価や現在値との組み合わせによっては計算結果が型の範囲を超える可能性があるため、入力値だけでなく計算結果も検証する必要があります。

② 計算結果が DB の型に収まるかチェック

「購入数 × 単価」のような乗算の結果や、それを保存先カラムから差し引いた更新後の値が、保存先カラムの型の範囲に収まるかを Ruby 側で明示的に検証します。Ruby 自体はオーバーフローしないので、検証の目的は「DB側で RangeError や桁落ちを起こさせないこと」です。

INT32_MIN = -(2**31)
INT32_MAX = 2**31 - 1

total_price = item.unit_price * count
new_gold    = current_user.gold - total_price

# gold カラムは MySQL の INT(符号付き32bit)想定
raise BadRequest, "total price out of range" unless total_price.between?(0, INT32_MAX)
raise BadRequest, "insufficient gold"        if current_user.gold < total_price
raise BadRequest, "gold out of range"        unless new_gold.between?(INT32_MIN, INT32_MAX)

current_user.gold = new_gold
current_user.items[item.id] += count
current_user.save!

ポイントは、入力値そのものや乗算の中間結果だけでなく、最終的に保存される値(ここでは new_gold)まで型の範囲に収まるかを確認することです。total_priceINT 範囲内でも、ユーザがすでに持っている所持ゴールドとの引き算でカラム範囲を超えるケースは普通に起こりえます。

なお、Ruby 公式ドキュメントでは固定長 int 範囲の検出に Integer#bit_length を使うパターンも紹介されています。new_gold.bit_length < 32 のように書けば符号ビットを除いた有効ビット数で判定できますが、コードの読みやすさという点では between? のほうが意図が伝わりやすいでしょう。

加えて、DB 上で直接演算する書き方(update_all で SQL 片を渡すなど)を使う場合は、Ruby 側のチェックを通り抜けて DB にそのまま値が渡ることに注意が必要です。アプリ側で計算結果を確定させてから保存するか、あるいは DB のカラム型自体を後述の BIGINT に広げる対応が必要になります。

なお、固定長 int の制約は DB だけの話ではありません。Redis や Memcached のカウンタ操作、C 拡張ライブラリ、JSON で受け取るクライアント(Unity の int は32bit)、外部の決済・分析 SaaS への連携など、Rails アプリの外側に値を渡す境界では同様の問題が発生しえます。「Ruby 側で大きな値が作れてしまう」こと自体が、これら外部システムを巻き込んだ不具合の起点になります。

③ 型を BIGINT にする

設計段階から「所持数・残高・累計値など、時間とともに増えうる数値」は BIGINT(符号付き64bit)を採用するのが無難です。Rails のマイグレーションでは t.bigint :coin_amount のように指定できます。INT を節約する意味は今の時代ほぼありません。「この値はどこまで大きくなりうるか」を設計時に見積もるのが根本的な対策になります。

まとめ

本記事では、ゲームAPIで頻出する数値バリデーションの落とし穴として、

  1. マイナス値を受け付けることで、減算が加算に化けるバグ
  2. 巨大な値による整数オーバーフローで、所持数・残高が壊れるバグ

の2つを紹介しました。

どちらもゲーム特有の問題ではなく、数量を扱うWeb API全般で繰り返し起きている脆弱性です。「型は整数型で宣言してある」「SQL インジェクション対策はしてある」だけでは防げません。

チェックリストとしては次の4点を押さえておくと安全です。

  • 下限チェック(購入数・交換数・消費数などは原則 > 0
  • 上限チェック(ビジネスロジック上の最大値)
  • 乗算・加算の結果が DB カラムの型の範囲に収まるか
  • DB カラムの型は十分大きいか

診断・コードレビューの際には、「この数値パラメータにマイナスを送ったらどうなるか」「DB のカラム型を超える巨大な値(例:int32 カラムなら 2**31 - 1 付近、乗算が絡むなら 10万や100万)を送ったらどうなるか」を必ず試すことをおすすめします。

登壇スライドでは本記事で扱った内容以外にも、

  • 同時並列リクエストで回数制限を突破する TOCTOU脆弱性(前回記事
  • HTTPS ペイロードのさらなる暗号化と鍵の保護
  • ガチャ ID とアイテム ID の整合性欠如による課金アイテムの不正取得
  • /proc/<pid>/mem を用いた Android のメモリ改ざんと対策

など、ゲーム特有のセキュリティトピックを扱っています。興味があれば公開スライドもあわせてご覧ください。

ゲームAPIの脆弱性は、ゲームの仕様やビジネスロジックを踏まえて攻撃者視点でレビューしないと見つかりにくいものが大半です。一般的な Web アプリケーション向けの脆弱性診断だけではカバーしきれず、「リリース後しばらくしてから被害が表面化する」「SNS でチート動画が拡散してから気づく」といった形で顕在化することも珍しくありません。

弊社では、長年ゲーム業界のアンチチートに携わってきた知見をもとに、チート対策支援サービスを提供しています。サーバAPIの脆弱性診断、クライアントの難読化・改ざん検知、通信ペイロードの暗号化設計、リリース前のチート耐性レビューなど、ゲームタイトルのフェーズに応じたご支援が可能です。

「診断は受けたが、ゲーム特有のロジックまでは見てもらえなかった」「リリースが近いので、チート耐性を一度棚卸ししたい」といったご相談がありましたら、お気軽にお問い合わせください。