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("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}")

View File

@ -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"

View File

@ -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")
}

View File

@ -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()
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)
// 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)
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()
}
clazz ?: throw Exception("Cannot find SafetyNetHelper class")
val helper = helperClass
.getMethod("get", Class::class.java, Context::class.java, Any::class.java)
.invoke(
null, SafetyNetHelper::class.java,
context, this@CheckSafetyNetEvent
) as SafetyNetHelper
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()
helper
}
helper.attest()
if (helper.version != Const.SNET_EXT_VER)
throw Exception("snet extension version mismatch")
} catch (e: Exception) {
if (e is CancellationException)
throw e
onError(e)
}
return
}
@Suppress("SameParameterValue")
private fun download(context: Context, askUser: Boolean) {
fun downloadInternal() = scope.launch {
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 {
withContext(Dispatchers.IO) {
svc.fetchSafetynet().byteStream().writeTo(apk)
}
try {
svc.fetchSafetynet().byteStream().writeTo(apk)
attest(context, abort)
} catch (e: IOException) {
if (e is CancellationException)
throw e
abort(e)
}
}
if (!askUser) {
downloadInternal()
return
}
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<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
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?)
}
}

View File

@ -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
val result = ctsProfileMatch && basicIntegrity
cachedResult = this
ctsState = cts
basicIntegrityState = basic
evalType = if (eval.contains("HARDWARE")) "HARDWARE" else "BASIC"
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
}.onFailure {
isSuccess = false
ctsState = false
basicIntegrityState = false
evalType = "N/A"
safetyNetTitle = R.string.safetynet_res_invalid
}
} ?: {
isSuccess = false
ctsState = false
@ -95,7 +83,7 @@ class SafetynetViewModel : BaseViewModel() {
}
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):
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'])

View File

@ -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