feat(soter): forge ISoterService Layer-A replies
Forge healthy com.tencent.soter.soterserver.ISoterService AIDL replies from inside the injected soterserver process so the SOTER capability probe reads available=true / damaged=false on a bootloader-unlocked device whose SOTER TA can no longer use its factory ATTK. Hardcodes the 13 obfuscation-stable transaction codes (R8 stripped the Stub) and fills the 5 parcelable payloads with detector-valid values: the export blob is a little-endian length-framed SOTER pubkey envelope that the SDK's retrieveJsonFromExportedData parses to a non-null model. Every request and forged reply is captured to per-UID NDJSON, debug-gated. Checkpoints 10.A (forge) and 10.M (reply marshalling).
This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
|||||||
|
package org.matrix.TEESimulator.interception.soter
|
||||||
|
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.util.Base64
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forges healthy `com.tencent.soter.soterserver.ISoterService` (Layer A: AIDL over
|
||||||
|
* `/dev/binder`) replies from inside the injected soterserver app process, so the SOTER
|
||||||
|
* capability probe (春秋 / DuckDetector `SoterCapabilityProbe`) reads `available = true`
|
||||||
|
* / `damaged = false` on a bootloader-unlocked device whose SOTER TA can no longer use
|
||||||
|
* its factory ATTK. Replaces the external SoterFixer loop + the Hail freeze.
|
||||||
|
*
|
||||||
|
* Unconditional by design: the forge decision never consults `ConfigurationManager` /
|
||||||
|
* `target.txt` (Phase 10 spec §Decision, gate G). It is mounted by the SOTER process
|
||||||
|
* supervisor (10.B/10.W) against the ISoterService binder, so `onPreTransact` only sees
|
||||||
|
* transactions on that binder — matching the raw transaction code is therefore enough.
|
||||||
|
*
|
||||||
|
* Diagnostics follow the module's standard three-layer capture (debug-gated, per-UID
|
||||||
|
* NDJSON via [SystemLogger]; see `logging/SystemLogger.kt`): a `tx` line for every
|
||||||
|
* transaction ([logTransaction]), the raw inbound request parcel, and the raw forged
|
||||||
|
* reply wire. Capture is scoped to targeted UIDs (`isUidLogged`) exactly like the
|
||||||
|
* keystore lane — it does NOT make the forge conditional; the forge still fires for all.
|
||||||
|
*
|
||||||
|
* Transaction codes are HARDCODED 1..13 in AIDL declaration order, NOT resolved via
|
||||||
|
* [org.matrix.TEESimulator.interception.keystore.InterceptorUtils.getTransactCode]: the
|
||||||
|
* shipped soterserver build is R8/ProGuard obfuscated — there is no `ISoterService$Stub`
|
||||||
|
* class and no `TRANSACTION_*` fields (recon 2026-06-26, `a$a.smali` packed-switch). The
|
||||||
|
* codes are fixed by Tencent's `ISoterService.aidl` and are obfuscation-independent.
|
||||||
|
*
|
||||||
|
* Scope boundary (10.A vs 10.M): the seven primitive-returning methods are fully forged
|
||||||
|
* here. The six parcelable-returning methods emit the correct AIDL envelope + the
|
||||||
|
* recon-verified `writeToParcel` field order; 10.M fills the payloads with
|
||||||
|
* detector-satisfying values — a framed SOTER pubkey envelope the SDK's
|
||||||
|
* `retrieveJsonFromExportedData` parses to a non-null `SoterPubKeyModel`, a non-zero sign
|
||||||
|
* session, and a 256-byte signature.
|
||||||
|
*/
|
||||||
|
object SoterServiceInterceptor : BinderInterceptor() {
|
||||||
|
|
||||||
|
/** The surviving, obfuscation-stable interface identifier (used by the 10.B/10.W mount). */
|
||||||
|
const val DESCRIPTOR = "com.tencent.soter.soterserver.ISoterService"
|
||||||
|
|
||||||
|
// AIDL transaction codes = FIRST_CALL_TRANSACTION (1) + declaration index, verified
|
||||||
|
// against the obfuscated `a$a.smali` packed-switch (recon 2026-06-26). NOTE the 5/6
|
||||||
|
// order: removeAuthKey precedes getAuthKey in the real .aidl (the spec prose had it
|
||||||
|
// reversed). Comments record each method's return shape.
|
||||||
|
private const val TX_GENERATE_APP_SECURE_KEY = 1 // int
|
||||||
|
private const val TX_GET_APP_SECURE_KEY = 2 // SoterExportResult
|
||||||
|
private const val TX_HAS_ASK_ALREADY = 3 // boolean
|
||||||
|
private const val TX_GENERATE_AUTH_KEY = 4 // int
|
||||||
|
private const val TX_REMOVE_AUTH_KEY = 5 // int (NOT getAuthKey)
|
||||||
|
private const val TX_GET_AUTH_KEY = 6 // SoterExportResult (NOT removeAuthKey)
|
||||||
|
private const val TX_REMOVE_ALL_AUTH_KEY = 7 // int
|
||||||
|
private const val TX_HAS_AUTH_KEY = 8 // boolean
|
||||||
|
private const val TX_INIT_SIGH = 9 // SoterSessionResult (sic: Tencent's spelling)
|
||||||
|
private const val TX_FINISH_SIGN = 10 // SoterSignResult
|
||||||
|
private const val TX_GET_DEVICE_ID = 11 // SoterDeviceResult
|
||||||
|
private const val TX_GET_VERSION = 12 // int (real service returns 1)
|
||||||
|
private const val TX_GET_EXTRA_PARAM = 13 // SoterExtraParam
|
||||||
|
|
||||||
|
/** SOTER success result code (`SoterCoreResult` ERR_OK). */
|
||||||
|
private const val SOTER_OK = 0
|
||||||
|
|
||||||
|
/** finishSign signature length the probe expects. */
|
||||||
|
private const val SIGNATURE_LEN = 256
|
||||||
|
|
||||||
|
/** `cpu_id` placeholder in the export envelope; the local probe never reads its value
|
||||||
|
* (the backend pins the real per-`cpu_id` ATTK, which the forge cannot satisfy). */
|
||||||
|
private const val CPU_ID = "0000000000000000"
|
||||||
|
|
||||||
|
/** Code -> Tencent method name, for the `tx` diagnostic line. Names from the recon decompile. */
|
||||||
|
private val methodNames =
|
||||||
|
mapOf(
|
||||||
|
TX_GENERATE_APP_SECURE_KEY to "generateAppSecureKey",
|
||||||
|
TX_GET_APP_SECURE_KEY to "getAppSecureKey",
|
||||||
|
TX_HAS_ASK_ALREADY to "hasAskAlready",
|
||||||
|
TX_GENERATE_AUTH_KEY to "generateAuthKey",
|
||||||
|
TX_REMOVE_AUTH_KEY to "removeAuthKey",
|
||||||
|
TX_GET_AUTH_KEY to "getAuthKey",
|
||||||
|
TX_REMOVE_ALL_AUTH_KEY to "removeAllAuthKey",
|
||||||
|
TX_HAS_AUTH_KEY to "hasAuthKey",
|
||||||
|
TX_INIT_SIGH to "initSigh",
|
||||||
|
TX_FINISH_SIGN to "finishSign",
|
||||||
|
TX_GET_DEVICE_ID to "getDeviceId",
|
||||||
|
TX_GET_VERSION to "getVersion",
|
||||||
|
TX_GET_EXTRA_PARAM to "getExtraParam",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The codes this interceptor forges; consumed by the supervisor's registration (10.B/10.W). */
|
||||||
|
val interceptedCodes: IntArray = methodNames.keys.toIntArray()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload of [SoterExportResult.exportData] for getAppSecureKey (txn 2) and getAuthKey
|
||||||
|
* (txn 6). The detector's capability probe gates `damaged=false` on
|
||||||
|
* `SoterCore.getApp/AuthKeyModel() != null`, and the SDK's `retrieveJsonFromExportedData`
|
||||||
|
* (`SoterCoreBase`) returns a non-null `SoterPubKeyModel` only when this exact framing
|
||||||
|
* parses: `[4-byte LITTLE-ENDIAN json length][UTF-8 json][signature bytes]`. A
|
||||||
|
* non-empty-but-unframed blob throws inside the SDK and is read as `damaged` silently.
|
||||||
|
* The JSON parser swallows every exception, so only the framing is load-bearing; the
|
||||||
|
* `pub_key` is a genuine RSA-2048 SubjectPublicKeyInfo so a probe that base64/X.509-parses
|
||||||
|
* the field locally still succeeds. Lazily built — keygen runs once, off the mount path.
|
||||||
|
*/
|
||||||
|
private val exportBlob: ByteArray by lazy { buildExportBlob() }
|
||||||
|
|
||||||
|
/** getDeviceId (txn 11) payload — well-formed, non-empty; the probe never parses it. */
|
||||||
|
private val deviceIdBlob = "TEESIM-SOTER-0001".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
/** finishSign (txn 10) signature payload — [SIGNATURE_LEN] bytes. */
|
||||||
|
private val signatureBlob = ByteArray(SIGNATURE_LEN)
|
||||||
|
|
||||||
|
private fun buildExportBlob(): ByteArray {
|
||||||
|
val pubKey =
|
||||||
|
runCatching {
|
||||||
|
val generator = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }
|
||||||
|
Base64.encodeToString(generator.generateKeyPair().public.encoded, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
.getOrDefault("")
|
||||||
|
val json =
|
||||||
|
"""{"pub_key":"$pubKey","counter":0,"cpu_id":"$CPU_ID","uid":0}"""
|
||||||
|
.toByteArray(Charsets.UTF_8)
|
||||||
|
val lengthPrefix = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(json.size).array()
|
||||||
|
return lengthPrefix + json + signatureBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreTransact(
|
||||||
|
txId: Long,
|
||||||
|
target: IBinder,
|
||||||
|
code: Int,
|
||||||
|
flags: Int,
|
||||||
|
callingUid: Int,
|
||||||
|
callingPid: Int,
|
||||||
|
data: Parcel,
|
||||||
|
): TransactionResult {
|
||||||
|
val method = methodNames[code]
|
||||||
|
if (method == null) {
|
||||||
|
// Not an ISoterService method we forge — record it as observed, then pass through.
|
||||||
|
logTransaction(txId, "code=$code", callingUid, callingPid, skipPost = true)
|
||||||
|
return TransactionResult.ContinueAndSkipPost
|
||||||
|
}
|
||||||
|
logTransaction(txId, method, callingUid, callingPid)
|
||||||
|
captureRequest(callingUid, txId, method, data)
|
||||||
|
|
||||||
|
return when (code) {
|
||||||
|
// Primitive returns — fully forged here.
|
||||||
|
TX_GENERATE_APP_SECURE_KEY,
|
||||||
|
TX_GENERATE_AUTH_KEY,
|
||||||
|
TX_REMOVE_AUTH_KEY,
|
||||||
|
TX_REMOVE_ALL_AUTH_KEY -> forgedReply(callingUid, txId, method) { writeInt(SOTER_OK) }
|
||||||
|
TX_GET_VERSION -> forgedReply(callingUid, txId, method) { writeInt(1) }
|
||||||
|
TX_HAS_ASK_ALREADY,
|
||||||
|
TX_HAS_AUTH_KEY -> forgedReply(callingUid, txId, method) { writeInt(1) } // boolean true
|
||||||
|
|
||||||
|
// Parcelable returns — correct envelope + recon field order, payloads filled (10.M).
|
||||||
|
TX_GET_APP_SECURE_KEY,
|
||||||
|
TX_GET_AUTH_KEY ->
|
||||||
|
forgedReply(callingUid, txId, method) {
|
||||||
|
writeInt(1) // non-null marker
|
||||||
|
writeInt(SOTER_OK) // resultCode
|
||||||
|
writeByteArray(exportBlob) // exportData — framed SOTER pubkey envelope
|
||||||
|
writeInt(exportBlob.size) // exportDataLength
|
||||||
|
}
|
||||||
|
TX_INIT_SIGH ->
|
||||||
|
forgedReply(callingUid, txId, method) {
|
||||||
|
writeInt(1)
|
||||||
|
writeLong(1L) // session — any non-zero satisfies the probe
|
||||||
|
writeInt(SOTER_OK) // resultCode
|
||||||
|
}
|
||||||
|
TX_FINISH_SIGN ->
|
||||||
|
forgedReply(callingUid, txId, method) {
|
||||||
|
writeInt(1)
|
||||||
|
writeInt(SOTER_OK) // resultCode — finishSign throws on != 0
|
||||||
|
writeByteArray(signatureBlob) // exportData = signature
|
||||||
|
writeInt(signatureBlob.size) // exportDataLength
|
||||||
|
}
|
||||||
|
TX_GET_DEVICE_ID ->
|
||||||
|
forgedReply(callingUid, txId, method) {
|
||||||
|
writeInt(1)
|
||||||
|
writeInt(SOTER_OK) // resultCode
|
||||||
|
writeByteArray(deviceIdBlob) // exportData = device id
|
||||||
|
writeInt(deviceIdBlob.size) // exportDataLength
|
||||||
|
}
|
||||||
|
TX_GET_EXTRA_PARAM ->
|
||||||
|
forgedReply(callingUid, txId, method) {
|
||||||
|
writeInt(1)
|
||||||
|
writeValue("optical") // SoterExtraParam.result = fingerprint sensor type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable: method != null means code is one of the 13 above.
|
||||||
|
else -> TransactionResult.ContinueAndSkipPost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshots the inbound request parcel to the per-UID NDJSON plane (debug + targeted only). */
|
||||||
|
private fun captureRequest(uid: Int, txId: Long, method: String, data: Parcel) {
|
||||||
|
if (!SystemLogger.isUidLogged(uid)) return
|
||||||
|
runCatching { data.marshall() }
|
||||||
|
.onSuccess { raw ->
|
||||||
|
SystemLogger.uidLogRaw(uid, txId, "$method-request", "len=${raw.size}", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an AIDL reply (`writeNoException()` then [body]) and snapshots its wire bytes to the
|
||||||
|
* per-UID NDJSON plane before handing it to the native hook. Parcelable bodies write their own
|
||||||
|
* `writeInt(1)` non-null marker; the native hook recycles the parcel after use.
|
||||||
|
*/
|
||||||
|
private fun forgedReply(
|
||||||
|
uid: Int,
|
||||||
|
txId: Long,
|
||||||
|
method: String,
|
||||||
|
body: Parcel.() -> Unit,
|
||||||
|
): TransactionResult.OverrideReply {
|
||||||
|
val reply = Parcel.obtain()
|
||||||
|
reply.writeNoException()
|
||||||
|
reply.body()
|
||||||
|
if (SystemLogger.isUidLogged(uid)) {
|
||||||
|
runCatching { reply.marshall() }
|
||||||
|
.onSuccess { raw ->
|
||||||
|
SystemLogger.uidLogRaw(uid, txId, "$method-reply", "len=${raw.size}", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TransactionResult.OverrideReply(reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user