fix(android): verify OTA APK signature before install + disable backup (Critical)

The updater fetched download_url from the server JSON and installed it via
PackageInstaller with NO verification, over cleartext (usesCleartextTraffic,
no pinning). A network MITM or compromised server could return a malicious APK
and have it silently installed (REQUEST_INSTALL_PACKAGES) → full device RCE.

Fix: before install, verify the downloaded APK (a) is our own package and
(b) shares a current signing certificate with the installed app
(GET_SIGNING_CERTIFICATES on P+, GET_SIGNATURES below). An attacker can't forge
our signing key, so this holds even over an untrusted/cleartext transport.
Fail-closed on any parse/verify error; the APK is deleted on mismatch. Gates
both the session-install and intent-fallback paths.

Also set android:allowBackup="false" so adb backup can't exfiltrate the
device token / config.

Compile-checked + signed debug APK builds. NOT verified on-device - needs a
real update cycle on a device (valid update installs; a wrong-signed APK is
rejected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-08 19:06:23 -05:00
parent 50ad1f670b
commit d41bd1f27d
2 changed files with 69 additions and 1 deletions

View file

@ -17,7 +17,7 @@
<application <application
android:name=".RemoteDisplayApp" android:name=".RemoteDisplayApp"
android:allowBackup="true" android:allowBackup="false"
android:icon="@android:drawable/ic_media_play" android:icon="@android:drawable/ic_media_play"
android:label="RemoteDisplay" android:label="RemoteDisplay"
android:largeHeap="true" android:largeHeap="true"

View file

@ -5,6 +5,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@ -17,6 +20,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateChecker(private val context: Context) { class UpdateChecker(private val context: Context) {
@ -107,6 +111,20 @@ class UpdateChecker(private val context: Context) {
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)") Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
// SECURITY (#5 review): never install an APK we didn't sign. The update
// is fetched from a server-supplied URL, often over cleartext with no
// pinning - a MITM or compromised server could otherwise return a
// malicious APK and get it silently installed (REQUEST_INSTALL_PACKAGES).
// Verify the downloaded APK is our package AND signed by the same key as
// the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport.
if (!verifyApkSignature(apkFile)) {
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete()
return
}
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
// Install the APK // Install the APK
handler.post { handler.post {
installApk(apkFile) installApk(apkFile)
@ -179,6 +197,56 @@ class UpdateChecker(private val context: Context) {
} }
} }
// True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error.
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val pm = context.packageManager
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags)
if (downloaded == null) {
Log.e(TAG, "Could not parse downloaded APK")
return false
}
if (downloaded.packageName != context.packageName) {
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
return false
}
val installed = pm.getPackageInfo(context.packageName, flags)
val downloadedSigs = signingCertHashes(downloaded)
val installedSigs = signingCertHashes(installed)
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
return false
}
// Share at least one current signing certificate.
val match = downloadedSigs.any { it in installedSigs }
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
match
} catch (e: Exception) {
Log.e(TAG, "Signature verification error: ${e.message}", e)
false
}
}
private fun signingCertHashes(info: PackageInfo): Set<String> {
val sigs: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION") info.signatures
}
return sigs?.mapNotNull { sha256(it.toByteArray()) }?.toSet() ?: emptySet()
}
private fun sha256(bytes: ByteArray): String? {
return try {
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
null
}
}
private fun getAppVersion(): String { private fun getAppVersion(): String {
return try { return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"