A route added to the main table on Android looks correct in every listing and still does not carry the traffic. The reason is that the main table is one option among several, and it is not consulted first. Android's networking service builds policy rules that select a routing table using information the destination address does not contain — the application's uid, a mark on the packet, the interface it arrived on — and the destination lookup happens afterwards, inside whichever table those rules chose.

Three decisions in a fixed order

A packet on its way out makes three separate decisions. First, a policy rule decides which routing table applies. Second, a route inside that table decides the next hop and the outgoing interface. Third, netfilter hooks decide whether the packet survives, is modified, or is redirected.

Ordering matters because each stage can only answer its own question. A firewall rule that accepts a packet does not select a table, and a route added to a table that no rule selects is inert. Much of the confusion around Android VPNs comes from fixing one of the three and observing no change, because the failure was in a different one.

StageSelectorWhat it cannot decide
Policy ruleuid range, mark, incoming interfaceWhich route exists inside the table
Route lookupDestination prefix, longest matchWhether the packet is allowed out
Netfilter hookChain position, match conditionsWhich table was selected

Marks are a packed field, not a label you own

Android packs several facts into the same mark value: which network was selected, whether an application explicitly chose a network, whether a socket is protected from the VPN, and permission-related bits. The AOSP fwmark header defines that layout for the current tree, and the layout is a platform internal that changes between releases.

That is why writing a whole mark value with iptables -j MARK --set-mark is dangerous on Android. It does not add a tag; it replaces the network selection the system already made. The packet then matches different rules, which can look like the VPN started working, or like an unrelated app lost connectivity, depending on which bits were overwritten.

If you must touch marks, read the current value first and use a mask so that only the bits you understand are modified:

ip rule show
ip route show table all | head -40
ip route get 10.20.4.11

A connection mark is a separate object from a packet mark, and neither is a route. Restoring the mark on reply packets is a distinct step from selecting a table on the way out, which is why one-directional fixes produce connections that open and then hang.

Who owns the rules and tables

Android's network service, netd, creates the rules and the per-network tables. The AOSP RouteController is where those rules are assembled for VPNs, physical networks and uid ranges, and its lifecycle is tied to network events rather than to a config file.

Two consequences follow. First, priorities and table numbers you find on one handset are build internals, not an interface: identical-looking numbers on another Android version can mean something else entirely. Second, the state is rebuilt when networks change, so a rule inserted from a root shell today may be gone after the next Wi-Fi transition.

That disappearance is often reported as the system ignoring a route. What actually happened is that the rule which selected the table no longer exists, and the route inside the table is now unreachable from the packet's perspective. Check the rule list, not the route list, when a working configuration stops working after a reconnect.

Hook-order questions have no universal answer

"Does iptables run before VPN routing" cannot be answered without naming the packet path. A locally generated packet traverses the output hook, where a mark change triggers a reroute check — a second lookup, not a retroactive edit to the first one. A packet arriving from a tethered client traverses the prerouting path and is forwarded instead, and the chains that see it are different ones.

So the same rule can be effective for an app's traffic and invisible to tethered traffic, and a counter that stays at zero is telling you which path the packet took, not whether the rule is wrong. Use counters to establish the path before changing any match condition.

This is also the stage where a policy-routing mistake becomes hard to see. Routing to a table that exists but does not contain the destination produces a "network unreachable" that looks like a firewall drop, while a firewall drop produces a timeout that looks like a routing black hole.

Diagnose inside the system, or leave it alone

For an authorised review, establish four facts before touching anything: the app's uid and profile, the network Android selected for it, whether the VPN includes that app at all, and whether the problem exists for both address families. A per-app exclusion explains a great many "VPN not working" reports without any routing fault.

Then read the rules, masks and tables that are actually in effect, and keep a network switch inside the observation window. If the behaviour changes across a reconnect, you are looking at managed lifecycle, not at a missing route.

For ordinary requirements, use the supported controls: per-app inclusion and exclusion in the VPN application, and the platform's own network selection for anything that needs a specific path. If a privileged integration genuinely has to cooperate with the routing machinery, target the implementation of the exact Android version you support and maintain its lifecycle through the component that owns it. Flushing platform chains or inserting a high-priority rule converts a diagnosis into a second network manager, and the phone will behave accordingly.

ተዛመድቲ ጽሑፋት