Compare commits

...

3 commits
v2.9.0 ... main

Author SHA1 Message Date
AR34
e5faf94563
Da (#81)
Some checks are pending
CI / build (push) Waiting to run
* Add Detect VPN from native

* Add some methods & fix docs

* Sonar fixes

* fix: устранить ложные нативные VPN-сигналы

---------

Co-authored-by: xtclovver <notcvnt@gmail.com>
2026-07-10 03:26:07 +03:00
xtclovver
219518c53f fix(checker): не считать kernel-route таблицы local утечкой VPN (#78)
Some checks failed
CI / build (push) Has been cancelled
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
таблицах — детектируется как прежде.
2026-06-12 14:43:29 +03:00
xtclovver
52b4550427
chore(quality): SonarCloud triage — safe fixes + UI tile bugfix (#77)
Some checks are pending
CI / build (push) Waiting to run
* 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", "<none>", 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.
2026-06-12 02:24:56 +03:00
33 changed files with 2876 additions and 77 deletions

3
.gitignore vendored
View file

@ -23,4 +23,5 @@ release.jks
!CONTRIBUTING.md
!app/src/test/resources/export/golden/*.md
/.codex
.marketplace-keys/
.marketplace-keys/
app-release/

106
README.md
View file

@ -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/<if>")` |
| `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 100200, 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 | Вердикт |
|-----|--------|----------|---------|

View file

@ -166,7 +166,7 @@ int ProgressCallback(
curl_off_t /* dlnow */,
curl_off_t /* ultotal */,
curl_off_t /* ulnow */) {
auto* state = static_cast<NativeCurlCancellationState*>(clientp);
const auto* state = static_cast<const NativeCurlCancellationState*>(clientp);
return state != nullptr && state->cancelled.load() ? 1 : 0;
}

File diff suppressed because it is too large Load diff

View file

@ -1209,6 +1209,7 @@ class MainActivity : AppCompatActivity() {
}
is CheckUpdate.IpConsensusReady -> {
markStageCompleted(RunningStage.IP_CONSENSUS)
updateTileFromIpConsensus(update.result)
}
is CheckUpdate.DomainReachabilityReady -> {
markStageCompleted(RunningStage.DOMAIN_REACHABILITY)

View file

@ -162,7 +162,7 @@ internal class SettingsCustomCheckEditorFragment :
// Override expansion listener to track openSectionIds
sectionView.findViewById<View>(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()

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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),
@ -492,6 +518,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 +547,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
@ -916,11 +964,163 @@ object NativeSignsChecker {
}.getOrDefault(false)
}
internal fun evaluateVpnSignals(
context: Context,
props: List<com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding>,
leaks: List<com.notcvnt.rknhardering.probe.NativeVpnLeakFinding>,
advanced: List<com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding>,
syscalls: List<com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding>,
): PartialOutcome {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
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)}"
}
}

View file

@ -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
}

View file

@ -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|<kind>|<detail>".
*/
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<Finding>,
evidence: MutableList<EvidenceItem>,
) {
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<Finding>()
val evidence = mutableListOf<EvidenceItem>()
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"
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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<String>): List<NativeVpnPropertyFinding> {
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<NativeVpnPropertyFinding> {
val rows = NativeSignsBridge.detectVpnProperties()
return parseVpnPropertyFindings(rows)
}
fun parseVpnLeakFindings(rows: Array<String>): List<NativeVpnLeakFinding> {
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<NativeVpnLeakFinding> {
val rows = NativeSignsBridge.detectVpnLeaks()
return parseVpnLeakFindings(rows)
}
fun parseVpnAdvancedFindings(rows: Array<String>): List<NativeVpnAdvancedFinding> {
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<NativeVpnAdvancedFinding> {
val rows = NativeSignsBridge.detectVpnAdvanced()
return parseVpnAdvancedFindings(rows)
}
fun parseVpnSyscallFindings(rows: Array<String>): List<NativeVpnSyscallFinding> {
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<NativeVpnSyscallFinding> {
val rows = NativeSignsBridge.detectVpnSyscalls()
return parseVpnSyscallFindings(rows)
}
}

View file

@ -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<String>)? = null
@Volatile
internal var detectVpnPropertiesOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnLeaksOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnAdvancedOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnSyscallsOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnDetectorOverride: ((ScanCancellationSignal?) -> Array<String>)? = null
@Volatile
private var initialized = false
@ -141,6 +158,36 @@ object NativeSignsBridge {
return runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray())
}
fun detectVpnProperties(): Array<String> {
detectVpnPropertiesOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray())
}
fun detectVpnLeaks(): Array<String> {
detectVpnLeaksOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray())
}
fun detectVpnAdvanced(): Array<String> {
detectVpnAdvancedOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray())
}
fun detectVpnSyscalls(): Array<String> {
detectVpnSyscallsOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray())
}
fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array<String> {
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<String>
private external fun nativeDetectRoot(): Array<String>
private external fun nativeDetectEmulator(): Array<String>
private external fun nativeDetectVpnProperties(): Array<String>
private external fun nativeDetectVpnLeaks(): Array<String>
private external fun nativeDetectVpnAdvanced(): Array<String>
private external fun nativeDetectVpnSyscalls(): Array<String>
private external fun nativeDetectVpnDetector(cancellationSignal: ScanCancellationSignal?): Array<String>
}

View file

@ -13,6 +13,8 @@ import java.util.Locale
object TunProbeDiagnosticsFormatter {
private const val NONE = "<none>"
fun format(
diagnostics: TunProbeDiagnostics,
settings: CheckSettings,
@ -109,8 +111,8 @@ object TunProbeDiagnosticsFormatter {
builder.appendLine("available: true")
builder.appendLine("interfaceName: ${path.interfaceName ?: "<missing>"}")
builder.appendLine("selectedMode: ${path.selectedMode?.name ?: "NONE"}")
builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: "<none>"}")
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) ?: "<none>"}")
builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: "<none>"}")
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) ?: "<none>"}"
PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: NONE}"
PublicIpProbeStatus.FAILED,
PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: "<none>"}"
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 { "<none>" }
.ifBlank { NONE }
"DIRECT preset=${settings.resolverConfig.preset.name} servers=$servers"
}
DnsResolverMode.DOH -> {
val bootstrap = settings.resolverConfig.effectiveDohBootstrapHosts()
.joinToString(", ") { maskIp(it) }
.ifBlank { "<none>" }
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: "<none>"} bootstrap=$bootstrap"
.ifBlank { NONE }
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: NONE} bootstrap=$bootstrap"
}
}
}

View file

@ -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
}
}

View file

@ -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"),

View file

@ -639,6 +639,64 @@
<string name="checker_native_isolation_clone">برنامه در یک محفظه شبیه‌سازی/dual-app اجرا می‌شود (کاربر %1$d)؛ VPN در پروفایل اصلی قابل تشخیص نیست</string>
<string name="checker_native_isolation_work_profile">برنامه در یک پروفایل کاری مدیریت‌شده اجرا می‌شود؛ VPN در پروفایل شخصی بدون روت قابل تشخیص نیست</string>
<string name="checker_native_vpn_signal">سیگنال VPN: %1$s</string>
<string name="vpn_desc_prop">ویژگی سیستمی VPN شناسایی شد</string>
<string name="vpn_desc_vpnhide">فایل مخفی‌سازی VPN یافت شد</string>
<string name="vpn_desc_hook">ویژگی فریمورک hook شناسایی شد</string>
<string name="vpn_desc_dns">ویژگی DNS به VPN اشاره می‌کند</string>
<string name="vpn_desc_file">فایل ابزار VPN یافت شد</string>
<string name="vpn_desc_lsposed">محیط LSPosed/Xposed شناسایی شد</string>
<string name="vpn_desc_tcp_port">اتصال TCP روی پورت VPN</string>
<string name="vpn_desc_udp_port">سوکت UDP روی پورت VPN</string>
<string name="vpn_desc_inet6">رابط VPN IPv6 در /proc</string>
<string name="vpn_desc_route">مسیر از طریق رابط VPN</string>
<string name="vpn_desc_arp">رکورد ARP روی رابط VPN</string>
<string name="vpn_desc_forwarding">Forwarding IP فعال است</string>
<string name="vpn_desc_rpfilter">فیلتر reverse path غیرفعال است</string>
<string name="vpn_desc_established">اتصال برقرار به IP خصوصی روی پورت VPN</string>
<string name="vpn_desc_policy_rules">قوانین مسیریابی VPN یافت شد</string>
<string name="vpn_desc_qdisc"> queue discipline VPN شناسایی شد</string>
<string name="vpn_desc_hidden_mac">آدرس‌های MAC پنهان در جدول همسایه</string>
<string name="vpn_desc_mss">MSS به طور مشکوکی پایین (احتمالاً تونل)</string>
<string name="vpn_desc_bindtodevice">setsockopt رهگیری شد (hook VPN)</string>
<string name="vpn_desc_port_conflict">تداخل پورت روی loopback (listener VPN)</string>
<string name="vpn_desc_bpf">دسترسی به نقشه‌های BPF netd</string>
<string name="vpn_desc_recverr">IP_RECVERR رهگیری شد (hook VPN)</string>
<string name="vpn_desc_sysfs_leak">رابط VPN از طریق sysfs نشت کرد</string>
<string name="vpn_desc_getifaddrs">رابط VPN در getifaddrs()</string>
<string name="vpn_desc_sysclassnet">رابط VPN در /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">رابط VPN در RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">رابط VPN IPv6 در /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">مسیر VPN IPv6 در /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">ترافیک VPN در /proc/net/dev</string>
<string name="vpn_desc_fib_trie">دسترسی به /proc/net/fib_trie مسدود شد (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE رابط VPN را نشت داد</string>
<string name="vpn_desc_inet_diag">inet_diag netlink مسدود شد (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() IP VPN را نشت داد</string>
<string name="vpn_desc_policy_rules_netlink">قوانین مسیریابی VPN از طریق netlink</string>
<string name="vpn_desc_route_count">شمارش مسیرها و رابط‌های ناشناس</string>
<string name="vpn_desc_udp_port_physical">تداخل پورت UDP روی رابط فیزیکی</string>
<string name="vpn_desc_trim_oracle">عدم تطابق bind probe و RTM_GETLINK</string>
<string name="vpn_desc_ifindexname">رابط VPN از طریق if_indextoname</string>
<string name="vpn_desc_pmtu_mss">تحلیل UDP PMTU و TCP MSS</string>
<string name="vpn_desc_udp_pmtu_ok">ارسال UDP موفق (بدون مشکل PMTU)</string>
<string name="vpn_desc_udp_pmtu_fail">ارسال UDP ناموفق (مشکل PMTU)</string>
<string name="vpn_desc_normal_pmtu">تشخیص path MTU عادی</string>
<string name="vpn_desc_traceroute">تست traceroute (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure تحت بار مداوم</string>
<string name="vpn_desc_gso_failed">پشتیبانی GSO در دسترس نیست (فقط تشخیصی)</string>
<string name="vpn_desc_gso_send_failed">ارسال GSO ناموفق بود (فقط تشخیصی)</string>
<string name="vpn_desc_gso_ok">GSO پشتیبانی می‌شود (رابط عادی)</string>
<string name="vpn_desc_hw_timestamp">بررسی تایم‌استمپینگ سخت‌افزاری</string>
<string name="vpn_status_not_found">یافت نشد</string>
<string name="vpn_status_unavailable">غیرقابل دسترس (بدون مجوز)</string>
<string name="checker_vpn_detector_category_name">تشخیص‌دهنده VPN (عمیق)</string>
<string name="checker_vpn_detector_unavailable">کتابخانه تشخیص‌دهنده VPN بارگذاری نشد</string>
<string name="checker_vpn_detector_unavailable_with_reason">کتابخانه تشخیص‌دهنده VPN بارگذاری نشد: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">نشانه عمیق VPN یافت نشد</string>
<!-- App update checker -->
<string name="update_dialog_title">به‌روزرسانی موجود است</string>
<string name="update_dialog_message">نسخه نصب‌شده: %1$s\nآخرین نسخه: %2$s</string>

View file

@ -660,6 +660,65 @@
<string name="checker_native_isolation_clone">Приложение работает в клонированном/dual-app контейнере (пользователь %1$d); VPN в основном профиле не обнаруживается</string>
<string name="checker_native_isolation_work_profile">Приложение работает в управляемом рабочем профиле; VPN в личном профиле не обнаруживается без root</string>
<!-- NativeSignsChecker: VPN сигналы -->
<string name="checker_native_vpn_signal">VPN сигнал: %1$s</string>
<string name="vpn_desc_prop">Обнаружены VPN-свойства системы</string>
<string name="vpn_desc_vpnhide">Файл сокрытия VPN</string>
<string name="vpn_desc_hook">Свойства хук-фреймворков</string>
<string name="vpn_desc_dns">DNS-свойства указывают на VPN</string>
<string name="vpn_desc_file">Файлы VPN-инструментов</string>
<string name="vpn_desc_lsposed">Обнаружена LSPosed/Xposed среда</string>
<string name="vpn_desc_tcp_port">TCP-соединение на VPN-порту</string>
<string name="vpn_desc_udp_port">UDP-сокет на VPN-порту</string>
<string name="vpn_desc_inet6">IPv6-интерфейс VPN в /proc</string>
<string name="vpn_desc_route">Маршрут через VPN-интерфейс</string>
<string name="vpn_desc_arp">ARP-запись VPN-интерфейса</string>
<string name="vpn_desc_forwarding">IP-форвардинг включён</string>
<string name="vpn_desc_rpfilter">Reverse path filter отключен</string>
<string name="vpn_desc_established">Установленное соединение к приватному IP через VPN-порт</string>
<string name="vpn_desc_policy_rules">Найдены VPN-правила роутинга</string>
<string name="vpn_desc_qdisc">Обнаружена VPN queue discipline</string>
<string name="vpn_desc_hidden_mac">Скрытые MAC-адреса в таблице соседей</string>
<string name="vpn_desc_mss">Подозрительно низкий MSS (возможно туннель)</string>
<string name="vpn_desc_bindtodevice">Перехват setsockopt (VPN-хук)</string>
<string name="vpn_desc_port_conflict">Конфликт портов на loopback (VPN listener)</string>
<string name="vpn_desc_bpf">Доступ к BPF-картам netd</string>
<string name="vpn_desc_recverr">Перехват IP_RECVERR (VPN-хук)</string>
<string name="vpn_desc_sysfs_leak">VPN-интерфейс утёк через sysfs</string>
<string name="vpn_desc_getifaddrs">VPN-интерфейс в getifaddrs()</string>
<string name="vpn_desc_sysclassnet">VPN-интерфейс в /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">VPN-интерфейс в RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">VPN IPv6-интерфейс в /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">VPN IPv6-маршрут в /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">VPN-трафик в /proc/net/dev</string>
<string name="vpn_desc_fib_trie">Доступ к /proc/net/fib_trie запрещён (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE утек интерфейс VPN</string>
<string name="vpn_desc_inet_diag">inet_diag netlink запрещён (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() утек IP VPN</string>
<string name="vpn_desc_policy_rules_netlink">VPN-правила роутинга через netlink</string>
<string name="vpn_desc_route_count">Подсчёт маршрутов и анонимных интерфейсов</string>
<string name="vpn_desc_udp_port_physical">Конфликт UDP-порта на физическом интерфейсе</string>
<string name="vpn_desc_trim_oracle">Расхождение bind probe и RTM_GETLINK</string>
<string name="vpn_desc_ifindexname">VPN-интерфейс через if_indextoname</string>
<string name="vpn_desc_pmtu_mss">Анализ UDP PMTU и TCP MSS</string>
<string name="vpn_desc_udp_pmtu_ok">UDP-отправка успешна (нет PMTU-проблемы)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP-отправка не удалась (PMTU-проблема)</string>
<string name="vpn_desc_normal_pmtu">Обнаружение нормального path MTU</string>
<string name="vpn_desc_traceroute">Traceroute-проба (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure при постоянной нагрузке</string>
<string name="vpn_desc_gso_failed">Поддержка GSO недоступна (только диагностика)</string>
<string name="vpn_desc_gso_send_failed">GSO-отправка не удалась (только диагностика)</string>
<string name="vpn_desc_gso_ok">GSO поддерживается (нормальный интерфейс)</string>
<string name="vpn_desc_hw_timestamp">Проверка аппаратного таймстампинга</string>
<string name="vpn_status_not_found">не обнаружено</string>
<string name="vpn_status_unavailable">недоступно (нет прав)</string>
<string name="checker_vpn_detector_category_name">VPN Детектор (глубокий)</string>
<string name="checker_vpn_detector_unavailable">Библиотека VPN-детектора не загружена</string>
<string name="checker_vpn_detector_unavailable_with_reason">Библиотека VPN-детектора не загружена: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">Глубоких признаков VPN не обнаружено</string>
<!-- App update checker -->
<string name="update_dialog_title">Доступно обновление</string>
<string name="update_dialog_message">Установленная версия: %1$s\nАктуальная версия: %2$s</string>

View file

@ -637,6 +637,64 @@
<string name="checker_native_isolation_clone">应用运行在克隆/双开容器中(用户 %1$d无法检测主资料中的 VPN</string>
<string name="checker_native_isolation_work_profile">应用运行在受管理的工作资料中;无法在无 root 的情况下检测个人资料中的 VPN</string>
<string name="checker_native_vpn_signal">VPN 信号:%1$s</string>
<string name="vpn_desc_prop">检测到 VPN 系统属性</string>
<string name="vpn_desc_vpnhide">发现 VPN 隐藏文件</string>
<string name="vpn_desc_hook">检测到 hook 框架属性</string>
<string name="vpn_desc_dns">DNS 属性指向 VPN</string>
<string name="vpn_desc_file">发现 VPN 工具文件</string>
<string name="vpn_desc_lsposed">检测到 LSPosed/Xposed 环境</string>
<string name="vpn_desc_tcp_port">VPN 端口上的 TCP 连接</string>
<string name="vpn_desc_udp_port">VPN 端口上的 UDP 套接字</string>
<string name="vpn_desc_inet6">/proc 中的 IPv6 VPN 接口</string>
<string name="vpn_desc_route">通过 VPN 接口的路由</string>
<string name="vpn_desc_arp">VPN 接口上的 ARP 条目</string>
<string name="vpn_desc_forwarding">IP 转发已启用</string>
<string name="vpn_desc_rpfilter">反向路径过滤已禁用</string>
<string name="vpn_desc_established">到私有 IP 的 VPN 端口已建立连接</string>
<string name="vpn_desc_policy_rules">发现 VPN 路由规则</string>
<string name="vpn_desc_qdisc">检测到 VPN 队列规则</string>
<string name="vpn_desc_hidden_mac">邻居表中隐藏的 MAC 地址</string>
<string name="vpn_desc_mss">异常低的 MSS可能是隧道</string>
<string name="vpn_desc_bindtodevice">setsockopt 被拦截VPN hook</string>
<string name="vpn_desc_port_conflict">loopback 端口冲突VPN 监听器)</string>
<string name="vpn_desc_bpf">访问 netd BPF 映射</string>
<string name="vpn_desc_recverr">IP_RECVERR 被拦截VPN hook</string>
<string name="vpn_desc_sysfs_leak">VPN 接口通过 sysfs 泄露</string>
<string name="vpn_desc_getifaddrs">getifaddrs() 中的 VPN 接口</string>
<string name="vpn_desc_sysclassnet">/sys/class/net 中的 VPN 接口</string>
<string name="vpn_desc_rtm_getlink">RTM_GETLINK 中的 VPN 接口</string>
<string name="vpn_desc_proc_if_inet6">/proc/net/if_inet6 中的 VPN IPv6 接口</string>
<string name="vpn_desc_proc_ipv6_route">/proc/net/ipv6_route 中的 VPN IPv6 路由</string>
<string name="vpn_desc_proc_net_dev">/proc/net/dev 中的 VPN 流量</string>
<string name="vpn_desc_fib_trie">/proc/net/fib_trie 访问被拒绝 (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE 泄露 VPN 接口</string>
<string name="vpn_desc_inet_diag">inet_diag netlink 被拒绝 (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() 泄露 VPN IP</string>
<string name="vpn_desc_policy_rules_netlink">通过 netlink 的 VPN 策略规则</string>
<string name="vpn_desc_route_count">路由计数和匿名接口</string>
<string name="vpn_desc_udp_port_physical">物理接口上的 UDP 端口冲突</string>
<string name="vpn_desc_trim_oracle">bind probe 与 RTM_GETLINK 不符</string>
<string name="vpn_desc_ifindexname">通过 if_indextoname 暴露的 VPN 接口</string>
<string name="vpn_desc_pmtu_mss">UDP PMTU 和 TCP MSS 分析</string>
<string name="vpn_desc_udp_pmtu_ok">UDP 发送成功(无 PMTU 瓶颈)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP 发送失败PMTU 问题)</string>
<string name="vpn_desc_normal_pmtu">正常路径 MTU 检测</string>
<string name="vpn_desc_traceroute">Traceroute 探测 (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT 计时神谕</string>
<string name="vpn_desc_backpressure">持续突发下的背压</string>
<string name="vpn_desc_gso_failed">GSO 支持不可用(仅诊断)</string>
<string name="vpn_desc_gso_send_failed">GSO 发送失败(仅诊断)</string>
<string name="vpn_desc_gso_ok">支持 GSO正常接口</string>
<string name="vpn_desc_hw_timestamp">硬件时间戳检查</string>
<string name="vpn_status_not_found">未发现</string>
<string name="vpn_status_unavailable">不可用(无权限)</string>
<string name="checker_vpn_detector_category_name">VPN 检测器(深度)</string>
<string name="checker_vpn_detector_unavailable">VPN 检测器库未加载</string>
<string name="checker_vpn_detector_unavailable_with_reason">VPN 检测器库未加载:%1$s</string>
<string name="checker_vpn_detector_no_anomalies">未检测到深度 VPN 迹象</string>
<!-- App update checker -->
<string name="update_dialog_title">有更新可用</string>
<string name="update_dialog_message">已安装版本:%1$s\n最新版本%2$s</string>

View file

@ -665,6 +665,65 @@
<string name="checker_native_isolation_clone">App runs in a cloned/dual-app container (user %1$d); a VPN in the primary profile is not observable</string>
<string name="checker_native_isolation_work_profile">App runs in a managed work profile; a VPN in the personal profile is not observable without root</string>
<!-- NativeSignsChecker: VPN signals -->
<string name="checker_native_vpn_signal">VPN signal: %1$s</string>
<string name="vpn_desc_prop">VPN system property detected</string>
<string name="vpn_desc_vpnhide">VPN hide file found</string>
<string name="vpn_desc_hook">Hook framework property detected</string>
<string name="vpn_desc_dns">DNS property points to VPN</string>
<string name="vpn_desc_file">VPN tool file found</string>
<string name="vpn_desc_lsposed">LSPosed/Xposed environment detected</string>
<string name="vpn_desc_tcp_port">TCP connection on VPN port</string>
<string name="vpn_desc_udp_port">UDP socket on VPN port</string>
<string name="vpn_desc_inet6">IPv6 VPN interface in /proc</string>
<string name="vpn_desc_route">Route via VPN interface</string>
<string name="vpn_desc_arp">ARP entry on VPN interface</string>
<string name="vpn_desc_forwarding">IP forwarding is enabled</string>
<string name="vpn_desc_rpfilter">Reverse path filter disabled</string>
<string name="vpn_desc_established">Established connection to private IP on VPN port</string>
<string name="vpn_desc_policy_rules">VPN routing rules found</string>
<string name="vpn_desc_qdisc">VPN queue discipline detected</string>
<string name="vpn_desc_hidden_mac">Hidden MAC addresses in neighbor table</string>
<string name="vpn_desc_mss">Suspiciously low MSS (possible tunnel)</string>
<string name="vpn_desc_bindtodevice">setsockopt intercepted (VPN hook)</string>
<string name="vpn_desc_port_conflict">Port conflict on loopback (VPN listener)</string>
<string name="vpn_desc_bpf">Access to netd BPF maps</string>
<string name="vpn_desc_recverr">IP_RECVERR intercepted (VPN hook)</string>
<string name="vpn_desc_sysfs_leak">VPN interface leaked via sysfs</string>
<string name="vpn_desc_getifaddrs">VPN interface in getifaddrs()</string>
<string name="vpn_desc_sysclassnet">VPN interface in /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">VPN interface in RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">VPN IPv6 interface in /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">VPN IPv6 route in /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">VPN traffic in /proc/net/dev</string>
<string name="vpn_desc_fib_trie">/proc/net/fib_trie access denied (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE leaked VPN interface</string>
<string name="vpn_desc_inet_diag">inet_diag netlink denied (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() leaked VPN IP</string>
<string name="vpn_desc_policy_rules_netlink">VPN policy rules via netlink</string>
<string name="vpn_desc_route_count">Route count and anonymous interfaces</string>
<string name="vpn_desc_udp_port_physical">UDP port conflict on physical interface</string>
<string name="vpn_desc_trim_oracle">Bind probe vs RTM_GETLINK discrepancy</string>
<string name="vpn_desc_ifindexname">VPN interface exposed via if_indextoname</string>
<string name="vpn_desc_pmtu_mss">UDP PMTU and TCP MSS analysis</string>
<string name="vpn_desc_udp_pmtu_ok">UDP send succeeded (no PMTU bottleneck)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP send failed (PMTU issue)</string>
<string name="vpn_desc_normal_pmtu">Normal path MTU detection</string>
<string name="vpn_desc_traceroute">Traceroute probe (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure under sustained burst</string>
<string name="vpn_desc_gso_failed">GSO support unavailable (diagnostic only)</string>
<string name="vpn_desc_gso_send_failed">GSO send failed (diagnostic only)</string>
<string name="vpn_desc_gso_ok">GSO supported (normal interface)</string>
<string name="vpn_desc_hw_timestamp">Hardware timestamping check</string>
<string name="vpn_status_not_found">not found</string>
<string name="vpn_status_unavailable">unavailable (no permission)</string>
<string name="checker_vpn_detector_category_name">VPN Detector (deep)</string>
<string name="checker_vpn_detector_unavailable">VPN detector library not loaded</string>
<string name="checker_vpn_detector_unavailable_with_reason">VPN detector library not loaded: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">No deep VPN signs detected</string>
<!-- App update checker -->
<string name="update_dialog_title">Update available</string>
<string name="update_dialog_message">Installed version: %1$s\nLatest version: %2$s</string>

View file

@ -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()

View file

@ -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()

View file

@ -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(

View file

@ -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(

View file

@ -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)
}
}
}
}

View file

@ -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(

View file

@ -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/<if>")` |
| `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 100200, 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 |
|-----|--------|----------|---------|

View file

@ -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/<if>")` |
| `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 100200، 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 |
|-----|--------|----------|---------|

View file

@ -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 端口 | 端口 51820WireGuard、1194OpenVPN、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/<if>")` |
| `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 100200oif=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 MSStcpi_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 |
|-----|--------|----------|---------|