feat: HUSI like scan

This commit is contained in:
xtclovver 2026-04-30 03:59:22 +03:00
parent c0e194cb68
commit e43c0f04ed
20 changed files with 870 additions and 64 deletions

1
.idea/misc.xml generated
View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View file

@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
@ -192,6 +193,7 @@ internal object CheckResultJsonExportFormatter {
put("source", app.source.name)
put("active", app.active)
put("confidence", app.confidence.name)
app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) }
}
}
@ -203,6 +205,20 @@ internal object CheckResultJsonExportFormatter {
put("kind", app.kind?.name)
put("source", app.source.name)
put("confidence", app.confidence.name)
app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) }
}
}
private fun technicalMetadataToJson(metadata: VpnAppTechnicalMetadata): JSONObject {
return JSONObject().apply {
put("versionName", metadata.versionName)
put("serviceNames", JSONArray().apply { metadata.serviceNames.forEach { put(it) } })
put("appType", metadata.appType)
put("coreType", metadata.coreType)
put("corePath", metadata.corePath)
put("goVersion", metadata.goVersion)
put("systemApp", metadata.systemApp)
put("matchedByNameHeuristic", metadata.matchedByNameHeuristic)
}
}

View file

@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
internal object CheckResultMarkdownExportFormatter {
@ -350,6 +351,7 @@ internal object CheckResultMarkdownExportFormatter {
add("source=${app.source}")
add("active=${app.active}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
}.joinToString(" | ")
}
@ -361,9 +363,24 @@ internal object CheckResultMarkdownExportFormatter {
add("kind=${app.kind ?: "<none>"}")
add("source=${app.source}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
}.joinToString(" | ")
}
private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List<String> {
if (metadata == null) return emptyList()
return buildList {
metadata.versionName?.let { add("version=$it") }
metadata.appType?.let { add("appType=$it") }
metadata.coreType?.let { add("coreType=$it") }
metadata.corePath?.let { add("corePath=$it") }
metadata.goVersion?.let { add("goVersion=$it") }
if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString()}")
if (metadata.systemApp) add("systemApp=true")
if (metadata.matchedByNameHeuristic) add("matchedByName=true")
}
}
private fun formatCallTransportLeak(
leak: CallTransportLeakResult,
privacyMode: Boolean,

View file

@ -20,6 +20,7 @@ import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.NativeInterfaceProbe
import com.notcvnt.rknhardering.probe.NativeSignsBridge
import com.notcvnt.rknhardering.probe.XrayApiScanResult
@ -589,6 +590,7 @@ object DebugDiagnosticsFormatter {
add("source=${app.source}")
add("active=${app.active}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
}.joinToString(" ")
}
@ -600,9 +602,24 @@ object DebugDiagnosticsFormatter {
add("kind=${app.kind ?: "<none>"}")
add("source=${app.source}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
}.joinToString(" ")
}
private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List<String> {
if (metadata == null) return emptyList()
return buildList {
metadata.versionName?.let { add("version=$it") }
if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString(",")}")
metadata.appType?.let { add("appType=$it") }
metadata.coreType?.let { add("coreType=$it") }
metadata.corePath?.let { add("corePath=$it") }
metadata.goVersion?.let { add("goVersion=$it") }
add("systemApp=${metadata.systemApp}")
add("matchedByNameHeuristic=${metadata.matchedByNameHeuristic}")
}
}
private fun formatCallTransportLeak(leak: CallTransportLeakResult): String {
return buildList {
add("service=${leak.service}")

View file

@ -66,6 +66,7 @@ import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunScope
import com.notcvnt.rknhardering.model.TargetGroup
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.network.DnsResolverConfig
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -1852,9 +1853,10 @@ class MainActivity : AppCompatActivity() {
if (infoSection != null && infoDivider != null) {
val infoFindings = category.findings.filter { it.isInformational }
val husiModelFindings = buildHusiModelFindings(category)
val checkFindings = category.findings.filterNot { it.isInformational || it.isError }
bindInfoSection(infoFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode)
bindInfoSection(infoFindings + husiModelFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode)
findingsContainer.removeAllViews()
for (finding in checkFindings) {
if (finding.description.startsWith("network_mcc_ru:")) continue
@ -1864,13 +1866,83 @@ class MainActivity : AppCompatActivity() {
}
findingsContainer.removeAllViews()
for (finding in category.findings) {
for (finding in category.findings + buildHusiModelFindings(category)) {
if (finding.isError) continue
if (finding.description.startsWith("network_mcc_ru:")) continue
findingsContainer.addView(createFindingView(finding, privacyMode))
}
}
private fun buildHusiModelFindings(category: CategoryResult): List<Finding> {
val findings = buildList {
category.matchedApps.forEach { app ->
husiModelFinding(
label = app.appName,
packageName = app.packageName,
serviceName = app.technicalMetadata?.serviceNames?.firstOrNull(),
metadata = app.technicalMetadata,
source = app.source,
)?.let(::add)
}
category.activeApps.forEach { app ->
husiModelFinding(
label = app.packageName ?: app.serviceName ?: "active VPN",
packageName = app.packageName,
serviceName = app.serviceName,
metadata = app.technicalMetadata,
source = app.source,
)?.let(::add)
}
}
return findings.distinctBy { it.packageName.orEmpty() to it.description }
}
private fun husiModelFinding(
label: String,
packageName: String?,
serviceName: String?,
metadata: VpnAppTechnicalMetadata?,
source: com.notcvnt.rknhardering.model.EvidenceSource?,
): Finding? {
val hasMeaningfulMetadata = metadata != null || !serviceName.isNullOrBlank() || !packageName.isNullOrBlank()
if (!hasMeaningfulMetadata) return null
val description = buildString {
append("HUSI model: ")
append(label)
packageName?.takeIf { it.isNotBlank() }?.let {
append(" · package: ")
append(it)
}
metadata?.versionName?.takeIf { it.isNotBlank() }?.let {
append(" · app version: ")
append(it)
}
append(" · app type: ")
append(metadata?.appType ?: "Other")
append(" · core type: ")
append(metadata?.coreType ?: "Unknown")
metadata?.corePath?.takeIf { it.isNotBlank() }?.let {
append(" · core path: ")
append(it)
}
metadata?.goVersion?.takeIf { it.isNotBlank() }?.let {
append(" · Go: ")
append(it)
}
serviceName?.takeIf { it.isNotBlank() }?.let {
append(" · service: ")
append(it)
}
}
return Finding(
description = description,
isInformational = true,
source = source,
packageName = packageName,
)
}
private fun bindInfoSection(
infoFindings: List<Finding>,
infoSection: LinearLayout,
@ -1967,6 +2039,18 @@ class MainActivity : AppCompatActivity() {
row.addView(indicator)
row.addView(description)
finding.packageName
?.takeIf { it.isNotBlank() }
?.let { packageName ->
row.isClickable = true
row.isFocusable = true
row.setOnClickListener {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
}
}
return row
}

View file

@ -33,11 +33,14 @@ import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayApiScanner
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import java.net.InetAddress
object BypassChecker {
@ -396,7 +399,7 @@ object BypassChecker {
)
}
private fun reportProxyResults(
private suspend fun reportProxyResults(
context: Context,
directIp: String?,
proxyChecks: List<LocalProxyCheckResult>,
@ -416,7 +419,7 @@ object BypassChecker {
}
}
private fun reportProxyCheck(
private suspend fun reportProxyCheck(
context: Context,
proxyCheck: LocalProxyCheckResult,
findings: MutableList<Finding>,
@ -426,6 +429,7 @@ object BypassChecker {
val proxyEndpoint = proxyCheck.endpoint
val candidateFamilies = VpnAppCatalog.familiesForPort(proxyEndpoint.port)
val familySuffix = candidateFamilies.takeIf { it.isNotEmpty() }?.joinToString()
val ownerMetadataSuffix = formatProxyOwnerMetadataSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)
val description = buildString {
append(
context.getString(
@ -440,6 +444,7 @@ object BypassChecker {
append("]")
}
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
if (proxyCheck.status != LocalProxyCheckStatus.CONFIRMED_BYPASS) {
append(context.getString(R.string.checker_bypass_open_proxy_review_suffix))
}
@ -463,6 +468,7 @@ object BypassChecker {
description = buildString {
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
@ -915,6 +921,19 @@ object BypassChecker {
return context.getString(R.string.checker_proxy_owner_suffix, ownerText)
}
private suspend fun formatProxyOwnerMetadataSuffix(
context: Context,
owner: LocalProxyOwner?,
status: LocalProxyOwnerStatus,
): String {
if (status != LocalProxyOwnerStatus.RESOLVED) return ""
val packageName = LocalProxyOwnerFormatter.packageName(owner) ?: return ""
val metadata = withContext(Dispatchers.IO) {
VpnAppMetadataScanner.scan(context, packageName)
}
return VpnAppMetadataScanner.formatMetadataSuffix(metadata)
}
private fun normalizeHost(host: String): String = host.substringBefore('%').lowercase()
private fun isAnyAddress(host: String): Boolean = host == "0.0.0.0" || host == "::" || host == ":::"

View file

@ -15,11 +15,12 @@ import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer
import com.notcvnt.rknhardering.probe.LocalSocketInspector
import com.notcvnt.rknhardering.probe.LocalSocketListener
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnClientSignal
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
import com.notcvnt.rknhardering.probe.LocalSocketInspector
import com.notcvnt.rknhardering.probe.LocalSocketListener
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
import com.notcvnt.rknhardering.vpn.VpnClientSignal
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
@ -1129,20 +1130,28 @@ object IndirectSignsChecker {
return SignalOutcome()
}
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
else -> EvidenceConfidence.LOW
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine))
append(familySuffix)
}
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = record.packageName,
serviceNames = listOfNotNull(record.serviceName),
)
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
else -> EvidenceConfidence.LOW
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine))
appLabel?.let { append(" ($it)") }
append(familySuffix)
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
}
findings.add(
Finding(
description = description,
@ -1158,13 +1167,13 @@ object IndirectSignsChecker {
source = EvidenceSource.ACTIVE_VPN,
detected = true,
confidence = confidence,
description = record.rawLine,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
description = record.rawLine,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
ActiveVpnApp(
packageName = record.packageName,
serviceName = record.serviceName,
@ -1172,8 +1181,9 @@ object IndirectSignsChecker {
kind = signature?.kind,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
),
)
technicalMetadata = metadata,
),
)
detected = true
needsReview = needsReview || signature == null
}
@ -1207,13 +1217,19 @@ object IndirectSignsChecker {
return SignalOutcome()
}
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = record.packageName,
serviceNames = listOfNotNull(record.serviceName),
)
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
else -> EvidenceConfidence.LOW
}
val serviceDisplay = if (record.packageName != null && record.serviceName != null) {
@ -1221,11 +1237,13 @@ object IndirectSignsChecker {
} else {
record.rawLine
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay))
append(familySuffix)
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay))
appLabel?.let { append(" ($it)") }
append(familySuffix)
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
}
findings.add(
Finding(
description = description,
@ -1241,22 +1259,23 @@ object IndirectSignsChecker {
source = EvidenceSource.ACTIVE_VPN,
detected = true,
confidence = confidence,
description = serviceDisplay,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
description = serviceDisplay,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
ActiveVpnApp(
packageName = record.packageName,
serviceName = record.serviceName,
family = signature?.family,
kind = signature?.kind,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
),
)
kind = signature?.kind,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
technicalMetadata = metadata,
),
)
detected = true
needsReview = needsReview || signature == null
}

View file

@ -268,7 +268,7 @@ object VpnCheckRunner {
}
} else null
val directDeferred = safeAsync(fallback = { Fallbacks.direct(context, it) }) {
val directDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.direct(context, it) }) {
dependencies.directCheck(
context,
tunActiveProbeDeferred?.await(),

View file

@ -129,6 +129,17 @@ enum class VpnAppKind {
GENERIC_VPN,
}
data class VpnAppTechnicalMetadata(
val versionName: String? = null,
val serviceNames: List<String> = emptyList(),
val appType: String? = null,
val coreType: String? = null,
val corePath: String? = null,
val goVersion: String? = null,
val systemApp: Boolean = false,
val matchedByNameHeuristic: Boolean = false,
)
data class Finding(
val description: String,
val detected: Boolean = false,
@ -159,6 +170,7 @@ data class MatchedVpnApp(
val source: EvidenceSource,
val active: Boolean,
val confidence: EvidenceConfidence,
val technicalMetadata: VpnAppTechnicalMetadata? = null,
)
data class ActiveVpnApp(
@ -168,6 +180,7 @@ data class ActiveVpnApp(
val kind: VpnAppKind?,
val source: EvidenceSource,
val confidence: EvidenceConfidence,
val technicalMetadata: VpnAppTechnicalMetadata? = null,
)
data class LocalProxyOwner(

View file

@ -1,5 +1,6 @@
package com.notcvnt.rknhardering.vpn
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@ -22,7 +23,6 @@ data class InstalledVpnDetectionResult(
)
object InstalledVpnAppDetector {
private const val TAG = "VpnAppDetector"
fun detect(context: Context): InstalledVpnDetectionResult {
val pm = context.packageManager
@ -60,6 +60,7 @@ object InstalledVpnAppDetector {
for (signature in VpnAppCatalog.signatures) {
if (!isPackageInstalled(pm, signature.packageName)) continue
val metadata = VpnAppMetadataScanner.scan(context, signature.packageName)
val confidence = when (signature.kind) {
VpnAppKind.TARGETED_BYPASS -> EvidenceConfidence.MEDIUM
VpnAppKind.GENERIC_VPN -> EvidenceConfidence.LOW
@ -68,7 +69,7 @@ object InstalledVpnAppDetector {
R.string.checker_vpn_installed_app,
signature.appName,
signature.packageName,
)
) + VpnAppMetadataScanner.formatMetadataSuffix(metadata)
findings.add(
Finding(
@ -101,6 +102,7 @@ object InstalledVpnAppDetector {
source = EvidenceSource.INSTALLED_APP,
active = false,
confidence = confidence,
technicalMetadata = metadata,
),
)
}
@ -115,6 +117,11 @@ object InstalledVpnAppDetector {
) {
for ((packageName, serviceNames) in queryVpnServiceProviders(pm)) {
val signature = VpnAppCatalog.findByPackageName(packageName)
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = packageName,
serviceNames = serviceNames,
)
val appName = signature?.appName ?: resolveAppName(pm, packageName)
val family = signature?.family
val kind = signature?.kind ?: VpnAppKind.GENERIC_VPN
@ -135,6 +142,7 @@ object InstalledVpnAppDetector {
append(" -> ")
append(serviceSuffix)
}
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
}
findings.add(
@ -167,6 +175,7 @@ object InstalledVpnAppDetector {
source = EvidenceSource.VPN_SERVICE_DECLARATION,
active = false,
confidence = confidence,
technicalMetadata = metadata,
)
}
}
@ -197,12 +206,17 @@ object InstalledVpnAppDetector {
val normalizedAppName = appName.uppercase(Locale.ROOT)
if (!normalizedAppName.contains("VPN")) continue
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = packageName,
matchedByNameHeuristic = true,
)
val confidence = EvidenceConfidence.LOW
val description = context.getString(
R.string.checker_vpn_installed_app_by_name,
appName,
packageName,
)
) + VpnAppMetadataScanner.formatMetadataSuffix(metadata)
findings.add(
Finding(
@ -233,6 +247,7 @@ object InstalledVpnAppDetector {
source = EvidenceSource.INSTALLED_APP,
active = false,
confidence = confidence,
technicalMetadata = metadata,
),
)
}
@ -281,9 +296,13 @@ object InstalledVpnAppDetector {
}
return resolveInfos
.asSequence()
.mapNotNull { resolveInfo ->
val serviceInfo = resolveInfo.serviceInfo ?: return@mapNotNull null
serviceInfo.packageName to serviceInfo.name
if (serviceInfo.permission != Manifest.permission.BIND_VPN_SERVICE) return@mapNotNull null
val packageName = serviceInfo.packageName?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val serviceName = serviceInfo.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
packageName to serviceName
}
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
}

View file

@ -0,0 +1,77 @@
package com.notcvnt.rknhardering.vpn
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
data class VpnApkInspectionResult(
val appType: String? = null,
val coreType: String? = null,
val corePath: String? = null,
val goVersion: String? = null,
) {
val isEmpty: Boolean
get() = appType == null && coreType == null && corePath == null && goVersion == null
}
object VpnApkInspector {
private const val MAX_DEX_BYTES = 15L * 1024L * 1024L
private const val MAX_NATIVE_LIB_BYTES = 64L * 1024L * 1024L
fun inspect(apkPaths: List<String>): VpnApkInspectionResult {
var appType: String? = null
var core: VpnCoreInspectionResult? = null
for (path in apkPaths.filter { it.isNotBlank() }.distinct()) {
val file = File(path)
if (!file.isFile) continue
runCatching {
ZipFile(file).use { zip ->
val entries = zip.entries().asSequence().filterNot(ZipEntry::isDirectory).toList()
if (appType == null) {
appType = inspectAppType(zip, entries)
}
if (core == null) {
core = inspectCoreType(zip, entries)
}
}
}
if (appType != null && core != null) break
}
return VpnApkInspectionResult(
appType = appType,
coreType = core?.coreType,
corePath = core?.corePath,
goVersion = core?.goVersion,
)
}
private fun inspectAppType(zip: ZipFile, entries: List<ZipEntry>): String? {
var fallbackType: String? = null
for (entry in entries) {
if (!entry.name.startsWith("classes") || !entry.name.endsWith(".dex")) continue
if (entry.size < 0L || entry.size > MAX_DEX_BYTES) continue
val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue
val appType = VpnAppClassifier.detectAppType(content) ?: continue
if (appType == VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID) {
fallbackType = appType
} else {
return appType
}
}
return fallbackType
}
private fun inspectCoreType(zip: ZipFile, entries: List<ZipEntry>): VpnCoreInspectionResult? {
for (entry in entries) {
if (!entry.name.startsWith("lib/") || !entry.name.endsWith(".so")) continue
if (entry.size < 0L || entry.size > MAX_NATIVE_LIB_BYTES) continue
val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue
VpnAppClassifier.detectCore(content, entry.name)?.let { return it }
}
return null
}
}

View file

@ -0,0 +1,94 @@
package com.notcvnt.rknhardering.vpn
import java.nio.charset.StandardCharsets
data class VpnCoreInspectionResult(
val coreType: String,
val corePath: String? = null,
val goVersion: String? = null,
)
object VpnAppClassifier {
const val APP_TYPE_V2RAYNG = "V2RayNG"
const val APP_TYPE_CLASH_FOR_ANDROID = "ClashForAndroid"
const val APP_TYPE_SING_BOX = "sing-box"
const val APP_TYPE_SAGERNET = "SagerNet"
const val APP_TYPE_SHADOWSOCKS_ANDROID = "shadowsocks-android"
const val CORE_TYPE_SING_BOX = "sing-box"
const val CORE_TYPE_XRAY_V2RAY = "Xray/V2Ray"
const val CORE_TYPE_CLASH_MIHOMO = "Clash/Mihomo"
const val CORE_TYPE_WIREGUARD = "WireGuard"
const val CORE_TYPE_OPENVPN = "OpenVPN"
const val CORE_TYPE_SHADOWSOCKS = "Shadowsocks"
private val appTypeMarkers = listOf(
APP_TYPE_V2RAYNG to listOf(
"com.v2ray.ang",
".dto.V2rayConfig",
".service.V2RayVpnService",
),
APP_TYPE_CLASH_FOR_ANDROID to listOf(
"com.github.kr328.clash",
".core.Clash",
".service.TunService",
),
APP_TYPE_SING_BOX to listOf("io.nekohasekai.sfa"),
APP_TYPE_SAGERNET to listOf(
"io.nekohasekai.sagernet",
".fmt.ConfigBuilder",
),
APP_TYPE_SHADOWSOCKS_ANDROID to listOf(
"com.github.shadowsocks",
".bg.VpnService",
"GuardedProcessPool",
),
)
private val coreTypeMarkers = listOf(
CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"),
CORE_TYPE_XRAY_V2RAY to listOf("xray", "v2ray", "v2fly"),
CORE_TYPE_CLASH_MIHOMO to listOf("mihomo", "clash"),
CORE_TYPE_WIREGUARD to listOf("wireguard"),
CORE_TYPE_OPENVPN to listOf("openvpn", "ovpn"),
CORE_TYPE_SHADOWSOCKS to listOf("shadowsocks"),
)
fun detectAppType(content: ByteArray): String? {
return detectFirst(content, appTypeMarkers, ignoreCase = false)
}
fun detectCore(content: ByteArray, entryName: String? = null): VpnCoreInspectionResult? {
val searchContent = if (entryName.isNullOrBlank()) {
content
} else {
entryName.encodeToByteArray() + byteArrayOf(0) + content
}
val coreType = detectFirst(searchContent, coreTypeMarkers, ignoreCase = true) ?: return null
return VpnCoreInspectionResult(
coreType = coreType,
corePath = entryName,
goVersion = findGoVersion(content),
)
}
fun findGoVersion(content: ByteArray): String? {
val text = content.toString(StandardCharsets.ISO_8859_1)
return Regex("""go1(?:\.\d+){1,2}""").find(text)?.value
}
private fun detectFirst(
content: ByteArray,
typedMarkers: List<Pair<String, List<String>>>,
ignoreCase: Boolean,
): String? {
val text = content.toString(StandardCharsets.ISO_8859_1)
val normalizedDexText = text.replace('/', '.').replace('$', '.')
return typedMarkers.firstOrNull { (_, markers) ->
markers.any { marker ->
text.contains(marker, ignoreCase = ignoreCase) ||
normalizedDexText.contains(marker, ignoreCase = ignoreCase)
}
}?.first
}
}

View file

@ -0,0 +1,119 @@
package com.notcvnt.rknhardering.vpn
import android.Manifest
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
object VpnAppMetadataScanner {
fun scan(
context: Context,
packageName: String?,
serviceNames: List<String> = emptyList(),
matchedByNameHeuristic: Boolean = false,
): VpnAppTechnicalMetadata? {
if (packageName.isNullOrBlank()) {
return serviceNames
.normalizeServiceNames()
.takeIf { it.isNotEmpty() }
?.let { VpnAppTechnicalMetadata(serviceNames = it, matchedByNameHeuristic = matchedByNameHeuristic) }
}
val pm = context.packageManager
val packageInfo = getPackageInfo(pm, packageName)
val appInfo = packageInfo?.applicationInfo
val apkInspection = appInfo
?.let(::apkPaths)
?.let(VpnApkInspector::inspect)
?.takeUnless { it.isEmpty }
if (packageInfo == null && apkInspection == null && serviceNames.isEmpty() && !matchedByNameHeuristic) {
return null
}
return VpnAppTechnicalMetadata(
versionName = packageInfo?.versionName?.takeIf { it.isNotBlank() },
serviceNames = (serviceNames + packageInfo.vpnServiceNames()).normalizeServiceNames(),
appType = apkInspection?.appType,
coreType = apkInspection?.coreType,
corePath = apkInspection?.corePath,
goVersion = apkInspection?.goVersion,
systemApp = appInfo?.isSystemApp() == true,
matchedByNameHeuristic = matchedByNameHeuristic,
)
}
fun resolveAppLabel(context: Context, packageName: String?): String? {
if (packageName.isNullOrBlank()) return null
val pm = context.packageManager
return runCatching {
val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0L))
} else {
@Suppress("DEPRECATION")
pm.getApplicationInfo(packageName, 0)
}
pm.getApplicationLabel(appInfo)?.toString()?.trim()?.takeIf { it.isNotBlank() }
}.getOrNull()
}
fun formatMetadataSuffix(metadata: VpnAppTechnicalMetadata?): String {
val details = metadataDetails(metadata)
return if (details.isEmpty()) "" else " [${details.joinToString(", ")}]"
}
fun metadataDetails(metadata: VpnAppTechnicalMetadata?): List<String> {
if (metadata == null) return emptyList()
return buildList {
metadata.versionName?.let { add("version=$it") }
metadata.appType?.let { add("app=$it") }
metadata.coreType?.let { add("core=$it") }
metadata.corePath?.let { add("path=$it") }
metadata.goVersion?.let { add("go=$it") }
if (metadata.serviceNames.isNotEmpty()) {
add("services=${metadata.serviceNames.take(3).joinToString()}")
}
if (metadata.matchedByNameHeuristic) add("nameHeuristic=true")
if (metadata.systemApp) add("systemApp=true")
}
}
private fun getPackageInfo(pm: PackageManager, packageName: String): PackageInfo? {
return runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong()))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(packageName, PackageManager.GET_SERVICES)
}
}.getOrNull()
}
private fun PackageInfo?.vpnServiceNames(): List<String> {
return this
?.services
?.filter { it.permission == Manifest.permission.BIND_VPN_SERVICE }
?.mapNotNull { it.name?.trim()?.takeIf(String::isNotEmpty) }
.orEmpty()
}
private fun apkPaths(appInfo: ApplicationInfo): List<String> {
return buildList {
appInfo.publicSourceDir?.takeIf { it.isNotBlank() }?.let(::add)
appInfo.splitPublicSourceDirs?.filter { it.isNotBlank() }?.let(::addAll)
}
}
private fun ApplicationInfo.isSystemApp(): Boolean {
val system = flags and ApplicationInfo.FLAG_SYSTEM != 0
val updatedSystem = flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
return system || updatedSystem
}
private fun List<String>.normalizeServiceNames(): List<String> =
map(String::trim).filter(String::isNotEmpty).distinct()
}

View file

@ -32,6 +32,7 @@ import com.notcvnt.rknhardering.model.ObservedIp
import com.notcvnt.rknhardering.model.TargetGroup
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VpnAppKind
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.XrayApiEndpoint
@ -192,6 +193,14 @@ internal fun exportRichCheckResult(): CheckResult {
source = EvidenceSource.INSTALLED_APP,
active = true,
confidence = EvidenceConfidence.HIGH,
technicalMetadata = VpnAppTechnicalMetadata(
versionName = "1.2.3",
serviceNames = listOf("ExampleService"),
appType = "V2RayNG",
coreType = "Xray/V2Ray",
corePath = "lib/arm64-v8a/libxray.so",
goVersion = "go1.24.1",
),
),
),
activeApps = listOf(
@ -202,6 +211,14 @@ internal fun exportRichCheckResult(): CheckResult {
kind = VpnAppKind.TARGETED_BYPASS,
source = EvidenceSource.ACTIVE_VPN,
confidence = EvidenceConfidence.HIGH,
technicalMetadata = VpnAppTechnicalMetadata(
versionName = "1.2.3",
serviceNames = listOf("ExampleService"),
appType = "V2RayNG",
coreType = "Xray/V2Ray",
corePath = "lib/arm64-v8a/libxray.so",
goVersion = "go1.24.1",
),
),
),
),

View file

@ -151,4 +151,32 @@ class CheckResultJsonExportFormatterTest {
assertTrue(icmp.getBoolean("needsReview"))
assertEquals("ICMP spoofing", icmp.getString("name"))
}
@Test
fun `json export includes vpn app technical metadata`() {
val json = JSONObject(
CheckResultJsonExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = exportRichCheckResult(),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
),
)
val metadata = json
.getJSONObject("results")
.getJSONObject("directSigns")
.getJSONArray("matchedApps")
.getJSONObject(0)
.getJSONObject("technicalMetadata")
assertEquals("V2RayNG", metadata.getString("appType"))
assertEquals("Xray/V2Ray", metadata.getString("coreType"))
assertEquals("1.2.3", metadata.getString("versionName"))
assertEquals("ExampleService", metadata.getJSONArray("serviceNames").getString(0))
}
}

View file

@ -108,4 +108,22 @@ class CheckResultMarkdownExportFormatterTest {
assertFalse(markdown.contains("## IP каналы"))
}
@Test
fun `markdown export includes vpn app technical metadata`() {
val markdown = CheckResultMarkdownExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = exportRichCheckResult(),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
)
assertTrue(markdown.contains("appType=V2RayNG"))
assertTrue(markdown.contains("coreType=Xray/V2Ray"))
assertTrue(markdown.contains("goVersion=go1.24.1"))
}
}

View file

@ -46,6 +46,32 @@ class VerdictEngineTest {
assertEquals(Verdict.NOT_DETECTED, verdict)
}
@Test
fun `metadata-only installed app evidence does not affect verdict`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(),
directSigns = CategoryResult(
name = "direct",
detected = false,
findings = emptyList(),
evidence = listOf(
EvidenceItem(
source = EvidenceSource.INSTALLED_APP,
detected = false,
confidence = EvidenceConfidence.LOW,
description = "Installed app with VPN in name",
),
),
),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
)
assertEquals(Verdict.NOT_DETECTED, verdict)
}
@Test
fun `R3a probeTargetDivergence with geoCountryMismatch yields detected`() {
val verdict = VerdictEngine.evaluate(

View file

@ -22,10 +22,19 @@ class InstalledVpnAppDetectorByNameTest {
private val pm get() = context.packageManager
private val shadow get() = shadowOf(pm)
private fun installApp(packageName: String, label: String, systemApp: Boolean = false) {
private fun installApp(
packageName: String,
label: String,
systemApp: Boolean = false,
updatedSystemApp: Boolean = false,
) {
val appInfo = ApplicationInfo().apply {
this.packageName = packageName
flags = if (systemApp) ApplicationInfo.FLAG_SYSTEM else 0
flags = when {
systemApp -> ApplicationInfo.FLAG_SYSTEM
updatedSystemApp -> ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
else -> 0
}
nonLocalizedLabel = label
}
val pkgInfo = PackageInfo().apply {
@ -91,6 +100,16 @@ class InstalledVpnAppDetectorByNameTest {
assertFalse(result.evidence.any { it.packageName == "com.android.vpndialogs" })
}
@Test
fun `updated system app with VPN in label is not detected`() {
installApp("com.android.updatedvpn", "Updated VPN", updatedSystemApp = true)
val result = InstalledVpnAppDetector.detect(context)
assertFalse(result.matchedApps.any { it.packageName == "com.android.updatedvpn" })
assertFalse(result.evidence.any { it.packageName == "com.android.updatedvpn" })
}
@Test
fun `app already matched by catalog is not duplicated by name heuristic`() {
val catalogPkg = VpnAppCatalog.signatures.first { it.kind == VpnAppKind.TARGETED_BYPASS }

View file

@ -0,0 +1,88 @@
package com.notcvnt.rknhardering.vpn
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class VpnApkInspectorTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `detects app type from dex marker`() {
val apk = createApk(
"classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
)
val result = VpnApkInspector.inspect(listOf(apk))
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType)
}
@Test
fun `detects core type and go version from native library`() {
val apk = createApk(
"lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.2".encodeToByteArray(),
)
val result = VpnApkInspector.inspect(listOf(apk))
assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, result.coreType)
assertEquals("lib/arm64-v8a/libbox.so", result.corePath)
assertEquals("go1.24.2", result.goVersion)
}
@Test
fun `corrupt apk falls back without crashing`() {
val file = temp.newFile("corrupt.apk").apply {
writeText("not a zip")
}
val result = VpnApkInspector.inspect(listOf(file.absolutePath))
assertNull(result.appType)
assertNull(result.coreType)
}
@Test
fun `oversized dex is skipped`() {
val oversizedDex = ByteArray(15 * 1024 * 1024 + 1) { 0 }
val apk = createApk(
"classes.dex" to oversizedDex,
"classes2.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(),
)
val result = VpnApkInspector.inspect(listOf(apk))
assertEquals(VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID, result.appType)
}
@Test
fun `shadowsocks marker is fallback when stronger app type appears later`() {
val apk = createApk(
"classes.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(),
"classes2.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
)
val result = VpnApkInspector.inspect(listOf(apk))
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType)
}
private fun createApk(vararg entries: Pair<String, ByteArray>): String {
val file = temp.newFile("test-${System.nanoTime()}.apk")
ZipOutputStream(file.outputStream()).use { zip ->
for ((name, content) in entries) {
zip.putNextEntry(ZipEntry(name))
zip.write(content)
zip.closeEntry()
}
}
return file.absolutePath
}
}

View file

@ -0,0 +1,117 @@
package com.notcvnt.rknhardering.vpn
import android.Manifest
import android.app.Application
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.ServiceInfo
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.rules.TemporaryFolder
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class VpnAppMetadataScannerTest {
@get:Rule
val temp = TemporaryFolder()
private val context = ApplicationProvider.getApplicationContext<Application>()
private val pm get() = context.packageManager
private val shadow get() = shadowOf(pm)
@Test
fun `scanner enriches version service app type and core type`() {
val apk = createApk(
"classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
"lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.1".encodeToByteArray(),
)
installVpnPackage(
packageName = "com.example.husi.clone",
label = "Husi Clone",
versionName = "1.2.3",
apkPath = apk,
serviceName = "com.example.husi.clone.VpnService",
)
val metadata = VpnAppMetadataScanner.scan(context, "com.example.husi.clone")
assertEquals("1.2.3", metadata?.versionName)
assertEquals(listOf("com.example.husi.clone.VpnService"), metadata?.serviceNames)
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, metadata?.appType)
assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, metadata?.coreType)
assertEquals("go1.24.1", metadata?.goVersion)
assertFalse(metadata?.systemApp ?: true)
}
@Test
fun `declared unknown VpnService app is matched with metadata`() {
val apk = createApk(
"classes.dex" to "Lio/nekohasekai/sfa/Main;".encodeToByteArray(),
)
installVpnPackage(
packageName = "com.example.unknownvpn",
label = "Unknown VPN",
versionName = "9.8.7",
apkPath = apk,
serviceName = "com.example.unknownvpn.TunnelService",
)
val result = InstalledVpnAppDetector.detect(context)
val matched = result.matchedApps.firstOrNull { it.packageName == "com.example.unknownvpn" }
assertTrue(matched != null)
assertEquals(VpnAppClassifier.APP_TYPE_SING_BOX, matched?.technicalMetadata?.appType)
assertEquals("9.8.7", matched?.technicalMetadata?.versionName)
assertEquals(listOf("com.example.unknownvpn.TunnelService"), matched?.technicalMetadata?.serviceNames)
}
private fun installVpnPackage(
packageName: String,
label: String,
versionName: String,
apkPath: String,
serviceName: String,
): ServiceInfo {
val appInfo = ApplicationInfo().apply {
this.packageName = packageName
nonLocalizedLabel = label
sourceDir = apkPath
publicSourceDir = apkPath
}
val service = ServiceInfo().apply {
this.packageName = packageName
name = serviceName
permission = Manifest.permission.BIND_VPN_SERVICE
applicationInfo = appInfo
}
val pkgInfo = PackageInfo().apply {
this.packageName = packageName
applicationInfo = appInfo
this.versionName = versionName
services = arrayOf(service)
}
shadow.installPackage(pkgInfo)
return service
}
private fun createApk(vararg entries: Pair<String, ByteArray>): String {
val file = temp.newFile("metadata-${System.nanoTime()}.apk")
ZipOutputStream(file.outputStream()).use { zip ->
for ((name, content) in entries) {
zip.putNextEntry(ZipEntry(name))
zip.write(content)
zip.closeEntry()
}
}
return file.absolutePath
}
}