iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ
こちらはピクシブ株式会社 Advent Calendar 2014の12/9の記事です。
こんにちは。iOSエンジニアの@shobyshobyです。 歌も歌えてコードも書けるエンジニアを目指して、毎週ボイトレに通っています。
さて、私は最近、pixiv公式iOS/Androidアプリ向けのSubscription課金の実装を担当していたのですが、 いざ機能を公開してみると、APIに投げられる不正なレシートが予想以上に多いことに気がつきました。
今回は、iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ対策を解説します。 有料会員登録の検証処理に漏れがある場合、お金を払わずに不正に有料会員になることができてしまうため、 アプリ内課金のバックエンド処理は慎重に設計、実装する必要があります。
※この記事では、AndroidのIn-app Billing Version 3 APIのSubscriptions、 iOS 7以降の形式のAuto-renewable subscriptionsを想定しており、 アプリ内課金の購入情報は、iOS側に合わせてレシートという呼び方をします。
アプリ内課金による有料会員登録の流れ
以下にAndroidの場合を示します。iOSも基本は同じ流れです
- pixivアプリからGooglePlayアプリに決済要求する
- pixivアプリがGooglePlayアプリから決済結果のレシートを受け取る
- pixivアプリからpixivサーバーにレシートを送信する
- pixivサーバーからGooglePlayAPIにレシートを送信する
- pixivサーバーがGooglePlayAPIからレシートの検証結果を受け取る
- pixivサーバーで有料会員登録を行う
- pixivサーバーからpixivアプリに有料会員登録結果を返す
iOSの場合はGooglePlayアプリがStoreKitになり、GooglePlayAPIがAppStoreAPIになりますが、基本的に同じ流れで処理を行います。
クライアントアプリの改ざん耐性
iOSやAndroidアプリの場合、クライアントアプリは、ユーザーが自由に操作できる端末上で動いているため、 JailBreakやRoot化により、クライアントアプリの挙動に変更を加えることが可能です*1。 そのため、クライアント単体でレシートを検証する方法は適さず、 検証用のサーバーを経由してGooglePlayやAppStoreの検証用APIを叩く方法が推奨されています。
また、ユーザーは端末に独自証明書をインストールすることも可能であり、 ユーザーの意思による中間者攻撃は防ぐことができません。 *2
そのため、GooglePlayアプリやStoreKitとクライアントアプリ間の通信、 クライアントアプリと検証サーバー間の通信は信頼できません。
つらい…
そのため、レシートの検証はサーバーサイドで行い、 検証サーバーが受け取ったレシートは一切信頼せず、きちんと検証処理を行うしかありません。
考慮すべき不正と対策概要
検証処理をサーバーサイドで行っている場合、考慮すべき不正は
- 購入処理を回避して、不正なレシートを検証サーバーに送る
- 購入処理を回避して、別アプリの正規購入レシートを検証サーバーに送る
- 購入処理を回避して、正しいアプリの別ユーザーによる正規購入レシートを検証サーバーに送る
の3つのパターンです。
Androidでの対策
- レシートとペアで送信されてきたGoogle Playの署名が正しいか検証する(1,2対策)
- developerPayloadにユーザーIDを埋め込み、ログイン中ユーザーのIDと一致するかを検証する(3対策)
iOSでの対策
- レシート検証APIから返ってきたレスポンスデータ中のstatus codeが0かどうかを検証する(1対策)
- 検証済みレシートのproduct_idが正規の物か確認する(2対策)
- 検証済みレシートのtransaction_idが他のユーザーで登録済みでないかを確認する(3対策)
Androidのレシート検証の仕組みはよくできており、 署名の検証によるレシートとアプリケーションの対応付け、 レシートへのユーザーID埋め込みによるレシートとユーザーの対応付けがきちんと行えるため、 正しく実装さえすれば特に問題は発生しません。
対してiOSの場合、 他のアプリケーション用のレシートをレシート検証APIに投げてもエラーにならない上に、 レシートに情報を埋め込むことができず、レシートとユーザーの対応付けも行えないため、 不正なレシートをきちんと考慮して検証処理を実装する必要があります。
Androidでの対策
レシートとペアで送信されてきたGoogle Playの署名が正しいか検証する
レシートはGooglePlay側でアプリケーション毎に固有の秘密鍵により署名されているため、 不正なレシートや、他のアプリケーションのレシートは署名の検証に失敗します。
PHPでの実装例は以下の通り。
// GooglePlayの管理画面から取得した公開鍵をPEM形式に変換したもの $public_key = file_get_contents($public_key_path); $public_key_id = openssl_get_publickey($public_key); $decoded_signature = base64_decode($signature); $result = (int)openssl_verify($data, $decoded_signature, $public_key_id); if ($result === 0) { throw new RuntimeException('署名の検証に失敗しました'); } elseif ($result === -1) { throw new RuntimeException('署名の検証でエラーが発生しました。'); } openssl_free_key($public_key_id);
developerPayloadにユーザーIDを埋め込み、ログイン中ユーザーのIDと一致するかを検証する
レシートにdeveloperPayloadという任意のデータを埋め込むことができます。 pixivの場合、ここにユーザーIDを埋め込み、レシートとpixivのユーザーアカウントを紐付けています。 これにより、同一のレシートが別のユーザーの有料会員登録に使用されることを防いでいます。
iOSでの対策
レシート検証APIから返ってきたレスポンスデータ中のstatus codeが0かどうかを検証する
レシートとして形式がおかしいもの、改ざんされたレシートなどの不正なレシートは、レスポンスデータのstatusが0以外になります。
検証済みレシートのproduct_idが正規の物か確認する
レシート検証APIでレシートが正規の物であることは確認できましたが、 この段階では他のアプリの正規レシートと差し替えられている可能性があります。 検証済みレシート中のproduct_idが、ストアで配信しているアイテムのproduct_idと一致することを確認することで、 正しく購入されたアイテムであることを確認できます。
検証済みレシートのtransaction_idが他のユーザーで登録済みでないかを確認する
iOSの場合、仕組み上、安全にレシートとサービスのアカウントを紐付けることができません。*3 そのため、決済をした後、一番最初にそのレシートを有料会員登録にしようしたユーザーを正規のユーザーとし、 以降のユーザーを不正ユーザーとして扱うことにしています。
アプリ内課金での各決済はそれぞれ異なるtransaction_idを持つため、 過去に有料会員登録で使用されたtransaction_idかどうかを確認し、重複登録を防いでいます。
まとめ
iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ対策を解説しました。 課金周りの処理はきちんと実装しましょう。
参考資料
- Google Play In-app Billing Security and Design
- In-App Purchase Programming Guide
- Receipt Validation Programming Guide
*1:調べると、JailBreak環境で課金処理を回避するようなツールがいくつか存在するようです
*2:証明書のPinningなどの対策はありますが、回避できてしまうようです
*3:SKPaymentにapplicationUsernameを設定できますが、レシートには含まれず、サーバーサイドでの検証処理には使えません