This document describes the L×Box threat model and the concrete protection mechanisms: what we close off, how exactly, and why. The traffic-leak section is covered in the most detail, because it's the least obvious part.
Related specs: 020 — Security & DPI Bypass, 119 — VPN Mode, 124 — per-app allowlist.
- Threat model
- Traffic leaks out of the tunnel — the main part
- Local attack surface
- Secrets on the device
- Summary table
L×Box is a VPN client: it accepts other apps' traffic, wraps it in a tunnel, and sends it to a remote node. Three classes of threat follow from that, and we defend against each:
| Class | Threat | Covered in |
|---|---|---|
| Traffic leak | Traffic escapes the tunnel or the routing policy — deanonymization, bypass simply doesn't work | §2 |
| Local surface | An open local proxy/API that another app on the device could abuse | §3 |
| Secret theft | Private keys, device tokens, subscription credentials leak out of the app | §4 |
Every routing rule matches traffic on one of two independent axes:
- WHERE (destination) — by the packet's destination:
ip_cidr, domain, port. The sender doesn't matter. - WHO (source / owner) — by which app owns the socket: UID → package_name.
These are different sets, and they must not be conflated. Leak protection is built on both axes, but with different rules.
0.0.0.0/0 is the IP-CIDR for "any IPv4 destination address". A rule using it matches traffic on the WHERE axis:
{ "ip_cidr": ["0.0.0.0/0"], "action": "reject" }- Matches all IPv4 traffic indiscriminately — apps and background processes alike, regardless of owner.
reject= the core drops the packet (RST / ICMP-unreachable, or a silent drop).- Practically equivalent to
final = reject, if the rule is last and nothing above it matched. The difference:final— the fallback for traffic that matched no rule at all.0.0.0.0/0— an active rule: it matches literally everything and short-circuits the chain, preventing rules placed below it from running.
Purpose: a kill-switch. "If nothing in the allowlist matched, don't let anything out." Closes leaks on the address axis.
The normal path for an app's traffic:
app socket → VpnService intercept → tun0 → core sees UID → resolves package → rules
The core knows the socket's UID → maps it to the package_name of an installed app. The traffic is "signed" by its owner.
But a process that binds directly to the tun0 interface (curl --interface tun0, termux, low-level network tools) bypasses the VpnService interception layer:
curl bind(tun0) → writes into tun "from the side" → core: UID = INVALID_UID → package = "" (empty)
Why such traffic is called ownerless:
- It didn't go through the normal entry point (the VpnService intercept) → the socket owner can't be resolved →
INVALID_UID. - With an empty UID, the core can't map the traffic to any installed app →
packageis the empty string. - The result is traffic inside the tunnel that is attributed to nothing: not the system, not an app from the list — a process that crawled into tun on its own.
Why it's dangerous: such traffic can escape the routing policy (e.g. bypass your allowlist / detour) — it's a potential leak channel. It reproduces trivially (curl --interface tun0 ...), so this is a real hole, not a theoretical one.
It catches the ownerless traffic from §2.3 — on the WHO axis. The block_unknown preset definition (wizard_template.json):
{ "invert": true, "package_name_regex": "^" }How to read it:
package_name_regex: "^"— the^regex matches any string (including the empty one): "there is some package".invert: true— flips the condition → the rule catches traffic whose package is NOT defined (empty /INVALID_UID).
So the rule isolates exactly the traffic that enters the tunnel without attribution to an installed app.
What to do with it (the outbound var in the preset):
| Value | Behavior |
|---|---|
reject (default) |
drop — ownerless traffic isn't let out at all |
direct |
send it outside the VPN (direct egress, bypassing the tunnel) |
Why it's needed:
- Close the leak. A process crawling into tun behind VpnService's back can't exfiltrate traffic past the routing policy.
- Tunnel hygiene. Only legitimate apps' traffic goes through the proxy; everything unattributed is brought under control (drop or direct).
- Different axis than §2.2. A legitimate app with a valid UID and destination
1.2.3.4is caught by0.0.0.0/0, but not by "Unknown traffic" — the sets barely overlap.
An important diagnostic caveat. Two independent layers touch ownerless traffic: the route layer (the routing engine sees the packet and its empty attribution and enforces block_unknown) and the connection journal (the Live/Profiler stream fed by the libbox CommandClient; there is no Clash API /connections — it was removed in the §122 CommandClient migration).
Consequences:
- "The rule fired" and "the connection shows up in the journal" describe different layers. The route layer always rejects ownerless
tun0-bind traffic viablock_unknown, regardless of what the journal shows. - Since §176 (the profiler switched to
FilterState(All)), an ownerlesstun0-bind connection does surface in the Live/Profiler journal — as a brief open+close event — even though it is still rejected at the route layer. (Before §176 the connection tracker filtered it out, so the older "won't be there" guidance no longer holds.)
More on diagnostic layers — DIAGNOSTICS.md.
Threat: another app on the same device abuses our local proxy or API (the class of vulnerabilities seen in mobile VLESS clients).
| Measure | How we defend | Why |
|---|---|---|
| TUN-only inbound | No SOCKS5/HTTP proxy on localhost by default — traffic enters only through TUN | An open local proxy with no auth = any app silently routes through the VPN |
| Local proxy — auth when non-localhost | If proxy_listen ≠ 127.x (reachable from the network), auth is forced on |
A LAN-reachable proxy with no password is an open relay |
| No Clash HTTP API | The Clash API was removed entirely in the §122 CommandClient migration — the core is built without with_clash_api, and an experimental.clash_api block in a config now causes a fatal start failure. Core control goes through the libbox CommandClient, an in-process command channel with no network socket at all |
An open localhost API is the classic mobile-VLESS vulnerability; with no HTTP surface there is nothing to scan or brute-force |
| Debug API — opt-in, off by default | The local HTTP Debug API (subscription/rule/settings CRUD, PUT /config) does not run unless the user turns it on (debug_enabled default false); an empty Bearer token also keeps the server down |
No local HTTP attack surface exists in the default install |
| Debug API — loopback + Bearer + Host check | Bound to 127.0.0.1 only (default port 9269). Every endpoint except /ping and /help requires Authorization: Bearer <token> (fail-closed on an empty token). A Host-header check rejects anything but 127.0.0.1 / localhost (anti-DNS-rebinding), so a rebinded evil.com gets 403 even if the token leaked |
A LAN-reachable or CSRF-style path to full config control would be catastrophic; see debug-api-reference.md and spec §031 |
| pprof server — on-demand, loopback-only, no auth | The §207 goroutine/CPU capture raises a libbox PProfServer on 127.0.0.1 (ports 6060–6065) only for the duration of a user-initiated capture, then tears it down. network_security_config permits cleartext HTTP only to loopback (127.0.0.1 / localhost / ::1); all external traffic stays cleartext-forbidden |
Diagnostics must not open a persistent unauthenticated port, and the cleartext exemption must stay loopback-scoped; see spec §207 |
| Automation receivers — exported but disabled | The §047/§157 automation receiver and Locale-plugin components are declared android:exported="true" but android:enabled="false", so they are inert until the user flips the master "accept automation commands" toggle |
Off by default means no third-party app can drive the client out of the box |
| VpnService / BootReceiver not exported | android:exported="false" |
Third-party apps can't invoke our core service components |
Source and roadmap — 020 — Security & DPI Bypass.
Automation caveat (§157). Once the automation master toggle is ON, the receiver accepts its control commands from any caller on the device — a broadcast carries no caller identity, so there is no per-app authentication (
android:permissionis deliberately unset). The commands are control-only (start/stop/switch node/set group/rebuild/refresh/reset/urltest — no secret-exfiltration path), and outgoing automation events carry status labels only. SeeAUTOMATION.md"Безопасность".
| Secret | How we protect it |
|---|---|
| WARP private key | The X25519 key is generated on the device and never leaves it — only the public key is sent to Cloudflare. We don't use third-party generator workers (they hand out a server-generated private key). See §025 WARP. |
| MASQUE key & device token | The §130 MASQUE transport uses a separate ECDSA P-256 keypair generated on the device; only the public part is enrolled with Cloudflare (PATCH enroll), the SEC1-DER private key never leaves the phone. The per-device Cloudflare Bearer token is stored locally and is marked never-log alongside the private key. See §130 MASQUE. |
| Debug API Bearer token | Held in settings storage; the Debug API is off by default and bound to loopback only (see §3). |
| Subscription credentials | Roadmap: encrypted storage (Android Keystore), URL masking in the UI — see the roadmap in spec 020. A §129 file-based subscription additionally persists its imported body as a plaintext HttpCache snapshot (keyed file:<uuid>) in the app's private storage; the same encrypted-storage roadmap item covers these snapshots. |
| Mechanism | Axis / layer | What it catches | Apps affected? |
|---|---|---|---|
0.0.0.0/0 → reject |
WHERE (destination IP) | all IPv4 traffic indiscriminately | yes, all |
final = reject |
fallback | everything that matched no rule above | yes, unless explicitly described |
Unknown traffic (block_unknown) |
WHO (socket owner) | traffic with an empty package (INVALID_UID) |
no — ownerless only |
| No Clash HTTP API (CommandClient) | local surface | in-process control, no network socket to attack | — |
| Debug API off by default; loopback + Bearer + Host check | local surface | unauthorized config control (opt-in only) | — |
| TUN-only / auth-on-LAN | local surface | other apps using the local proxy | — |
| On-device key gen (WARP X25519, MASQUE P-256) | secrets | private-key leakage | — |
Key takeaway: ownerless traffic = entered tun bypassing the VpnService intercept (curl --interface tun0, termux bind), the UID doesn't resolve → the package is empty → it's caught only by the "Unknown traffic" rule, not by destination rules like 0.0.0.0/0. So full leak protection needs both axes.