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.
This commit is contained in:
topjohnwu 2021-04-15 04:47:57 -07:00
parent c4e8dda37c
commit 94ec11db58
8 changed files with 188 additions and 96 deletions

View File

@ -207,7 +207,7 @@ dependencies {
implementation("io.noties.markwon:image:${vMarkwon}") implementation("io.noties.markwon:image:${vMarkwon}")
implementation("com.caverock:androidsvg:1.4") 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:core:${vLibsu}")
implementation("com.github.topjohnwu.libsu:io:${vLibsu}") implementation("com.github.topjohnwu.libsu:io:${vLibsu}")

View File

@ -29,7 +29,7 @@ object Const {
const val MAGISK_LOG = "/cache/magisk.log" const val MAGISK_LOG = "/cache/magisk.log"
// Versions // Versions
const val SNET_EXT_VER = 15 const val SNET_EXT_VER = 16
const val SNET_REVISION = "22.0" const val SNET_REVISION = "22.0"
const val BOOTCTL_REVISION = "22.0" const val BOOTCTL_REVISION = "22.0"

View File

@ -1,6 +1,5 @@
package com.topjohnwu.magisk.data.repository package com.topjohnwu.magisk.data.repository
import android.os.Build
import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Config.Value.BETA_CHANNEL import com.topjohnwu.magisk.core.Config.Value.BETA_CHANNEL
import com.topjohnwu.magisk.core.Config.Value.CANARY_CHANNEL import com.topjohnwu.magisk.core.Config.Value.CANARY_CHANNEL
@ -66,7 +65,7 @@ class NetworkService(
} }
// Fetch files // Fetch files
suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() } // suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() }
suspend fun fetchBootctl() = wrap { jsd.fetchBootctl() } suspend fun fetchBootctl() = wrap { jsd.fetchBootctl() }
suspend fun fetchInstaller() = wrap { suspend fun fetchInstaller() = wrap {
val sha = fetchMainVersion() val sha = fetchMainVersion()
@ -76,4 +75,7 @@ class NetworkService(
suspend fun fetchString(url: String) = wrap { raw.fetchString(url) } suspend fun fetchString(url: String) = wrap { raw.fetchString(url) }
private suspend fun fetchMainVersion() = api.fetchBranch(MAGISK_MAIN, "master").commit.sha 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")
} }

View File

@ -1,6 +1,12 @@
@file:Suppress("DEPRECATION")
package com.topjohnwu.magisk.ui.safetynet package com.topjohnwu.magisk.ui.safetynet
import android.content.Context 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.R
import com.topjohnwu.magisk.arch.ContextExecutor import com.topjohnwu.magisk.arch.ContextExecutor
import com.topjohnwu.magisk.arch.ViewEventWithScope 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.DynamicClassLoader
import com.topjohnwu.magisk.ktx.writeTo import com.topjohnwu.magisk.ktx.writeTo
import com.topjohnwu.magisk.view.MagiskDialog import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.signing.CryptoUtils
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import dalvik.system.BaseDexClassLoader
import dalvik.system.DexFile import dalvik.system.DexFile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.KoinComponent
import org.koin.core.inject import org.koin.core.inject
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.lang.reflect.Field
import java.lang.reflect.InvocationHandler 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( class CheckSafetyNetEvent(
private val callback: (SafetyNetResult) -> Unit = {} private val callback: (SafetyNetResult) -> Unit = {}
) : ViewEventWithScope(), ContextExecutor, KoinComponent, SafetyNetHelper.Callback { ) : ViewEventWithScope(), ContextExecutor, KoinComponent, SafetyNetHelper.Callback {
@ -32,92 +46,94 @@ class CheckSafetyNetEvent(
private lateinit var apk: File private lateinit var apk: File
private lateinit var dex: File private lateinit var dex: File
private lateinit var nonce: ByteArray
override fun invoke(context: Context) { override fun invoke(context: Context) {
apk = File("${context.filesDir.parent}/snet", "snet.jar") apk = File("${context.filesDir.parent}/snet", "snet.jar")
dex = File(apk.parent, "snet.dex") dex = File(apk.parent, "snet.dex")
scope.launch { scope.launch(Dispatchers.IO) {
attest(context) { attest(context) {
// Download and retry // Download and retry
withContext(Dispatchers.IO) { Shell.sh("rm -rf " + apk.parent).exec()
Shell.sh("rm -rf " + apk.parent).exec() apk.parentFile?.mkdir()
apk.parentFile?.mkdir() withContext(Dispatchers.Main) {
showDialog(context)
} }
download(context, true)
} }
} }
} }
private suspend fun attest(context: Context, onError: suspend (Exception) -> Unit) { private suspend fun attest(context: Context, onError: suspend (Exception) -> Unit) {
val helper: SafetyNetHelper
try { try {
val helper = withContext(Dispatchers.IO) { val loader = DynamicClassLoader(apk)
val loader = DynamicClassLoader(apk)
val dex = DexFile.loadDex(apk.path, dex.path, 0)
// Scan through the dex and find our helper class // Scan through the dex and find our helper class
var helperClass: Class<*>? = null var clazz: Class<*>? = null
for (className in dex.entries()) { loop@for (dex in loader.getDexFiles()) {
if (className.startsWith("x.")) { for (name in dex.entries()) {
val cls = loader.loadClass(className) if (name.startsWith("x.")) {
val cls = loader.loadClass(name)
if (InvocationHandler::class.java.isAssignableFrom(cls)) { if (InvocationHandler::class.java.isAssignableFrom(cls)) {
helperClass = cls clazz = cls
break 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) { } catch (e: Exception) {
if (e is CancellationException)
throw e
onError(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 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<DexFile> {
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) MagiskDialog(context)
.applyTitle(R.string.proprietary_title) .applyTitle(R.string.proprietary_title)
.applyMessage(R.string.proprietary_notice) .applyMessage(R.string.proprietary_notice)
.cancellable(false) .cancellable(false)
.applyButton(MagiskDialog.ButtonType.POSITIVE) { .applyButton(MagiskDialog.ButtonType.POSITIVE) {
titleRes = android.R.string.ok titleRes = android.R.string.ok
onClick { downloadInternal() } onClick { download(context) }
} }
.applyButton(MagiskDialog.ButtonType.NEGATIVE) { .applyButton(MagiskDialog.ButtonType.NEGATIVE) {
titleRes = android.R.string.cancel titleRes = android.R.string.cancel
@ -129,7 +145,94 @@ class CheckSafetyNetEvent(
.reveal() .reveal()
} }
override fun onResponse(response: JSONObject?) { private fun String.decode(): ByteArray {
callback(SafetyNetResult(response)) 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<String>?
)
@JsonClass(generateAdapter = true)
data class SafetyNetResponse(
val nonce: String,
val ctsProfileMatch: Boolean,
val basicIntegrity: Boolean,
val evaluationType: String = ""
)

View File

@ -1,14 +1,12 @@
package com.topjohnwu.magisk.ui.safetynet package com.topjohnwu.magisk.ui.safetynet
import org.json.JSONObject
interface SafetyNetHelper { interface SafetyNetHelper {
val version: Int val version: Int
fun attest() fun attest(nonce: ByteArray)
interface Callback { interface Callback {
fun onResponse(response: JSONObject?) fun onResponse(response: String?)
} }
} }

View File

@ -5,10 +5,9 @@ import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.utils.set import com.topjohnwu.magisk.utils.set
import org.json.JSONObject
data class SafetyNetResult( data class SafetyNetResult(
val response: JSONObject? = null, val response: SafetyNetResponse? = null,
val dismiss: Boolean = false val dismiss: Boolean = false
) )
@ -43,20 +42,20 @@ class SafetynetViewModel : BaseViewModel() {
init { init {
cachedResult?.also { cachedResult?.also {
resolveResponse(SafetyNetResult(it)) handleResponse(SafetyNetResult(it))
} ?: attest() } ?: attest()
} }
private fun attest() { private fun attest() {
isChecking = true isChecking = true
CheckSafetyNetEvent { CheckSafetyNetEvent {
resolveResponse(it) handleResponse(it)
}.publish() }.publish()
} }
fun reset() = attest() fun reset() = attest()
private fun resolveResponse(response: SafetyNetResult) { private fun handleResponse(response: SafetyNetResult) {
isChecking = false isChecking = false
if (response.dismiss) { if (response.dismiss) {
@ -65,26 +64,15 @@ class SafetynetViewModel : BaseViewModel() {
} }
response.response?.apply { response.response?.apply {
runCatching { val result = ctsProfileMatch && basicIntegrity
val cts = getBoolean("ctsProfileMatch") cachedResult = this
val basic = getBoolean("basicIntegrity") ctsState = ctsProfileMatch
val eval = optString("evaluationType") basicIntegrityState = basicIntegrity
val result = cts && basic evalType = if (evaluationType.contains("HARDWARE")) "HARDWARE" else "BASIC"
cachedResult = this isSuccess = result
ctsState = cts safetyNetTitle =
basicIntegrityState = basic if (result) R.string.safetynet_attest_success
evalType = if (eval.contains("HARDWARE")) "HARDWARE" else "BASIC" else R.string.safetynet_attest_failure
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
}
} ?: { } ?: {
isSuccess = false isSuccess = false
ctsState = false ctsState = false
@ -95,7 +83,7 @@ class SafetynetViewModel : BaseViewModel() {
} }
companion object { companion object {
private var cachedResult: JSONObject? = null private var cachedResult: SafetyNetResponse? = null
} }
} }

View File

@ -363,7 +363,7 @@ def build_stub(args):
def build_snet(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') error('snet sources have to be bind mounted on top of the stub folder')
header('* Building snet extension') header('* Building snet extension')
proc = execv([gradlew, 'stub:assembleRelease']) proc = execv([gradlew, 'stub:assembleRelease'])

View File

@ -1,5 +1,6 @@
#Wed Apr 14 22:32:11 PDT 2021
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-all.zip