はじめに
こんにちは。カイザーです。最近、SafetyNet Attestation APIを使用してRoot化チェックについて調査する機会があったので、記事にまとめることにしました。
SafetyNet Attestation APIとは
GoogleがAndroid向けに提供しているAPIで、アプリを実行するAndroid端末のセキュリティチェックを行うことができます。
これにより、Root化をはじめとするOSの改ざんを検知することができ、アプリを安全な環境でのみ実行するための、判断材料とすることができます。
注意点
このAPIは、Androidアプリからリクエストすると、JWS形式で結果がレスポンスされます。
しかし、そのAPIをアプリ内で判定すると、通信が改ざんされた場合や、そもそもアプリ自体が改ざんされて判定ロジックが書き換えらた場合、チェックを通過してしまいます。
下記に注意点をいくつか挙げますが、最終的にはアプリごとに工夫が必要です。
レスポンス結果はアプリ内で判定しない
アプリ内で判定してしまうと、通信やアプリが改ざんされた場合、チェックが効かなくなります。
レスポンス結果は、自前のサーバサイドでAPIを用意し、判断するようにしましょう。
JWSのキーチェーンをチェックする
このAPIレスポンスはJWSで返却されます。JWSは、電子署名されたJSONなので、レスポンス内容を検証し、改ざんを検出することが出来ます。
キーチェーンの証明書の発行元のチェックと、署名の内容のチェックを行い、改ざんされていないことを確認する必要があります。
今回は、Android側の実装をメインとしているため、省略しています。
レスポンス結果は正しくチェックする
JWSのPayloadに格納されているレスポンス結果には、判定結果がbooleanで返却されますが、それ以外の項目も重要な判断材料となります。これらを正しくチェックすることにより改ざんのチェックが可能となります。
- nonce
nonceはサーバサイドで発行し、サーバサイドで検証できるようにすると、以前の正常レスポンスでAPIリクエストを行われていることをチェック出来ます。
ちなみに、このことはリプレイアタックといいます。 - タイムスタンプ
タイムスタンプが現在時刻と比較してかけ離れている場合は、リプレイアタックである可能性があります。
タイムスタンプを検証して、最近のもの以外は弾くようにしましょう。 - APK証明書のSHA-256ハッシュ
署名に使用したAPK証明書を検証し、APKが改ざんされて再ビルドされたものではないことを確認します。 - APKのSHA-256ハッシュ
APKがGoogle Playなどにリリースしたものから改ざんされていないことを確認するために、APKのSHA-256ハッシュが同一かどうか確認します。
今回は、Android側の実装をメインとしているため、APK証明書のSHA-256ハッシュとAPKのSHA-256ハッシュの検証以外は、省略します。
今回の構成
今回は、Android版の実装をメインで説明するため、サーバサイドでの検証は簡易的なものとします。
なお、実際にRoot化チェックをする場合は、下記のような構成が必要だと考えています。
この構成の場合、検証結果NGの場合はログインさせないようにすることで、この後のサービス利用をブロックするケースとなります。
ただ、サーバサイドがメインになってしまうため、また別記事にしたいと思います。
APIキーの取得
Google API Consoleにアクセスし、APIキーを作成します。
そして「Android Device Verification」を有効にします。
このAPIは無料ですが、1APIキーにつき毎月1万リクエストしかできません。
例えば、起動時のみにチェックするとしても、毎月1万回以上起動されるアプリであれば、上限申請をしましょう。
ただし、上限を撤廃することは出来ないので注意して注意してください。
Android側の実装
先にAndroid側の実装を紹介します。
今回はKotlinで実装していきます。
build.gradle(app)への追記
1 2 3 | implementation'com.google.android.gms:play-services-safetynet:16.0.0' implementation'com.squareup.retrofit2:retrofit:2.5.0' implementation'com.squareup.retrofit2:converter-moshi:2.5.0' |
SafetyNet Attestation APIを使用するため、play-services-safetynetを導入します。
また、自前サーバとのAPI通信を行うため、retrofitとconverter-moshiも導入します。
Retrofitの準備
先に、自前サーバとの通信を実装していきます。
データクラスの作成
今回はJSONからデータクラスへの変換にmoshiを使用します。Kotlinとの親和性が高く、データクラスを作成するだけで、変換できるようになります。
自前サーバのAPIへのリクエストとレスポンスに使用するデータクラスのみ定義します。
1 2 3 | data classAttestationCheckRequest(varjwsResult:String) data classAttestationCheckResponse(varresult:Boolean) |
APIサービスの定義
今回は確認用API1本のみだけなので、下記インタフェースを定義します。
1 2 3 4 | interfaceAttestationCheckService{ @POST("/") fun check(@BodyattestationCheckRequest:AttestationCheckRequest):Call } |
Attestation APIへのリクエスト
SafetyNet APIのリクエストを行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | /** * SafetyNet Attestation APIにリクエストし、結果をJWSで受け取る */ privatefun attest(){ // 1. リクエストに必要なnonce生成。 val nonce=getRequestNonce("Attest") // 2. APIリクエスト SafetyNet.getClient(this).attest(nonce,API_KEY) .addOnSuccessListener{ val result=it.jwsResult Log.e("Attest","Attest Success") // 3. リクエストが成功した場合は、JWSを持って結果チェック check(result) } .addOnFailureListener({ if(it isApiException){ it.statusCode Log.e("Attest",it.message+it.statusCode) }else{ Log.e("Attest",it.message) } }) } /** * Nonce生成 */ privatefun getRequestNonce(data:String):ByteArray{ val byteStream=ByteArrayOutputStream() val bytes=ByteArray(24) random.nextBytes(bytes) byteStream.write(bytes) byteStream.write(data.toByteArray()) returnbyteStream.toByteArray() } |
attest()メソッドの第二引数には、GCPで取得したAPIキーを設定します。
APIリクエストが成功した場合は、OnSuccessListenerがコールされます。
ただし、Root化されているかどうかはこの時点ではまだ分からないため、JWS形式のレスポンス内容を確認する必要があります。
通信エラーなどの場合は、OnFailureListenerがコールされます。
自前のチェックAPIコール
先ほど記述した通り、アプリ内でレスポンスを判断することは、抜け道の原因となります。
そのため、レスポンスを判断するためのAPIを自前で用意して、サーバサイドで判断させます。
今回は、チェックAPIを用意したので、そのコールを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /** * SafetyNet Attestation APIのレスポンス(JWS)を元に、結果を確認する * @param jwsResult SafetyNet Attestation APIのレスポンス(JWSの文字列) */ privatefun check(jwsResult:String){ // 1. 自作したAPIにリクエストするRetrofitサービスの生成 val service=Retrofit.Builder() .baseUrl("http://[IP or Domain]:8080") .addConverterFactory(MoshiConverterFactory.create()) .build() .create(AttestationCheckService::class.java) // 2. JWSを渡してリクエスト service.check(AttestationCheckRequest(jwsResult)).enqueue(object:Callback<attestationcheckresponse>{ override fun onResponse(call:Call<attestationcheckresponse>?,response:Response<attestationcheckresponse>?){ response?.body()?.let{ // 3. 自作のチェックAPIで返却された結果で判定 if(it.result){ Log.e("Check","OK") }else{ Log.d("Check","NG") } } } override fun onFailure(call:Call<attestationcheckresponse>?,t:Throwable?){ t?.printStackTrace() } }) } |
SafetyNet APIのレスポンスのJWSをそのまま送信しています。
サーバサイドの実装
Safety Attestation APIのレスポンスを自前サーバで検証するため、Golangで作成しました。
JWSのパースには、go-joseを使用しました。
なお、payloadの取り出しにUnsafePayloadWithoutVerification()関数を使用していますが、実際にはVerify()関数を使用してください。証明書と署名の検証が出来た場合のみpayloadを取り出すことができるためです。
この辺りの細かい検証関係は、別記事で書きたいと思います。
レスポンスの検証は、下記を行なっています。
- APKパッケージ名
- APKのSHA-256ハッシュ値
- APKの証明書のSHA-256ハッシュ値
- BasicIntegrityがtrueであること
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | packagemain import( "encoding/json" "io/ioutil" "net/http" "reflect" jose"gopkg.in/square/go-jose.v2" ) // Attestation はアプリからのリクエストBodyのJSON typeAttestationstruct{ JWSResult string`json:"jwsResult"` } // CheckResult はアプリにレスポンスするJSON形式 typeCheckResultstruct{ Result bool`json:"result"` } // AttestPayload はJWSのペイロードのJSON形式 typeAttestPayloadstruct{ Nonce string `json:"nonce"` TimestampMs int `json:"timestampMs"` ApkPackageName string `json:"apkPackageName"` ApkDigestSha256 string `json:"apkDigestSha256"` CtsProfileMatch bool `json:"ctsProfileMatch"` ApkCertificateDigestSha256[]string`json:"apkCertificateDigestSha256"` BasicIntegrity bool `json:"basicIntegrity"` Advice string `json:"advice"` } // チェックに使用する定数定義 const( apkPackageName ="com.example.yourname.safetynetattestationsample" apkDigestSha256="YOUR_APK_SHA256" ) varapkCertificateDigestSha256=[]string{"YOUR_APK_CERTIFICATE_SHA256"} func main(){ http.HandleFunc("/",handler) http.ListenAndServe(":8080",nil) } func handler(whttp.ResponseWriter,r*http.Request){ b,err:=ioutil.ReadAll(r.Body) deferr.Body.Close() iferr!=nil{ http.Error(w,err.Error(),500) return } // リクエストBodyをアンマーシャル varattest Attestation err=json.Unmarshal(b,&attest) iferr!=nil{ http.Error(w,err.Error(),500) return } // go-joseを使用してJWSをパース jws,err:=jose.ParseSigned(attest.JWSResult) iferr!=nil{ http.Error(w,err.Error(),500) return } // 本来であれば、証明書の検証と署名の検証が必要だが、今回は省略。 payload:=jws.UnsafePayloadWithoutVerification() // payloadに入っているJSONをアンマーシャル varattestPayload AttestPayload err=json.Unmarshal(payload,&attestPayload) iferr!=nil{ http.Error(w,err.Error(),500) return } checkResult:=CheckResult{false} // レスポンス内容を検証する ifattestPayload.ApkPackageName==apkPackageName&& attestPayload.ApkDigestSha256==apkDigestSha256&& reflect.DeepEqual(attestPayload.ApkCertificateDigestSha256,apkCertificateDigestSha256)&& attestPayload.BasicIntegrity{ checkResult.Result=true } // 検証結果をレスポンスする output,err:=json.Marshal(checkResult) iferr!=nil{ http.Error(w,err.Error(),500) return } w.Header().Set("content-type","application/json") w.Write(output) } |
さいごに
いかがでしたか? ゲームのチート対策など、Root化チェックが必要なアプリは数多いと思います。
しっかり対策して、アプリ・サービスのセキュリティを高めましょう。
今回は、サーバ側でJWSの検証関係を省きましたが、「しっかり検証編」としてさらに調査して続きを書きたいと思います。