mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-10 01:29:58 +00:00
Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5faf94563 | ||
|
|
219518c53f | ||
|
|
52b4550427 |
33 changed files with 2876 additions and 77 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
106
README.md
|
|
@ -546,6 +546,110 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne
|
|||
|
||||
Любой из сигналов даёт `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), **никогда** `detected`.
|
||||
|
||||
#### 12.5 VPN-сигналы (`evaluateVpnSignals`)
|
||||
|
||||
Комплексная проверка VPN через нативные JNI-вызовы. Все проверки работают на **нерутованных** устройствах — при отсутствии прав (SELinux/capabilities) проверка помечается как `unavailable` и не крашится.
|
||||
|
||||
**Свойства и файлы (`nativeDetectVpnProperties`):**
|
||||
|
||||
| Проверка | Что ищем | Источник |
|
||||
|----------|----------|----------|
|
||||
| DNS-свойства | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` |
|
||||
| VPN-свойства | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` |
|
||||
| vpnhide файлы | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` и др. | `access(F_OK)` |
|
||||
| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
|
||||
| Hook-свойства | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` |
|
||||
|
||||
Высокая уверенность: `vpn_prop`, `vpnhide`, `hook_prop`. Средняя: остальные.
|
||||
|
||||
**Утечки через /proc (`nativeDetectVpnLeaks`):**
|
||||
|
||||
| Проверка | Что ищем | Источник |
|
||||
|----------|----------|----------|
|
||||
| TCP VPN-порты | Соединения на портах 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` |
|
||||
| UDP VPN-порты | Сокеты на портах 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` |
|
||||
| if_inet6 | Интерфейсы tun/wg/ppp/tap в `/proc/net/if_inet6` | `/proc/net/if_inet6` |
|
||||
| Route VPN | Маршруты через tun/wg/ppp/tap | `/proc/net/route` |
|
||||
| FIB trie | `/32 host` записи (не LOCAL) — хост-маршруты VPN | `/proc/net/fib_trie` |
|
||||
|
||||
Высокая уверенность: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`.
|
||||
|
||||
**Продвинутые проверки (`nativeDetectVpnAdvanced`):**
|
||||
|
||||
| Проверка | Что ищем | Источник |
|
||||
|----------|----------|----------|
|
||||
| ARP VPN | Записи на tun/wg/ppp интерфейсах | `/proc/net/arp` |
|
||||
| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` и др. |
|
||||
| ESTABLISHED VPN | Соединения к приватным IP на VPN-портах | `/proc/net/tcp` |
|
||||
|
||||
**Нетрадиционные syscall-проверки (`nativeDetectVpnSyscalls`):**
|
||||
|
||||
Проверки через прямые netlink-запросы и пробные соединения. При отсутствии прав возвращают `unavailable`:
|
||||
|
||||
| Проверка | Метод | Что обнаруживает |
|
||||
|----------|-------|-------------------|
|
||||
| RTM_GETRULE | Netlink dump роутинг-правил | VPN policy routing rules |
|
||||
| RTM_GETQDISC | Netlink dump queueing discipline | VPN qdisc туннели |
|
||||
| RTM_GETNEIGH | Netlink dump таблицы соседей | Скрытые MAC-адреса (нулевые LLADDR) |
|
||||
| TCP_INFO MSS | Connect к 8.8.8.8:443, чтение `snd_mss` | Снижение MSS (признак туннеля) |
|
||||
| SO_BINDTODEVICE | Пробный bind на несуществующий интерфейс | Перехват setsockopt VPN-хуком |
|
||||
| Loopback port bind | Попытка занять порты 51820/1194/443/8443 | Конфликты портов (VPN listener) |
|
||||
| BPF OBJ GET | Попытка открыть `/sys/fs/bpf/` карты | Доступ к BPF-картам netd |
|
||||
| IP_RECVERR | Пробный setsockopt IP_RECVERR | Перехват IP_RECVERR VPN-хуком |
|
||||
|
||||
Высокая уверенность: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`.
|
||||
|
||||
#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`)
|
||||
|
||||
|
||||
Новые детекты вынесены в отдельную секцию внутри категории Native и сгруппированы по 4 подкатегориям. Данные приходят из нового JNI-метода `nativeDetectVpnDetector(cancellationSignal)`, строки имеют префикс `vdet|`.
|
||||
|
||||
**Прямые признаки (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| Проверка (kind) | Что ищем | Источник |
|
||||
|-----------------|----------|----------|
|
||||
| `sysfs_vpn_leak` | Утечка tun/wg/ppp/xfrm через sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` |
|
||||
| `getifaddrs_vpn` | VPN-интерфейсы в списке `getifaddrs()` | `getifaddrs()` |
|
||||
| `sysclassnet_vpn` | VPN-интерфейсы в `/sys/class/net` | `stat("/sys/class/net/<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 100–200, oif=tun) | Netlink `RTM_GETRULE` dump |
|
||||
|
||||
**Сетевые признаки (Network signs) — `EvidenceSource.NATIVE_SOCKET`:**
|
||||
|
||||
| Проверка (kind) | Что ищем | Источник |
|
||||
|-----------------|----------|----------|
|
||||
| `fib_trie_denied` | `/proc/net/fib_trie` недоступен (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
|
||||
| `inet_diag_denied` | inet_diag netlink запрещён (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
|
||||
| `bindtodevice_leak` | `SO_BINDTODEVICE` к tun + подтверждение `getsockopt` | `setsockopt(SO_BINDTODEVICE)` |
|
||||
| `getsockname_leak` | `getsockname()` возвращает приватный VPN-IP | `getsockname()` на UDP-сокете |
|
||||
| `udp_port_conflict_physical` | Конфликт UDP-портов (500/4500/1194/1701/51820) на физическом IP | `bind()` на физический IP |
|
||||
| `route_count` | Число маршрутов и уникальных интерфейсов | Netlink `RTM_GETROUTE` dump |
|
||||
| `trim_oracle` | Расхождение bind-probe vs RTM_GETLINK по числу iface | `if_indextoname()` vs `RTM_GETLINK` |
|
||||
|
||||
**Косвенные признаки (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:**
|
||||
|
||||
| Проверка (kind) | Что ищем | Источник |
|
||||
|-----------------|----------|----------|
|
||||
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
|
||||
| `udp_pmtu_ok` / `udp_pmtu_fail` | Успех/неудача отправки 1500 байт по UDP | `sendto()` 1500 байт |
|
||||
| `normal_pmtu` | Path MTU основного физического интерфейса | `fetchMtu()` через `getifaddrs()` |
|
||||
| `timing_oracle` | ARM CNTVCT циклы для `sendto()` (мин/макс/сред) | `mrs cntvct_el0` (aarch64) |
|
||||
| `backpressure` | Пропускная способность при 50000 UDP-пакетах с отменой скана | Неблокирующий `sendto()` burst + проверка cancellation каждые 64 пакета |
|
||||
| `gso_failed` / `gso_send_failed` / `gso_ok` | Диагностика поддержки UDP GSO; результат не является признаком VPN | `UDP_SEGMENT=1200` + отправка 4800 байт |
|
||||
| `hw_timestamp` | Аппаратный таймстампинг (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
|
||||
|
||||
**Пробы окружения (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| Проверка (kind) | Что ищем | Источник |
|
||||
|-----------------|----------|----------|
|
||||
| `traceroute_denied` | Traceroute-проба (TTL=1 UDP) заблокирована | `setsockopt(IP_TTL=1)` + `sendto()` |
|
||||
|
||||
Высокая уверенность (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Сырые измерения и GSO-результаты информационные; остальные аномалии → `needsReview`.
|
||||
|
||||
---
|
||||
|
||||
## Вердикт (`VerdictEngine`)
|
||||
|
|
@ -571,7 +675,7 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne
|
|||
|
||||
- `geoHit` = `GeoIP.outsideRu == true` (кроме роуминга)
|
||||
- `directHit` = detected-evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY`
|
||||
- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`
|
||||
- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` либо high-confidence `NATIVE_SOCKET`
|
||||
|
||||
| Geo | Direct | Indirect | Вердикт |
|
||||
|-----|--------|----------|---------|
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1209,6 +1209,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
is CheckUpdate.IpConsensusReady -> {
|
||||
markStageCompleted(RunningStage.IP_CONSENSUS)
|
||||
updateTileFromIpConsensus(update.result)
|
||||
}
|
||||
is CheckUpdate.DomainReachabilityReady -> {
|
||||
markStageCompleted(RunningStage.DOMAIN_REACHABILITY)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 100–200, oif=tun) | Netlink `RTM_GETRULE` dump |
|
||||
|
||||
**Network signs — `EvidenceSource.NATIVE_SOCKET`:**
|
||||
|
||||
| Check (kind) | What it looks for | Source |
|
||||
|--------------|-------------------|--------|
|
||||
| `fib_trie_denied` | `/proc/net/fib_trie` unavailable (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
|
||||
| `inet_diag_denied` | inet_diag netlink denied (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
|
||||
| `bindtodevice_leak` | `SO_BINDTODEVICE` to tun + `getsockopt` confirm | `setsockopt(SO_BINDTODEVICE)` |
|
||||
| `getsockname_leak` | `getsockname()` returns private VPN IP | `getsockname()` on UDP socket |
|
||||
| `udp_port_conflict_physical` | UDP port conflict (500/4500/1194/1701/51820) on physical IP | `bind()` on physical IP |
|
||||
| `route_count` | Route count and unique interface count | Netlink `RTM_GETROUTE` dump |
|
||||
| `trim_oracle` | bind-probe vs RTM_GETLINK iface count mismatch | `if_indextoname()` vs `RTM_GETLINK` |
|
||||
|
||||
**Indirect signs — `EvidenceSource.NATIVE_ROUTE`:**
|
||||
|
||||
| Check (kind) | What it looks for | Source |
|
||||
|--------------|-------------------|--------|
|
||||
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
|
||||
| `udp_pmtu_ok` / `udp_pmtu_fail` | Success/failure sending 1500 bytes over UDP | `sendto()` 1500 bytes |
|
||||
| `normal_pmtu` | Path MTU of primary physical interface | `fetchMtu()` via `getifaddrs()` |
|
||||
| `timing_oracle` | ARM CNTVCT cycles for `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) |
|
||||
| `backpressure` | Throughput under 50000 UDP packets with scan cancellation | Non-blocking `sendto()` burst + cancellation check every 64 packets |
|
||||
| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO capability diagnostics; the result is not VPN evidence | `UDP_SEGMENT=1200` + 4800-byte send |
|
||||
| `hw_timestamp` | Hardware timestamping (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
|
||||
|
||||
**Environment probes — `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| Check (kind) | What it looks for | Source |
|
||||
|--------------|-------------------|--------|
|
||||
| `traceroute_denied` | Traceroute probe (TTL=1 UDP) blocked | `setsockopt(IP_TTL=1)` + `sendto()` |
|
||||
|
||||
High confidence (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Raw measurements and GSO results are informational; other anomalies → `needsReview`.
|
||||
|
||||
---
|
||||
|
||||
## Verdict (`VerdictEngine`)
|
||||
|
|
@ -544,7 +647,7 @@ The `expectedRoamingExit` flag (resolved by `HomeNetworkCatalog` from SIM MCC/MN
|
|||
|
||||
- `geoHit` = `GeoIP.outsideRu == true` (excluding roaming)
|
||||
- `directHit` = detected evidence from `DIRECT_NETWORK_CAPABILITIES` or `SYSTEM_PROXY`
|
||||
- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`
|
||||
- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`, or high-confidence `NATIVE_SOCKET`
|
||||
|
||||
| Geo | Direct | Indirect | Verdict |
|
||||
|-----|--------|----------|---------|
|
||||
|
|
|
|||
|
|
@ -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 100–200، oif=tun) | Netlink `RTM_GETRULE` dump |
|
||||
**نشانههای شبکه (Network signs) — `EvidenceSource.NATIVE_SOCKET`:**
|
||||
|
||||
| بررسی (kind) | چه چیزی جستجو میشود | منبع |
|
||||
|--------------|---------------------|------|
|
||||
| `fib_trie_denied` | `/proc/net/fib_trie` در دسترس نیست (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
|
||||
| `inet_diag_denied` | inet_diag netlink مسدود شده (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
|
||||
| `bindtodevice_leak` | `SO_BINDTODEVICE` به tun + تایید `getsockopt` | `setsockopt(SO_BINDTODEVICE)` |
|
||||
| `getsockname_leak` | `getsockname()` IP خصوصی VPN را برمیگرداند | `getsockname()` روی سوکت UDP |
|
||||
| `udp_port_conflict_physical` | تداخل پورت UDP (500/4500/1194/1701/51820) روی IP فیزیکی | `bind()` روی IP فیزیکی |
|
||||
| `route_count` | تعداد مسیرها و اینترفیسهای یکتا | Netlink `RTM_GETROUTE` dump |
|
||||
| `trim_oracle` | عدم تطابق تعداد iface بین bind-probe و RTM_GETLINK | `if_indextoname()` در مقابل `RTM_GETLINK` |
|
||||
**نشانههای غیرمستقیم (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:**
|
||||
|
||||
| بررسی (kind) | چه چیزی جستجو میشود | منبع |
|
||||
|--------------|---------------------|------|
|
||||
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
|
||||
| `udp_pmtu_ok` / `udp_pmtu_fail` | موفقیت/شکست ارسال 1500 بایت روی UDP | `sendto()` 1500 بایت |
|
||||
| `normal_pmtu` | Path MTU اینترفیس فیزیکی اصلی | `fetchMtu()` از طریق `getifaddrs()` |
|
||||
| `timing_oracle` | چرخههای ARM CNTVCT برای `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) |
|
||||
| `backpressure` | نرخ انتقال زیر 50000 بسته UDP با پشتیبانی لغو اسکن | `sendto()` غیرمسدودکننده + بررسی لغو هر 64 بسته |
|
||||
| `gso_failed` / `gso_send_failed` / `gso_ok` | تشخیص قابلیت UDP GSO؛ نتیجه مدرک VPN نیست | `UDP_SEGMENT=1200` + ارسال 4800 بایت |
|
||||
| `hw_timestamp` | تایماستمپینگ سختافزاری (`SIOCSHWTSTAMP`، `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
|
||||
|
||||
**پراbeهای محیطی (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| بررسی (kind) | چه چیزی جستجو میشود | منبع |
|
||||
|--------------|---------------------|------|
|
||||
| `traceroute_denied` | تست traceroute (TTL=1 UDP) مسدود شده | `setsockopt(IP_TTL=1)` + `sendto()` |
|
||||
|
||||
اطمینان بالا (→ `detected = true`): `sysfs_vpn_leak`، `getifaddrs_vpn`، `sysclassnet_vpn`، `rtm_getlink_vpn`، `proc_if_inet6_vpn`، `proc_ipv6_route_vpn`، `proc_net_dev_vpn`، `ifindexname_vpn`، `vpn_policy_rules_netlink`، `bindtodevice_leak`، `getsockname_leak`، `udp_port_conflict_physical`. اندازهگیریهای خام و نتایج GSO اطلاعاتی هستند؛ سایر ناهنجاریها → `needsReview`.
|
||||
|
||||
---
|
||||
|
||||
## نتیجه نهایی (`VerdictEngine`)
|
||||
|
|
@ -546,7 +648,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می
|
|||
|
||||
- `geoHit` = `GeoIP.outsideRu == true` (به جز رومینگ)
|
||||
- `directHit` = detected-evidence از `DIRECT_NETWORK_CAPABILITIES` یا `SYSTEM_PROXY`
|
||||
- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH`
|
||||
- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` یا `NATIVE_SOCKET` با اطمینان بالا
|
||||
|
||||
| Geo | Direct | Indirect | Verdict |
|
||||
|-----|--------|----------|---------|
|
||||
|
|
|
|||
|
|
@ -520,6 +520,109 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*`
|
|||
|
||||
满足其中任一条件均会产生 `needsReview = true`(`EvidenceSource.SANDBOX_ISOLATION`),**不会**产生 `detected`。
|
||||
|
||||
#### 12.5 VPN 信号 (`evaluateVpnSignals`)
|
||||
|
||||
通过原生 JNI 调用进行全面的 VPN 检测。所有检查均可在**未 root** 的设备上运行——当权限不足(SELinux/capabilities)时,检查标记为 `unavailable` 而不会崩溃。
|
||||
|
||||
**属性和文件 (`nativeDetectVpnProperties`):**
|
||||
|
||||
| 检查 | 查找内容 | 来源 |
|
||||
|------|----------|------|
|
||||
| DNS 属性 | `net.dns1-4`、`net.vpn.dns1-2`、`dhcp.tun0.dns1-2` | `__system_property_get` |
|
||||
| VPN 属性 | `net.vpn.default_iface`、`vpn.enable`、`net.tun0.dns1-2`、`net.ppp0.dns1-2` | `__system_property_get` |
|
||||
| vpnhide 文件 | `/data/local/vpnhide`、`/data/adb/vpnhide`、`/data/local/bypass` 等 | `access(F_OK)` |
|
||||
| LSPosed/Xposed | `/data/adb/lspd`、`/data/adb/modules/lsposed`、`/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
|
||||
| Hook 属性 | `persist.sys.lspd`、`persist.sys.lsposed`、`ro.lsposed.hidden` | `__system_property_get` |
|
||||
|
||||
高置信度:`vpn_prop`、`vpnhide`、`hook_prop`。中等:其余。
|
||||
|
||||
**通过 /proc 泄漏 (`nativeDetectVpnLeaks`):**
|
||||
|
||||
| 检查 | 查找内容 | 来源 |
|
||||
|------|----------|------|
|
||||
| TCP VPN 端口 | 端口 443、1194、51820、8443、1723、500、4500 上的连接 | `/proc/net/tcp[6]` |
|
||||
| UDP VPN 端口 | 端口 51820(WireGuard)、1194(OpenVPN)、500、4500 上的套接字 | `/proc/net/udp[6]` |
|
||||
| if_inet6 | `/proc/net/if_inet6` 中的 tun/wg/ppp/tap 接口 | `/proc/net/if_inet6` |
|
||||
| 路由 VPN | 通过 tun/wg/ppp/tap 的路由 | `/proc/net/route` |
|
||||
| FIB trie | `/32 host` 条目(非 LOCAL)——VPN 主机路由 | `/proc/net/fib_trie` |
|
||||
|
||||
高置信度:`udp_vpn_port`、`route_vpn_iface`、`arp_vpn_iface`、`inet6_vpn_iface`。
|
||||
|
||||
**高级检查 (`nativeDetectVpnAdvanced`):**
|
||||
|
||||
| 检查 | 查找内容 | 来源 |
|
||||
|------|----------|------|
|
||||
| ARP VPN | tun/wg/ppp 接口上的条目 | `/proc/net/arp` |
|
||||
| Sysctl | `rp_filter=0`、`ip_forward=1`、`forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` 等 |
|
||||
| ESTABLISHED VPN | 到私有 IP 的 VPN 端口连接 | `/proc/net/tcp` |
|
||||
|
||||
**非常规 syscall 检查 (`nativeDetectVpnSyscalls`):**
|
||||
|
||||
通过直接 netlink 请求和探测连接进行检查。权限不足时返回 `unavailable`:
|
||||
|
||||
| 检查 | 方法 | 检测内容 |
|
||||
|------|------|----------|
|
||||
| RTM_GETRULE | Netlink dump 路由策略规则 | VPN 策略路由规则 |
|
||||
| RTM_GETQDISC | Netlink dump 队列规则 | VPN qdisc 隧道 |
|
||||
| RTM_GETNEIGH | Netlink dump 邻居表 | 隐藏的 MAC 地址(零 LLADDR) |
|
||||
| TCP_INFO MSS | 连接到 8.8.8.8:443,读取 `snd_mss` | MSS 降低(隧道指标) |
|
||||
| SO_BINDTODEVICE | 探测绑定到不存在的接口 | VPN hook 拦截 setsockopt |
|
||||
| Loopback 端口绑定 | 尝试占用端口 51820/1194/443/8443 | 端口冲突(VPN 监听器) |
|
||||
| BPF OBJ GET | 尝试打开 `/sys/fs/bpf/` 映射 | 访问 netd BPF 映射 |
|
||||
| IP_RECVERR | 探测 setsockopt IP_RECVERR | VPN hook 拦截 IP_RECVERR |
|
||||
|
||||
高置信度:`vpn_policy_rules`、`hidden_mac_neighbors`、`tcp_mss_low`、`loopback_port_conflict`、`bpf_map_accessible`。
|
||||
|
||||
#### 12.6 深度 VPN 检测器 (`VpnNativeDetectorChecker`)
|
||||
|
||||
新检测器位于 Native 类别下的独立子章节,分为 4 个子类别。数据来自新的 JNI 方法 `nativeDetectVpnDetector()`,行前缀为 `vdet|`:
|
||||
|
||||
**直接迹象(Direct signs)— `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| 检查 (kind) | 查找内容 | 来源 |
|
||||
|-------------|----------|------|
|
||||
| `sysfs_vpn_leak` | 通过 sysfs 泄漏 tun/wg/ppp/xfrm | `/sys/class/net`、`/sys/devices/virtual/net`、`/proc/sys/net/ipv4|6/conf|neigh` |
|
||||
| `getifaddrs_vpn` | `getifaddrs()` 列表中的 VPN 接口 | `getifaddrs()` |
|
||||
| `sysclassnet_vpn` | `/sys/class/net` 中的 VPN 接口 | `stat("/sys/class/net/<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 100–200,oif=tun) | Netlink `RTM_GETRULE` dump |
|
||||
|
||||
**网络迹象(Network signs)— `EvidenceSource.NATIVE_SOCKET`:**
|
||||
|
||||
| 检查 (kind) | 查找内容 | 来源 |
|
||||
|-------------|----------|------|
|
||||
| `fib_trie_denied` | `/proc/net/fib_trie` 不可用(SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
|
||||
| `inet_diag_denied` | inet_diag netlink 被拒绝(SELinux) | `socket(NETLINK_SOCK_DIAG)` |
|
||||
| `bindtodevice_leak` | `SO_BINDTODEVICE` 到 tun + `getsockopt` 确认 | `setsockopt(SO_BINDTODEVICE)` |
|
||||
| `getsockname_leak` | `getsockname()` 返回私有 VPN IP | UDP 套接字上的 `getsockname()` |
|
||||
| `udp_port_conflict_physical` | 物理 IP 上的 UDP 端口冲突(500/4500/1194/1701/51820) | 物理 IP 上的 `bind()` |
|
||||
| `route_count` | 路由数量和唯一接口数量 | Netlink `RTM_GETROUTE` dump |
|
||||
| `trim_oracle` | bind-probe 与 RTM_GETLINK 接口数不匹配 | `if_indextoname()` 对比 `RTM_GETLINK` |
|
||||
|
||||
**间接迹象(Indirect signs)— `EvidenceSource.NATIVE_ROUTE`:**
|
||||
|
||||
| 检查 (kind) | 查找内容 | 来源 |
|
||||
|-------------|----------|------|
|
||||
| `pmtu_mss_combined` | UDP PMTU + TCP MSS(tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
|
||||
| `udp_pmtu_ok` / `udp_pmtu_fail` | 通过 UDP 发送 1500 字节成功/失败 | `sendto()` 1500 字节 |
|
||||
| `normal_pmtu` | 主物理接口的路径 MTU | 通过 `getifaddrs()` 的 `fetchMtu()` |
|
||||
| `timing_oracle` | `sendto()` 的 ARM CNTVCT 周期(最小/最大/平均) | `mrs cntvct_el0`(aarch64) |
|
||||
| `backpressure` | 50000 个 UDP 包下的吞吐量,支持扫描取消 | 非阻塞 `sendto()` 突发,每 64 个包检查一次取消状态 |
|
||||
| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO 能力诊断;结果不是 VPN 证据 | `UDP_SEGMENT=1200` + 发送 4800 字节 |
|
||||
| `hw_timestamp` | 硬件时间戳(`SIOCSHWTSTAMP`、`SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
|
||||
|
||||
**环境探测(Environment probes)— `EvidenceSource.NATIVE_INTERFACE`:**
|
||||
|
||||
| 检查 (kind) | 查找内容 | 来源 |
|
||||
|-------------|----------|------|
|
||||
| `traceroute_denied` | traceroute 探测(TTL=1 UDP)被阻止 | `setsockopt(IP_TTL=1)` + `sendto()` |
|
||||
|
||||
高置信度(→ `detected = true`):`sysfs_vpn_leak`、`getifaddrs_vpn`、`sysclassnet_vpn`、`rtm_getlink_vpn`、`proc_if_inet6_vpn`、`proc_ipv6_route_vpn`、`proc_net_dev_vpn`、`ifindexname_vpn`、`vpn_policy_rules_netlink`、`bindtodevice_leak`、`getsockname_leak`、`udp_port_conflict_physical`。原始测量值和 GSO 结果仅供参考;其他异常 → `needsReview`。
|
||||
|
||||
---
|
||||
|
||||
## 结论 (`VerdictEngine`)
|
||||
|
|
@ -545,7 +648,7 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*`
|
|||
|
||||
- `geoHit` = `GeoIP.outsideRu == true`(漫游除外)
|
||||
- `directHit` = 来自 `DIRECT_NETWORK_CAPABILITIES` 或 `SYSTEM_PROXY` 的 detected evidence
|
||||
- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence
|
||||
- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence,或高置信度 `NATIVE_SOCKET`
|
||||
|
||||
| Geo | Direct | Indirect | Verdict |
|
||||
|-----|--------|----------|---------|
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue