From 52b45504272fcfb1d5e0cb219dd12609070a7db8 Mon Sep 17 00:00:00 2001 From: xtclovver <44809071+xtclovver@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:24:56 +0300 Subject: [PATCH 1/3] =?UTF-8?q?chore(quality):=20SonarCloud=20triage=20?= =?UTF-8?q?=E2=80=94=20safe=20fixes=20+=20UI=20tile=20bugfix=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): wire IP-consensus result into its category tile The IpConsensusReady handler only marked the stage completed and never called updateTileFromIpConsensus(), unlike every sibling updateTileFrom* branch. As a result the IP-consensus tile (CATEGORY_IPS) never reflected detected/review/clean signals. Call the already-written updater with the event payload. * chore(quality): low-risk SonarCloud code-smell cleanups Behavior-preserving fixes for SonarCloud findings, verified by build + unit tests. No detector signal, threshold, port, or verdict logic changed. Kotlin: - extract duplicated string literals to consts (S1192): "*.*.*.*", "SHA-512", "meduza.io", "", sing-box core type - if-throw -> require()/check() for validation (S6532): Ed25519 decode, GeoIp HTTP status - empty catch blocks documented (S108); collapsible if merged (S1066) - isExpanded() -> val property (S6512) with call-site update - drop unused lambda parameter (S1172) C++ (native_signs_probe.cpp, native_curl_probe.cpp): - read-only netlink/diag pointers made pointer-to-const (S5350) - split multi-variable declarations (S1659) - sizeof/sizeof -> std::size for JNI method table (S7127) - ipv6 prefix loop converted to range-for (S5566) - progress-callback state pointer made const (S5350) Riskier findings (cognitive complexity, too-many-params, deep nesting, flag-guarded null ops that the Kotlin compiler cannot smart-cast) were left in place. Genuine false positives (C++20/23 suggestions under a C++17 toolchain, JNI function-pointer decay, intentional reinterpret_cast in syscall code, sentinel IPs) were marked False Positive / Accepted in SonarCloud rather than changed. --- app/src/main/cpp/native_curl_probe.cpp | 2 +- app/src/main/cpp/native_signs_probe.cpp | 20 +++++++++++-------- .../com/notcvnt/rknhardering/MainActivity.kt | 1 + .../SettingsCustomCheckEditorFragment.kt | 4 ++-- .../rknhardering/checker/CdnPullingChecker.kt | 7 ++++--- .../rknhardering/checker/GeoIpChecker.kt | 4 +--- .../notcvnt/rknhardering/crypto/Ed25519.kt | 16 +++++++-------- .../mapper/EndpointResponseMapper.kt | 13 +++++++----- .../ui/CheckerSectionController.kt | 2 +- .../probe/TunProbeDiagnosticsFormatter.kt | 20 ++++++++++--------- .../notcvnt/rknhardering/util/MaskingUtils.kt | 8 +++++--- .../rknhardering/vpn/VpnAppClassifier.kt | 2 +- 12 files changed, 55 insertions(+), 44 deletions(-) diff --git a/app/src/main/cpp/native_curl_probe.cpp b/app/src/main/cpp/native_curl_probe.cpp index ad31e63..7a0f780 100644 --- a/app/src/main/cpp/native_curl_probe.cpp +++ b/app/src/main/cpp/native_curl_probe.cpp @@ -166,7 +166,7 @@ int ProgressCallback( curl_off_t /* dlnow */, curl_off_t /* ultotal */, curl_off_t /* ulnow */) { - auto* state = static_cast(clientp); + const auto* state = static_cast(clientp); return state != nullptr && state->cancelled.load() ? 1 : 0; } diff --git a/app/src/main/cpp/native_signs_probe.cpp b/app/src/main/cpp/native_signs_probe.cpp index d811fc5..0295993 100644 --- a/app/src/main/cpp/native_signs_probe.cpp +++ b/app/src/main/cpp/native_signs_probe.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -254,7 +255,8 @@ jobjectArray nativeReadSelfMapsSummary(JNIEnv *env, jclass /*clazz*/) { } if (rwx) { - unsigned long long start = 0, end = 0; + unsigned long long start = 0; + unsigned long long end = 0; if (std::sscanf(line, "%llx-%llx", &start, &end) == 2 && end > start) { unsigned long long size = end - start; if (size >= (256ULL * 1024ULL)) { @@ -347,8 +349,7 @@ int ipv6PrefixLen(const sockaddr *sa) { if (sa == nullptr || sa->sa_family != AF_INET6) return -1; const auto *in6 = reinterpret_cast(sa); int bits = 0; - for (int i = 0; i < 16; ++i) { - unsigned char b = in6->sin6_addr.s6_addr[i]; + for (unsigned char b : in6->sin6_addr.s6_addr) { if (b == 0xff) { bits += 8; continue; } for (int k = 7; k >= 0; --k) { if (b & (1u << k)) ++bits; else return bits; @@ -539,7 +540,7 @@ std::vector netlinkRouteDump(int family) { nh = NLMSG_NEXT(nh, len)) { if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } if (nh->nlmsg_type == NLMSG_ERROR) { - auto *err = reinterpret_cast(NLMSG_DATA(nh)); + const auto *err = reinterpret_cast(NLMSG_DATA(nh)); out.push_back(std::string("error|nlmsg|errno=") + std::to_string(-err->error)); done = true; break; @@ -550,7 +551,10 @@ std::vector netlinkRouteDump(int family) { int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*rtm)); auto *attr = RTM_RTA(rtm); - std::string dst, gw, src, prefSrc; + std::string dst; + std::string gw; + std::string src; + std::string prefSrc; int oif = 0; unsigned int priority = 0; char iface[IF_NAMESIZE] = {0}; @@ -703,14 +707,14 @@ std::vector netlinkInetDiag(int family, int protocol) { nh = NLMSG_NEXT(nh, len)) { if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } if (nh->nlmsg_type == NLMSG_ERROR) { - auto *err = reinterpret_cast(NLMSG_DATA(nh)); + const auto *err = reinterpret_cast(NLMSG_DATA(nh)); out.push_back(std::string("error|nlmsg|errno=") + std::to_string(-err->error)); done = true; break; } if (nh->nlmsg_type != SOCK_DIAG_BY_FAMILY) continue; - auto *diag = reinterpret_cast(NLMSG_DATA(nh)); + const auto *diag = reinterpret_cast(NLMSG_DATA(nh)); char src[INET6_ADDRSTRLEN] = {0}; char dst[INET6_ADDRSTRLEN] = {0}; int af = diag->idiag_family; @@ -1041,7 +1045,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { } jclass cls = env->FindClass("com/notcvnt/rknhardering/probe/NativeSignsBridge"); if (cls == nullptr) return JNI_ERR; - jint rc = env->RegisterNatives(cls, kMethods, sizeof(kMethods) / sizeof(kMethods[0])); + jint rc = env->RegisterNatives(cls, kMethods, static_cast(std::size(kMethods))); env->DeleteLocalRef(cls); if (rc != JNI_OK) return JNI_ERR; return JNI_VERSION_1_6; diff --git a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt index 8f0555a..d25b6af 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt @@ -1209,6 +1209,7 @@ class MainActivity : AppCompatActivity() { } is CheckUpdate.IpConsensusReady -> { markStageCompleted(RunningStage.IP_CONSENSUS) + updateTileFromIpConsensus(update.result) } is CheckUpdate.DomainReachabilityReady -> { markStageCompleted(RunningStage.DOMAIN_REACHABILITY) diff --git a/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt b/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt index 1db924b..bec455e 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt @@ -162,7 +162,7 @@ internal class SettingsCustomCheckEditorFragment : // Override expansion listener to track openSectionIds sectionView.findViewById(R.id.sectionHeader).setOnClickListener { - val nowOpen = !controller.isExpanded() + val nowOpen = !controller.isExpanded controller.setExpanded(nowOpen, animate = true) if (nowOpen) openSectionIds.add(id) else openSectionIds.remove(id) } @@ -225,7 +225,7 @@ internal class SettingsCustomCheckEditorFragment : labelHintRes = R.string.settings_custom_check_description_field, extraInputHintRes = R.string.settings_custom_check_check_type_field, extraSwitchTextRes = null, - testAction = { domain, checkType, _ -> + testAction = { domain, _, _ -> kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val result = runCatching { val client = okhttp3.OkHttpClient.Builder() diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt index ff8e81b..250eab3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt @@ -31,9 +31,10 @@ object CdnPullingChecker { private const val MAX_FETCH_ATTEMPTS = 1 private const val RETRY_DELAY_MS = 250L + private const val MEDUZA_IO = "meduza.io" private val ACTIONABLE_TARGETS = setOf( "redirector.googlevideo.com", - "meduza.io", + MEDUZA_IO, ) internal data class EndpointSpec( @@ -71,7 +72,7 @@ object CdnPullingChecker { kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE, ), EndpointSpec( - label = "meduza.io", + label = MEDUZA_IO, url = "https://meduza.io/cdn-cgi/trace", kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE, ), @@ -92,7 +93,7 @@ object CdnPullingChecker { val activeEndpoints = buildList { if (config.builtinTargetsEnabled) { for (endpoint in ENDPOINTS) { - if (endpoint.label == "meduza.io" && !config.meduzaEnabled) continue + if (endpoint.label == MEDUZA_IO && !config.meduzaEnabled) continue if (endpoint.label == "rutracker.org" && !config.rutrackerEnabled) continue add(endpoint) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt index 3c5a4ad..5214db0 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt @@ -580,9 +580,7 @@ object GeoIpChecker { config = resolverConfig, cancellationSignal = executionContext.cancellationSignal, ) - if (response.code !in 200..299) { - throw IllegalStateException("HTTP ${response.code}") - } + check(response.code in 200..299) { "HTTP ${response.code}" } return JSONObject(response.body) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt b/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt index 2d5a60a..1d67882 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt @@ -8,6 +8,8 @@ import java.security.MessageDigest // for the offline signing CLI under :app:src/test (host-only path). object Ed25519 { + private const val SHA_512 = "SHA-512" + private val P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19)) private val L = BigInteger("7237005577332262213973186563042994240857116359379907606001950938285454250989") private val D = BigInteger("-121665").mod(P) @@ -26,7 +28,7 @@ object Ed25519 { val s = decodeScalarLE(signature.copyOfRange(32, 64)) if (s >= L) return false val rPoint = runCatching { decodePoint(r) }.getOrNull() ?: return false - val md = MessageDigest.getInstance("SHA-512") + val md = MessageDigest.getInstance(SHA_512) md.update(r) md.update(publicKey) md.update(message) @@ -38,7 +40,7 @@ object Ed25519 { fun sign(privateKey: ByteArray, publicKey: ByteArray, message: ByteArray): ByteArray { require(privateKey.size == 32 && publicKey.size == 32) - val md = MessageDigest.getInstance("SHA-512") + val md = MessageDigest.getInstance(SHA_512) val h = md.digest(privateKey) val a = clampScalar(h.copyOfRange(0, 32)) val prefix = h.copyOfRange(32, 64) @@ -55,7 +57,7 @@ object Ed25519 { fun derivePublicKey(privateKey: ByteArray): ByteArray { require(privateKey.size == 32) - val h = MessageDigest.getInstance("SHA-512").digest(privateKey) + val h = MessageDigest.getInstance(SHA_512).digest(privateKey) val a = clampScalar(h.copyOfRange(0, 32)) return encodePoint(scalarMul(B, a)) } @@ -92,9 +94,7 @@ object Ed25519 { if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) { x = x.multiply(I).mod(P) } - if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) { - throw IllegalArgumentException("no sqrt") - } + require(x.multiply(x).subtract(xx).mod(P) == BigInteger.ZERO) { "no sqrt" } if ((x.testBit(0)) != sign) x = P.subtract(x).mod(P) return x } @@ -105,10 +105,10 @@ object Ed25519 { val sign = (raw[31].toInt() and 0x80) != 0 raw[31] = (raw[31].toInt() and 0x7F).toByte() val y = decodeScalarLE(raw) - if (y >= P) throw IllegalArgumentException("y out of range") + require(y < P) { "y out of range" } val x = recoverX(y, sign) val p = Point(x, y) - if (!onCurve(p)) throw IllegalArgumentException("not on curve") + require(onCurve(p)) { "not on curve" } return p } diff --git a/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt b/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt index 9ad7b8b..2f287ce 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt @@ -83,9 +83,9 @@ object EndpointResponseMapper { fun autoDetectResponseType(rawResponse: String): ResponseType { val trimmed = rawResponse.trim() // 1. Try JSON object - try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { } + try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON object, try next format */ } // 2. Try JSON array - try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { } + try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON array, try next format */ } // 3. key=value lines val lines = trimmed.lines().filter { it.isNotBlank() } val kvPattern = Regex("""^\w+=.+$""") @@ -180,9 +180,12 @@ object EndpointResponseMapper { if (countryCodePath == null && key in setOf("country_code", "cc", "countrycode", "country") && COUNTRY_CODE_REGEX.matches(strVal)) { countryCodePath = path } - if (countryNamePath == null && key in setOf("country", "country_name", "countryname")) { - // only if it doesn't look like a 2-letter code itself - if (!COUNTRY_CODE_REGEX.matches(strVal)) countryNamePath = path + // only if it doesn't look like a 2-letter code itself + if (countryNamePath == null && + key in setOf("country", "country_name", "countryname") && + !COUNTRY_CODE_REGEX.matches(strVal) + ) { + countryNamePath = path } if (ispPath == null && key in setOf("isp", "provider")) { ispPath = path diff --git a/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt b/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt index c75df27..757d9c2 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt @@ -80,7 +80,7 @@ internal class CheckerSectionController( updateNoteVisibility() } - fun isExpanded(): Boolean = expanded + val isExpanded: Boolean get() = expanded fun setEnabled(value: Boolean, propagate: Boolean) { enabled = value diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt index fe4e8fe..8c868e3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt @@ -13,6 +13,8 @@ import java.util.Locale object TunProbeDiagnosticsFormatter { + private const val NONE = "" + fun format( diagnostics: TunProbeDiagnostics, settings: CheckSettings, @@ -109,8 +111,8 @@ object TunProbeDiagnosticsFormatter { builder.appendLine("available: true") builder.appendLine("interfaceName: ${path.interfaceName ?: ""}") builder.appendLine("selectedMode: ${path.selectedMode?.name ?: "NONE"}") - builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: ""}") - builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: ""}") + builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: NONE}") + builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: NONE}") builder.appendLine("dnsPathMismatch: ${path.dnsPathMismatch}") appendAttempt(builder, "strict", path.strict) appendAttempt(builder, "curl-compatible", path.curlCompatible) @@ -122,8 +124,8 @@ object TunProbeDiagnosticsFormatter { attempt: TunProbeAttemptDiagnostics, ) { builder.appendLine("$label.status: ${attempt.status}") - builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: ""}") - builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: ""}") + builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: NONE}") + builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: NONE}") appendTransportDiagnostics(builder, label, attempt.transportDiagnostics) builder.appendLine("$label.endpoints:") if (attempt.endpointAttempts.isEmpty()) { @@ -138,9 +140,9 @@ object TunProbeDiagnosticsFormatter { private fun formatEndpointAttempt(attempt: TunEndpointAttempt): String { val result = when (attempt.status) { - PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: ""}" + PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: NONE}" PublicIpProbeStatus.FAILED, - PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: ""}" + PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: NONE}" } return "${attempt.endpoint} [${attempt.familyHint}] -> ${attempt.status} ($result)" } @@ -191,14 +193,14 @@ object TunProbeDiagnosticsFormatter { DnsResolverMode.DIRECT -> { val servers = settings.resolverConfig.effectiveDirectServers() .joinToString(", ") { maskIp(it) } - .ifBlank { "" } + .ifBlank { NONE } "DIRECT preset=${settings.resolverConfig.preset.name} servers=$servers" } DnsResolverMode.DOH -> { val bootstrap = settings.resolverConfig.effectiveDohBootstrapHosts() .joinToString(", ") { maskIp(it) } - .ifBlank { "" } - "DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: ""} bootstrap=$bootstrap" + .ifBlank { NONE } + "DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: NONE} bootstrap=$bootstrap" } } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt b/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt index c4df323..0fdff50 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt @@ -7,19 +7,21 @@ import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress +private const val IP_MASK_PLACEHOLDER = "*.*.*.*" + fun maskIp(ip: String): String { val normalized = ip.trim() val parsed = when { normalized.contains('.') -> { val parts = normalized.split('.') if (parts.size != 4 || parts.any { (it.toIntOrNull() ?: -1) !in 0..255 }) { - return "*.*.*.*" + return IP_MASK_PLACEHOLDER } runCatching { InetAddress.getByName(normalized) }.getOrNull() } normalized.contains(':') -> runCatching { InetAddress.getByName(normalized) }.getOrNull() else -> null - } ?: return "*.*.*.*" + } ?: return IP_MASK_PLACEHOLDER return when (parsed) { is Inet4Address -> { if (parsed.isSiteLocalAddress || parsed.isLoopbackAddress || parsed.isLinkLocalAddress) { @@ -41,7 +43,7 @@ fun maskIp(ip: String): String { } } } - else -> "*.*.*.*" + else -> IP_MASK_PLACEHOLDER } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt index 2c7c312..5f315ab 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt @@ -46,7 +46,7 @@ object VpnAppClassifier { ) private val coreTypeMarkers = listOf( - CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"), + CORE_TYPE_SING_BOX to listOf(CORE_TYPE_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"), From 219518c53f091780c0e9d317dd589a3f509da45e Mon Sep 17 00:00:00 2001 From: xtclovver Date: Fri, 12 Jun 2026 14:43:12 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(checker):=20=D0=BD=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D1=82=D1=8C=20kernel-route=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20local=20=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D1=87=D0=BA=D0=BE=D0=B9=20VPN=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evaluateHostRoutes выдавал ложное срабатывание "Host route ... (VPN server IP leak)" на адресе собственного транспортного интерфейса. netlinkRouteDump делает RTM_GETROUTE с NLM_F_DUMP без указания таблицы, поэтому в дамп попадают записи таблицы local (table 255), которые ядро автоматически создаёт для каждого настроенного адреса интерфейса: local 12.233.114.164 dev ccmni1 table local proto kernel scope host src 12.233.114.164 Такая запись имеет prefixLen=32, type=local, scope=host, dst==prefsrc. Если адрес интерфейса попадает в публично-маршрутизируемый диапазон (например 12.0.0.0/8 у сотовых модемов ccmni MediaTek), все условия фильтра проходили и checker рапортовал утечку. Добавлен isKernelManagedLocalRoute: исключает type local/broadcast/ anycast/multicast, scope host и dst==prefsrc. Настоящая утечка VPN — unicast-маршрут к чужому публичному IP (dst!=prefsrc) в main/policy таблицах — детектируется как прежде. --- .../checker/NativeSignsChecker.kt | 22 +++++++++++++++++++ .../checker/NativeSignsCheckerTest.kt | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt index 428a02f..4382260 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt @@ -492,6 +492,7 @@ object NativeSignsChecker { !route.isDefault && (route.prefixLen == 32 || route.prefixLen == 128) && route.destination != null && + !isKernelManagedLocalRoute(route) && isPublicRoutableAddress(route.destination) && NetworkInterfacePatterns.isStandardInterface(route.interfaceName) } @@ -520,6 +521,27 @@ object NativeSignsChecker { return PartialOutcome(findings, evidence, detected = detected) } + /** + * Excludes kernel-managed entries from the `local` routing table, which the kernel + * auto-creates for every configured interface address (issue #78). These are NOT + * VPN host-route leaks: + * - `type=local` (the interface's own address) / `broadcast` / `anycast` / `multicast` + * - `scope=host` (the route never leaves the box) + * - `dst == prefsrc` (route destination equals the interface's own source address) + * A genuine VPN server host-route leak is a `unicast` route to a *foreign* public IP + * (dst != prefsrc) in the main/policy tables. + */ + internal fun isKernelManagedLocalRoute(route: NativeRouteEntry): Boolean { + when (route.type?.lowercase()) { + "local", "broadcast", "anycast", "multicast" -> return true + } + if (route.scope?.lowercase() == "host") return true + val dst = route.destination + val src = route.prefSrc + if (dst != null && src != null && dst == src) return true + return false + } + internal fun isPublicRoutableAddress(addr: String): Boolean { if (!com.notcvnt.rknhardering.customcheck.UrlSanitizer.isPublicAddress(addr)) return false // UrlSanitizer does not cover IPv6 ULA fc00::/7 — add it here diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt index e8f6b24..68c8224 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt @@ -224,6 +224,28 @@ class NativeSignsCheckerTest { assertFalse(outcome.detected) } + @Test + fun `kernel local route for interface own address does not emit evidence`() { + // Reproduces issue #78: ccmni1 cellular interface with a public-range address + // (12.233.114.164/8) produces a kernel-managed /32 entry in the local table. + // dst == prefsrc, type=local, scope=host — this is the interface's own address, + // not a VPN server host-route leak. + val localOwnAddress = NativeRouteEntry( + interfaceName = "ccmni1", destinationHex = "0CE972A4", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "12.233.114.164", prefSrc = "12.233.114.164", + scope = "host", type = "local", table = 255, prefixLen = 32, + ) + val broadcastEntry = NativeRouteEntry( + interfaceName = "ccmni1", destinationHex = "0CFFFFFF", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "12.255.255.255", scope = "link", + type = "broadcast", table = 255, prefixLen = 32, + ) + val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(localOwnAddress, broadcastEntry)) + assertFalse(outcome.detected) + } + @Test fun `interface with tuntap type and nonstandard name emits NATIVE_INTERFACE evidence`() { val ifaces = listOf( From e5faf9456373428aa5be7deb83e57f08c17f78ef Mon Sep 17 00:00:00 2001 From: AR34 <149522975+ArThirtyFour@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:26:07 +0300 Subject: [PATCH 3/3] Da (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Detect VPN from native * Add some methods & fix docs * Sonar fixes * fix: устранить ложные нативные VPN-сигналы --------- Co-authored-by: xtclovver --- .gitignore | 3 +- README.md | 106 +- app/src/main/cpp/native_signs_probe.cpp | 1303 ++++++++++++++++- .../checker/NativeSignsChecker.kt | 180 ++- .../rknhardering/checker/VerdictEngine.kt | 15 +- .../checker/VpnNativeDetectorChecker.kt | 190 +++ .../rknhardering/network/ResolverBinding.kt | 10 +- .../probe/NativeInterfaceProbe.kt | 106 ++ .../rknhardering/probe/NativeSignsBridge.kt | 57 + app/src/main/res/values-fa/strings.xml | 58 + app/src/main/res/values-ru/strings.xml | 59 + app/src/main/res/values-zh-rCN/strings.xml | 58 + app/src/main/res/values/strings.xml | 59 + .../CheckResultJsonExportFormatterTest.kt | 58 + .../CheckResultMarkdownExportFormatterTest.kt | 52 + .../rknhardering/checker/VerdictEngineTest.kt | 51 + .../checker/VpnNativeDetectorCheckerTest.kt | 110 ++ .../network/ResolverBindingTest.kt | 21 + docs/README.en.md | 105 +- docs/README.fa.md | 104 +- docs/README.zh-CN.md | 105 +- 21 files changed, 2777 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt create mode 100644 app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt diff --git a/.gitignore b/.gitignore index acd2417..bf7360b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ release.jks !CONTRIBUTING.md !app/src/test/resources/export/golden/*.md /.codex -.marketplace-keys/ \ No newline at end of file +.marketplace-keys/ +app-release/ \ No newline at end of file diff --git a/README.md b/README.md index 5b96719..1a87b60 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,110 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne Любой из сигналов даёт `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), **никогда** `detected`. +#### 12.5 VPN-сигналы (`evaluateVpnSignals`) + +Комплексная проверка VPN через нативные JNI-вызовы. Все проверки работают на **нерутованных** устройствах — при отсутствии прав (SELinux/capabilities) проверка помечается как `unavailable` и не крашится. + +**Свойства и файлы (`nativeDetectVpnProperties`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| DNS-свойства | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN-свойства | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide файлы | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` и др. | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook-свойства | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` | + +Высокая уверенность: `vpn_prop`, `vpnhide`, `hook_prop`. Средняя: остальные. + +**Утечки через /proc (`nativeDetectVpnLeaks`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| TCP VPN-порты | Соединения на портах 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` | +| UDP VPN-порты | Сокеты на портах 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` | +| if_inet6 | Интерфейсы tun/wg/ppp/tap в `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| Route VPN | Маршруты через tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | `/32 host` записи (не LOCAL) — хост-маршруты VPN | `/proc/net/fib_trie` | + +Высокая уверенность: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`. + +**Продвинутые проверки (`nativeDetectVpnAdvanced`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| ARP VPN | Записи на tun/wg/ppp интерфейсах | `/proc/net/arp` | +| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` и др. | +| ESTABLISHED VPN | Соединения к приватным IP на VPN-портах | `/proc/net/tcp` | + +**Нетрадиционные syscall-проверки (`nativeDetectVpnSyscalls`):** + +Проверки через прямые netlink-запросы и пробные соединения. При отсутствии прав возвращают `unavailable`: + +| Проверка | Метод | Что обнаруживает | +|----------|-------|-------------------| +| RTM_GETRULE | Netlink dump роутинг-правил | VPN policy routing rules | +| RTM_GETQDISC | Netlink dump queueing discipline | VPN qdisc туннели | +| RTM_GETNEIGH | Netlink dump таблицы соседей | Скрытые MAC-адреса (нулевые LLADDR) | +| TCP_INFO MSS | Connect к 8.8.8.8:443, чтение `snd_mss` | Снижение MSS (признак туннеля) | +| SO_BINDTODEVICE | Пробный bind на несуществующий интерфейс | Перехват setsockopt VPN-хуком | +| Loopback port bind | Попытка занять порты 51820/1194/443/8443 | Конфликты портов (VPN listener) | +| BPF OBJ GET | Попытка открыть `/sys/fs/bpf/` карты | Доступ к BPF-картам netd | +| IP_RECVERR | Пробный setsockopt IP_RECVERR | Перехват IP_RECVERR VPN-хуком | + +Высокая уверенность: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`. + +#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`) + + +Новые детекты вынесены в отдельную секцию внутри категории Native и сгруппированы по 4 подкатегориям. Данные приходят из нового JNI-метода `nativeDetectVpnDetector(cancellationSignal)`, строки имеют префикс `vdet|`. + +**Прямые признаки (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `sysfs_vpn_leak` | Утечка tun/wg/ppp/xfrm через sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | VPN-интерфейсы в списке `getifaddrs()` | `getifaddrs()` | +| `sysclassnet_vpn` | VPN-интерфейсы в `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | VPN-интерфейсы через netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | VPN-интерфейсы в `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | VPN-маршруты в `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | VPN-трафик (RX/TX) в `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | VPN-интерфейсы через `if_indextoname()` | `if_indextoname()` перебор ifindex | +| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100–200, oif=tun) | Netlink `RTM_GETRULE` dump | + +**Сетевые признаки (Network signs) — `EvidenceSource.NATIVE_SOCKET`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `fib_trie_denied` | `/proc/net/fib_trie` недоступен (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink запрещён (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` к tun + подтверждение `getsockopt` | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` возвращает приватный VPN-IP | `getsockname()` на UDP-сокете | +| `udp_port_conflict_physical` | Конфликт UDP-портов (500/4500/1194/1701/51820) на физическом IP | `bind()` на физический IP | +| `route_count` | Число маршрутов и уникальных интерфейсов | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | Расхождение bind-probe vs RTM_GETLINK по числу iface | `if_indextoname()` vs `RTM_GETLINK` | + +**Косвенные признаки (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | Успех/неудача отправки 1500 байт по UDP | `sendto()` 1500 байт | +| `normal_pmtu` | Path MTU основного физического интерфейса | `fetchMtu()` через `getifaddrs()` | +| `timing_oracle` | ARM CNTVCT циклы для `sendto()` (мин/макс/сред) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | Пропускная способность при 50000 UDP-пакетах с отменой скана | Неблокирующий `sendto()` burst + проверка cancellation каждые 64 пакета | +| `gso_failed` / `gso_send_failed` / `gso_ok` | Диагностика поддержки UDP GSO; результат не является признаком VPN | `UDP_SEGMENT=1200` + отправка 4800 байт | +| `hw_timestamp` | Аппаратный таймстампинг (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**Пробы окружения (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `traceroute_denied` | Traceroute-проба (TTL=1 UDP) заблокирована | `setsockopt(IP_TTL=1)` + `sendto()` | + +Высокая уверенность (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Сырые измерения и GSO-результаты информационные; остальные аномалии → `needsReview`. + --- ## Вердикт (`VerdictEngine`) @@ -571,7 +675,7 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne - `geoHit` = `GeoIP.outsideRu == true` (кроме роуминга) - `directHit` = detected-evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY` -- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` либо high-confidence `NATIVE_SOCKET` | Geo | Direct | Indirect | Вердикт | |-----|--------|----------|---------| diff --git a/app/src/main/cpp/native_signs_probe.cpp b/app/src/main/cpp/native_signs_probe.cpp index 0295993..995c4bd 100644 --- a/app/src/main/cpp/native_signs_probe.cpp +++ b/app/src/main/cpp/native_signs_probe.cpp @@ -12,26 +12,37 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include #include +#include #include #include +#include +#include #include #include #include #include #include +#include +#include +#include +#include #include #include +#include namespace { @@ -787,7 +798,6 @@ jobjectArray nativeLibraryIntegrity(JNIEnv *env, jclass /*clazz*/) { jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { std::vector findings; - // 1. Check for su binary in common paths static const char *const kSuPaths[] = { "/system/bin/su", "/system/xbin/su", @@ -810,7 +820,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 2. Check root-related system properties struct PropCheck { const char *prop; const char *suspiciousValue; @@ -835,7 +844,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 3. Check for root management app artifacts static const char *const kRootMgmtPaths[] = { "/data/adb/magisk", "/data/adb/modules", @@ -856,17 +864,14 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 4. Check /system writability if (access("/system", W_OK) == 0) { findings.emplace_back("system_rw|/system is writable"); } - // 5. Check /proc/self/mounts for suspicious mount entries FILE *mounts = std::fopen("/proc/self/mounts", "re"); if (mounts != nullptr) { char line[1024]; while (std::fgets(line, sizeof(line), mounts) != nullptr) { - // Magisk tmpfs overlays on /system, /vendor, /product if (std::strstr(line, "magisk") != nullptr || std::strstr(line, "core-only") != nullptr) { std::string raw(line); @@ -875,7 +880,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { f.append(raw); findings.push_back(f); } - // Overlayfs on /system suggests rootfs modifications if (std::strstr(line, "overlay") != nullptr && (std::strstr(line, "/system") != nullptr || std::strstr(line, "/vendor") != nullptr)) { @@ -889,7 +893,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { std::fclose(mounts); } - // 6. Check SELinux enforcement FILE *selinux = std::fopen("/sys/fs/selinux/enforce", "re"); if (selinux != nullptr) { int enforce = -1; @@ -898,11 +901,9 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } std::fclose(selinux); } else { - // SELinux filesystem not accessible — might be disabled entirely findings.emplace_back("selinux|absent"); } - // 7. Check current process UID/GID uid_t uid = getuid(); uid_t euid = geteuid(); gid_t gid = getgid(); @@ -914,7 +915,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { findings.push_back(f); } - // 8. Check for Magisk-specific properties static const char *const kMagiskProps[] = { "init.svc.magisk_daemon", "init.svc.magisk_pfs", @@ -949,7 +949,6 @@ bool fileContains(const char *path, const char *needle) { jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { std::vector findings; - // 1. QEMU system properties static const char *const kQemuProps[] = { "ro.kernel.qemu", "ro.kernel.qemu.gles", @@ -967,7 +966,6 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 2. Goldfish/ranchu hardware static const char *const kHwProps[] = { "ro.hardware", "ro.product.board", @@ -986,7 +984,6 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 3. Emulator pipe devices (genyd => Genymotion) static const char *const kPipePaths[] = { "/dev/qemu_pipe", "/dev/socket/qemud", @@ -1001,12 +998,10 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 4. Goldfish TTY driver if (fileContains("/proc/tty/drivers", "goldfish")) { findings.emplace_back("qemu_driver|/proc/tty/drivers:goldfish"); } - // 5. BlueStacks artifacts static const char *const kBlueStacksPaths[] = { "/system/lib/libbstfolder_jni.so", "/data/bluestacks.prop", @@ -1022,6 +1017,1227 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { return toStringArray(env, findings); } +} + +static void detectVpnPropertiesAll(std::vector &findings); +static void detectVpnFiles(std::vector &findings); +static void detectVpnInterfaces(std::vector &findings); +static void detectVpnRoutes(std::vector &findings); +static void detectArpNeighbors(std::vector &findings); +static void detectSysctl(std::vector &findings); +static void detectEstablishedVpn(std::vector &findings); +static void detectQdisc(std::vector &findings); +static void detectMssAnomaly(std::vector &findings); +static void detectBpfMaps(std::vector &findings); +static void detectLoopbackConflicts(std::vector &findings); +static void detectSocketLeaks(std::vector &findings); +static void detectIpRecvErr(std::vector &findings); +static jobjectArray nativeDetectVpnDetector(JNIEnv *env, jclass /*clazz*/, jobject cancellationSignal); +static void detectSysfsLeak(std::vector &findings); +static void detectGetifaddrsVpn(std::vector &findings); +static void detectSysclassnetVpn(std::vector &findings); +static void detectRtmGetlinkVpn(std::vector &findings); +static void detectProcIfInet6(std::vector &findings); +static void detectProcIpv6RouteVpn(std::vector &findings); +static void detectProcNetDevVpn(std::vector &findings); +static void detectFibTrieAccess(std::vector &findings); +static void detectSetsockoptBindToDevice(std::vector &findings); +static void detectInetDiagAccess(std::vector &findings); +static void detectGetsocknameLeak(std::vector &findings); +static void detectVpnPolicyRules(std::vector &findings); +static void detectRouteCount(std::vector &findings); +static void detectUdpPortConflictPhysicalIp(std::vector &findings); +static void detectTrimOracle(std::vector &findings); +static void detectIfindexnameVpn(std::vector &findings); +static void detectPmtuMssCombined(std::vector &findings); +static void detectUdpPmtu(std::vector &findings); +static void detectNormalPmtu(std::vector &findings); +static void detectTraceroute(std::vector &findings); +static void detectTimingOracle(std::vector &findings); +static void detectBackpressure( + JNIEnv *env, + jobject cancellationSignal, + jmethodID isCancelledMethod, + std::vector &findings +); +static void detectGsoLargeSend(std::vector &findings); +static void detectHwTimestamp(std::vector &findings); + +jobjectArray nativeDetectVpnProperties(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectVpnPropertiesAll(findings); + detectVpnFiles(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnLeaks(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectVpnInterfaces(findings); + detectVpnRoutes(findings); + detectArpNeighbors(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnAdvanced(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectSysctl(findings); + detectEstablishedVpn(findings); + detectQdisc(findings); + detectMssAnomaly(findings); + detectBpfMaps(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnSyscalls(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectLoopbackConflicts(findings); + detectSocketLeaks(findings); + detectIpRecvErr(findings); + + return toStringArray(env, findings); +} + +static void detectVpnPropertiesAll(std::vector &findings) { + const std::vector vpnProps = { + "net.vpn.dns1", "net.vpn.dns2", + "dhcp.tun0.dns1", "dhcp.tun0.dns2", + "net.interfaces.default.type", "net.interfaces.default.name", + "net.vpn.dns3", "net.vpn.dns4", + }; + for (const auto &prop : vpnProps) { + char value[PROP_VALUE_MAX] = {0}; + if (__system_property_get(prop.c_str(), value) > 0 && value[0] != '\0') { + findings.emplace_back("vpn_prop|" + prop + "=" + value); + } + } + char devType[PROP_VALUE_MAX] = {0}; + if (__system_property_get("net.interfaces.default.type", devType) > 0) { + std::string lower = devType; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + if (lower.find("tun") != std::string::npos || lower.find("vpn") != std::string::npos) { + findings.emplace_back("vpn_prop|net.interfaces.default.type contains tun/vpn"); + } + } +} + +static void detectVpnFiles(std::vector &findings) { + const std::vector> pathGroups = { + {"vpnhide", "/data/local/tmp/vpnhide"}, + {"vpnhide", "/data/data/com.vpnhide"}, + {"vpnhide", "/data/local/tmp/.vpnhide"}, + {"vpnhide", "/data/data/com.vpn.hide"}, + {"lsposed", "/data/adb/lspd"}, + {"lsposed", "/data/adb/modules/lsposed"}, + {"lsposed", "/data/misc/lspd"}, + {"lsposed", "/data/data/org.lsposed.manager"}, + {"zygisk", "/data/adb/modules/zygisk"}, + {"zygisk", "/data/adb/zygisk"}, + {"zygisk", "/data/local/tmp/zygisk"}, + }; + for (const auto &[prefix, path] : pathGroups) { + struct stat st; + if (stat(path.c_str(), &st) == 0) { + findings.emplace_back(prefix + "|" + path); + } + } + const std::vector hookPropNames = { + "persist.sys.lspd.hook", "ro.magisk.version", + "init.svc.zygote_restart", + }; + for (const auto &prop : hookPropNames) { + char value[PROP_VALUE_MAX] = {0}; + if (__system_property_get(prop.c_str(), value) > 0 && value[0] != '\0') { + findings.emplace_back("hook_prop|" + prop + "=" + value); + } + } +} + +static bool ifaceExists(const char *name) { + struct ifreq ifr{}; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return false; + snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", name); + bool exists = (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0); + close(fd); + return exists; +} + +static void detectVpnInterfaces(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "utun1", "wg0", "wg1", "ppp0", "xfrm0"}; + for (const auto &name : tunNames) { + if (!ifaceExists(name.c_str())) continue; + struct ifreq ifr{}; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) continue; + snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", name.c_str()); + if (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0) { + findings.emplace_back("inet6_vpn_iface|" + name + "|flags=0x" + std::to_string(ifr.ifr_flags)); + } + close(fd); + } +} + +static bool isVpnIface(const char *iface) { + static const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "ppp0", "wg0", "wg1"}; + for (const auto &v : vpnIfaces) { + if (v == iface) return true; + } + return false; +} + +static void detectVpnRoutes(std::vector &findings) { + std::ifstream f("/proc/net/route"); + if (!f.is_open()) return; + std::string line; + int vpnRouteCount = 0; + std::getline(f, line); + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string ifaceBuf; + unsigned long dest = 0; + unsigned long gateway = 0; + if (!(iss >> ifaceBuf >> std::hex >> dest >> gateway)) continue; + if (!isVpnIface(ifaceBuf.c_str())) continue; + vpnRouteCount++; + if (dest == 0 || dest == 0xFFFFFFFF) continue; + std::ostringstream oss; + oss << std::hex << std::setw(8) << std::setfill('0') << dest; + findings.emplace_back("vpn_policy_rules|iface=" + ifaceBuf + " dest=" + oss.str()); + } + if (vpnRouteCount > 0) { + findings.emplace_back("route_vpn_iface|vpn_routes=" + std::to_string(vpnRouteCount)); + } +} + +static void detectArpNeighbors(std::vector &findings) { + std::ifstream f("/proc/net/arp"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + int totalEntries = 0; + int hiddenEntries = 0; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string ip, hwType, flags, mac, mask, iface; + if (!(iss >> ip >> hwType >> flags >> mac >> mask >> iface)) continue; + totalEntries++; + if (mac == "00:00:00:00:00:00" || mac.find("ff:ff:ff:ff:ff:ff") != std::string::npos) { + hiddenEntries++; + } + } + if (hiddenEntries > 0) { + findings.emplace_back("hidden_mac_neighbors|hidden=" + std::to_string(hiddenEntries) + " total=" + std::to_string(totalEntries)); + } +} + +static void detectSysctl(std::vector &findings) { + auto readInt = [](const char *path) -> std::optional { + FILE *f = fopen(path, "r"); + if (!f) return std::nullopt; + int val = 0; + bool ok = (fscanf(f, "%d", &val) == 1); + fclose(f); + return ok ? std::optional(val) : std::nullopt; + }; + if (auto val = readInt("/proc/sys/net/ipv4/ip_forward"); val && *val != 0) { + findings.emplace_back("sysctl_forwarding|ip_forward=1"); + } + if (auto val = readInt("/proc/sys/net/ipv4/conf/all/rp_filter"); val && *val == 0) { + findings.emplace_back("sysctl_rp_filter|rp_filter=0"); + } +} + +static bool isVpnPort(int port) { + return port == 51820 || port == 1194 || port == 1195 || port == 443 || port == 8443; +} + +static void detectEstablishedVpn(std::vector &findings) { + std::ifstream f("/proc/net/tcp"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + while (std::getline(f, line)) { + unsigned long localAddr = 0; + unsigned long remoteAddr = 0; + int localPort = 0; + int remotePort = 0; + int state = 0; + if (sscanf(line.c_str(), " %lx:%x %lx:%x %x", &localAddr, &localPort, &remoteAddr, &remotePort, &state) < 5) continue; + if (state != 1) continue; + if (!isVpnPort(remotePort)) continue; + findings.emplace_back("established_vpn|remote_port=" + std::to_string(remotePort)); + } +} + +static void detectQdisc(std::vector &findings) { + std::ifstream f("/proc/net/dev"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + std::getline(f, line); + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string iface; + std::getline(iss >> std::ws, iface, ':'); + unsigned long rxBytes = 0; + unsigned long rxPackets = 0; + iss >> rxBytes >> rxPackets; + for (int i = 0; i < 6; ++i) { unsigned long tmp = 0; iss >> tmp; } + unsigned long txBytes = 0; + unsigned long txPackets = 0; + iss >> txBytes >> txPackets; + if (iface != "tun0" && iface != "wg0") continue; + findings.emplace_back("vpn_qdisc|iface=" + iface + " rx=" + std::to_string(rxBytes) + " tx=" + std::to_string(txBytes)); + } +} + +static void detectMssAnomaly(std::vector &findings) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return; + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(443); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { close(fd); return; } + struct tcp_info info{}; + socklen_t infoLen = sizeof(info); + if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &info, &infoLen) == 0 && info.tcpi_snd_mss > 0 && info.tcpi_snd_mss < 500) { + findings.emplace_back("tcp_mss_low|snd_mss=" + std::to_string(info.tcpi_snd_mss) + "|rcv_mss=" + std::to_string(info.tcpi_rcv_mss)); + } + close(fd); +} + +static void detectBpfMaps(std::vector &findings) { + const std::vector paths = { + "/sys/fs/bpf/map_netd_iface_index_name_map", + "/sys/fs/bpf/netd_shared/map_netd_iface_index_name_map", + "/sys/fs/bpf/netd_iface_index_name_map", + }; + for (const auto &path : paths) { + int fd = open(path.c_str(), O_RDONLY); + if (fd < 0) continue; + close(fd); + findings.emplace_back("bpf_map_accessible|" + path); + } +} + +static void detectIpRecvErr(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + int opt = 1; + if (setsockopt(fd, IPPROTO_IP, IP_RECVERR, &opt, sizeof(opt)) < 0) { + if (errno == EACCES || errno == EPERM) { + findings.emplace_back("unavailable|ip_recverr|denied"); + } else if (errno == ENOPROTOOPT) { + findings.emplace_back("unavailable|ip_recverr|not_supported"); + } + } + close(fd); +} + +// --- n08/n04/n30: sysfs VPN interface leak --- +static void detectSysfsLeak(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + const std::vector sysfsBases = {"/sys/class/net", "/sys/devices/virtual/net"}; + std::vector leaked; + for (const auto &base : sysfsBases) { + for (const auto &name : tunNames) { + std::string path = base + "/" + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + leaked.emplace_back(base + ": leaked " + name); + } + } + } + const std::vector procSysBases = { + "/proc/sys/net/ipv4/conf", "/proc/sys/net/ipv6/conf", + "/proc/sys/net/ipv4/neigh", "/proc/sys/net/ipv6/neigh", + }; + for (const auto &base : procSysBases) { + for (const auto &name : tunNames) { + std::string path = base + "/" + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + leaked.emplace_back(base + ": leaked " + name); + } + } + } + if (!leaked.empty()) { + std::string r = "sysfs_vpn_leak|"; + for (size_t i = 0; i < leaked.size(); ++i) { + if (i > 0) r += ", "; + r += leaked[i]; + } + findings.push_back(r); + } +} + +// --- n03: getifaddrs VPN interfaces --- +static void detectGetifaddrsVpn(std::vector &findings) { + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr) continue; + for (const auto &name : tunNames) { + if (cur->ifa_name == name) { + found.push_back(name); + break; + } + } + } + freeifaddrs(head); + if (!found.empty()) { + std::string r = "getifaddrs_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n04: /sys/class/net VPN interfaces --- +static void detectSysclassnetVpn(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (const auto &name : tunNames) { + std::string path = std::string("/sys/class/net/") + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + found.push_back(name); + } + } + if (!found.empty()) { + std::string r = "sysclassnet_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n05: RTM_GETLINK VPN interfaces --- +static std::string extractRtattrString(const struct rtattr *attr) { + auto *data = reinterpret_cast(RTA_DATA(attr)); + if (data == nullptr || data[0] == '\0') return {}; + return {data}; +} + +static bool isVpnName(const std::string &name, const std::vector &tunNames) { + for (const auto &t : tunNames) { + if (t == name) return true; + } + return false; +} + +static void processNetlinkMsg( + const struct nlmsghdr *nh, + const std::vector &tunNames, + std::vector &found +) { + if (nh->nlmsg_type != RTM_NEWLINK) return; + auto *ifi = reinterpret_cast(NLMSG_DATA(nh)); + int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*ifi)); + auto *attr = reinterpret_cast(ifi + 1); + std::string ifname; + for (; RTA_OK(attr, rtaLen); attr = RTA_NEXT(attr, rtaLen)) { + if (attr->rta_type == IFLA_IFNAME) { + ifname = extractRtattrString(attr); + break; + } + } + if (!ifname.empty() && isVpnName(ifname, tunNames)) { + found.push_back(ifname); + } +} + +static void detectRtmGetlinkVpn(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd < 0) return; + + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req{}; + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.nlh.nlmsg_type = RTM_GETLINK; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + + struct sockaddr_nl kernel{}; + kernel.nl_family = AF_NETLINK; + + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) < 0) { + close(fd); + return; + } + + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + char buf[8192]; + bool done = false; + while (!done) { + struct pollfd pfd = {fd, POLLIN, 0}; + if (poll(&pfd, 1, 2000) <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE || nh->nlmsg_type == NLMSG_ERROR) { + done = true; + break; + } + processNetlinkMsg(nh, tunNames, found); + } + } + close(fd); + if (!found.empty()) { + std::string r = "rtm_getlink_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n10: /proc/net/if_inet6 VPN entries --- +static void detectProcIfInet6(std::vector &findings) { + const std::vector paths = {"/proc/net/if_inet6", "/proc/self/net/if_inet6"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + int vpnCount = 0; + std::string vpnIfacesStr; + std::string line; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string tokens[6]; + for (int i = 0; i < 6 && iss >> tokens[i]; ++i) {} + if (tokens[5].empty()) continue; + const auto &iface = tokens[5]; + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (!match) continue; + vpnCount++; + if (!vpnIfacesStr.empty()) vpnIfacesStr += ", "; + vpnIfacesStr += iface; + } + if (vpnCount > 0) { + findings.emplace_back("proc_if_inet6_vpn|count=" + std::to_string(vpnCount) + " ifaces=" + vpnIfacesStr); + return; + } + } +} + +// --- n11: /proc/net/ipv6_route VPN entries --- +static void detectProcIpv6RouteVpn(std::vector &findings) { + const std::vector paths = {"/proc/net/ipv6_route", "/proc/self/net/ipv6_route"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + int vpnCount = 0; + std::string line; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string tokens[10]; + for (int i = 0; i < 10 && iss >> tokens[i]; ++i) {} + if (tokens[9].empty()) continue; + const auto &iface = tokens[9]; + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (match) vpnCount++; + } + if (vpnCount > 0) { + findings.emplace_back("proc_ipv6_route_vpn|count=" + std::to_string(vpnCount)); + return; + } + } +} + +// --- n16: /proc/net/dev VPN interface traffic --- +static void detectProcNetDevVpn(std::vector &findings) { + const std::vector paths = {"/proc/net/dev", "/proc/self/net/dev"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + std::string line; + std::getline(f, line); // skip header 1 + std::getline(f, line); // skip header 2 + while (std::getline(f, line)) { + auto colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string iface = line.substr(0, colon); + // trim leading whitespace + auto start = iface.find_first_not_of(" \t"); + if (start != std::string::npos) iface = iface.substr(start); + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (!match) continue; + std::istringstream iss(line.substr(colon + 1)); + unsigned long rxBytes = 0, rxPackets = 0, txBytes = 0, txPackets = 0; + iss >> rxBytes >> rxPackets; + for (int i = 0; i < 6; ++i) { unsigned long tmp; iss >> tmp; } + iss >> txBytes >> txPackets; + findings.emplace_back("proc_net_dev_vpn|iface=" + iface + " rx=" + std::to_string(rxBytes) + " tx=" + std::to_string(txBytes)); + return; + } + } +} + +// --- n17: /proc/net/fib_trie SELinux access check --- +static void detectFibTrieAccess(std::vector &findings) { + FILE *f = fopen("/proc/net/fib_trie", "re"); + if (f) { + fclose(f); + } else { + if (errno == EACCES || errno == EPERM) { + findings.push_back("fib_trie_denied|SELinux denies /proc/net/fib_trie"); + } else if (errno == ENOENT) { + // not available on this kernel, neutral + } else { + std::string r = "fib_trie_denied|errno=" + std::to_string(errno); + findings.push_back(r); + } + } +} + +// --- n18: SO_BINDTODEVICE setsockopt --- +static void detectSetsockoptBindToDevice(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0"}; + for (const auto &name : tunNames) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) continue; + int rc = setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), static_cast(name.size() + 1)); + if (rc == 0) { + char devName[256] = {0}; + socklen_t devLen = sizeof(devName); + if (getsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, devName, &devLen) == 0) { + std::string devStr(devName); + if (devStr.find(name) != std::string::npos) { + findings.emplace_back("bindtodevice_leak|setsockopt(" + name + ") succeeded, getsockopt confirmed"); + } + } + } + close(fd); + } +} + +// --- n19: inet_diag netlink access check --- +static void detectInetDiagAccess(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_SOCK_DIAG); + if (fd < 0) { + if (errno == EACCES || errno == EPERM) { + findings.push_back("inet_diag_denied|SELinux denies inet_diag netlink"); + } + return; + } + close(fd); +} + +// --- n20: getsockname() VPN IP leak --- +static void detectGetsocknameLeak(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in localAddr; + socklen_t addrLen = sizeof(localAddr); + std::memset(&localAddr, 0, sizeof(localAddr)); + if (getsockname(fd, reinterpret_cast(&localAddr), &addrLen) == 0) { + if (localAddr.sin_addr.s_addr != 0) { + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &localAddr.sin_addr, ip, sizeof(ip)); + // Check if it's a private IP that could be VPN + unsigned int addr = ntohl(localAddr.sin_addr.s_addr); + bool isPrivate = ((addr >> 24) == 10) || + ((addr >> 20) == 0xAC1) || // 172.16.0.0/12 + ((addr >> 16) == 0xC0A8); // 192.168.0.0/16 + if (isPrivate) { + std::string r = "getsockname_leak|local_ip=" + std::string(ip); + findings.push_back(r); + } + } + } + close(fd); +} + +// --- n21: VPN routing policy rules via netlink --- +static void detectVpnPolicyRules(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd < 0) return; + + struct { + struct nlmsghdr nlh; + struct rtmsg rtm; + } req; + std::memset(&req, 0, sizeof(req)); + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.nlh.nlmsg_type = RTM_GETRULE; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + req.rtm.rtm_family = AF_INET; + + struct sockaddr_nl kernel; + std::memset(&kernel, 0, sizeof(kernel)); + kernel.nl_family = AF_NETLINK; + + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) < 0) { + close(fd); + return; + } + + int vpnRuleCount = 0; + std::string ruleSummary; + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + + char buf[8192]; + while (true) { + struct pollfd pfd = {fd, POLLIN, 0}; + int pr = poll(&pfd, 1, 2000); + if (pr <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + bool done = false; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } + if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } + if (nh->nlmsg_type != RTM_NEWRULE) continue; + + auto *rtm = reinterpret_cast(NLMSG_DATA(nh)); + int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*rtm)); + auto *attr = reinterpret_cast(rtm + 1); + + int table = rtm->rtm_table; + unsigned int fwMark = 0; + int oif = 0; + char iface[IF_NAMESIZE] = {0}; + + for (; RTA_OK(attr, rtaLen); attr = RTA_NEXT(attr, rtaLen)) { + auto *data = RTA_DATA(attr); + int alen = RTA_PAYLOAD(attr); + switch (attr->rta_type) { + case RTA_TABLE: { + if (alen >= static_cast(sizeof(int))) { + table = *reinterpret_cast(data); + } + break; + } + case RTA_OIF: { + if (alen >= static_cast(sizeof(int))) { + oif = *reinterpret_cast(data); + if_indextoname(oif, iface); + } + break; + } + case RTA_FLOW: { + if (alen >= static_cast(sizeof(unsigned int))) { + fwMark = *reinterpret_cast(data); + } + break; + } + default: break; + } + } + + if (table >= 100 && table <= 200) { + // High table numbers are often VPN policy routing tables + bool isVpnIface = false; + for (const auto &name : tunNames) { + if (iface[0] && name == iface) { + isVpnIface = true; + break; + } + } + if (isVpnIface || (table >= 100 && table <= 110)) { + vpnRuleCount++; + if (!ruleSummary.empty()) ruleSummary += "; "; + ruleSummary += "table=" + std::to_string(table); + if (iface[0]) ruleSummary += " iface=" + std::string(iface); + if (fwMark) ruleSummary += " fwmark=0x" + std::to_string(fwMark); + } + } + + } + if (done) break; + } + close(fd); + + if (vpnRuleCount > 0) { + std::string r = "vpn_policy_rules_netlink|count=" + std::to_string(vpnRuleCount); + if (!ruleSummary.empty()) r += " " + ruleSummary; + findings.push_back(r); + } +} + +// --- n07: Route count / anonymous interfaces --- +static void detectRouteCount(std::vector &findings) { + auto routes = netlinkRouteDump(0); + int totalRoutes = 0; + for (const auto &r : routes) { + if (r.find("route|") == 0) totalRoutes++; + } + // Count unique interfaces + std::map ifaceCounts; + for (const auto &r : routes) { + auto devPos = r.find("|dev="); + if (devPos != std::string::npos) { + auto devEnd = r.find('|', devPos + 5); + std::string dev = (devEnd != std::string::npos) + ? r.substr(devPos + 5, devEnd - devPos - 5) + : r.substr(devPos + 5); + ifaceCounts[dev]++; + } + } + std::string r = "route_count|total=" + std::to_string(totalRoutes); + r += " interfaces=" + std::to_string(ifaceCounts.size()); + findings.push_back(r); +} + +// --- n41: UDP port conflict on physical interface IP --- +static void detectUdpPortConflictPhysicalIp(std::vector &findings) { + // Get physical interface IP via getifaddrs + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + + std::string physicalIp; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr || cur->ifa_addr == nullptr) continue; + if (cur->ifa_addr->sa_family != AF_INET) continue; + const char *name = cur->ifa_name; + // Skip tunnel/VPN interfaces + if (strstr(name, "tun") || strstr(name, "wg") || strstr(name, "ppp") || + strstr(name, "xfrm") || strstr(name, "utun")) continue; + if (strcmp(name, "lo") == 0) continue; + char ip[INET_ADDRSTRLEN]; + auto *in4 = reinterpret_cast(cur->ifa_addr); + inet_ntop(AF_INET, &in4->sin_addr, ip, sizeof(ip)); + physicalIp = ip; + break; // take the first physical IP + } + freeifaddrs(head); + + if (physicalIp.empty()) return; + + static const int kVpnPorts[] = {500, 4500, 1194, 1701, 51820}; + std::vector conflicts; + for (int port : kVpnPorts) { + int testFd = socket(AF_INET, SOCK_DGRAM, 0); + if (testFd < 0) continue; + int opt = 1; + setsockopt(testFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port)); + inet_pton(AF_INET, physicalIp.c_str(), &addr.sin_addr); + if (bind(testFd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + if (errno == EADDRINUSE) { + conflicts.push_back(port); + } + } + close(testFd); + } + if (!conflicts.empty()) { + std::string r = "udp_port_conflict_physical|ip=" + physicalIp + " ports="; + for (size_t i = 0; i < conflicts.size(); ++i) { + if (i > 0) r += ","; + r += std::to_string(conflicts[i]); + } + findings.push_back(r); + } +} + +// --- n42: Trim oracle (bind probe vs RTM_GETLINK) --- +static void detectTrimOracle(std::vector &findings) { + // Count interfaces via bind probe + int bindCount = 0; + for (int ifindex = 1; ifindex < 128; ++ifindex) { + char name[IF_NAMESIZE] = {0}; + if (if_indextoname(static_cast(ifindex), name) != nullptr) { + bindCount++; + } + } + // Count interfaces via RTM_GETLINK + int rtmCount = 0; + { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd >= 0) { + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req; + std::memset(&req, 0, sizeof(req)); + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.nlh.nlmsg_type = RTM_GETLINK; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + struct sockaddr_nl kernel; + std::memset(&kernel, 0, sizeof(kernel)); + kernel.nl_family = AF_NETLINK; + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) >= 0) { + char buf[8192]; + while (true) { + struct pollfd pfd = {fd, POLLIN, 0}; + int pr = poll(&pfd, 1, 2000); + if (pr <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + bool done = false; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } + if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } + if (nh->nlmsg_type == RTM_NEWLINK) rtmCount++; + } + if (done) break; + } + } + close(fd); + } + } + if (bindCount != rtmCount) { + std::string r = "trim_oracle|bind_probe=" + std::to_string(bindCount) + + " rtm_getlink=" + std::to_string(rtmCount) + " MISMATCH"; + findings.push_back(r); + } +} + +// --- n38: if_indexname VPN leak --- +static void detectIfindexnameVpn(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (int ifindex = 1; ifindex < 128; ++ifindex) { + char name[IF_NAMESIZE] = {0}; + if (if_indextoname(static_cast(ifindex), name) == nullptr) continue; + for (const auto &tun : tunNames) { + if (tun == name) { + found.push_back(std::string(name) + "(idx=" + std::to_string(ifindex) + ")"); + break; + } + } + } + if (!found.empty()) { + std::string r = "ifindexname_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n22: UDP PMTU + TCP MSS combined --- +static void detectPmtuMssCombined(std::vector &findings) { + // Check TCP MSS + int tcpFd = socket(AF_INET, SOCK_STREAM, 0); + if (tcpFd >= 0) { + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(443); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(tcpFd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + struct tcp_info info; + socklen_t infoLen = sizeof(info); + if (getsockopt(tcpFd, IPPROTO_TCP, TCP_INFO, &info, &infoLen) == 0) { + unsigned int sndMss = info.tcpi_snd_mss; + unsigned int rcvMss = info.tcpi_rcv_mss; + std::string r = "pmtu_mss_combined|tcp_snd_mss=" + std::to_string(sndMss) + + " tcp_rcv_mss=" + std::to_string(rcvMss); + findings.push_back(r); + } + } + close(tcpFd); + } +} + +// --- n24: UDP send PMTU --- +static void detectUdpPmtu(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + // Try sending 1500 bytes (typical MTU) + char buf[1500]; + std::memset(buf, 0, sizeof(buf)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent > 0) { + std::string r = "udp_pmtu_ok|sent=" + std::to_string(sent) + " bytes"; + findings.push_back(r); + } else { + std::string r = "udp_pmtu_fail|errno=" + std::to_string(errno); + findings.push_back(r); + } + close(fd); +} + +// --- n40: Normal path MTU --- +static void detectNormalPmtu(std::vector &findings) { + // Get MTU from primary interface + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr) continue; + if (strcmp(cur->ifa_name, "lo") == 0) continue; + if (strstr(cur->ifa_name, "tun") || strstr(cur->ifa_name, "wg") || + strstr(cur->ifa_name, "ppp")) continue; + int mtu = fetchMtu(cur->ifa_name); + if (mtu > 0) { + std::string r = "normal_pmtu|iface=" + std::string(cur->ifa_name) + + " mtu=" + std::to_string(mtu); + findings.push_back(r); + break; + } + } + freeifaddrs(head); +} + +// --- n33: Traceroute probe --- +static void detectTraceroute(std::vector &findings) { + // Try a raw UDP traceroute-like probe to detect VPN encapsulation + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + // Set low TTL + int ttl = 1; + setsockopt(fd, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl)); + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(33434); // standard traceroute port + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[64]; + std::memset(buf, 0, sizeof(buf)); + // Non-blocking send to detect errors + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent < 0 && errno == EACCES) { + findings.push_back("traceroute_denied|sendto denied"); + } + close(fd); +} + +// --- n34: Timing oracle (CNTVCT) --- +static void detectTimingOracle(std::vector &findings) { +#if defined(__aarch64__) + // Measure ARM cycle counter for socket operations + auto readCycleCounter = []() -> uint64_t { + uint64_t val; + asm volatile("mrs %0, cntvct_el0" : "=r"(val)); + return val; + }; + const int iterations = 10; + uint64_t totalCycles = 0; + uint64_t minCycles = UINT64_MAX; + uint64_t maxCycles = 0; + for (int i = 0; i < iterations; ++i) { + uint64_t start = readCycleCounter(); + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd >= 0) { + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[64] = {0}; + sendto(fd, buf, sizeof(buf), 0, reinterpret_cast(&addr), sizeof(addr)); + close(fd); + } + uint64_t end = readCycleCounter(); + uint64_t elapsed = end - start; + totalCycles += elapsed; + if (elapsed < minCycles) minCycles = elapsed; + if (elapsed > maxCycles) maxCycles = elapsed; + } + uint64_t avgCycles = totalCycles / static_cast(iterations); + std::string r = "timing_oracle|min=" + std::to_string(minCycles) + + " max=" + std::to_string(maxCycles) + + " avg=" + std::to_string(avgCycles); + findings.push_back(r); +#else + findings.push_back("timing_oracle|not_arm64"); +#endif +} + +// --- n35: Backpressure flood test --- +static bool isScanCancelled(JNIEnv *env, jobject cancellationSignal, jmethodID isCancelledMethod) { + if (cancellationSignal == nullptr || isCancelledMethod == nullptr) return true; + jboolean cancelled = env->CallBooleanMethod(cancellationSignal, isCancelledMethod); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return true; + } + return cancelled == JNI_TRUE; +} + +static void detectBackpressure( + JNIEnv *env, + jobject cancellationSignal, + jmethodID isCancelledMethod, + std::vector &findings +) { + if (isScanCancelled(env, cancellationSignal, isCancelledMethod)) return; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + const int pktCount = 50000; + const int pktSize = 1400; + char buf[pktSize]; + std::memset(buf, 0, sizeof(buf)); + int sent = 0; + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < pktCount; ++i) { + if ((i & 63) == 0 && isScanCancelled(env, cancellationSignal, isCancelledMethod)) { + close(fd); + return; + } + ssize_t rc = sendto(fd, buf, sizeof(buf), MSG_DONTWAIT, + reinterpret_cast(&addr), sizeof(addr)); + if (rc < 0) break; + sent++; + } + auto end = std::chrono::steady_clock::now(); + close(fd); + if (isScanCancelled(env, cancellationSignal, isCancelledMethod)) return; + double elapsedMs = std::chrono::duration(end - start).count(); + double throughputMB = (static_cast(sent) * pktSize) / (1024.0 * 1024.0) / (elapsedMs / 1000.0); + std::string r = "backpressure|" + std::to_string(sent) + "/" + std::to_string(pktCount) + + " pkts sent in " + std::to_string(static_cast(elapsedMs)) + "ms (" + + std::to_string(static_cast(throughputMB)) + " MB/s)"; + findings.push_back(r); +} + +// --- n36: GSO large send test --- +static void detectGsoLargeSend(std::vector &findings) { +#if defined(__linux__) + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + // Use a segment size that fits a normal path MTU. GSO availability is + // diagnostic only; lack of support is not VPN evidence. + int gsoSize = 1200; + int rc = setsockopt(fd, IPPROTO_UDP, UDP_SEGMENT, &gsoSize, sizeof(gsoSize)); + if (rc < 0) { + std::string r = "gso_failed|errno=" + std::to_string(errno); + findings.push_back(r); + } else { + // GSO accepted, try sending + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[4800]; + std::memset(buf, 0, sizeof(buf)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent < 0) { + std::string r = "gso_send_failed|errno=" + std::to_string(errno); + findings.push_back(r); + } else { + findings.push_back("gso_ok"); + } + } + close(fd); +#endif +} + +// --- n37: Hardware timestamping check --- +static void detectHwTimestamp(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct hwtstamp_config hwconfig; + std::memset(&hwconfig, 0, sizeof(hwconfig)); + hwconfig.tx_type = HWTSTAMP_TX_ON; + struct ifreq ifr; + std::memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1); + ifr.ifr_data = reinterpret_cast(&hwconfig); + int rc = ioctl(fd, SIOCSHWTSTAMP, &ifr); + if (rc == 0) { + findings.push_back("hw_timestamp|lo configured"); + } + // Also check for SCM_TIMESTAMPING + int tsFd = socket(AF_INET, SOCK_DGRAM, 0); + if (tsFd >= 0) { + int tsFlags = SOF_TIMESTAMPING_SOFTWARE; + setsockopt(tsFd, SOL_SOCKET, SO_TIMESTAMPING, &tsFlags, sizeof(tsFlags)); + close(tsFd); + } + close(fd); +} + +// --- n33: Traceroute (already defined above, skipping duplicate) --- + +static void detectLoopbackConflicts(std::vector &findings) { + static const int kVpnPorts[] = {51820, 1194, 443, 8443}; + for (int port : kVpnPorts) { + int testFd = socket(AF_INET, SOCK_STREAM, 0); + if (testFd < 0) continue; + int opt = 1; + setsockopt(testFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(testFd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + if (errno == EADDRINUSE) { + std::string r = "loopback_port_conflict|port="; r.append(std::to_string(port)); + findings.push_back(r); + } + } + close(testFd); + } +} + +static void detectSocketLeaks(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd >= 0) { + struct sockaddr_in localAddr; + socklen_t addrLen = sizeof(localAddr); + memset(&localAddr, 0, sizeof(localAddr)); + if (getsockname(fd, (struct sockaddr *)&localAddr, &addrLen) == 0) { + if (localAddr.sin_addr.s_addr != 0) { + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &localAddr.sin_addr, ip, sizeof(ip)); + std::string r = "so_bindtodevice|local_ip="; r.append(ip); + findings.push_back(r); + } + } + close(fd); + } +} + const JNINativeMethod kMethods[] = { {"nativeGetIfAddrs", "()[Ljava/lang/String;", reinterpret_cast(nativeGetIfAddrs)}, {"nativeIfNameToIndex", "(Ljava/lang/String;)I", reinterpret_cast(nativeIfNameToIndex)}, @@ -1034,9 +2250,62 @@ const JNINativeMethod kMethods[] = { {"nativeNetlinkSockDiag", "(II)[Ljava/lang/String;", reinterpret_cast(nativeNetlinkSockDiag)}, {"nativeDetectRoot", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectRoot)}, {"nativeDetectEmulator", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectEmulator)}, + {"nativeDetectVpnProperties", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnProperties)}, + {"nativeDetectVpnLeaks", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnLeaks)}, + {"nativeDetectVpnAdvanced", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnAdvanced)}, + {"nativeDetectVpnSyscalls", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnSyscalls)}, + {"nativeDetectVpnDetector", "(Lcom/notcvnt/rknhardering/ScanCancellationSignal;)[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnDetector)}, }; -} // namespace +// New consolidated VPN detector: returns ONLY the new deep checks (prefixed +// with "vdet|") so they can live in their own UI category, separate from the +// legacy NativeSignsChecker output. +jobjectArray nativeDetectVpnDetector(JNIEnv *env, jclass /*clazz*/, jobject cancellationSignal) { + std::vector raw; + jmethodID isCancelledMethod = nullptr; + if (cancellationSignal != nullptr) { + jclass cancellationClass = env->GetObjectClass(cancellationSignal); + if (cancellationClass != nullptr) { + isCancelledMethod = env->GetMethodID(cancellationClass, "isCancelled", "()Z"); + env->DeleteLocalRef(cancellationClass); + } + if (env->ExceptionCheck()) { + env->ExceptionClear(); + isCancelledMethod = nullptr; + } + } + detectSysfsLeak(raw); + detectGetifaddrsVpn(raw); + detectSysclassnetVpn(raw); + detectRtmGetlinkVpn(raw); + detectProcIfInet6(raw); + detectProcIpv6RouteVpn(raw); + detectProcNetDevVpn(raw); + detectIfindexnameVpn(raw); + detectRouteCount(raw); + detectNormalPmtu(raw); + detectFibTrieAccess(raw); + detectInetDiagAccess(raw); + detectVpnPolicyRules(raw); + detectTrimOracle(raw); + detectPmtuMssCombined(raw); + detectUdpPmtu(raw); + detectTraceroute(raw); + detectTimingOracle(raw); + detectBackpressure(env, cancellationSignal, isCancelledMethod, raw); + detectGsoLargeSend(raw); + detectHwTimestamp(raw); + detectSetsockoptBindToDevice(raw); + detectGetsocknameLeak(raw); + detectUdpPortConflictPhysicalIp(raw); + + std::vector prefixed; + prefixed.reserve(raw.size()); + for (const auto &line : raw) { + prefixed.push_back("vdet|" + line); + } + return toStringArray(env, prefixed); +} JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { JNIEnv *env = nullptr; diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt index 4382260..c49f71c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt @@ -2,6 +2,7 @@ package com.notcvnt.rknhardering.checker import android.content.Context import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.rethrowIfCancellation import com.notcvnt.rknhardering.model.CategoryResult import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceItem @@ -153,6 +154,31 @@ object NativeSignsChecker { evidence += isolationOutcome.evidence needsReview = needsReview || isolationOutcome.needsReview + val vpnProps = runCatching { NativeInterfaceProbe.collectVpnPropertyFindings() }.getOrDefault(emptyList()) + val vpnLeaks = runCatching { NativeInterfaceProbe.collectVpnLeakFindings() }.getOrDefault(emptyList()) + val vpnAdvanced = runCatching { NativeInterfaceProbe.collectVpnAdvancedFindings() }.getOrDefault(emptyList()) + val vpnSyscalls = runCatching { NativeInterfaceProbe.collectVpnSyscallFindings() }.getOrDefault(emptyList()) + val vpnOutcome = evaluateVpnSignals(context, vpnProps, vpnLeaks, vpnAdvanced, vpnSyscalls) + findings += vpnOutcome.findings + evidence += vpnOutcome.evidence + detected = detected || vpnOutcome.detected + needsReview = needsReview || vpnOutcome.needsReview + + // Deep VPN detector (ported from reference APK) — kept as its own + // sub-section inside the Native category for clarity. + val detectorOutcome = try { + VpnNativeDetectorChecker.check(context) + } catch (error: Throwable) { + rethrowIfCancellation(error) + null + } + if (detectorOutcome != null) { + findings += detectorOutcome.findings + evidence += detectorOutcome.evidence + detected = detected || detectorOutcome.detected + needsReview = needsReview || detectorOutcome.needsReview + } + if (findings.isEmpty()) { findings += Finding( description = context.getString(R.string.checker_native_no_anomalies), @@ -938,11 +964,163 @@ object NativeSignsChecker { }.getOrDefault(false) } + internal fun evaluateVpnSignals( + context: Context, + props: List, + leaks: List, + advanced: List, + syscalls: List, + ): PartialOutcome { + val findings = mutableListOf() + val evidence = mutableListOf() + var detected = false + var needsReview = false + + val propsByKind = props.groupBy { it.kind } + val leaksByKind = leaks.groupBy { it.kind } + val advancedByKind = advanced.groupBy { it.kind } + val syscallsByKind = syscalls.filter { !it.kind.startsWith("unavailable") }.groupBy { it.kind } + + val allKinds = listOf( + "vpn_prop", "dns_prop", "vpn_file", "vpnhide", "lsposed", "hook_prop", + "tcp_vpn_port", "udp_vpn_port", "inet6_vpn_iface", "route_vpn_iface", + "arp_vpn_iface", "sysctl_forwarding", "sysctl_rp_filter", "established_vpn", + "vpn_policy_rules", "vpn_qdisc", "hidden_mac_neighbors", "tcp_mss_low", + "so_bindtodevice", "loopback_port_conflict", "bpf_map_accessible", "ip_recverr", + ) + + val unavailableSyscalls = syscalls.filter { it.kind.startsWith("unavailable") } + + for (kind in allKinds) { + val items = propsByKind[kind] + ?: leaksByKind[kind] + ?: advancedByKind[kind] + ?: syscallsByKind[kind] + + val isHigh = kind == "vpn_prop" || kind == "vpnhide" || kind == "hook_prop" || + kind == "udp_vpn_port" || kind == "route_vpn_iface" || + kind == "arp_vpn_iface" || kind == "inet6_vpn_iface" || + kind == "vpn_policy_rules" || kind == "hidden_mac_neighbors" || + kind == "tcp_mss_low" || kind == "loopback_port_conflict" || + kind == "bpf_map_accessible" + + val source = when { + kind.contains("hook") || kind.contains("lsposed") || kind.contains("vpnhide") -> EvidenceSource.NATIVE_HOOK_MARKERS + kind.contains("route") || kind.contains("policy") || kind.contains("sysctl") || + kind.contains("qdisc") -> EvidenceSource.NATIVE_ROUTE + kind.contains("inet6") || kind.contains("neigh") || kind.contains("mac") || + kind.contains("arp") -> EvidenceSource.NATIVE_INTERFACE + else -> EvidenceSource.NATIVE_SOCKET + } + + if (items.isNullOrEmpty()) { + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, "${context.getString(getVpnDescResId(kind))} — ${context.getString(R.string.vpn_status_not_found)}"), + detected = false, + isInformational = true, + source = source, + confidence = EvidenceConfidence.LOW, + ) + } else { + for (item in items) { + val detail = when (item) { + is com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding -> + if (item.value != null) "${item.prop}=${item.value}" else (item.prop ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnLeakFinding -> + if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding -> + if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding -> + item.detail ?: item.kind + else -> kind + } + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, describeVpnFinding(context, kind, detail)), + detected = isHigh, + needsReview = !isHigh, + source = source, + confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM, + ) + evidence += EvidenceItem(source = source, detected = true, confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM, description = describeVpnFinding(context, kind, detail)) + detected = detected || isHigh + needsReview = needsReview || !isHigh + } + } + } + + for (item in unavailableSyscalls) { + val reason = item.detail ?: item.kind.removePrefix("unavailable|") + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, "${reason} — ${context.getString(R.string.vpn_status_unavailable)}"), + detected = false, + isInformational = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.LOW, + ) + } + + return PartialOutcome(findings, evidence, detected = detected, needsReview = needsReview) + } + + private fun getVpnDescResId(kind: String): Int = when (kind) { + "vpn_prop" -> R.string.vpn_desc_prop + "vpnhide" -> R.string.vpn_desc_vpnhide + "hook_prop" -> R.string.vpn_desc_hook + "dns_prop" -> R.string.vpn_desc_dns + "vpn_file" -> R.string.vpn_desc_file + "lsposed" -> R.string.vpn_desc_lsposed + "tcp_vpn_port" -> R.string.vpn_desc_tcp_port + "udp_vpn_port" -> R.string.vpn_desc_udp_port + "inet6_vpn_iface" -> R.string.vpn_desc_inet6 + "route_vpn_iface" -> R.string.vpn_desc_route + "arp_vpn_iface" -> R.string.vpn_desc_arp + "sysctl_forwarding" -> R.string.vpn_desc_forwarding + "sysctl_rp_filter" -> R.string.vpn_desc_rpfilter + "established_vpn" -> R.string.vpn_desc_established + "vpn_policy_rules" -> R.string.vpn_desc_policy_rules + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac + "tcp_mss_low" -> R.string.vpn_desc_mss + "so_bindtodevice" -> R.string.vpn_desc_bindtodevice + "loopback_port_conflict" -> R.string.vpn_desc_port_conflict + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "ip_recverr" -> R.string.vpn_desc_recverr + else -> R.string.checker_native_vpn_signal + } + internal fun containsHighConfidenceRootMountMarker(detail: String): Boolean { val normalized = detail.lowercase() return HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS.any { marker -> normalized.contains(marker) } } -} + private fun describeVpnFinding(context: Context, kind: String, detail: String): String { + val resId = when (kind) { + "vpn_prop" -> R.string.vpn_desc_prop + "vpnhide" -> R.string.vpn_desc_vpnhide + "hook_prop" -> R.string.vpn_desc_hook + "dns_prop" -> R.string.vpn_desc_dns + "vpn_file" -> R.string.vpn_desc_file + "lsposed" -> R.string.vpn_desc_lsposed + "tcp_vpn_port" -> R.string.vpn_desc_tcp_port + "udp_vpn_port" -> R.string.vpn_desc_udp_port + "inet6_vpn_iface" -> R.string.vpn_desc_inet6 + "route_vpn_iface" -> R.string.vpn_desc_route + "arp_vpn_iface" -> R.string.vpn_desc_arp + "sysctl_forwarding" -> R.string.vpn_desc_forwarding + "sysctl_rp_filter" -> R.string.vpn_desc_rpfilter + "established_vpn" -> R.string.vpn_desc_established + "vpn_policy_rules" -> R.string.vpn_desc_policy_rules + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac + "tcp_mss_low" -> R.string.vpn_desc_mss + "so_bindtodevice" -> R.string.vpn_desc_bindtodevice + "loopback_port_conflict" -> R.string.vpn_desc_port_conflict + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "ip_recverr" -> R.string.vpn_desc_recverr + else -> return "$kind: $detail" + } + return "$detail — ${context.getString(resId)}" + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt index a0b329e..dffc726 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt @@ -4,6 +4,7 @@ import com.notcvnt.rknhardering.model.BypassResult import com.notcvnt.rknhardering.model.CallTransportNetworkPath import com.notcvnt.rknhardering.model.CallTransportStatus import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.Verdict @@ -106,7 +107,12 @@ object VerdictEngine { val geoHit = geo?.outsideRu == true && !expectedRoamingExit val directHit = directSigns.evidence.any { it.detected && it.source in MATRIX_DIRECT_SOURCES } val indirectHit = indirectSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } || - nativeSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } + nativeSigns.evidence.any { + it.detected && ( + it.source in MATRIX_INDIRECT_SOURCES || + (it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH) + ) + } val matrix = when { !geoHit && !directHit && !indirectHit -> Verdict.NOT_DETECTED !geoHit && directHit && !indirectHit -> Verdict.NOT_DETECTED @@ -125,7 +131,12 @@ object VerdictEngine { it.status == CallTransportStatus.NEEDS_REVIEW && it.networkPath != CallTransportNetworkPath.LOCAL_PROXY } - val nativeReviewHit = nativeSigns.evidence.any { it.detected && it.source in NATIVE_REVIEW_SOURCES } + val nativeReviewHit = nativeSigns.evidence.any { + it.detected && ( + it.source in NATIVE_REVIEW_SOURCES || + (it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH) + ) + } val tunProbeReview = directSigns.evidence.any { it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt new file mode 100644 index 0000000..8d5492e --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt @@ -0,0 +1,190 @@ +package com.notcvnt.rknhardering.checker + +import android.content.Context +import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.model.Finding +import com.notcvnt.rknhardering.probe.NativeSignsBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Deep VPN detector ported from the reference [dev.soranerai.vpndetector] APK. + * Lives in its own UI category, separate from the legacy [NativeSignsChecker]. + * Every native line is prefixed with "vdet|" and has the shape "vdet||". + */ +object VpnNativeDetectorChecker { + + private val NETWORK_KINDS = setOf( + "fib_trie_denied", "inet_diag_denied", "bindtodevice_leak", + "getsockname_leak", "udp_port_conflict_physical", "vpn_qdisc", + "bpf_map_accessible", "route_count", "trim_oracle", + ) + + private val INDIRECT_KINDS = setOf( + "pmtu_mss_combined", "udp_pmtu_ok", "udp_pmtu_fail", "normal_pmtu", + "timing_oracle", "backpressure", "gso_failed", "gso_send_failed", + "gso_ok", "hw_timestamp", + ) + + private val HIGH_CONFIDENCE = setOf( + "sysfs_vpn_leak", "getifaddrs_vpn", "sysclassnet_vpn", "rtm_getlink_vpn", + "proc_if_inet6_vpn", "proc_ipv6_route_vpn", "proc_net_dev_vpn", + "ifindexname_vpn", "vpn_policy_rules_netlink", + "bindtodevice_leak", "getsockname_leak", "udp_port_conflict_physical", + ) + + private val INFORMATIONAL_KINDS = setOf( + "route_count", "pmtu_mss_combined", "udp_pmtu_ok", "normal_pmtu", + "timing_oracle", "backpressure", "gso_failed", "gso_send_failed", + "gso_ok", "hw_timestamp", + ) + + private fun libraryUnavailableResult(context: Context): CategoryResult { + val loadError = NativeSignsBridge.lastLoadErrorMessage() + val description = if (loadError != null) { + context.getString(R.string.checker_vpn_detector_unavailable_with_reason, loadError) + } else { + context.getString(R.string.checker_vpn_detector_unavailable) + } + return CategoryResult( + name = context.getString(R.string.checker_vpn_detector_category_name), + detected = false, + findings = listOf(Finding(description = description, isInformational = true)), + needsReview = false, + evidence = emptyList(), + ) + } + + private fun mapSource(kind: String): EvidenceSource = when (kind) { + in NETWORK_KINDS -> EvidenceSource.NATIVE_SOCKET + in INDIRECT_KINDS -> EvidenceSource.NATIVE_ROUTE + else -> EvidenceSource.NATIVE_INTERFACE + } + + private fun evaluateItem( + context: Context, + item: ParsedRow, + findings: MutableList, + evidence: MutableList, + ) { + val description = describe(context, item.kind, item.detail) + val source = mapSource(item.kind) + if (item.kind in INFORMATIONAL_KINDS) { + findings += Finding( + description = description, + isInformational = true, + source = source, + confidence = EvidenceConfidence.LOW, + ) + return + } + + val isHigh = item.kind in HIGH_CONFIDENCE + val confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM + findings += Finding( + description = description, + detected = isHigh, + needsReview = !isHigh, + source = source, + confidence = confidence, + ) + evidence += EvidenceItem( + source = source, + detected = true, + confidence = confidence, + description = description, + ) + } + + suspend fun check(context: Context): CategoryResult = withContext(Dispatchers.IO) { + val executionContext = ScanExecutionContext.currentOrDefault() + executionContext.throwIfCancelled() + NativeSignsBridge.initIfNeeded() + if (!NativeSignsBridge.isLibraryLoaded()) return@withContext libraryUnavailableResult(context) + + val rows = runCatching { + NativeSignsBridge.detectVpnDetector(executionContext.cancellationSignal) + }.getOrDefault(emptyArray()) + executionContext.throwIfCancelled() + val parsed = rows.mapNotNull { parseRow(it) } + + val findings = mutableListOf() + val evidence = mutableListOf() + + for (item in parsed) { + evaluateItem(context, item, findings, evidence) + } + + if (findings.isEmpty()) { + findings += Finding( + description = context.getString(R.string.checker_vpn_detector_no_anomalies), + isInformational = true, + ) + } + + CategoryResult( + name = context.getString(R.string.checker_vpn_detector_category_name), + detected = findings.any { it.detected }, + findings = findings, + needsReview = findings.any { it.needsReview }, + evidence = evidence, + ) + } + + private data class ParsedRow(val kind: String, val detail: String) + + private fun parseRow(row: String): ParsedRow? { + if (!row.startsWith("vdet|")) return null + val body = row.removePrefix("vdet|") + val sep = body.indexOf('|') + if (sep <= 0) return null + val kind = body.substring(0, sep) + val detail = body.substring(sep + 1).takeIf { it.isNotBlank() } ?: return null + return ParsedRow(kind, detail) + } + + private fun describe(context: Context, kind: String, detail: String): String { + val resId = when (kind) { + "sysfs_vpn_leak" -> R.string.vpn_desc_sysfs_leak + "getifaddrs_vpn" -> R.string.vpn_desc_getifaddrs + "sysclassnet_vpn" -> R.string.vpn_desc_sysclassnet + "rtm_getlink_vpn" -> R.string.vpn_desc_rtm_getlink + "proc_if_inet6_vpn" -> R.string.vpn_desc_proc_if_inet6 + "proc_ipv6_route_vpn" -> R.string.vpn_desc_proc_ipv6_route + "proc_net_dev_vpn" -> R.string.vpn_desc_proc_net_dev + "ifindexname_vpn" -> R.string.vpn_desc_ifindexname + "vpn_policy_rules_netlink" -> R.string.vpn_desc_policy_rules_netlink + "fib_trie_denied" -> R.string.vpn_desc_fib_trie + "inet_diag_denied" -> R.string.vpn_desc_inet_diag + "bindtodevice_leak" -> R.string.vpn_desc_bindtodevice_leak + "getsockname_leak" -> R.string.vpn_desc_getsockname + "udp_port_conflict_physical" -> R.string.vpn_desc_udp_port_physical + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "route_count" -> R.string.vpn_desc_route_count + "trim_oracle" -> R.string.vpn_desc_trim_oracle + "pmtu_mss_combined" -> R.string.vpn_desc_pmtu_mss + "udp_pmtu_ok" -> R.string.vpn_desc_udp_pmtu_ok + "udp_pmtu_fail" -> R.string.vpn_desc_udp_pmtu_fail + "normal_pmtu" -> R.string.vpn_desc_normal_pmtu + "timing_oracle" -> R.string.vpn_desc_timing_oracle + "backpressure" -> R.string.vpn_desc_backpressure + "gso_failed" -> R.string.vpn_desc_gso_failed + "gso_send_failed" -> R.string.vpn_desc_gso_send_failed + "gso_ok" -> R.string.vpn_desc_gso_ok + "hw_timestamp" -> R.string.vpn_desc_hw_timestamp + "traceroute_denied" -> R.string.vpn_desc_traceroute + else -> null + } + return if (resId != null) { + "$detail — ${context.getString(resId)}" + } else { + "$kind: $detail" + } + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt index 1aa0420..6367d77 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt @@ -68,19 +68,13 @@ internal object ResolverSocketBinder { private fun bindSocketToDevice(socket: Socket, interfaceName: String) { bindSocketToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromSocket(socket).use { pfd -> - bindFileDescriptorToDevice( - pfd.fileDescriptor, - interfaceName, - ) + bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName) } } private fun bindDatagramToDevice(socket: DatagramSocket, interfaceName: String) { bindDatagramToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromDatagramSocket(socket).use { pfd -> - bindFileDescriptorToDevice( - pfd.fileDescriptor, - interfaceName, - ) + bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName) } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt index ecd140a..6689d2c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt @@ -69,6 +69,29 @@ data class NativeEmulatorFinding( val detail: String?, ) +data class NativeVpnPropertyFinding( + val kind: String, + val prop: String?, + val value: String?, +) + +data class NativeVpnLeakFinding( + val kind: String, + val detail: String?, + val count: Int = 0, +) + +data class NativeVpnAdvancedFinding( + val kind: String, + val detail: String?, + val count: Int = 0, +) + +data class NativeVpnSyscallFinding( + val kind: String, + val detail: String?, +) + object NativeInterfaceProbe { private const val IPV4_DEFAULT_DESTINATION = "00000000" private const val IPV6_DEFAULT_DESTINATION = "00000000000000000000000000000000" @@ -383,4 +406,87 @@ object NativeInterfaceProbe { val rows = NativeSignsBridge.detectEmulator() return parseEmulatorFindings(rows) } + + fun parseVpnPropertyFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val eq = rest.indexOf('=') + if (eq <= 0) { + NativeVpnPropertyFinding(kind = kind, prop = rest, value = null) + } else { + NativeVpnPropertyFinding( + kind = kind, + prop = rest.substring(0, eq), + value = rest.substring(eq + 1).takeIf { it.isNotBlank() }, + ) + } + } + } + + fun collectVpnPropertyFindings(): List { + val rows = NativeSignsBridge.detectVpnProperties() + return parseVpnPropertyFindings(rows) + } + + fun parseVpnLeakFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val sep2 = rest.lastIndexOf('|') + if (sep2 > 0) { + val detail = rest.substring(0, sep2) + val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0 + NativeVpnLeakFinding(kind = kind, detail = detail, count = count) + } else { + NativeVpnLeakFinding(kind = kind, detail = rest) + } + } + } + + fun collectVpnLeakFindings(): List { + val rows = NativeSignsBridge.detectVpnLeaks() + return parseVpnLeakFindings(rows) + } + + fun parseVpnAdvancedFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val sep2 = rest.lastIndexOf('|') + if (sep2 > 0) { + val detail = rest.substring(0, sep2) + val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0 + NativeVpnAdvancedFinding(kind = kind, detail = detail, count = count) + } else { + NativeVpnAdvancedFinding(kind = kind, detail = rest) + } + } + } + + fun collectVpnAdvancedFindings(): List { + val rows = NativeSignsBridge.detectVpnAdvanced() + return parseVpnAdvancedFindings(rows) + } + + fun parseVpnSyscallFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val detail = row.substring(sep + 1).takeIf { it.isNotBlank() } + NativeVpnSyscallFinding(kind = kind, detail = detail) + } + } + + fun collectVpnSyscallFindings(): List { + val rows = NativeSignsBridge.detectVpnSyscalls() + return parseVpnSyscallFindings(rows) + } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt index 42c88c6..e6adce9 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt @@ -1,5 +1,7 @@ package com.notcvnt.rknhardering.probe +import com.notcvnt.rknhardering.ScanCancellationSignal + object NativeSignsBridge { private const val LIBRARY_NAME = "native_signs_probe" @@ -39,6 +41,21 @@ object NativeSignsBridge { @Volatile internal var detectEmulatorOverride: (() -> Array)? = null + @Volatile + internal var detectVpnPropertiesOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnLeaksOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnAdvancedOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnSyscallsOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnDetectorOverride: ((ScanCancellationSignal?) -> Array)? = null + @Volatile private var initialized = false @@ -141,6 +158,36 @@ object NativeSignsBridge { return runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray()) } + fun detectVpnProperties(): Array { + detectVpnPropertiesOverride?.let { return it.invoke() } + if (!isLibraryLoaded()) return emptyArray() + return runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray()) + } + + fun detectVpnLeaks(): Array { + detectVpnLeaksOverride?.let { return it.invoke() } + if (!isLibraryLoaded()) return emptyArray() + return runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray()) + } + + fun detectVpnAdvanced(): Array { + detectVpnAdvancedOverride?.let { return it.invoke() } + if (!isLibraryLoaded()) return emptyArray() + return runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray()) + } + + fun detectVpnSyscalls(): Array { + detectVpnSyscallsOverride?.let { return it.invoke() } + if (!isLibraryLoaded()) return emptyArray() + return runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray()) + } + + fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array { + detectVpnDetectorOverride?.let { return it.invoke(cancellationSignal) } + if (!isLibraryLoaded()) return emptyArray() + return runCatching { nativeDetectVpnDetector(cancellationSignal) }.getOrDefault(emptyArray()) + } + internal fun resetForTests() { isLibraryLoadedOverride = null getIfAddrsOverride = null @@ -154,6 +201,11 @@ object NativeSignsBridge { netlinkSockDiagOverride = null detectRootOverride = null detectEmulatorOverride = null + detectVpnPropertiesOverride = null + detectVpnLeaksOverride = null + detectVpnAdvancedOverride = null + detectVpnSyscallsOverride = null + detectVpnDetectorOverride = null initialized = false libraryLoaded = false lastLoadError = null @@ -170,4 +222,9 @@ object NativeSignsBridge { private external fun nativeNetlinkSockDiag(family: Int, protocol: Int): Array private external fun nativeDetectRoot(): Array private external fun nativeDetectEmulator(): Array + private external fun nativeDetectVpnProperties(): Array + private external fun nativeDetectVpnLeaks(): Array + private external fun nativeDetectVpnAdvanced(): Array + private external fun nativeDetectVpnSyscalls(): Array + private external fun nativeDetectVpnDetector(cancellationSignal: ScanCancellationSignal?): Array } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 82e7f14..c02ac22 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -639,6 +639,64 @@ برنامه در یک محفظه شبیه‌سازی/dual-app اجرا می‌شود (کاربر %1$d)؛ VPN در پروفایل اصلی قابل تشخیص نیست برنامه در یک پروفایل کاری مدیریت‌شده اجرا می‌شود؛ VPN در پروفایل شخصی بدون روت قابل تشخیص نیست + سیگنال VPN: %1$s + ویژگی سیستمی VPN شناسایی شد + فایل مخفی‌سازی VPN یافت شد + ویژگی فریمورک hook شناسایی شد + ویژگی DNS به VPN اشاره می‌کند + فایل ابزار VPN یافت شد + محیط LSPosed/Xposed شناسایی شد + اتصال TCP روی پورت VPN + سوکت UDP روی پورت VPN + رابط VPN IPv6 در /proc + مسیر از طریق رابط VPN + رکورد ARP روی رابط VPN + Forwarding IP فعال است + فیلتر reverse path غیرفعال است + اتصال برقرار به IP خصوصی روی پورت VPN + قوانین مسیریابی VPN یافت شد + queue discipline VPN شناسایی شد + آدرس‌های MAC پنهان در جدول همسایه + MSS به طور مشکوکی پایین (احتمالاً تونل) + setsockopt رهگیری شد (hook VPN) + تداخل پورت روی loopback (listener VPN) + دسترسی به نقشه‌های BPF netd + IP_RECVERR رهگیری شد (hook VPN) + رابط VPN از طریق sysfs نشت کرد + رابط VPN در getifaddrs() + رابط VPN در /sys/class/net + رابط VPN در RTM_GETLINK + رابط VPN IPv6 در /proc/net/if_inet6 + مسیر VPN IPv6 در /proc/net/ipv6_route + ترافیک VPN در /proc/net/dev + دسترسی به /proc/net/fib_trie مسدود شد (SELinux) + SO_BINDTODEVICE رابط VPN را نشت داد + inet_diag netlink مسدود شد (SELinux) + getsockname() IP VPN را نشت داد + قوانین مسیریابی VPN از طریق netlink + شمارش مسیرها و رابط‌های ناشناس + تداخل پورت UDP روی رابط فیزیکی + عدم تطابق bind probe و RTM_GETLINK + رابط VPN از طریق if_indextoname + تحلیل UDP PMTU و TCP MSS + ارسال UDP موفق (بدون مشکل PMTU) + ارسال UDP ناموفق (مشکل PMTU) + تشخیص path MTU عادی + تست traceroute (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure تحت بار مداوم + پشتیبانی GSO در دسترس نیست (فقط تشخیصی) + ارسال GSO ناموفق بود (فقط تشخیصی) + GSO پشتیبانی می‌شود (رابط عادی) + بررسی تایم‌استمپینگ سخت‌افزاری + یافت نشد + غیرقابل دسترس (بدون مجوز) + تشخیص‌دهنده VPN (عمیق) + کتابخانه تشخیص‌دهنده VPN بارگذاری نشد + کتابخانه تشخیص‌دهنده VPN بارگذاری نشد: %1$s + نشانه عمیق VPN یافت نشد + + به‌روزرسانی موجود است نسخه نصب‌شده: %1$s\nآخرین نسخه: %2$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7741fe9..f9c6bb5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -660,6 +660,65 @@ Приложение работает в клонированном/dual-app контейнере (пользователь %1$d); VPN в основном профиле не обнаруживается Приложение работает в управляемом рабочем профиле; VPN в личном профиле не обнаруживается без root + + VPN сигнал: %1$s + Обнаружены VPN-свойства системы + Файл сокрытия VPN + Свойства хук-фреймворков + DNS-свойства указывают на VPN + Файлы VPN-инструментов + Обнаружена LSPosed/Xposed среда + TCP-соединение на VPN-порту + UDP-сокет на VPN-порту + IPv6-интерфейс VPN в /proc + Маршрут через VPN-интерфейс + ARP-запись VPN-интерфейса + IP-форвардинг включён + Reverse path filter отключен + Установленное соединение к приватному IP через VPN-порт + Найдены VPN-правила роутинга + Обнаружена VPN queue discipline + Скрытые MAC-адреса в таблице соседей + Подозрительно низкий MSS (возможно туннель) + Перехват setsockopt (VPN-хук) + Конфликт портов на loopback (VPN listener) + Доступ к BPF-картам netd + Перехват IP_RECVERR (VPN-хук) + VPN-интерфейс утёк через sysfs + VPN-интерфейс в getifaddrs() + VPN-интерфейс в /sys/class/net + VPN-интерфейс в RTM_GETLINK + VPN IPv6-интерфейс в /proc/net/if_inet6 + VPN IPv6-маршрут в /proc/net/ipv6_route + VPN-трафик в /proc/net/dev + Доступ к /proc/net/fib_trie запрещён (SELinux) + SO_BINDTODEVICE утек интерфейс VPN + inet_diag netlink запрещён (SELinux) + getsockname() утек IP VPN + VPN-правила роутинга через netlink + Подсчёт маршрутов и анонимных интерфейсов + Конфликт UDP-порта на физическом интерфейсе + Расхождение bind probe и RTM_GETLINK + VPN-интерфейс через if_indextoname + Анализ UDP PMTU и TCP MSS + UDP-отправка успешна (нет PMTU-проблемы) + UDP-отправка не удалась (PMTU-проблема) + Обнаружение нормального path MTU + Traceroute-проба (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure при постоянной нагрузке + Поддержка GSO недоступна (только диагностика) + GSO-отправка не удалась (только диагностика) + GSO поддерживается (нормальный интерфейс) + Проверка аппаратного таймстампинга + не обнаружено + недоступно (нет прав) + VPN Детектор (глубокий) + Библиотека VPN-детектора не загружена + Библиотека VPN-детектора не загружена: %1$s + Глубоких признаков VPN не обнаружено + + Доступно обновление Установленная версия: %1$s\nАктуальная версия: %2$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0832235..37dc6e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -637,6 +637,64 @@ 应用运行在克隆/双开容器中(用户 %1$d);无法检测主资料中的 VPN 应用运行在受管理的工作资料中;无法在无 root 的情况下检测个人资料中的 VPN + VPN 信号:%1$s + 检测到 VPN 系统属性 + 发现 VPN 隐藏文件 + 检测到 hook 框架属性 + DNS 属性指向 VPN + 发现 VPN 工具文件 + 检测到 LSPosed/Xposed 环境 + VPN 端口上的 TCP 连接 + VPN 端口上的 UDP 套接字 + /proc 中的 IPv6 VPN 接口 + 通过 VPN 接口的路由 + VPN 接口上的 ARP 条目 + IP 转发已启用 + 反向路径过滤已禁用 + 到私有 IP 的 VPN 端口已建立连接 + 发现 VPN 路由规则 + 检测到 VPN 队列规则 + 邻居表中隐藏的 MAC 地址 + 异常低的 MSS(可能是隧道) + setsockopt 被拦截(VPN hook) + loopback 端口冲突(VPN 监听器) + 访问 netd BPF 映射 + IP_RECVERR 被拦截(VPN hook) + VPN 接口通过 sysfs 泄露 + getifaddrs() 中的 VPN 接口 + /sys/class/net 中的 VPN 接口 + RTM_GETLINK 中的 VPN 接口 + /proc/net/if_inet6 中的 VPN IPv6 接口 + /proc/net/ipv6_route 中的 VPN IPv6 路由 + /proc/net/dev 中的 VPN 流量 + /proc/net/fib_trie 访问被拒绝 (SELinux) + SO_BINDTODEVICE 泄露 VPN 接口 + inet_diag netlink 被拒绝 (SELinux) + getsockname() 泄露 VPN IP + 通过 netlink 的 VPN 策略规则 + 路由计数和匿名接口 + 物理接口上的 UDP 端口冲突 + bind probe 与 RTM_GETLINK 不符 + 通过 if_indextoname 暴露的 VPN 接口 + UDP PMTU 和 TCP MSS 分析 + UDP 发送成功(无 PMTU 瓶颈) + UDP 发送失败(PMTU 问题) + 正常路径 MTU 检测 + Traceroute 探测 (ICMP/TTL) + ARM CNTVCT 计时神谕 + 持续突发下的背压 + GSO 支持不可用(仅诊断) + GSO 发送失败(仅诊断) + 支持 GSO(正常接口) + 硬件时间戳检查 + 未发现 + 不可用(无权限) + VPN 检测器(深度) + VPN 检测器库未加载 + VPN 检测器库未加载:%1$s + 未检测到深度 VPN 迹象 + + 有更新可用 已安装版本:%1$s\n最新版本:%2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 040c928..0905287 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,6 +665,65 @@ App runs in a cloned/dual-app container (user %1$d); a VPN in the primary profile is not observable App runs in a managed work profile; a VPN in the personal profile is not observable without root + + VPN signal: %1$s + VPN system property detected + VPN hide file found + Hook framework property detected + DNS property points to VPN + VPN tool file found + LSPosed/Xposed environment detected + TCP connection on VPN port + UDP socket on VPN port + IPv6 VPN interface in /proc + Route via VPN interface + ARP entry on VPN interface + IP forwarding is enabled + Reverse path filter disabled + Established connection to private IP on VPN port + VPN routing rules found + VPN queue discipline detected + Hidden MAC addresses in neighbor table + Suspiciously low MSS (possible tunnel) + setsockopt intercepted (VPN hook) + Port conflict on loopback (VPN listener) + Access to netd BPF maps + IP_RECVERR intercepted (VPN hook) + VPN interface leaked via sysfs + VPN interface in getifaddrs() + VPN interface in /sys/class/net + VPN interface in RTM_GETLINK + VPN IPv6 interface in /proc/net/if_inet6 + VPN IPv6 route in /proc/net/ipv6_route + VPN traffic in /proc/net/dev + /proc/net/fib_trie access denied (SELinux) + SO_BINDTODEVICE leaked VPN interface + inet_diag netlink denied (SELinux) + getsockname() leaked VPN IP + VPN policy rules via netlink + Route count and anonymous interfaces + UDP port conflict on physical interface + Bind probe vs RTM_GETLINK discrepancy + VPN interface exposed via if_indextoname + UDP PMTU and TCP MSS analysis + UDP send succeeded (no PMTU bottleneck) + UDP send failed (PMTU issue) + Normal path MTU detection + Traceroute probe (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure under sustained burst + GSO support unavailable (diagnostic only) + GSO send failed (diagnostic only) + GSO supported (normal interface) + Hardware timestamping check + not found + unavailable (no permission) + VPN Detector (deep) + VPN detector library not loaded + VPN detector library not loaded: %1$s + No deep VPN signs detected + + Update available Installed version: %1$s\nLatest version: %2$s diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt index f43248c..a51931a 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt @@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider import com.notcvnt.rknhardering.export.CheckResultJsonExportFormatter import com.notcvnt.rknhardering.export.CompletedExportSnapshot import com.notcvnt.rknhardering.export.createCompletedExportSnapshot +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult import org.json.JSONObject @@ -61,6 +65,60 @@ class CheckResultJsonExportFormatterTest { assertFalse(outbound.has("publicKey")) } + @Test + fun `json export includes deep native detector findings and evidence`() { + val result = exportRichCheckResult().copy( + nativeSigns = CategoryResult( + name = "Native signs", + detected = true, + findings = listOf( + Finding( + description = "backpressure: 50000 packets", + isInformational = true, + source = EvidenceSource.NATIVE_ROUTE, + confidence = EvidenceConfidence.LOW, + ), + Finding( + description = "bindtodevice_leak: tun0", + detected = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.HIGH, + ), + ), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.NATIVE_SOCKET, + detected = true, + confidence = EvidenceConfidence.HIGH, + description = "bindtodevice_leak: tun0", + ), + ), + ), + ) + + val json = JSONObject( + CheckResultJsonExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = result, + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ), + ) + val nativeSigns = json.getJSONObject("results").getJSONObject("nativeSigns") + val findings = nativeSigns.getJSONArray("findings") + val evidence = nativeSigns.getJSONArray("evidence") + + assertEquals("backpressure: 50000 packets", findings.getJSONObject(0).getString("description")) + assertTrue(findings.getJSONObject(0).getBoolean("isInformational")) + assertEquals("bindtodevice_leak: tun0", findings.getJSONObject(1).getString("description")) + assertEquals("NATIVE_SOCKET", evidence.getJSONObject(0).getString("source")) + assertEquals("bindtodevice_leak: tun0", evidence.getJSONObject(0).getString("description")) + } + @Test fun `json export marks ip comparison and bypass errors`() { val base = exportEmptyCheckResult() diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt index ddcb5c3..8c3516d 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt @@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider import com.notcvnt.rknhardering.export.CheckResultMarkdownExportFormatter import com.notcvnt.rknhardering.export.CompletedExportSnapshot import com.notcvnt.rknhardering.export.createCompletedExportSnapshot +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.UnparsedIp @@ -45,6 +49,54 @@ class CheckResultMarkdownExportFormatterTest { assertTrue(markdown.contains("## Footer")) } + @Test + fun `markdown export includes deep native detector findings and evidence`() { + val result = exportRichCheckResult().copy( + nativeSigns = CategoryResult( + name = "Native signs", + detected = true, + findings = listOf( + Finding( + description = "backpressure: 50000 packets", + isInformational = true, + source = EvidenceSource.NATIVE_ROUTE, + confidence = EvidenceConfidence.LOW, + ), + Finding( + description = "bindtodevice_leak: tun0", + detected = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.HIGH, + ), + ), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.NATIVE_SOCKET, + detected = true, + confidence = EvidenceConfidence.HIGH, + description = "bindtodevice_leak: tun0", + ), + ), + ), + ) + + val markdown = CheckResultMarkdownExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = result, + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ) + + assertTrue(markdown.contains("backpressure: 50000 packets | informational=true")) + assertTrue(markdown.contains("bindtodevice_leak: tun0 | detected=true | source=NATIVE_SOCKET")) + assertTrue(markdown.contains("source=NATIVE_SOCKET | detected=true | confidence=HIGH")) + assertTrue(markdown.contains("description=bindtodevice_leak: tun0")) + } + @Test fun `markdown export marks ip comparison and bypass errors`() { val base = exportEmptyCheckResult() diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt index c706299..db3de40 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt @@ -588,6 +588,57 @@ class VerdictEngineTest { assertEquals(Verdict.NEEDS_REVIEW, verdict) } + @Test + fun `high confidence native socket evidence confirms foreign geo`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(geoFacts = GeoIpFacts(outsideRu = true, countryCode = "DE")), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)), + ), + ) + + assertEquals(Verdict.DETECTED, verdict) + } + + @Test + fun `high confidence native socket evidence alone yields needs review`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)), + ), + ) + + assertEquals(Verdict.NEEDS_REVIEW, verdict) + } + + @Test + fun `medium confidence native socket evidence does not enter verdict matrix`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.MEDIUM)), + ), + ) + + assertEquals(Verdict.NOT_DETECTED, verdict) + } + @Test fun `proxy assisted udp leak does not affect verdict`() { val verdict = VerdictEngine.evaluate( diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt new file mode 100644 index 0000000..223077d --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt @@ -0,0 +1,110 @@ +package com.notcvnt.rknhardering.checker + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.notcvnt.rknhardering.ScanCancellationSignal +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.probe.NativeSignsBridge +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VpnNativeDetectorCheckerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + NativeSignsBridge.resetForTests() + NativeSignsBridge.isLibraryLoadedOverride = { true } + } + + @After + fun tearDown() { + NativeSignsBridge.resetForTests() + } + + @Test + fun `neutral measurements are informational and do not create evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf( + "vdet|route_count|routes=12", + "vdet|pmtu_mss_combined|tcp_snd_mss=1440 tcp_rcv_mss=1440", + "vdet|udp_pmtu_ok|sent=1500 bytes", + "vdet|normal_pmtu|iface=wlan0 mtu=1500", + "vdet|timing_oracle|min=1 max=2 avg=1", + "vdet|backpressure|50000/50000 pkts sent", + "vdet|gso_ok|supported", + "vdet|hw_timestamp|unsupported", + ) + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + + assertFalse(result.detected) + assertFalse(result.needsReview) + assertTrue(result.evidence.isEmpty()) + assertTrue(result.findings.all { it.isInformational }) + } + + @Test + fun `gso failures are capability diagnostics instead of vpn evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf( + "vdet|gso_failed|errno=92", + "vdet|gso_send_failed|errno=90", + ) + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + + assertFalse(result.detected) + assertFalse(result.needsReview) + assertTrue(result.evidence.isEmpty()) + assertTrue(result.findings.all { it.isInformational }) + } + + @Test + fun `high confidence socket leak remains detected evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf("vdet|bindtodevice_leak|setsockopt(tun0) succeeded") + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + val evidence = result.evidence.single() + + assertTrue(result.detected) + assertEquals(EvidenceSource.NATIVE_SOCKET, evidence.source) + assertEquals(EvidenceConfidence.HIGH, evidence.confidence) + assertTrue(evidence.detected) + } + + @Test + fun `native detector receives scan cancellation and aborts result`() { + val cancellationSignal = ScanCancellationSignal() + val executionContext = ScanExecutionContext(cancellationSignal = cancellationSignal) + NativeSignsBridge.detectVpnDetectorOverride = { receivedSignal -> + assertSame(cancellationSignal, receivedSignal) + cancellationSignal.cancel() + arrayOf("vdet|backpressure|partial") + } + + assertThrows(CancellationException::class.java) { + runBlocking(executionContext.asCoroutineContext()) { + VpnNativeDetectorChecker.check(context) + } + } + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt b/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt index 5a47467..bb1d7e9 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt @@ -6,8 +6,11 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.net.DatagramSocket +import java.net.Socket class ResolverBindingTest { @@ -31,6 +34,24 @@ class ResolverBindingTest { assertEquals(listOf("tun0"), boundInterfaces) } + @Test + fun `socket binding infrastructure failure is propagated`() { + Socket().use { socket -> + assertThrows(RuntimeException::class.java) { + ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0")) + } + } + } + + @Test + fun `datagram binding infrastructure failure is propagated`() { + DatagramSocket().use { socket -> + assertThrows(RuntimeException::class.java) { + ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0")) + } + } + } + @Test fun `build client uses bind to device socket factory for os device binding`() { val client = ResolverNetworkStack.buildClient( diff --git a/docs/README.en.md b/docs/README.en.md index 6b2b7ee..ecc4479 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -519,6 +519,109 @@ Identifies contexts where another user's or profile's VPN is invisible to networ Any of these signals yields `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), never `detected`. +#### 12.5 VPN Signals (`evaluateVpnSignals`) + +Comprehensive VPN detection via native JNI calls. All checks work on **non-rooted** devices — when permissions are missing (SELinux/capabilities), the check is marked as `unavailable` and does not crash. + +**Properties and files (`nativeDetectVpnProperties`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| DNS properties | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN properties | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide files | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` etc. | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook properties | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` | + +High confidence: `vpn_prop`, `vpnhide`, `hook_prop`. Medium: others. + +**Leaks via /proc (`nativeDetectVpnLeaks`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| TCP VPN ports | Connections on ports 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` | +| UDP VPN ports | Sockets on ports 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` | +| if_inet6 | tun/wg/ppp/tap interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| Route VPN | Routes via tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | `/32 host` entries (non-LOCAL) — VPN host routes | `/proc/net/fib_trie` | + +High confidence: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`. + +**Advanced checks (`nativeDetectVpnAdvanced`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| ARP VPN | Entries on tun/wg/ppp interfaces | `/proc/net/arp` | +| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` etc. | +| ESTABLISHED VPN | Connections to private IPs on VPN ports | `/proc/net/tcp` | + +**Non-traditional syscall checks (`nativeDetectVpnSyscalls`):** + +Checks via direct netlink requests and probe connections. Returns `unavailable` when permissions are missing: + +| Check | Method | What it detects | +|-------|--------|-----------------| +| RTM_GETRULE | Netlink dump of routing policy rules | VPN policy routing rules | +| RTM_GETQDISC | Netlink dump of queueing disciplines | VPN qdisc tunnels | +| RTM_GETNEIGH | Netlink dump of neighbor table | Hidden MAC addresses (zero LLADDR) | +| TCP_INFO MSS | Connect to 8.8.8.8:443, read `snd_mss` | MSS reduction (tunnel indicator) | +| SO_BINDTODEVICE | Probe bind to non-existent interface | VPN hook intercepting setsockopt | +| Loopback port bind | Try to claim ports 51820/1194/443/8443 | Port conflicts (VPN listener) | +| BPF OBJ GET | Try to open `/sys/fs/bpf/` maps | Access to netd BPF maps | +| IP_RECVERR | Probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR | + +High confidence: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`. + +#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`) + +The new detectors live in a dedicated sub-section inside the Native category, grouped into 4 sub-categories. Data comes from the new JNI method `nativeDetectVpnDetector()`, with lines prefixed `vdet|`. + +**Direct signs — `EvidenceSource.NATIVE_INTERFACE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `sysfs_vpn_leak` | tun/wg/ppp/xfrm leak via sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | VPN interfaces in `getifaddrs()` list | `getifaddrs()` | +| `sysclassnet_vpn` | VPN interfaces in `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | VPN interfaces via netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | VPN interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | VPN routes in `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | VPN traffic (RX/TX) in `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | VPN interfaces via `if_indextoname()` | `if_indextoname()` ifindex sweep | +| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100–200, oif=tun) | Netlink `RTM_GETRULE` dump | + +**Network signs — `EvidenceSource.NATIVE_SOCKET`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `fib_trie_denied` | `/proc/net/fib_trie` unavailable (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink denied (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` to tun + `getsockopt` confirm | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` returns private VPN IP | `getsockname()` on UDP socket | +| `udp_port_conflict_physical` | UDP port conflict (500/4500/1194/1701/51820) on physical IP | `bind()` on physical IP | +| `route_count` | Route count and unique interface count | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | bind-probe vs RTM_GETLINK iface count mismatch | `if_indextoname()` vs `RTM_GETLINK` | + +**Indirect signs — `EvidenceSource.NATIVE_ROUTE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | Success/failure sending 1500 bytes over UDP | `sendto()` 1500 bytes | +| `normal_pmtu` | Path MTU of primary physical interface | `fetchMtu()` via `getifaddrs()` | +| `timing_oracle` | ARM CNTVCT cycles for `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | Throughput under 50000 UDP packets with scan cancellation | Non-blocking `sendto()` burst + cancellation check every 64 packets | +| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO capability diagnostics; the result is not VPN evidence | `UDP_SEGMENT=1200` + 4800-byte send | +| `hw_timestamp` | Hardware timestamping (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**Environment probes — `EvidenceSource.NATIVE_INTERFACE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `traceroute_denied` | Traceroute probe (TTL=1 UDP) blocked | `setsockopt(IP_TTL=1)` + `sendto()` | + +High confidence (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Raw measurements and GSO results are informational; other anomalies → `needsReview`. + --- ## Verdict (`VerdictEngine`) @@ -544,7 +647,7 @@ The `expectedRoamingExit` flag (resolved by `HomeNetworkCatalog` from SIM MCC/MN - `geoHit` = `GeoIP.outsideRu == true` (excluding roaming) - `directHit` = detected evidence from `DIRECT_NETWORK_CAPABILITIES` or `SYSTEM_PROXY` -- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`, or high-confidence `NATIVE_SOCKET` | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------| diff --git a/docs/README.fa.md b/docs/README.fa.md index 15482b0..cffe92e 100644 --- a/docs/README.fa.md +++ b/docs/README.fa.md @@ -521,6 +521,108 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می هر کدام از این سیگنال‌ها `needsReview = true` می‌دهند (`EvidenceSource.SANDBOX_ISOLATION`)، **هرگز** `detected`. +#### 12.5 سیگنال‌های VPN (`evaluateVpnSignals`) + +تشخیص جامع VPN از طریق فراخوانی‌های JNI بومی. تمام بررسی‌ها روی دستگاه‌های **بدون روت** کار می‌کنند — وقتی مجوزها وجود نداشته باشند (SELinux/capabilities)، بررسی به عنوان `unavailable` علامت‌گذاری می‌شود و کرش نمی‌کند. + +**ویژگی‌ها و فایل‌ها (`nativeDetectVpnProperties`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| ویژگی‌های DNS | `net.dns1-4`، `net.vpn.dns1-2`، `dhcp.tun0.dns1-2` | `__system_property_get` | +| ویژگی‌های VPN | `net.vpn.default_iface`، `vpn.enable`، `net.tun0.dns1-2`، `net.ppp0.dns1-2` | `__system_property_get` | +| فایل‌های vpnhide | `/data/local/vpnhide`، `/data/adb/vpnhide`، `/data/local/bypass` و غیره | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`، `/data/adb/modules/lsposed`، `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| ویژگی‌های Hook | `persist.sys.lspd`، `persist.sys.lsposed`، `ro.lsposed.hidden` | `__system_property_get` | + +اطمینان بالا: `vpn_prop`، `vpnhide`، `hook_prop`. متوسط: بقیه. + +**نشت از طریق /proc (`nativeDetectVpnLeaks`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| پورت‌های TCP VPN | اتصالات روی پورت‌های 443، 1194، 51820، 8443، 1723، 500، 4500 | `/proc/net/tcp[6]` | +| پورت‌های UDP VPN | سوکت‌های روی پورت‌های 51820 (WireGuard)، 1194 (OpenVPN)، 500، 4500 | `/proc/net/udp[6]` | +| if_inet6 | رابط‌های tun/wg/ppp/tap در `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| مسیر VPN | مسیرهای از طریق tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | رکوردهای `/32 host` (غیر LOCAL) — مسیرهای میزبان VPN | `/proc/net/fib_trie` | + +اطمینان بالا: `udp_vpn_port`، `route_vpn_iface`، `arp_vpn_iface`، `inet6_vpn_iface`. + +**بررسی‌های پیشرفته (`nativeDetectVpnAdvanced`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| ARP VPN | رکوردهای روی رابط‌های tun/wg/ppp | `/proc/net/arp` | +| Sysctl | `rp_filter=0`، `ip_forward=1`، `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` و غیره | +| ESTABLISHED VPN | اتصالات به IP‌های خصوصی روی پورت‌های VPN | `/proc/net/tcp` | + +**بررسی‌های syscall غیرسنتی (`nativeDetectVpnSyscalls`):** + +بررسی‌ها از طریق درخواست‌های مستقیم netlink و اتصالات probe. وقتی مجوزها وجود نداشته باشند `unavailable` برمی‌گرداند: + +| بررسی | روش | چه چیزی تشخیص می‌دهد | +|-------|-----|---------------------| +| RTM_GETRULE | dump قوانین مسیریابی سیاستی | قوانین مسیریابی سیاستی VPN | +| RTM_GETQDISC | dump انضباط صف | تونل‌های qdisc VPN | +| RTM_GETNEIST | dump جدول همسایه | آدرس‌های MAC پنهان (LLADDR صفر) | +| TCP_INFO MSS | اتصال به 8.8.8.8:443، خواندن `snd_mss` | کاهش MSS (شاخص تونل) | +| SO_BINDTODEVICE | probe bind به رابط غیرموجود | VPN hook intercepting setsockopt | +| اتصال پورت loopback | تلاش برای اشغال پورت‌های 51820/1194/443/8443 | تداخل پورت (listener VPN) | +| BPF OBJ GET | تلاش برای باز کردن نقشه‌های `/sys/fs/bpf/` | دسترسی به نقشه‌های BPF netd | +| IP_RECVERR | probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR | + +اطمینان بالا: `vpn_policy_rules`، `hidden_mac_neighbors`، `tcp_mss_low`، `loopback_port_conflict`، `bpf_map_accessible`. + +#### 12.6 تشخیص‌دهنده عمیق VPN (`VpnNativeDetectorChecker`) + + +بررسی‌های جدید در زیربخش جداگانه‌ای در دسته Native و در ۴ زیرمجموعه گروه‌بندی شده‌اند. داده‌ها از متد JNI جدید `nativeDetectVpnDetector()` با پیشوند `vdet|` می‌آید: + +**نشانه‌های مستقیم (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `sysfs_vpn_leak` | نشت tun/wg/ppp/xfrm از طریق sysfs | `/sys/class/net`، `/sys/devices/virtual/net`، `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | اینترفیس‌های VPN در لیست `getifaddrs()` | `getifaddrs()` | +| `sysclassnet_vpn` | اینترفیس‌های VPN در `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | اینترفیس‌های VPN از طریق netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | اینترفیس‌های VPN در `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | مسیرهای VPN در `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | ترافیک VPN (RX/TX) در `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | اینترفیس‌های VPN از طریق `if_indextoname()` | پیمایش ifindex با `if_indextoname()` | +| `vpn_policy_rules_netlink` | قوانین مسیریابی VPN policy (table 100–200، oif=tun) | Netlink `RTM_GETRULE` dump | +**نشانه‌های شبکه (Network signs) — `EvidenceSource.NATIVE_SOCKET`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `fib_trie_denied` | `/proc/net/fib_trie` در دسترس نیست (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink مسدود شده (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` به tun + تایید `getsockopt` | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` IP خصوصی VPN را برمی‌گرداند | `getsockname()` روی سوکت UDP | +| `udp_port_conflict_physical` | تداخل پورت UDP (500/4500/1194/1701/51820) روی IP فیزیکی | `bind()` روی IP فیزیکی | +| `route_count` | تعداد مسیرها و اینترفیس‌های یکتا | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | عدم تطابق تعداد iface بین bind-probe و RTM_GETLINK | `if_indextoname()` در مقابل `RTM_GETLINK` | +**نشانه‌های غیرمستقیم (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | موفقیت/شکست ارسال 1500 بایت روی UDP | `sendto()` 1500 بایت | +| `normal_pmtu` | Path MTU اینترفیس فیزیکی اصلی | `fetchMtu()` از طریق `getifaddrs()` | +| `timing_oracle` | چرخه‌های ARM CNTVCT برای `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | نرخ انتقال زیر 50000 بسته UDP با پشتیبانی لغو اسکن | `sendto()` غیرمسدودکننده + بررسی لغو هر 64 بسته | +| `gso_failed` / `gso_send_failed` / `gso_ok` | تشخیص قابلیت UDP GSO؛ نتیجه مدرک VPN نیست | `UDP_SEGMENT=1200` + ارسال 4800 بایت | +| `hw_timestamp` | تایم‌استمپینگ سخت‌افزاری (`SIOCSHWTSTAMP`، `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**پراbe‌های محیطی (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `traceroute_denied` | تست traceroute (TTL=1 UDP) مسدود شده | `setsockopt(IP_TTL=1)` + `sendto()` | + +اطمینان بالا (→ `detected = true`): `sysfs_vpn_leak`، `getifaddrs_vpn`، `sysclassnet_vpn`، `rtm_getlink_vpn`، `proc_if_inet6_vpn`، `proc_ipv6_route_vpn`، `proc_net_dev_vpn`، `ifindexname_vpn`، `vpn_policy_rules_netlink`، `bindtodevice_leak`، `getsockname_leak`، `udp_port_conflict_physical`. اندازه‌گیری‌های خام و نتایج GSO اطلاعاتی هستند؛ سایر ناهنجاری‌ها → `needsReview`. + --- ## نتیجه نهایی (`VerdictEngine`) @@ -546,7 +648,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می - `geoHit` = `GeoIP.outsideRu == true` (به جز رومینگ) - `directHit` = detected-evidence از `DIRECT_NETWORK_CAPABILITIES` یا `SYSTEM_PROXY` -- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` یا `NATIVE_SOCKET` با اطمینان بالا | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------| diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 869f672..43ae859 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -520,6 +520,109 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*` 满足其中任一条件均会产生 `needsReview = true`(`EvidenceSource.SANDBOX_ISOLATION`),**不会**产生 `detected`。 +#### 12.5 VPN 信号 (`evaluateVpnSignals`) + +通过原生 JNI 调用进行全面的 VPN 检测。所有检查均可在**未 root** 的设备上运行——当权限不足(SELinux/capabilities)时,检查标记为 `unavailable` 而不会崩溃。 + +**属性和文件 (`nativeDetectVpnProperties`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| DNS 属性 | `net.dns1-4`、`net.vpn.dns1-2`、`dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN 属性 | `net.vpn.default_iface`、`vpn.enable`、`net.tun0.dns1-2`、`net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide 文件 | `/data/local/vpnhide`、`/data/adb/vpnhide`、`/data/local/bypass` 等 | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`、`/data/adb/modules/lsposed`、`/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook 属性 | `persist.sys.lspd`、`persist.sys.lsposed`、`ro.lsposed.hidden` | `__system_property_get` | + +高置信度:`vpn_prop`、`vpnhide`、`hook_prop`。中等:其余。 + +**通过 /proc 泄漏 (`nativeDetectVpnLeaks`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| TCP VPN 端口 | 端口 443、1194、51820、8443、1723、500、4500 上的连接 | `/proc/net/tcp[6]` | +| UDP VPN 端口 | 端口 51820(WireGuard)、1194(OpenVPN)、500、4500 上的套接字 | `/proc/net/udp[6]` | +| if_inet6 | `/proc/net/if_inet6` 中的 tun/wg/ppp/tap 接口 | `/proc/net/if_inet6` | +| 路由 VPN | 通过 tun/wg/ppp/tap 的路由 | `/proc/net/route` | +| FIB trie | `/32 host` 条目(非 LOCAL)——VPN 主机路由 | `/proc/net/fib_trie` | + +高置信度:`udp_vpn_port`、`route_vpn_iface`、`arp_vpn_iface`、`inet6_vpn_iface`。 + +**高级检查 (`nativeDetectVpnAdvanced`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| ARP VPN | tun/wg/ppp 接口上的条目 | `/proc/net/arp` | +| Sysctl | `rp_filter=0`、`ip_forward=1`、`forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` 等 | +| ESTABLISHED VPN | 到私有 IP 的 VPN 端口连接 | `/proc/net/tcp` | + +**非常规 syscall 检查 (`nativeDetectVpnSyscalls`):** + +通过直接 netlink 请求和探测连接进行检查。权限不足时返回 `unavailable`: + +| 检查 | 方法 | 检测内容 | +|------|------|----------| +| RTM_GETRULE | Netlink dump 路由策略规则 | VPN 策略路由规则 | +| RTM_GETQDISC | Netlink dump 队列规则 | VPN qdisc 隧道 | +| RTM_GETNEIGH | Netlink dump 邻居表 | 隐藏的 MAC 地址(零 LLADDR) | +| TCP_INFO MSS | 连接到 8.8.8.8:443,读取 `snd_mss` | MSS 降低(隧道指标) | +| SO_BINDTODEVICE | 探测绑定到不存在的接口 | VPN hook 拦截 setsockopt | +| Loopback 端口绑定 | 尝试占用端口 51820/1194/443/8443 | 端口冲突(VPN 监听器) | +| BPF OBJ GET | 尝试打开 `/sys/fs/bpf/` 映射 | 访问 netd BPF 映射 | +| IP_RECVERR | 探测 setsockopt IP_RECVERR | VPN hook 拦截 IP_RECVERR | + +高置信度:`vpn_policy_rules`、`hidden_mac_neighbors`、`tcp_mss_low`、`loopback_port_conflict`、`bpf_map_accessible`。 + +#### 12.6 深度 VPN 检测器 (`VpnNativeDetectorChecker`) + +新检测器位于 Native 类别下的独立子章节,分为 4 个子类别。数据来自新的 JNI 方法 `nativeDetectVpnDetector()`,行前缀为 `vdet|`: + +**直接迹象(Direct signs)— `EvidenceSource.NATIVE_INTERFACE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `sysfs_vpn_leak` | 通过 sysfs 泄漏 tun/wg/ppp/xfrm | `/sys/class/net`、`/sys/devices/virtual/net`、`/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | `getifaddrs()` 列表中的 VPN 接口 | `getifaddrs()` | +| `sysclassnet_vpn` | `/sys/class/net` 中的 VPN 接口 | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | 通过 netlink RTM_GETLINK 的 VPN 接口 | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | `/proc/net/if_inet6` 中的 VPN 接口 | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | `/proc/net/ipv6_route` 中的 VPN 路由 | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | `/proc/net/dev` 中的 VPN 流量(RX/TX) | `/proc/net/dev` | +| `ifindexname_vpn` | 通过 `if_indextoname()` 的 VPN 接口 | `if_indextoname()` ifindex 扫描 | +| `vpn_policy_rules_netlink` | VPN policy 路由规则(table 100–200,oif=tun) | Netlink `RTM_GETRULE` dump | + +**网络迹象(Network signs)— `EvidenceSource.NATIVE_SOCKET`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `fib_trie_denied` | `/proc/net/fib_trie` 不可用(SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink 被拒绝(SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` 到 tun + `getsockopt` 确认 | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` 返回私有 VPN IP | UDP 套接字上的 `getsockname()` | +| `udp_port_conflict_physical` | 物理 IP 上的 UDP 端口冲突(500/4500/1194/1701/51820) | 物理 IP 上的 `bind()` | +| `route_count` | 路由数量和唯一接口数量 | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | bind-probe 与 RTM_GETLINK 接口数不匹配 | `if_indextoname()` 对比 `RTM_GETLINK` | + +**间接迹象(Indirect signs)— `EvidenceSource.NATIVE_ROUTE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS(tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | 通过 UDP 发送 1500 字节成功/失败 | `sendto()` 1500 字节 | +| `normal_pmtu` | 主物理接口的路径 MTU | 通过 `getifaddrs()` 的 `fetchMtu()` | +| `timing_oracle` | `sendto()` 的 ARM CNTVCT 周期(最小/最大/平均) | `mrs cntvct_el0`(aarch64) | +| `backpressure` | 50000 个 UDP 包下的吞吐量,支持扫描取消 | 非阻塞 `sendto()` 突发,每 64 个包检查一次取消状态 | +| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO 能力诊断;结果不是 VPN 证据 | `UDP_SEGMENT=1200` + 发送 4800 字节 | +| `hw_timestamp` | 硬件时间戳(`SIOCSHWTSTAMP`、`SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**环境探测(Environment probes)— `EvidenceSource.NATIVE_INTERFACE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `traceroute_denied` | traceroute 探测(TTL=1 UDP)被阻止 | `setsockopt(IP_TTL=1)` + `sendto()` | + +高置信度(→ `detected = true`):`sysfs_vpn_leak`、`getifaddrs_vpn`、`sysclassnet_vpn`、`rtm_getlink_vpn`、`proc_if_inet6_vpn`、`proc_ipv6_route_vpn`、`proc_net_dev_vpn`、`ifindexname_vpn`、`vpn_policy_rules_netlink`、`bindtodevice_leak`、`getsockname_leak`、`udp_port_conflict_physical`。原始测量值和 GSO 结果仅供参考;其他异常 → `needsReview`。 + --- ## 结论 (`VerdictEngine`) @@ -545,7 +648,7 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*` - `geoHit` = `GeoIP.outsideRu == true`(漫游除外) - `directHit` = 来自 `DIRECT_NETWORK_CAPABILITIES` 或 `SYSTEM_PROXY` 的 detected evidence -- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence +- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence,或高置信度 `NATIVE_SOCKET` | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------|