Skip to content

Commit 6b2d4a5

Browse files
committed
fix(android): don't capture apps through a tunnel with no live exit
Provide mode Always keeps the VpnService tunnel up whenever the device is on a network, so it stays ready to relay for peers. When the device is not connected as a client, the tunnel has no live exit. updatePfd() chose routing from the offline flag alone, so in the provide-only, not-connected state it fell through to the deny-list branch and captured every other app into a dead tunnel, blackholing their DNS. Add a pure vpnPacketFlowMode() that decides routing from device state. It escapes other apps only when the device is offline, or when the tunnel is up purely to provide with no live exit (no kill switch, no connect request). Kill-switch and connect-requested states never escape, so their traffic stays captured and does not leak to the ISP. The escape tunnel is built from an address only: no routes, no DNS, both address families unblocked, so Android routes nothing into it and every app keeps its native network path. The tunnel always establishes, using a documentation fallback address when the SDK supplies none, so states cannot fail open. Register route-local and connect-change listeners so intent toggles rebuild the tunnel. Add 39 unit tests covering the mode logic, including an exhaustive truth table and an escape biconditional sweep; removing any gate breaks the tests.
1 parent c39f1ef commit 6b2d4a5

3 files changed

Lines changed: 685 additions & 43 deletions

File tree

app/app/src/main/java/com/bringyour/network/MainService.kt

Lines changed: 119 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.os.Build
1212
import android.os.Handler
1313
import android.os.ParcelFileDescriptor
1414
import android.system.OsConstants.AF_INET
15+
import android.system.OsConstants.AF_INET6
1516
import android.util.Log
1617
import androidx.core.app.NotificationCompat
1718
import com.bringyour.network.utils.sdkStringListToList
@@ -30,6 +31,13 @@ import kotlin.concurrent.thread
3031
companion object {
3132
const val NOTIFICATION_ID = 101
3233
const val NOTIFICATION_CHANNEL_ID = "URnetwork"
34+
/**
35+
* IPv4 used to establish the tunnel when the SDK has not handed back a
36+
* tunnel address, so a tunnel is always built (fail-closed). TEST-NET-1
37+
* (192.0.2.0/24) is reserved documentation space. With capture routes it
38+
* is a blocking blackhole; with no routes (escape) it routes nothing.
39+
*/
40+
const val ESCAPE_FALLBACK_ADDRESS = "192.0.2.1"
3341

3442
fun defaultExcludedPackageNames(): List<String> {
3543
// TODO grass, spectrum, session, discord
@@ -85,6 +93,10 @@ import kotlin.concurrent.thread
8593

8694
private var deviceOfflineSub: Sub? = null
8795
private var windowStatusChangeSub: Sub? = null
96+
/** Rebuilds the TUN when the kill switch (routeLocal) toggles. */
97+
private var routeLocalSub: Sub? = null
98+
/** Rebuilds the TUN when a connect request starts or stops. */
99+
private var connectChangeSub: Sub? = null
88100
private var blockActionOverridesSub: Sub? = null
89101
private var dnsResolverSettingsSub: Sub? = null
90102
private var connected: Boolean = false
@@ -250,6 +262,19 @@ import kotlin.concurrent.thread
250262
if (boundDevice === currentDevice) reconcilePfd()
251263
}
252264
}
265+
// killSwitch (routeLocal) and connectRequested (connectEnabled) are
266+
// material inputs to vpnPacketFlowMode: toggling either must rebuild
267+
// the TUN so the routing mode changes, not just the service start.
268+
routeLocalSub = currentDevice.addRouteLocalChangeListener {
269+
Handler(mainLooper).post {
270+
if (boundDevice === currentDevice) reconcilePfd()
271+
}
272+
}
273+
connectChangeSub = currentDevice.addConnectChangeListener {
274+
Handler(mainLooper).post {
275+
if (boundDevice === currentDevice) reconcilePfd()
276+
}
277+
}
253278
reconcilePfd()
254279
}
255280

@@ -267,13 +292,19 @@ import kotlin.concurrent.thread
267292
val app = application as MainApplication
268293
val (includedAppIds, excludedAppIds) = tunnelAppSplit()
269294
val clientIpv4 = vpnTunnelIpv4Address(app.device?.tunnelLocalAddress())
295+
// The tunnel binds this address (falling back to ESCAPE_FALLBACK_ADDRESS)
296+
// in updatePfd, so the DNS self-collision filter must use the same bound
297+
// address: a DNS entry equal to the bound address is locally terminated.
298+
val boundIpv4 = clientIpv4 ?: ESCAPE_FALLBACK_ADDRESS
270299
val deviceDnsIpv4s = tunnelDnsServers()
271300
return VpnPacketFlowConfiguration(
272301
offline = offline,
273302
connected = connected,
303+
killSwitch = app.device?.routeLocal == false,
304+
connectRequested = app.device?.connectEnabled == true,
274305
includedAppIds = includedAppIds.toSet(),
275306
excludedAppIds = excludedAppIds.toSet(),
276-
dnsIpv4s = vpnDnsServersForClient(clientIpv4, deviceDnsIpv4s, dnsIpv4s),
307+
dnsIpv4s = vpnDnsServersForClient(boundIpv4, deviceDnsIpv4s, dnsIpv4s),
277308
clientIpv4 = clientIpv4,
278309
)
279310
}
@@ -317,59 +348,101 @@ import kotlin.concurrent.thread
317348
val tunnelExcludedAppIds = configuration.excludedAppIds
318349
val tunnelDnsIpv4s = configuration.dnsIpv4s
319350

320-
if (configuration.offline) {
321-
// Log.i(TAG, "[io]OFFLINE")
322-
// when offline, only allow traffic from a fake package name
323-
// in this way, the vpn service remains active but no apps detect it as an interface
324-
builder.addAllowedApplication("${packageName}.offline")
325-
} else if (tunnelIncludedAppIds.isNotEmpty()) {
326-
// per-app inclusions take precedence: allowlist mode, only the
327-
// included apps use the tunnel. tunnelAppSplit sanitizes the VPN
328-
// owner before this mode decision, so a stale self-only rule
329-
// cannot create an empty Android UID set.
330-
for (includedPackageName in tunnelIncludedAppIds) {
331-
try {
332-
builder.addAllowedApplication(includedPackageName)
333-
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
334-
}
351+
// Routing mode is a pure decision so the fail-closed/escape path is
352+
// unit-testable without an Android runtime. Named args avoid a silent
353+
// positional swap of the two booleans. ESCAPE (offline, or up purely
354+
// for provide with no live exit) keeps the tunnel established but adds
355+
// no routes and no DNS, so Android points nothing at it: every app
356+
// (including the tunnel owner) keeps its native network and DNS, and
357+
// there is no dependence on an installed allow-listed package.
358+
val mode = vpnPacketFlowMode(
359+
offline = configuration.offline,
360+
connected = configuration.connected,
361+
killSwitch = configuration.killSwitch,
362+
connectRequested = configuration.connectRequested,
363+
includedAppIds = tunnelIncludedAppIds,
364+
)
365+
when (mode) {
366+
VpnPacketFlowMode.ESCAPE -> {
367+
// no app rules; the escape build below adds routes/DNS only for
368+
// non-escape modes, so nothing is captured
335369
}
336-
} else {
337-
// denylist mode: everything uses the tunnel except this app,
338-
// the default excluded apps, and the per-app exclusions
339-
builder.addDisallowedApplication(packageName)
340-
for (excludedPackageName in defaultExcludedPackageNames() + tunnelExcludedAppIds) {
341-
try {
342-
builder.addDisallowedApplication(excludedPackageName)
343-
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
370+
VpnPacketFlowMode.PER_APP_ALLOWLIST -> {
371+
// per-app inclusions take precedence: allowlist mode, only
372+
// the included apps use the tunnel. tunnelAppSplit
373+
// sanitizes the VPN owner before this mode decision, so a
374+
// stale self-only rule cannot create an empty Android UID
375+
// set.
376+
for (includedPackageName in tunnelIncludedAppIds) {
377+
try {
378+
builder.addAllowedApplication(includedPackageName)
379+
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
380+
}
381+
}
382+
}
383+
VpnPacketFlowMode.DENYLIST -> {
384+
// denylist mode: everything not the tunnel owner uses the
385+
// tunnel, except the default excluded apps and per-app
386+
// exclusions
387+
builder.addDisallowedApplication(packageName)
388+
for (excludedPackageName in defaultExcludedPackageNames() + tunnelExcludedAppIds) {
389+
try {
390+
builder.addDisallowedApplication(excludedPackageName)
391+
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
392+
}
393+
}
344394
}
345395
}
346-
}
347396
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
348397
builder.setMetered(false)
349398
}
350399

351400
when (configuration.ipv6Policy) {
352401
VpnIpv6Policy.BLOCK_UNSUPPORTED -> {
353-
// Deliberately omit IPv6 address/route/DNS and allowFamily.
354-
// Android blocks the unconfigured family while this IPv4-only
355-
// VPN is active, matching the remote provider capability.
402+
// For CAPTURE modes (allowlist/denylist): omit IPv6 entirely.
403+
// Remote providers are IPv4-only, so the tunnel cannot forward
404+
// IPv6; blocking the unconfigured family is correct there.
405+
// The ESCAPE path below separately calls allowFamily(AF_INET6)
406+
// so the phone's other apps keep their own IPv6 connectivity.
356407
}
357408
}
358409

359410
val clientIpv4 = configuration.clientIpv4
360-
if (clientIpv4 != null) {
411+
val isEscape = mode == VpnPacketFlowMode.ESCAPE
412+
// Always establish a tunnel, even when the SDK has not handed back a
413+
// tunnel address. Without an address builder.establish() throws and
414+
// the catch retains the previous interface, or with no prior interface
415+
// leaves no TUN at all. For a kill-switch or connected state that
416+
// fails OPEN (traffic to the ISP in the clear). Use a fixed
417+
// documentation address (same class the always-on guard uses) as a
418+
// fail-closed fallback: with capture routes it is a blocking blackhole;
419+
// with no routes (escape) it routes nothing.
420+
val tunnelAddress = clientIpv4 ?: ESCAPE_FALLBACK_ADDRESS
421+
val ipv6Report = if (isEscape) "on" else "off"
422+
if (isEscape) {
423+
// Escaping: allow BOTH families. With no app rules every app is in
424+
// scope, so without allowFamily(AF_INET6) the unconfigured IPv6
425+
// family would be BLOCKED for every app (the exact bug this fix
426+
// removes, on the v6 half). Address only, no routes, no DNS:
427+
// Android routes nothing into it and every app keeps its native
428+
// network and DNS.
361429
builder.allowFamily(AF_INET)
362-
builder.addAddress(
363-
clientIpv4,
364-
clientIpv4PrefixLength
365-
)
366-
// DNS from the SDK device (see `tunnelDnsServers`). It must be a
367-
// distinct address routed through the TUN: Android locally
368-
// terminates packets addressed to clientIpv4 before PacketFlow can
369-
// hand them to UpgradeMux.
370-
for (dnsIpv4 in tunnelDnsIpv4s) {
371-
builder.addDnsServer(dnsIpv4)
372-
}
430+
builder.allowFamily(AF_INET6)
431+
} else {
432+
builder.allowFamily(AF_INET)
433+
}
434+
builder.addAddress(
435+
tunnelAddress,
436+
clientIpv4PrefixLength
437+
)
438+
if (!isEscape) {
439+
// DNS from the SDK device (see `tunnelDnsServers`). It must
440+
// be a distinct address routed through the TUN: Android
441+
// locally terminates packets addressed to clientIpv4 before
442+
// PacketFlow can hand them to UpgradeMux.
443+
for (dnsIpv4 in tunnelDnsIpv4s) {
444+
builder.addDnsServer(dnsIpv4)
445+
}
373446
if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) {
374447
builder.addRoute("0.0.0.0", 0)
375448
builder.excludeRoute(IpPrefix(InetAddress.getByName("10.0.0.0"), 8))
@@ -421,7 +494,7 @@ import kotlin.concurrent.thread
421494
builder.addRoute("8.0.0.0", 7)
422495
builder.addRoute("11.0.0.0", 8)
423496
}
424-
}
497+
}
425498
app.device?.let { device ->
426499
val pfd = try {
427500
builder.establish()
@@ -453,7 +526,7 @@ import kotlin.concurrent.thread
453526
"[service]tunnel applied offline=${configuration.offline} connected=${configuration.connected} " +
454527
"included=${configuration.includedAppIds.size} excluded=${configuration.excludedAppIds.size} " +
455528
"dns=${configuration.dnsIpv4s} address=${configuration.clientIpv4} " +
456-
"tunnelIpv6=off underlyingIpv6=blocked",
529+
"tunnelIpv6=${ipv6Report}",
457530
)
458531
if (app.service?.get() == this@MainService) {
459532
device.tunnelStarted = true
@@ -544,6 +617,10 @@ import kotlin.concurrent.thread
544617
blockActionOverridesSub = null
545618
dnsResolverSettingsSub?.close()
546619
dnsResolverSettingsSub = null
620+
routeLocalSub?.close()
621+
routeLocalSub = null
622+
connectChangeSub?.close()
623+
connectChangeSub = null
547624
boundDevice = null
548625
if (closePacketFlow) {
549626
packetFlow?.close()

app/app/src/main/java/com/bringyour/network/VpnPacketFlowConfiguration.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ package com.bringyour.network
1010
internal data class VpnPacketFlowConfiguration(
1111
val offline: Boolean,
1212
val connected: Boolean,
13+
// killSwitch = !device.routeLocal ("Allow local traffic when disconnected").
14+
// When on, the tunnel must capture/block even without an exit: that is the
15+
// feature, not a bug. connectRequested = device.connectEnabled: when the
16+
// user wants their own traffic tunneled, never escape on a transient
17+
// provider dip (intent does not flap; liveness does).
18+
val killSwitch: Boolean,
19+
val connectRequested: Boolean,
1320
val includedAppIds: Set<String>,
1421
val excludedAppIds: Set<String>,
1522
val dnsIpv4s: List<String>,
@@ -27,6 +34,42 @@ internal enum class VpnIpv6Policy {
2734
BLOCK_UNSUPPORTED,
2835
}
2936

37+
/**
38+
* Which routing policy to apply when building the VPN packet flow.
39+
*
40+
* ESCAPE keeps the tunnel technically up (so provide stays armed) but lets no
41+
* real app see it: used when the device is offline OR when there is no live
42+
* provider exit yet (connected == false). Failing closed on !connected stops
43+
* other apps being captured into a tunnel that has no working egress, which
44+
* blackholes their DNS and connectivity.
45+
*/
46+
internal enum class VpnPacketFlowMode {
47+
ESCAPE,
48+
PER_APP_ALLOWLIST,
49+
DENYLIST,
50+
}
51+
52+
/**
53+
* Decides the routing mode from the tunnel config. Pure, so it is
54+
* unit-testable without an Android runtime.
55+
*/
56+
internal fun vpnPacketFlowMode(
57+
offline: Boolean,
58+
connected: Boolean,
59+
killSwitch: Boolean,
60+
connectRequested: Boolean,
61+
includedAppIds: Set<String>,
62+
): VpnPacketFlowMode = when {
63+
offline -> VpnPacketFlowMode.ESCAPE
64+
// Escape only when the tunnel is up PURELY for provide (no kill switch,
65+
// no connect intent, no live exit). In every other not-connected case the
66+
// user asked for capture (kill switch) or for their traffic to be held
67+
// (connect): those must not escape, or they leak to the ISP in the clear.
68+
!connected && !killSwitch && !connectRequested -> VpnPacketFlowMode.ESCAPE
69+
includedAppIds.isNotEmpty() -> VpnPacketFlowMode.PER_APP_ALLOWLIST
70+
else -> VpnPacketFlowMode.DENYLIST
71+
}
72+
3073
internal fun vpnPacketFlowNeedsRebuild(
3174
packetFlowActive: Boolean,
3275
applied: VpnPacketFlowConfiguration?,

0 commit comments

Comments
 (0)