From 94ec11db588b3096aef68cdbcfd16bf0b436b7c5 Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Thu, 15 Apr 2021 04:47:57 -0700 Subject: [PATCH] Update snet.jar extension The existing API key was revoked for some reason. Release an updated extension jar with a new API key. In addition, add some offline signature verification and change how results are parsed to workaround some dumbass Xposed module "faking" success results, since many users really don't know better. --- app/build.gradle.kts | 2 +- .../java/com/topjohnwu/magisk/core/Const.kt | 2 +- .../magisk/data/repository/NetworkService.kt | 6 +- .../ui/safetynet/CheckSafetyNetEvent.kt | 223 +++++++++++++----- .../magisk/ui/safetynet/SafetyNetHelper.kt | 6 +- .../magisk/ui/safetynet/SafetynetViewModel.kt | 40 ++-- build.py | 2 +- gradle/wrapper/gradle-wrapper.properties | 3 +- 8 files changed, 188 insertions(+), 96 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c066d6ccf..fc9aafbc0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -207,7 +207,7 @@ dependencies { implementation("io.noties.markwon:image:${vMarkwon}") implementation("com.caverock:androidsvg:1.4") - val vLibsu = "3.1.1" + val vLibsu = "3.1.2" implementation("com.github.topjohnwu.libsu:core:${vLibsu}") implementation("com.github.topjohnwu.libsu:io:${vLibsu}") diff --git a/app/src/main/java/com/topjohnwu/magisk/core/Const.kt b/app/src/main/java/com/topjohnwu/magisk/core/Const.kt index d8557e849..97d8b484f 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/Const.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/Const.kt @@ -29,7 +29,7 @@ object Const { const val MAGISK_LOG = "/cache/magisk.log" // Versions - const val SNET_EXT_VER = 15 + const val SNET_EXT_VER = 16 const val SNET_REVISION = "22.0" const val BOOTCTL_REVISION = "22.0" diff --git a/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt b/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt index 391e11e70..45c57cdbc 100644 --- a/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt +++ b/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt @@ -1,6 +1,5 @@ package com.topjohnwu.magisk.data.repository -import android.os.Build import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Config.Value.BETA_CHANNEL import com.topjohnwu.magisk.core.Config.Value.CANARY_CHANNEL @@ -66,7 +65,7 @@ class NetworkService( } // Fetch files - suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() } + // suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() } suspend fun fetchBootctl() = wrap { jsd.fetchBootctl() } suspend fun fetchInstaller() = wrap { val sha = fetchMainVersion() @@ -76,4 +75,7 @@ class NetworkService( suspend fun fetchString(url: String) = wrap { raw.fetchString(url) } private suspend fun fetchMainVersion() = api.fetchBranch(MAGISK_MAIN, "master").commit.sha + + // Temporary for canary builds, switch to release link at next public release + suspend fun fetchSafetynet() = fetchFile("https://github.com/topjohnwu/Magisk/releases/download/v22.1/snet.jar") } diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt index c712f6780..2250c5072 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt @@ -1,6 +1,12 @@ +@file:Suppress("DEPRECATION") + package com.topjohnwu.magisk.ui.safetynet import android.content.Context +import android.util.Base64 +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.ContextExecutor import com.topjohnwu.magisk.arch.ViewEventWithScope @@ -9,21 +15,29 @@ import com.topjohnwu.magisk.data.repository.NetworkService import com.topjohnwu.magisk.ktx.DynamicClassLoader import com.topjohnwu.magisk.ktx.writeTo import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.signing.CryptoUtils import com.topjohnwu.superuser.Shell +import dalvik.system.BaseDexClassLoader import dalvik.system.DexFile -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject +import org.bouncycastle.asn1.ASN1Encoding +import org.bouncycastle.asn1.ASN1Primitive +import org.bouncycastle.est.jcajce.JsseDefaultHostnameAuthorizer import org.koin.core.KoinComponent import org.koin.core.inject import timber.log.Timber +import java.io.ByteArrayInputStream import java.io.File import java.io.IOException +import java.lang.reflect.Field import java.lang.reflect.InvocationHandler +import java.security.GeneralSecurityException +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.X509Certificate -@Suppress("DEPRECATION") class CheckSafetyNetEvent( private val callback: (SafetyNetResult) -> Unit = {} ) : ViewEventWithScope(), ContextExecutor, KoinComponent, SafetyNetHelper.Callback { @@ -32,92 +46,94 @@ class CheckSafetyNetEvent( private lateinit var apk: File private lateinit var dex: File + private lateinit var nonce: ByteArray override fun invoke(context: Context) { apk = File("${context.filesDir.parent}/snet", "snet.jar") dex = File(apk.parent, "snet.dex") - scope.launch { + scope.launch(Dispatchers.IO) { attest(context) { // Download and retry - withContext(Dispatchers.IO) { - Shell.sh("rm -rf " + apk.parent).exec() - apk.parentFile?.mkdir() + Shell.sh("rm -rf " + apk.parent).exec() + apk.parentFile?.mkdir() + withContext(Dispatchers.Main) { + showDialog(context) } - download(context, true) } } } private suspend fun attest(context: Context, onError: suspend (Exception) -> Unit) { + val helper: SafetyNetHelper try { - val helper = withContext(Dispatchers.IO) { - val loader = DynamicClassLoader(apk) - val dex = DexFile.loadDex(apk.path, dex.path, 0) + val loader = DynamicClassLoader(apk) - // Scan through the dex and find our helper class - var helperClass: Class<*>? = null - for (className in dex.entries()) { - if (className.startsWith("x.")) { - val cls = loader.loadClass(className) + // Scan through the dex and find our helper class + var clazz: Class<*>? = null + loop@for (dex in loader.getDexFiles()) { + for (name in dex.entries()) { + if (name.startsWith("x.")) { + val cls = loader.loadClass(name) if (InvocationHandler::class.java.isAssignableFrom(cls)) { - helperClass = cls - break + clazz = cls + break@loop } } } - helperClass ?: throw Exception() - - val helper = helperClass - .getMethod("get", Class::class.java, Context::class.java, Any::class.java) - .invoke( - null, SafetyNetHelper::class.java, - context, this@CheckSafetyNetEvent - ) as SafetyNetHelper - - if (helper.version < Const.SNET_EXT_VER) - throw Exception() - helper } - helper.attest() + clazz ?: throw Exception("Cannot find SafetyNetHelper class") + + helper = clazz.getMethod("get", Class::class.java, Context::class.java, Any::class.java) + .invoke(null, SafetyNetHelper::class.java, context, this) as SafetyNetHelper + + if (helper.version != Const.SNET_EXT_VER) + throw Exception("snet extension version mismatch") } catch (e: Exception) { - if (e is CancellationException) - throw e onError(e) - } - } - - @Suppress("SameParameterValue") - private fun download(context: Context, askUser: Boolean) { - fun downloadInternal() = scope.launch { - val abort: suspend (Exception) -> Unit = { - Timber.e(it) - callback(SafetyNetResult()) - } - try { - withContext(Dispatchers.IO) { - svc.fetchSafetynet().byteStream().writeTo(apk) - } - attest(context, abort) - } catch (e: IOException) { - if (e is CancellationException) - throw e - abort(e) - } - } - - if (!askUser) { - downloadInternal() return } + val random = SecureRandom() + nonce = ByteArray(24) + random.nextBytes(nonce) + helper.attest(nonce) + } + + private fun Class<*>.field(name: String): Field = + getDeclaredField(name).apply { isAccessible = true } + + // All of these fields are whitelisted + private fun BaseDexClassLoader.getDexFiles(): List { + val pathList = BaseDexClassLoader::class.java.field("pathList").get(this) + val dexElements = pathList.javaClass.field("dexElements").get(pathList) as Array<*> + val fileField = dexElements.javaClass.componentType.field("dexFile") + return dexElements.map { fileField.get(it) as DexFile } + } + + private fun download(context: Context) = scope.launch(Dispatchers.IO) { + val abort: suspend (Exception) -> Unit = { + Timber.e(it) + withContext(Dispatchers.Main) { + callback(SafetyNetResult()) + } + } + try { + svc.fetchSafetynet().byteStream().writeTo(apk) + attest(context, abort) + } catch (e: IOException) { + abort(e) + } + } + + private fun showDialog(context: Context) { MagiskDialog(context) .applyTitle(R.string.proprietary_title) .applyMessage(R.string.proprietary_notice) .cancellable(false) .applyButton(MagiskDialog.ButtonType.POSITIVE) { titleRes = android.R.string.ok - onClick { downloadInternal() } + onClick { download(context) } } .applyButton(MagiskDialog.ButtonType.NEGATIVE) { titleRes = android.R.string.cancel @@ -129,7 +145,94 @@ class CheckSafetyNetEvent( .reveal() } - override fun onResponse(response: JSONObject?) { - callback(SafetyNetResult(response)) + private fun String.decode(): ByteArray { + return if (contains("\\+|/".toRegex())) + Base64.decode(this, Base64.DEFAULT) + else + Base64.decode(this, Base64.URL_SAFE) + } + + private fun String.parseJws(): SafetyNetResponse? { + val jws = split('.') + val secondDot = lastIndexOf('.') + val rawHeader = String(jws[0].decode()) + val payload = String(jws[1].decode()) + var signature = jws[2].decode() + val signedBytes = substring(0, secondDot).toByteArray() + + val moshi = Moshi.Builder().build() + val header = moshi.adapter(JwsHeader::class.java).fromJson(rawHeader) ?: return null + + val alg = when (header.algorithm) { + "RS256" -> "SHA256withRSA" + "ES256" -> { + // Convert to DER encoding + signature = ASN1Primitive.fromByteArray(signature).getEncoded(ASN1Encoding.DER) + "SHA256withECDSA" + } + else -> return null + } + + // Verify signature + val certB64 = header.certificates?.first() ?: return null + val certDer = certB64.decode() + val bis = ByteArrayInputStream(certDer) + val cert: X509Certificate + try { + cert = CryptoUtils.readCertificate(bis) + val verifier = Signature.getInstance(alg) + verifier.initVerify(cert.publicKey) + verifier.update(signedBytes) + if (!verifier.verify(signature)) + return null + } catch (e: GeneralSecurityException) { + Timber.e(e) + return null + } + + // Verify hostname + val hostNameVerifier = JsseDefaultHostnameAuthorizer(setOf()) + try { + if (!hostNameVerifier.verify("attest.android.com", cert)) + return null + } catch (e: IOException) { + Timber.e(e) + return null + } + + val response = moshi.adapter(SafetyNetResponse::class.java).fromJson(payload) ?: return null + + // Verify results + if (!response.nonce.decode().contentEquals(nonce)) + return null + + return response + } + + override fun onResponse(response: String?) { + if (response != null) { + scope.launch(Dispatchers.Default) { + val res = response.parseJws() + withContext(Dispatchers.Main) { + callback(SafetyNetResult(res)) + } + } + } else { + callback(SafetyNetResult()) + } } } + +@JsonClass(generateAdapter = true) +data class JwsHeader( + @Json(name = "alg") val algorithm: String, + @Json(name = "x5c") val certificates: List? +) + +@JsonClass(generateAdapter = true) +data class SafetyNetResponse( + val nonce: String, + val ctsProfileMatch: Boolean, + val basicIntegrity: Boolean, + val evaluationType: String = "" +) diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt index 50bed6032..013f45dd5 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt @@ -1,14 +1,12 @@ package com.topjohnwu.magisk.ui.safetynet -import org.json.JSONObject - interface SafetyNetHelper { val version: Int - fun attest() + fun attest(nonce: ByteArray) interface Callback { - fun onResponse(response: JSONObject?) + fun onResponse(response: String?) } } diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt index b2a505460..59131a661 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt @@ -5,10 +5,9 @@ import com.topjohnwu.magisk.BR import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.utils.set -import org.json.JSONObject data class SafetyNetResult( - val response: JSONObject? = null, + val response: SafetyNetResponse? = null, val dismiss: Boolean = false ) @@ -43,20 +42,20 @@ class SafetynetViewModel : BaseViewModel() { init { cachedResult?.also { - resolveResponse(SafetyNetResult(it)) + handleResponse(SafetyNetResult(it)) } ?: attest() } private fun attest() { isChecking = true CheckSafetyNetEvent { - resolveResponse(it) + handleResponse(it) }.publish() } fun reset() = attest() - private fun resolveResponse(response: SafetyNetResult) { + private fun handleResponse(response: SafetyNetResult) { isChecking = false if (response.dismiss) { @@ -65,26 +64,15 @@ class SafetynetViewModel : BaseViewModel() { } response.response?.apply { - runCatching { - val cts = getBoolean("ctsProfileMatch") - val basic = getBoolean("basicIntegrity") - val eval = optString("evaluationType") - val result = cts && basic - cachedResult = this - ctsState = cts - basicIntegrityState = basic - evalType = if (eval.contains("HARDWARE")) "HARDWARE" else "BASIC" - isSuccess = result - safetyNetTitle = - if (result) R.string.safetynet_attest_success - else R.string.safetynet_attest_failure - }.onFailure { - isSuccess = false - ctsState = false - basicIntegrityState = false - evalType = "N/A" - safetyNetTitle = R.string.safetynet_res_invalid - } + val result = ctsProfileMatch && basicIntegrity + cachedResult = this + ctsState = ctsProfileMatch + basicIntegrityState = basicIntegrity + evalType = if (evaluationType.contains("HARDWARE")) "HARDWARE" else "BASIC" + isSuccess = result + safetyNetTitle = + if (result) R.string.safetynet_attest_success + else R.string.safetynet_attest_failure } ?: { isSuccess = false ctsState = false @@ -95,7 +83,7 @@ class SafetynetViewModel : BaseViewModel() { } companion object { - private var cachedResult: JSONObject? = null + private var cachedResult: SafetyNetResponse? = null } } diff --git a/build.py b/build.py index dea5d8e19..01dbea51b 100755 --- a/build.py +++ b/build.py @@ -363,7 +363,7 @@ def build_stub(args): def build_snet(args): - if not op.exists(op.join('snet', 'src', 'main', 'java', 'com', 'topjohnwu', 'snet')): + if not op.exists(op.join('stub', 'src', 'main', 'java', 'com', 'topjohnwu', 'snet')): error('snet sources have to be bind mounted on top of the stub folder') header('* Building snet extension') proc = execv([gradlew, 'stub:assembleRelease']) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 442d9132e..336a74c2a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Apr 14 22:32:11 PDT 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-all.zip