feat(spoof): periodic bulletin refresh via BulletinPoller
BulletinPoller fetches the Pixel security bulletin index page on its own HandlerThread with 5s/30s/2m/10m/30m bootstrap backoff, then 24h steady cadence. The first <td>YYYY-MM-DD</td> match is the latest published patch; newer-than-current dates flow through PatchLevelManager.updateTo for validation + atomic write + resetprop. Persists the last 10 attempts to last_bulletin_fetch.json (atomic rename) with status, http_code, parsed_date, applied, and error fields so operators can audit history without logcat. Sepolicy rule appends TCP-socket allow rules for both ksu and magisk source domains so HttpsURLConnection survives SELinux enforcement on either root provider. Uninstall.sh cleans the three new artifacts.
This commit is contained in:
@@ -9,6 +9,7 @@ import android.os.Looper
|
|||||||
import java.security.Security
|
import java.security.Security
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import org.matrix.TEESimulator.config.BootStateManager
|
import org.matrix.TEESimulator.config.BootStateManager
|
||||||
|
import org.matrix.TEESimulator.config.BulletinPoller
|
||||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||||
import org.matrix.TEESimulator.config.PatchLevelManager
|
import org.matrix.TEESimulator.config.PatchLevelManager
|
||||||
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
||||||
@@ -59,6 +60,8 @@ object App {
|
|||||||
|
|
||||||
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
||||||
|
|
||||||
|
BulletinPoller.start()
|
||||||
|
|
||||||
// This starts the message queue processing. It blocks here indefinitely
|
// This starts the message queue processing. It blocks here indefinitely
|
||||||
// processing messages until Looper.myLooper().quit() is called.
|
// processing messages until Looper.myLooper().quit() is called.
|
||||||
Looper.loop()
|
Looper.loop()
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package org.matrix.TEESimulator.config
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.matrix.TEESimulator.BuildConfig
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
|
||||||
|
object BulletinPoller {
|
||||||
|
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
|
||||||
|
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
|
||||||
|
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
|
||||||
|
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
|
||||||
|
private const val HISTORY_CAP = 10
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
|
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
|
||||||
|
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
|
||||||
|
|
||||||
|
private lateinit var handler: Handler
|
||||||
|
@Volatile private var bootstrapStep = 0
|
||||||
|
@Volatile private var steadyArmed = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val thread = HandlerThread("BulletinPoller").apply { start() }
|
||||||
|
handler = Handler(thread.looper)
|
||||||
|
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollOnce() {
|
||||||
|
val result = fetchAndParse()
|
||||||
|
appendHistory(result)
|
||||||
|
scheduleNext(result.status == "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleNext(success: Boolean) {
|
||||||
|
if (success || steadyArmed) {
|
||||||
|
steadyArmed = true
|
||||||
|
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bootstrapStep++
|
||||||
|
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
|
||||||
|
steadyArmed = true
|
||||||
|
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
||||||
|
} else {
|
||||||
|
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FetchResult(
|
||||||
|
val ts: Long,
|
||||||
|
val status: String,
|
||||||
|
val httpCode: Int?,
|
||||||
|
val parsedDate: String?,
|
||||||
|
val applied: Boolean,
|
||||||
|
val error: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fetchAndParse(): FetchResult {
|
||||||
|
val ts = System.currentTimeMillis()
|
||||||
|
var conn: HttpsURLConnection? = null
|
||||||
|
return try {
|
||||||
|
conn =
|
||||||
|
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
|
||||||
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
|
readTimeout = READ_TIMEOUT_MS
|
||||||
|
setRequestProperty(
|
||||||
|
"User-Agent",
|
||||||
|
"TEESimulator/${BuildConfig.VERSION_NAME}",
|
||||||
|
)
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code != 200) {
|
||||||
|
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
|
||||||
|
}
|
||||||
|
val html = conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
|
||||||
|
if (date == null) {
|
||||||
|
return FetchResult(
|
||||||
|
ts,
|
||||||
|
"parse_error",
|
||||||
|
code,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"no <td>YYYY-MM-DD</td> match",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val current = currentPatch()
|
||||||
|
val isNewer = current == null || date > current
|
||||||
|
if (isNewer) {
|
||||||
|
PatchLevelManager.updateTo(date)
|
||||||
|
}
|
||||||
|
FetchResult(ts, "success", code, date, isNewer, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
FetchResult(ts, "network_error", null, null, false, e.toString())
|
||||||
|
} finally {
|
||||||
|
conn?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentPatch(): String? {
|
||||||
|
val f = File(PATCH_FILE)
|
||||||
|
if (!f.exists()) return null
|
||||||
|
return try {
|
||||||
|
f.readLines()
|
||||||
|
.firstOrNull { it.startsWith("system=") }
|
||||||
|
?.substringAfter("system=")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it != "prop" && it.isNotEmpty() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendHistory(result: FetchResult) {
|
||||||
|
try {
|
||||||
|
val target = File(HISTORY_FILE)
|
||||||
|
val staging = File(HISTORY_STAGING)
|
||||||
|
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
|
||||||
|
val history =
|
||||||
|
existing
|
||||||
|
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
|
||||||
|
?: JSONArray()
|
||||||
|
val entry =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("ts", result.ts)
|
||||||
|
put("status", result.status)
|
||||||
|
put("http_code", result.httpCode ?: JSONObject.NULL)
|
||||||
|
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
|
||||||
|
put("applied", result.applied)
|
||||||
|
put("error", result.error ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
history.put(entry)
|
||||||
|
while (history.length() > HISTORY_CAP) history.remove(0)
|
||||||
|
|
||||||
|
val latestKnown =
|
||||||
|
(0 until history.length())
|
||||||
|
.mapNotNull {
|
||||||
|
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
|
||||||
|
d.isNotBlank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lastOrNull()
|
||||||
|
|
||||||
|
val root =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("latest_known_date", latestKnown ?: JSONObject.NULL)
|
||||||
|
put("history", history)
|
||||||
|
}
|
||||||
|
staging.writeText(root.toString(2))
|
||||||
|
Files.move(
|
||||||
|
staging.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SystemLogger.error("BulletinPoller: failed to persist history", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
allow keystore {adb_data_file shell_data_file} file *
|
allow keystore {adb_data_file shell_data_file} file *
|
||||||
allow crash_dump keystore process *
|
allow crash_dump keystore process *
|
||||||
|
|
||||||
|
allow ksu self:tcp_socket { create connect read write getopt setopt }
|
||||||
|
allow ksu node:tcp_socket node_bind
|
||||||
|
allow ksu port:tcp_socket name_connect
|
||||||
|
allow magisk self:tcp_socket { create connect read write getopt setopt }
|
||||||
|
allow magisk node:tcp_socket node_bind
|
||||||
|
allow magisk port:tcp_socket name_connect
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ done
|
|||||||
rm -rf "$CONFIG_DIR/persistent_keys"
|
rm -rf "$CONFIG_DIR/persistent_keys"
|
||||||
rm -f "$CONFIG_DIR/tee_status.txt"
|
rm -f "$CONFIG_DIR/tee_status.txt"
|
||||||
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
|
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
|
||||||
|
rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user