pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

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も基本は同じ流れです

f:id:devpixiv:20141208224418p:plain

  1. pixivアプリからGooglePlayアプリに決済要求する
  2. pixivアプリがGooglePlayアプリから決済結果のレシートを受け取る
  3. pixivアプリからpixivサーバーにレシートを送信する
  4. pixivサーバーからGooglePlayAPIにレシートを送信する
  5. pixivサーバーがGooglePlayAPIからレシートの検証結果を受け取る
  6. pixivサーバーで有料会員登録を行う
  7. pixivサーバーからpixivアプリに有料会員登録結果を返す

iOSの場合はGooglePlayアプリがStoreKitになり、GooglePlayAPIがAppStoreAPIになりますが、基本的に同じ流れで処理を行います。

クライアントアプリの改ざん耐性

iOSやAndroidアプリの場合、クライアントアプリは、ユーザーが自由に操作できる端末上で動いているため、 JailBreakやRoot化により、クライアントアプリの挙動に変更を加えることが可能です*1。 そのため、クライアント単体でレシートを検証する方法は適さず、 検証用のサーバーを経由してGooglePlayやAppStoreの検証用APIを叩く方法が推奨されています。

また、ユーザーは端末に独自証明書をインストールすることも可能であり、 ユーザーの意思による中間者攻撃は防ぐことができません。 *2

そのため、GooglePlayアプリやStoreKitとクライアントアプリ間の通信、 クライアントアプリと検証サーバー間の通信は信頼できません。

f:id:devpixiv:20141208225517p:plain

つらい…

そのため、レシートの検証はサーバーサイドで行い、 検証サーバーが受け取ったレシートは一切信頼せず、きちんと検証処理を行うしかありません。

考慮すべき不正と対策概要

検証処理をサーバーサイドで行っている場合、考慮すべき不正は

  1. 購入処理を回避して、不正なレシートを検証サーバーに送る
  2. 購入処理を回避して、別アプリの正規購入レシートを検証サーバーに送る
  3. 購入処理を回避して、正しいアプリの別ユーザーによる正規購入レシートを検証サーバーに送る

の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アプリ内課金の不正なレシートによる有料会員登録を防ぐ対策を解説しました。 課金周りの処理はきちんと実装しましょう。

参考資料

*1:調べると、JailBreak環境で課金処理を回避するようなツールがいくつか存在するようです

*2:証明書のPinningなどの対策はありますが、回避できてしまうようです

*3:SKPaymentにapplicationUsernameを設定できますが、レシートには含まれず、サーバーサイドでの検証処理には使えません