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:
parent
c4e8dda37c
commit
94ec11db58
@ -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}")
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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<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)
|
||||
.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 = ""
|
||||
)
|
||||
|
@ -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?)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
2
build.py
2
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'])
|
||||
|
3
gradle/wrapper/gradle-wrapper.properties
vendored
3
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user