こんにちは、セキュリティ技術Gの村上です。
今回は先日のDeNA Techcon 2017のカジュアルトークにて発表させて頂いたものに、ソースコード等の追加情報を交えて、SafetyNetを利用してチートからゲームを守る方法を説明したいと思います。今回の記事はSafetyNetの対応の都合上Android向けの情報になります。
最近のチート手法と、対策、対策の問題点
最近のスマートフォンのアプリゲームでは、「一緒にゲームをプレイする相手が異常に強い」や、「ランキングでありえないスコアのものがランクインしている」など、チート行為をするプレイヤーが話題になることがあります。これらの多くはチート行為を補助するための専用アプリやツールをスマートフォンにインストールして行われています。
今回対象とするチート行為はそのようなツールを用いて
- メモリを改ざんしてゲーム上で利用されている数値を書き換える
- ゲームのファイルを改ざんして改造アプリを作る
等の方法によってゲームの「クライアント」に対して行われているものになります。
上記のようなチート行為に対する対策としては、以下のようなものが考えられます。
- ゲーム進行上重要な値のメモリ上での暗号化
- ゲームのファイルのハッシュを取り比較する等による改ざん検知
- root化端末の検知
一方で、この対策に対するカウンター行為としても以下のようなものが考えられます。
- 暗号化ロジックの解析を行い、本来の数値を取得し改ざんを行う
- 各種検知ロジックの記述された関数自体を無効化するような書き換え
- root化していることをシステムに知らせないようなツールを利用する
クライアントがプレイヤーの手元に配布されている以上、クライアントに対するチートを完全に防ぐことは不可能です。また、対策を回避されるたびに対策の追加や変更をしてもいたちごっこになるだけになります。結果として、チート対策をいれるためにゲーム開発や運用のコストが大きくなってしまいます。
チート緩和策としてのroot化端末検知
これらの問題があるチート対策ですが、多くのチートプレイヤーはこれらの行為を行うのに「root化端末」を用いて行っています。理由としては、チート行為を補助するアプリはroot化端末上でのみ利用できる機能を利用しているからです。
つまり、root化端末検知を低コストかつ高精度で検知することができれば一定の抑止策になると考えられます。はたしてそんな対策方法があるのでしょうか?
SafetyNetとは
そこで、SafetyNetの出番です。SafetyNetとは、GoogleがAndroid向けに提供しているセキュリティシステムの名前です。
また、これをAndroidアプリ開発者が利用できるようにして提供しているものをSafetyNetAPIと呼びます。
[Checking Device Compatibility with SafetyNet]
https://developer.android.com/training/safetynet/index.html
◇ SafetyNetで出来ること
SafetyNetで出来ることは
- root化端末検知
- アプリ改ざん検知
と、なります。今回まさに求めているものです。
◇ 動作環境
導入にはGoogle 開発者サービス (Google Play Services)の7.0以上がインストールされている必要がありますが、これはandroid2.3.x以上ならば標準でインストールされており、適切にアップデートが行われていれば現行の機種ではすべて利用できる、というものになります。
SafetyNetの仕組み
SafetyNetはAndroidアプリプログラムにライブラリを利用する形で組み込みます。ライブラリを利用すると、端末内の情報を解析し、その情報がGoogleのサーバーへ送信されます。Googleのサーバーはその結果を見て各種情報を備えたJWS(JSON Web Signature)形式のJWT(JSON Web Token)を返します。開発者はこのJWTに含まれる各種情報を読み取って、root化端末やアプリ改ざんの検知を行うことができます。
SafetyNetの利用
実際にSafetyNetをどのように利用するかを説明していきます。まずSafetyNetの検証によってどのようなパラメータがGoogleのサーバーから送信されてくるのか、検証方法などを説明した後に実際のゲームに組み込むフローを紹介します。
◇ SafetyNetで得られるJWT
まず以下のようなコードにてSafetyNetを使用し、Googleのサーバーから端末内の解析結果の情報をJWTで受け取り、ペイロードを確認します。
▼ 呼び出しコードサンプル
import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.api.ResultCallback; import com.google.android.gms.common.api.Status; import com.google.android.gms.safetynet.SafetyNet; import com.google.android.gms.safetynet.SafetyNetApi; /* 中略 */ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GoogleApiClient mGoogleApiClient = new GoogleApiClient.Builder(this) .addApi(SafetyNet.API) .build(); mGoogleApiClient.connect(); byte[] nonce = {{ゲームサーバーから取得等を行う}}; // 16バイト以上でサーバーで生成したものを使用するのを推奨。テストで動作させるためには簡単なものを生成して利用するので大丈夫です SafetyNet.SafetyNetApi.attest(mGoogleApiClient, nonce) .setResultCallback(new ResultCallback<SafetyNetApi.AttestationResult>() { @Override public void onResult(SafetyNetApi.AttestationResult result) { Status status = result.getStatus(); if (status.isSuccess()) { result_st = status; String[] jwtParts = result.getJwsResult().split("\\."); String decodedPayload = new String(Base64.decode(jwtParts[1], Base64.DEFAULT)); try { JSONObject root = new JSONObject(decodedPayload); Log.d("SafetyNet JWT: ", root.toString()); } catch (JSONException e) {e.printStackTrace();} } else {} } }); ############## 略 }
上記コードで以下のようなパラメータをログに表示することができます。
{ "nonce" : "R2Rra24fVm5xa2Mg", "timestampMs" : 9860437986543, "apkPackageName" : "com.package.name.of.requesting.app", "apkCertificateDigestSha256" : "WBCHofSC1pylANVojYU1NEXzyIK9nUa0BYUOXBOhdAo=", "apkDigestSha256" : "cBzTdg6aQTuMUPnNTr6bhKR\/GlmSSNxhIkjUfdHLPRw=", "ctsProfileMatch" : true, "basicIntegrity" : true }
◇ パラメータの意味
それぞれのパラメータの役割については以下の様になります。
今回の観点で重要なのは、ctsProfileMatchとbasicIntegrityの2つになります。これらを利用することでroot化端末についての検知が可能なります。
この2つのパラメータは true のときに正規の端末と言え、falseのときにはroot化されているような端末だと判定できます。
この2つのパラメータの違いについてですが、現在のところではbasicIntegirtyよりもctsProfileMatchのほうが様々な観点で検証を行う結果falseになりやすいらしく、root化端末を検知するという観点においては誤検知につながる可能性もあるので、通常root化端末かどうか判定を行うにはbasicIntegrityで行うほうが良いようです。しかし、GoogleはSafetyNetについて随時仕様の変更などを行っているように見受けられるので今後判定の基準などが変更される可能性もあります。使用する際にはその点に注意したほうがよいでしょう。
JWTの署名検証をおこなう
取得したJWTですが、攻撃者の視点になると「検証結果の内容を書き換えてしまえばよい」と考えられます。
そこで、正確に検知するためには取得したJWTのパラメータを読み込む前に署名を検証して内容が改ざんされていないかを検証する必要があります。
以下のコードはRubyで署名を検証するコードのサンプルになります。
# jwt_validation_sample.rb require 'jwt' class InvalidLeafCertificate < StandardError; end class InvalidCertificateChain < StandardError; end X509_STORE = OpenSSL::X509::Store.new X509_STORE.set_default_paths #X509_STOREにて、システムの証明書が格納されているパスを代入するメソッド # 正しい署名の検証に成功すればtrue # 不正なものがあればfalse def improved_valid_jwt_signature?(jwt) jwt = jwt.gsub(/(\r\n|\r|\n|\f)/,"") ::JWT.decode(jwt, nil, true) do |header| # 証明書チェインのすべての証明書をインスタンス化 certs = header['x5c'].map{|c| OpenSSL::X509::Certificate.new(Base64.decode64(c))} # リーフ証明書の取り出し leaf_cert = certs.first # 証明書チェインの取り出し cert_chain = certs.drop(1) # リーフ証明書と今検証しようとしているホスト名の整合性の確認 unless OpenSSL::SSL.verify_certificate_identity(leaf_cert, 'attest.android.com') raise InvalidLeafCertificate, "Certificate isn't issued for the hostname attest.android.com" end # 証明書チェインを用いて、証明書の正当性を確認 unless X509_STORE.verify(leaf_cert, cert_chain) raise InvalidCertificateChain, "Certificate chain verification is failed" end # 検証に使った証明書たち pp X509_STORE.chain # JWT検証用の公開鍵を取り出す leaf_cert.public_key end true rescue JWT::VerificationError, JWT::DecodeError, InvalidLeafCertificate, InvalidCertificateChain false end ################ # 実行サンプル用出力 # ################ MAL_JWT = "#{不正なJWT(省略)}" SAFETYNET_JWT="#{SafetyNetAPIで取得した正規のJWT(省略)}" puts "malformed JWT" puts "result == #{improved_valid_jwt_signature?(MAL_JWT)}" puts "safetynet JWT" puts "improved_result == #{improved_valid_jwt_signature?(SAFETYNET_JWT)}"
これを実行すると以下のような出力になります。
% ruby jwt_validation_sample.rb malformed JWT improved_result == false safetynet JWT [#<OpenSSL::X509::Certificate subject=#<OpenSSL::X509::Name CN=attest.android.com,O=Google Inc,L=Mountain View,ST=California,C=US>, issuer=#<OpenSSL::X509::Name CN=Google Internet Authority G2,O=Google Inc,C=US>, serial=#<OpenSSL::BN 8205469772949076569>, not_before=2016-07-12 15:43:54 UTC, not_after=2017-07-11 00:00:00 UTC>, #<OpenSSL::X509::Certificate subject=#<OpenSSL::X509::Name CN=Google Internet Authority G2,O=Google Inc,C=US>, issuer=#<OpenSSL::X509::Name CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US>, serial=#<OpenSSL::BN 146066>, not_before=2015-04-01 00:00:00 UTC, not_after=2017-12-31 23:59:59 UTC>, #<OpenSSL::X509::Certificate subject=#<OpenSSL::X509::Name CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US>, issuer=#<OpenSSL::X509::Name CN=GeoTrust Global CA,O=GeoTrust Inc.,C=US>, serial=#<OpenSSL::BN 144470>, not_before=2002-05-21 04:00:00 UTC, not_after=2022-05-21 04:00:00 UTC>] improved_result == true
JWTの検証をサーバーを利用して行う
JWTの検証を行う際、この処理をゲームのクライアントアプリケーション上で行うと、クライアントからゲームサーバーにデータを送信する際に「JWTが正しいという検証の結果」自体を改ざんされてしまう可能性が残ります。
そこでJWTのパラメータの検証や署名の検証をゲームサーバー上で行うことで、JWTの検証結果の改ざんを行えなくなり安全になります。
SafetyNetを利用した処理の流れのまとめ
今回説明したSafetyNetを利用してゲームを守る方法のフローをまとめます。
まず今回のシステムではクライアントアプリとゲームサーバーの両方を用います。
図にしてみると以下のような構成になります。
ゲームサーバーからnonceを生成してアプリに渡す
アプリにSafetyNetライブラリを組み込み、サーバーから得たnonceを用いてメソッドを使用し、Googleのサーバーから端末の解析結果のJWTを取得する
取得したJWTをゲームサーバーに送信する
ゲームサーバーでJWTの署名の検証を行う
正しい署名の場合、JWTのペイロードを取り出して各パラメータを検証する
- 各パラメータに問題がなければ通常通りゲームを開始し、不正な端末の場合はゲームの進行を止めるような処理を行う
まとめ
スマートフォンゲームのクオリティや市場が活発になるにつれて、チート行為を行うユーザーも増加、チート技術の習熟も進んでいます。個別の対応をゲームごとに導入するのはもちろんですが、SafetyNetのように共通で提供されたソリューションも複合的に用いることで、より強固な対策がコストを抑えて導入できる可能性があります。チート行為を行うユーザーの動向を把握し、攻撃方法に合わせて適切な対応を行えるようにしておくことが重要になります。
The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.