28 KiB
PrivStack --- Implementation Plan
VPN-Invisible Transparent Proxy: Magisk Module + Android APK Controller
Based on: 21 parallel research agents analyzing box4magisk, NetProxy-Magisk, RKNHardering, v2rayNG, Amnezia VPN, Hide-My-Applist, Riru-VpnHide, tproxy internals, Magisk lifecycle, Android API edge cases, and UI/UX patterns.
Executive Summary
PrivStack is a greenfield two-component system for rooted Android devices:
- Magisk module --- transparent proxy via
tproxy + iptables(no VPN, no TUN, no icon) - Android APK --- pure controller with zero network permissions (Kotlin + Jetpack Compose)
Core principle: sing-box tproxy in whitelist mode. By default ALL traffic goes direct. User explicitly selects which apps route through proxy. Banking apps never see VPN indicators.
Detection coverage: Defeats 9 of 15 known detection vectors completely, 4 partially. Remaining 2 are server-side (require residential IP).
Technology Stack
| Component | Language / Framework | Notes |
|---|---|---|
| Module scripts | POSIX sh (busybox compatible) | iptables.sh, dns.sh, net_handler.sh, boot scripts |
Daemon (privd) |
Go (statically compiled for arm64) | Root daemon, state machine, health monitor |
CLI (privctl) |
Go (same binary or separate) | Thin JSON-RPC client over Unix socket |
| APK | Kotlin, Jetpack Compose, Material 3, Hilt DI | Zero network permissions, pure controller |
| Transport core | sing-box (primary), xray-core (optional) | tproxy inbound, protocol outbounds |
| Build: APK | Gradle (Kotlin DSL) | Standard Android project |
| Build: daemon | go build with CGO_ENABLED=0 GOOS=linux GOARCH=arm64 |
Static binary, no libc dependency |
| Build: module | zip |
Magisk module ZIP from assembled directory |
Phase 0: Foundation (Week 1-2)
0.1 Repository Setup
privstack/
├── module/ # Magisk module
│ ├── module.prop
│ ├── customize.sh
│ ├── post-fs-data.sh
│ ├── service.sh
│ ├── sepolicy.rule
│ ├── uninstall.sh
│ ├── scripts/
│ │ ├── iptables.sh # Chain setup/teardown
│ │ ├── routing.sh # ip rule/route for fwmark
│ │ ├── dns.sh # DNS interception (TPROXY-based)
│ │ └── net_handler.sh # Network change handler
│ ├── defaults/
│ │ └── config.json # Default canonical config
│ └── binaries/
│ └── arm64/ # sing-box, privctl (built separately)
├── daemon/ # privd source (Go)
│ ├── cmd/privd/
│ ├── cmd/privctl/
│ ├── internal/
│ │ ├── config/ # Config parsing + validation
│ │ ├── core/ # sing-box process management
│ │ ├── netfilter/ # iptables rule generation
│ │ ├── health/ # Health monitoring
│ │ ├── rescue/ # Auto-recovery
│ │ ├── ipc/ # Unix socket JSON-RPC server
│ │ └── watcher/ # inotifyd network change handling
│ └── go.mod
├── app/ # Android APK (Kotlin + Compose)
│ ├── app/src/main/
│ │ ├── kotlin/com/privstack/panel/
│ │ │ ├── ipc/ # su -c privctl executor
│ │ │ ├── model/ # Kotlin data classes
│ │ │ ├── repository/ # Cache-backed repos
│ │ │ ├── import/ # URI parser, subscription, Amnezia
│ │ │ ├── advisor/ # App classification + placement
│ │ │ └── ui/ # Compose screens
│ │ └── AndroidManifest.xml
│ └── build.gradle.kts
└── docs/
└── PRIVSTACK_ARCHITECTURE.md
0.2 Decisions Locked
| Decision | Choice | Rationale |
|---|---|---|
| Primary core | sing-box | Native tproxy, stable FakeDNS, Hysteria2/TUIC support, 12-20MB RAM |
| Secondary core | xray-core (optional) | gRPC hot-swap API; user preference |
| Proxy mode | Whitelist (default direct) | Banks never proxied; safest for detection |
| Loop prevention | GID-based (--gid-owner 23333) |
Cleaner than fwmark; no core config dependency |
| IPC protocol | JSON-RPC 2.0 over Unix socket | Simple, debuggable, newline-delimited |
| APK permissions | CAMERA only (optional, for QR) | No INTERNET, no VPN, no ACCESS_NETWORK_STATE |
| Config format | JSON (not shell-sourced ini) | Validated, no injection risk |
| Root managers | Magisk + KernelSU + APatch | Detect via env vars, adapt busybox path |
Phase 1: Magisk Module Shell Layer (Week 2-3)
1.1 iptables.sh --- Chain Architecture
Whitelist mode: default = RETURN (direct). Only listed UIDs get proxied.
OUTPUT (mangle)
└→ PRIVSTACK_OUT
├→ GID 23333: RETURN (loop prevention --- sing-box)
├→ mark 0xff: RETURN (belt-and-suspenders mark check)
├→ UID 1073: RETURN (NetworkStack --- captive portal)
├→ PRIVSTACK_BYPASS (reserved IPs) → RETURN
├→ PRIVSTACK_APP (whitelist UIDs)
│ UID 10157: MARK 0x1 (Chrome)
│ UID 10203: MARK 0x1 (Telegram)
│ default: RETURN (everything else → direct)
└→ (no final MARK --- only whitelisted marked)
PREROUTING (mangle)
└→ PRIVSTACK_PRE
├→ DIVERT (socket match → MARK + ACCEPT)
├→ PRIVSTACK_BYPASS (reserved IPs)
├→ TPROXY mark 0x1 → port 12345 (TCP+UDP)
OUTPUT (nat) --- DNS only
└→ PRIVSTACK_DNS
├→ GID 23333: RETURN
├→ UDP/TCP :53 → REDIRECT :1053 (ALL DNS through sing-box)
Policy routing (IPv4 + IPv6):
# IPv4
ip rule add fwmark 0x1 table 100 pref 100
ip route add local 0.0.0.0/0 dev lo table 100
# IPv6 — MUST mirror. IPv6 is critical for RKN bypass
# (blocked IPv4 resources often reachable via IPv6)
ip -6 rule add fwmark 0x1 table 106 pref 100
ip -6 route add local ::/0 dev lo table 106
IPv6 rules — full mirror of IPv4 (DO NOT disable IPv6):
IPv6 is essential in Russia — many resources blocked by IPv4 address are still reachable via IPv6. Every iptables rule MUST have an ip6tables counterpart.
# Mirror ALL chains in ip6tables:
ip6tables -t mangle -N PRIVSTACK_OUT
ip6tables -t mangle -N PRIVSTACK_PRE
ip6tables -t mangle -N PRIVSTACK_BYPASS
ip6tables -t mangle -N PRIVSTACK_APP
ip6tables -t mangle -N DIVERT
# PRIVSTACK_BYPASS for IPv6 reserved ranges:
for cidr in ::1/128 fe80::/10 fc00::/7 ff00::/8; do
ip6tables -w 100 -t mangle -A PRIVSTACK_BYPASS -d $cidr -j RETURN
done
# Same GID/UID/MARK rules, same APP whitelist, same TPROXY target
# sing-box must listen on [::1]:12345 in addition to 127.0.0.1:12345
# or on [::]:12345 (dual-stack)
# API protection — MUST also be in ip6tables:
ip6tables -A OUTPUT -o lo -p tcp --dport $API_PORT -m owner --uid-owner 0 -j ACCEPT
ip6tables -A OUTPUT -o lo -p tcp --dport $API_PORT -j REJECT
ICMP/ICMPv6 handling (prevent black hole):
# ICMP from whitelisted apps would get MARKed but no TPROXY handler exists
# for ICMP → routing loop until TTL=0. Fix: let ICMP go direct.
iptables -t mangle -A PRIVSTACK_OUT -p icmp -j RETURN
ip6tables -t mangle -A PRIVSTACK_OUT -p icmpv6 -j RETURN
Key: iptables -w 100 and ip6tables -w 100 on all commands (MIUI compat, concurrent access).
Atomic rule application via iptables-restore:
# Generate complete ruleset, apply atomically (no partial-apply risk):
iptables-save > /data/adb/privstack/backup/iptables_pre.rules
ip6tables-save > /data/adb/privstack/backup/ip6tables_pre.rules
iptables-restore < /data/adb/privstack/run/rules.v4
ip6tables-restore < /data/adb/privstack/run/rules.v6
Anti-loopback (IPv4 + IPv6):
iptables -A OUTPUT -d 127.0.0.1 -p tcp -m owner --gid-owner 23333 \
-m tcp --dport 12345 -j REJECT
ip6tables -A OUTPUT -d ::1 -p tcp -m owner --gid-owner 23333 \
-m tcp --dport 12345 -j REJECT
API port protection (RKNHardering vector):
iptables -A OUTPUT -o lo -p tcp --dport 9090 -m owner --uid-owner 0 -j ACCEPT
iptables -A OUTPUT -o lo -p tcp --dport 9090 -j REJECT
1.2 net_handler.sh --- Network Change
- Monitor:
inotifydon/data/misc/net/:w - Debounce: 2-second lock file
- Actions:
- Flush + rebuild
PRIVSTACK_BYPASSwith current local IPs - Verify
ip rulefwmark entry -> re-add if missing (airplane mode fix) - Verify
PREROUTING -j PRIVSTACK_PRE-> re-add if missing - Detect new tethering interfaces -> add TPROXY rules
- Flush + rebuild
1.3 dns.sh --- DNS Interception
DO NOT disable Private DNS. Apps can detect Private DNS state via Settings.Global.getString("private_dns_mode"). Instead, use TPROXY to transparently intercept DoT and DoH traffic.
# DO NOT disable Private DNS --- apps can detect it via Settings.Global!
# Instead, TPROXY transparently intercepts both plain DNS (53) and DoT (853).
# sing-box handles DNS resolution internally via DoH through the proxy tunnel.
# Redirect plain DNS (port 53) to sing-box DNS inbound
iptables -t nat -A PRIVSTACK_DNS -p udp --dport 53 \
-m owner ! --gid-owner 23333 -j REDIRECT --to-ports 1053
iptables -t nat -A PRIVSTACK_DNS -p tcp --dport 53 \
-m owner ! --gid-owner 23333 -j REDIRECT --to-ports 1053
# DoT (port 853) goes through TPROXY naturally as TCP traffic
# DoH (port 443) goes through TPROXY naturally as HTTPS traffic
# Chrome's built-in DoH also captured by TPROXY (it's just HTTPS to dns.google)
1.4 post-fs-data.sh
- Create
/data/adb/privstack/skeleton - Set permissions (binaries 0750, config 0600)
- Enable
ip_forward, disablerp_filter - Verify binaries exist
1.5 service.sh
- Detect root manager (Magisk/KSU/APatch)
- Wait
sys.boot_completed(120s timeout) + 5s settle - Set
ulimit -SHn 1000000 - Launch
privdvianohup setsid - Set
oom_score_adj=-17on daemon PID (phantom process kill protection)
1.6 customize.sh
- Validate arch=arm64, API>=28
- Check TPROXY kernel support (
/proc/config.gz) - Preserve existing config on upgrade
- Copy binaries, set capabilities (
cap_net_admin,cap_net_raw+ep)
Phase 2: Daemon (privd) in Go (Week 3-5)
2.1 State Machine
STOPPED → STARTING → RUNNING → DEGRADED → RESCUE → STOPPED (rollback)
↑ ↑ |
└─────── stop() ──────┘ ok → RUNNING
2.2 Start Sequence
1. Read + validate config.json
2. Render sing-box config from template + node
3. Spawn sing-box (uid=0, gid=23333) → wait for listen (30s)
4. Backup iptables: iptables-save > backup/
5. Run scripts/iptables.sh start
6. Run scripts/dns.sh start
7. Run initial health check
8. Start health loop (30s interval)
9. Start inotifyd watchers
→ State: RUNNING
2.3 Stop Sequence (ORDER MATTERS)
1. scripts/dns.sh stop
2. scripts/iptables.sh stop (BEFORE killing sing-box!)
3. SIGTERM → sing-box, wait 5s, SIGKILL if needed
4. Clean PIDs, state files
→ State: STOPPED
2.4 Health Monitor
| Check | Method | Failure -> |
|---|---|---|
| sing-box alive | kill -0 $PID |
Rescue: restart core |
| Port listening | TCP connect :12345 | Rescue: restart core |
| iptables intact | iptables -C PREROUTING -j PRIVSTACK_PRE |
Rescue: re-apply rules |
| Routing intact | ip rule show | grep fwmark |
Rescue: re-add rule |
| DNS working | Resolve example.com |
Warning only |
3 consecutive failures -> DEGRADED -> RESCUE (3 attempts) -> STOPPED + rollback.
2.5 Hot-Swap (Node Switch)
1. Render new sing-box config
2. SIGTERM → sing-box
3. Spawn new sing-box
4. Wait for listen
→ iptables untouched, zero network gap for non-proxied apps
2.6 IPC Server
Unix socket at /data/adb/privstack/run/daemon.sock.
Protocol: JSON-RPC 2.0, newline-delimited.
20 methods: status, start, stop, reload, health, audit, config-get/set/list/delete, config-import, config-export, logs, logs-stream, resolve-uid, app-list, routing-set, version.
2.7 sing-box Config Generation
The daemon renders sing-box JSON configs from the canonical config. Example generated output:
{
"log": {"level": "warn"},
"dns": {
"servers": [
{"tag": "proxy-dns", "address": "https://1.1.1.1/dns-query", "detour": "proxy"},
{"tag": "direct-dns", "address": "local"}
],
"rules": [{"rule_set": ["geosite-category-ads-all"], "server": "block"}]
},
"inbounds": [
{"type": "tproxy", "tag": "tproxy-in", "listen": "::", "listen_port": 12345,
"sniff": true, "sniff_override_destination": false},
{"type": "direct", "tag": "dns-in", "listen": "::", "listen_port": 1053,
"override_address": "1.1.1.1", "override_port": 53}
],
"outbounds": [
{"type": "vless", "tag": "proxy", "server": "...", "server_port": 443,
"uuid": "...", "flow": "xtls-rprx-vision",
"tls": {"enabled": true, "server_name": "...",
"reality": {"enabled": true, "public_key": "...", "short_id": "..."},
"utls": {"enabled": true, "fingerprint": "chrome"}}},
{"type": "direct", "tag": "direct"},
{"type": "block", "tag": "block"},
{"type": "dns", "tag": "dns-out"}
],
"route": {
"auto_detect_interface": true,
"default_mark": 255,
"rules": [
{"protocol": "dns", "outbound": "dns-out"},
{"ip_is_private": true, "outbound": "direct"}
]
}
}
2.8 Config Rendering (Go)
The daemon's internal/config package handles template rendering:
// internal/config/renderer.go
type ConfigRenderer struct {
templateDir string
}
// RenderSingBox generates a sing-box JSON config from the canonical config
// and the active node profile.
func (r *ConfigRenderer) RenderSingBox(cfg *CanonicalConfig, node *Node) ([]byte, error) {
tmpl, err := os.ReadFile(filepath.Join(r.templateDir, "singbox_tproxy.json.tmpl"))
if err != nil {
return nil, fmt.Errorf("read template: %w", err)
}
t, err := template.New("singbox").Parse(string(tmpl))
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
data := templateData{
ListenPort: cfg.Proxy.Port,
DNSPort: cfg.Proxy.DNSPort,
LogLevel: cfg.Transport.LogLevel,
Node: node,
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
// Validate generated JSON
var check json.RawMessage
if err := json.Unmarshal(buf.Bytes(), &check); err != nil {
return nil, fmt.Errorf("invalid generated config: %w", err)
}
return buf.Bytes(), nil
}
Phase 3: APK Controller (Week 5-8)
3.1 Package: com.privstack.panel
Manifest:
<!-- NO INTERNET. NO VPN. NO ACCESS_NETWORK_STATE. -->
<uses-permission android:name="android.permission.CAMERA" />
3.2 IPC Layer
// ipc/PrivctlExecutor.kt
class PrivctlExecutor @Inject constructor() {
suspend fun execute(method: String, params: JsonObject = JsonObject(emptyMap())): PrivctlResult =
withContext(Dispatchers.IO) {
val json = buildJsonObject {
put("method", method)
put("params", params)
}.toString()
val process = ProcessBuilder(
"su", "-c",
"/data/adb/privstack/bin/privctl $method '$json'"
).start()
val stdout = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
when {
exitCode == 0 -> PrivctlResult.Success(Json.parseToJsonElement(stdout))
exitCode == 1 -> PrivctlResult.Error(stdout)
else -> PrivctlResult.RootDenied
}
}
}
sealed class PrivctlResult {
data class Success(val data: JsonElement) : PrivctlResult()
data class Error(val message: String) : PrivctlResult()
data object RootDenied : PrivctlResult()
data object Timeout : PrivctlResult()
}
3.3 Data Models
// model/Node.kt
@Serializable
data class Node(
val name: String,
val protocol: Protocol,
val address: String,
val port: Int,
val uuid: String? = null,
val password: String? = null,
val flow: String? = null,
val security: Security = Security.NONE,
val sni: String? = null,
val fingerprint: String = "chrome",
val publicKey: String? = null,
val shortId: String? = null,
val method: String? = null, // Shadowsocks cipher
val alpn: List<String>? = null,
val transport: TransportConfig? = null,
)
@Serializable
enum class Protocol { VLESS, VMESS, TROJAN, SHADOWSOCKS, HYSTERIA2, TUIC }
@Serializable
enum class Security { NONE, TLS, REALITY }
@Serializable
data class TransportConfig(
val type: String = "tcp", // tcp, ws, grpc, h2
val path: String? = null,
val host: String? = null,
val serviceName: String? = null,
)
// model/DaemonStatus.kt
@Serializable
data class DaemonStatus(
val state: DaemonState,
val uptimeSec: Long = 0,
val server: String? = null,
val egressIp: String? = null,
val txBytes: Long = 0,
val rxBytes: Long = 0,
val latencyMs: Int? = null,
val dnsOk: Boolean = false,
val activeProfile: String? = null,
val corePid: Int? = null,
val iptablesActive: Boolean = false,
)
@Serializable
enum class DaemonState { STOPPED, STARTING, RUNNING, DEGRADED, RESCUE, STOPPING }
3.4 Navigation (4 tabs)
| Tab | Screen | Key Features |
|---|---|---|
| Dashboard | Connection state, traffic sparkline, egress IP, latency, DNS | Pulsing ring animation, connect button |
| Nodes | Server list by subscription groups, import, QR scan | TabRow, latency badges, long-press menu |
| Apps | Whitelist app picker with templates | Checkbox list, search, template chips, badge count |
| Settings | Routing mode, DNS, advanced, module info | SegmentedButtonRow, switches, version |
3.5 Dashboard Screen
// ui/dashboard/DashboardScreen.kt
@Composable
fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {
val status by viewModel.status.collectAsStateWithLifecycle()
val traffic by viewModel.trafficHistory.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ConnectionRing(
state = status.state,
onClick = { viewModel.toggleConnection() },
)
Spacer(modifier = Modifier.height(24.dp))
StatusCard(status)
Spacer(modifier = Modifier.height(16.dp))
TrafficSparkline(
data = traffic,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
)
Spacer(modifier = Modifier.height(16.dp))
MetricsRow(
latency = status.latencyMs,
egressIp = status.egressIp,
dnsOk = status.dnsOk,
)
}
}
3.6 Whitelist App Picker
Banner: "Selected apps route through proxy. Everything else connects directly."
Templates: [Browsers] [Social] [Streaming] [All except banks]
Search: [________________________]
[ ] Show system apps
[x] Chrome com.android.chrome
[x] Telegram org.telegram.messenger
[ ] Sberbank ru.sberbankmobile
[ ] YouTube com.google.android.youtube
...
[Apply --- 7 apps selected]
3.7 Import Flow
Auto-detect format:
vpn:// → Amnezia (qCompress + base64url + zlib)
vless:// → VLESS URI
vmess:// → VMess base64 JSON
trojan:// → Trojan URI
ss:// → Shadowsocks SIP002
proxies: → Clash YAML (Meta)
[json] → v2rayNG backup / sing-box config
base64 → Subscription body
Quick Add: paste → parse → validate → preview → confirm → activate
3.8 Link Parser (Kotlin)
// import/LinkParser.kt
object LinkParser {
fun parse(uri: String): Result<Node> = runCatching {
val trimmed = uri.trim()
when {
trimmed.startsWith("vless://") -> parseVless(trimmed)
trimmed.startsWith("vmess://") -> parseVmess(trimmed)
trimmed.startsWith("trojan://") -> parseTrojan(trimmed)
trimmed.startsWith("ss://") -> parseShadowsocks(trimmed)
trimmed.startsWith("hysteria2://") || trimmed.startsWith("hy2://") -> parseHysteria2(trimmed)
trimmed.startsWith("tuic://") -> parseTuic(trimmed)
trimmed.startsWith("vpn://") -> parseAmnezia(trimmed)
else -> error("Unsupported URI scheme")
}
}
private fun parseVless(uri: String): Node {
// vless://uuid@host:port?security=reality&sni=...&fp=chrome&pbk=...&sid=...#name
val parsed = Uri.parse(uri)
val params = parsed.queryParameterNames.associateWith { parsed.getQueryParameter(it) }
return Node(
name = parsed.fragment ?: "${parsed.host}:${parsed.port}",
protocol = Protocol.VLESS,
address = parsed.host ?: error("missing host"),
port = parsed.port.takeIf { it > 0 } ?: error("missing port"),
uuid = parsed.userInfo ?: error("missing uuid"),
flow = params["flow"],
security = when (params["security"]) {
"reality" -> Security.REALITY
"tls" -> Security.TLS
else -> Security.NONE
},
sni = params["sni"],
fingerprint = params["fp"] ?: "chrome",
publicKey = params["pbk"],
shortId = params["sid"],
transport = params["type"]?.let { type ->
TransportConfig(
type = type,
path = params["path"],
host = params["host"],
serviceName = params["serviceName"],
)
},
)
}
// parseTrojan, parseVmess, parseShadowsocks, etc. follow same pattern
}
3.9 Audit Module (14 checks)
| Check | Severity | What |
|---|---|---|
vpn_api_surface |
CRITICAL | No TRANSPORT_VPN visible |
tun_interface |
HIGH | No tun0/wg0 |
not_vpn_capability |
CRITICAL | NET_CAPABILITY_NOT_VPN present |
core_api_protected |
MEDIUM | API port restricted to UID 0 |
dns_leak |
MEDIUM | DNS through proxy |
package_visibility |
HIGH | Proxy apps not visible to risk apps |
iptables_loop_safe |
HIGH | GID exemption in OUTPUT |
proc_net_leak |
HIGH | Proxy port not in /proc/net/tcp |
Phase 4: Protocol Support (Week 8-10)
4.1 Primary: sing-box Protocols
| Protocol | Status | sing-box type |
|---|---|---|
| VLESS + Reality | Primary | vless + tls.reality |
| VLESS + TLS | Supported | vless + tls |
| Trojan | Supported | trojan |
| VMess | Supported | vmess |
| Shadowsocks 2022 | Supported | shadowsocks |
| Hysteria2 | Supported | hysteria2 |
| TUIC v5 | Supported | tuic |
4.2 Secondary: AmneziaWG (via wireproxy-awg)
iptables TPROXY → sing-box tproxy-in → wireproxy-awg SOCKS5 → AWG server
- wireproxy-awg runs as separate process (gVisor netstack, no kernel interface)
- TCP only through SOCKS5 (UDP ASSOCIATE not implemented)
- DNS via static UDPProxyTunnel
- Full AWG obfuscation: Jc, S1-S4, H1-H4, I1-I5
4.3 NOT Supported (by design)
- Standard WireGuard via TUN (creates visible interface)
- OpenVPN (not a proxy protocol)
- Any protocol requiring VpnService
Phase 5: Isolation Layer (Week 10-11)
5.1 Work Profile (Primary)
APK's Placement Advisor recommends profile separation:
| Category | Profile | Examples |
|---|---|---|
| Banking | Work | Sberbank, T-Bank, VTB, Alfa |
| Government | Work | Gosuslugi, Nalog |
| Telecom | Work | MTS, Megafon, Beeline |
| Marketplace | Work | Samokat, MegaMarket, Ozon |
| Proxy tools | Personal | PrivStack, Terminal |
| Browsers | Personal | Chrome (proxied via whitelist) |
5.2 Hide-My-Applist (Fallback)
If Work Profile not feasible, HMA via LSPosed:
- Single hook:
shouldFilterApplicationin system_server - Hides VPN/proxy packages from aggressive apps
- Limitation: requires Xposed (itself detectable)
Phase 6: Testing & Hardening (Week 11-12)
6.1 Detection Test Matrix
Run audit against each vector with module active:
| # | Test | Expected | Tool |
|---|---|---|---|
| 1 | NetworkCapabilities.TRANSPORT_VPN |
Not set | RKNHardering |
| 2 | NetworkInterface.getNetworkInterfaces() |
Only wlan0/rmnet | VPN-Detector |
| 3 | VPN icon in statusbar | Not shown | Visual |
| 4 | NOT_VPN capability |
Present | RKNHardering |
| 5 | /proc/net/tcp scan |
Proxy port not visible (Android 10+) | RKNHardering |
| 6 | Package enumeration | Proxy apps hidden (Work Profile) | Sberbank test |
| 7 | sing-box API port | Rejected for non-root | netcat test |
| 8 | DNS leak | All DNS through proxy | dnsleaktest.com |
| 9 | Airplane mode toggle | Rules survive/re-applied | Manual |
| 10 | WiFi <-> mobile switch | Bypass IPs updated, no leak | Manual |
| 11 | MIUI iptables flush | Rules auto-restored (30s check) | MIUI device |
6.2 ROM Compatibility
| ROM | Known Issues | Workaround |
|---|---|---|
| MIUI/HyperOS | Periodic iptables flush | Health monitor re-applies rules |
| Samsung OneUI | KNOX strict SELinux | Test sepolicy.rule, no permissive |
| OnePlus ColorOS | iptables-nft vs legacy | Detect and use iptables-legacy if available |
| Huawei EMUI | May lack TPROXY kernel | Auto-fallback to REDIRECT (TCP only) |
| AOSP/Lineage | Standard behavior | No workarounds needed |
6.3 Edge Cases
- App install after rules active (whitelist: safe, goes direct)
- Shared UID apps (warn user in UI)
- Multi-user / Work Profile UIDs (
user * 100000 + appid) - VPN coexistence (detect tun0, warn user, bypass VPN UID)
- Captive portal on hotel WiFi (UID 1073 bypassed)
- Full IPv6 support via mirrored ip6tables (critical for RKN bypass where IPv4 is blocked)
- IPv6-only network with NAT64 (sing-box handles dual-stack natively)
- sing-box tproxy listen on dual-stack
[::]:12345(covers both IPv4 and IPv6) - Phantom process killing Android 12+ (
oom_score_adj=-17,setsid)
Deliverables Summary
| Component | Language | Size Est. | Priority |
|---|---|---|---|
scripts/iptables.sh |
Shell | ~500 lines | P0 --- core |
scripts/dns.sh |
Shell | ~80 lines | P0 --- core |
scripts/net_handler.sh |
Shell | ~150 lines | P0 --- stability |
module/*.sh (boot scripts) |
Shell | ~200 lines | P0 --- core |
privd daemon |
Go | ~2000 lines | P0 --- core |
privctl CLI |
Go | ~300 lines | P0 --- core |
| APK IPC layer | Kotlin | ~400 lines | P1 --- UI |
| APK Dashboard | Compose | ~600 lines | P1 --- UI |
| APK Node management | Compose | ~800 lines | P1 --- UI |
| APK App picker | Compose | ~500 lines | P1 --- UI |
| APK Import/Export | Kotlin | ~700 lines | P1 --- UI |
| APK Audit module | Kotlin | ~400 lines | P2 --- security |
| APK Placement Advisor | Kotlin | ~300 lines | P2 --- security |
| wireproxy-awg integration | Shell/Config | ~200 lines | P3 --- AWG |
Total estimated: ~7000 lines of code.
Risk Register
| Risk | Impact | Mitigation |
|---|---|---|
| TPROXY not in kernel | No UDP proxy | Auto-detect, fallback to REDIRECT |
| Mintsifry deadline Apr 15 | Banks enforce detection | tproxy defeats all on-device vectors |
| MIUI flushes iptables | Traffic leak | 30s health check + inotifyd repair |
| Phantom process killing | Daemon dies | oom_score_adj=-17 + setsid + watchdog |
| SELinux denials | Module fails on strict ROMs | Explicit sepolicy.rule, not permissive |
| sing-box crash | Traffic blackhole (TPROXY to dead port) | Health monitor -> rescue in <30s |
| Datacenter IP detected | Server-side check, unavoidable | Document: use residential exit IP |
| IPv6 rules out of sync | IPv6 traffic bypasses proxy | Mirror ALL iptables rules in ip6tables; test with curl -6 |
| ICMP black hole | Whitelisted app ping loops | -p icmp -j RETURN before APP chain |
| Partial iptables apply | Black hole on power loss | Use iptables-restore / ip6tables-restore for atomicity |