Dual VPN routing issue: private CIDR should go via corporate VPN interface in sing-box
Operating system
macOS
System version
26.1
Installation type
sing-box for macOS Graphical Client
If you are using a graphical client, please provide the version of the client.
Version 1.12.9 (1)
Version
sing-box version 1.12.12
Environment: go1.25.3 darwin/arm64
Tags: with_acme,with_clash_api,with_dhcp,with_gvisor,with_quic,with_tailscale,with_utls,with_wireguard
CGO: enabled
Description
1. Background
Due to my work environment, I need to use a corporate VPN to access some internal IP ranges, while also needing sing-box for accessing the open Internet. So I have to run two VPNs at the same time:
- Corporate VPN: Tunnelblick 8.0 (build 6300)
- Proxy: clash, sing-box
2. Problem
I had been using clash, but it hasn’t been maintained for more than two years (the repo was taken down). Recently I found there’s a replacement [client](https://github.com/clash-verge-rev/clash-verge-rev) (84.9K), but it feels too heavy. So I started looking for alternatives and eventually found sing-box. Its advantages:
- Lightweight, the client is under 10 MB.
- Supports automatic failover, so when the selected group goes down, the proxy doesn’t get interrupted and I don’t have to manually switch to another group.
The second point is especially important for me. Because my provider is unstable, I used to have to manually switch nodes from time to time, which was very annoying. So I switched to sing-box directly.
However, a new issue came up: some IP ranges that must go through the corporate VPN started to time out after switching to sing-box.
ERROR[0005] [2634791637 5.0s] connection: open connection to 172.20.8.6:9848 using outbound/direct[direct]: dial tcp 172.20.8.6:9848: i/o timeout
ERROR[0005] [2065905086 5.0s] connection: open connection to 172.20.8.6:9848 using outbound/direct[direct]: dial tcp 172.20.8.6:9848: i/o timeout
From the logs and rules, everything looks correct — it’s already using direct to access that IP. But it still times out. I suspect this is because two VPNs are enabled at the same time, and clash may have handled this scenario automatically, while sing-box does not.
As a result, some IP ranges that should go through the corporate VPN are effectively “hijacked” and sent out via the public network together with other direct IPs, causing access failures.
Verification:
➜ sing-box git:(stable) route get 172.10.8.6
route to: 172.10.8.6
destination: 172.10.8.6
gateway: syn-172-110-110-10.res.spectrum.com
interface: utun6
flags: <UP,GATEWAY,HOST,DONE,WASCLONED,IFSCOPE,IFREF>
recvpipe sendpipe ssthresh rtt,msec rttvar hopcount mtu expire
0 0 36411581 39 18 0 1500 0
➜ sing-box git:(stable) route get www.baidu.com
route to: 223.109.82.212
destination: default
mask: default
gateway: 172.10.1.1
interface: en7
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
recvpipe sendpipe ssthresh rtt,msec rttvar hopcount mtu expire
0 0 0 0 0 0 1500 0
➜ sing-box git:(stable)
From the output of route get for IPs that should and should not go through the corporate VPN, we can see that different network interfaces are used, which basically confirms the suspicion above.
Once we know the cause, the solution is clear: Update the configuration so that IP ranges needing the corporate VPN use a dedicated network interface, while others use the default interface.
{
"outbounds": [
{
"tag": "direct",
"type": "direct"
},
// New outbound for IP ranges that must go through the corporate VPN
{
"tag": "direct-vpn",
"type": "direct",
"bind_interface": "utun6"
}
],
"route": {
"rules": [
// Corporate VPN internal IP ranges must be before ip_is_private!
{
"ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
"outbound": "direct-vpn"
},
// Other private IPs go after
{
"ip_is_private": true,
"outbound": "direct"
}
]
}
}
In short, add this under outbounds:
{
"tag": "direct-vpn",
"type": "direct",
"bind_interface": "utun6"
}
And add this under route.rules:
// Corporate VPN internal IP ranges must be before ip_is_private!
{
"ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
"outbound": "direct-vpn"
},
After restarting sing-box, I saw logs like the following, which shows the configuration has taken effect. This finally solved the issue that had troubled me for a week.
INFO[0000] [3699172302 1ms] outbound/direct[direct-vpn]: outbound connection to 172.10.8.18:9848
INFO[0000] [300026688 1ms] outbound/direct[direct-vpn]: outbound connection to 172.10.8.18:9848
Reproduction
see issue detail
Logs
Supporter
- [ ] I am a sponsor
Integrity requirements
- [x] I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
- [x] I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
- [x] I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
- [x] I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.
There is a simpler solution, if you are using Tun, just add the private ip you want sing-box to let alone to the Tun's route_exclude_address section, that way sing-box will not hijack them, and they will be properly routed by the system's routing table. If you are using system proxy (which seems you are not), simply setting the private ip range to use the direct outbound should work.
You mean configuring ip_is_private, right? That probably won’t solve the problem.
{
"route": {
"rules": [
{
"ip_is_private": true,
"action": "route",
"outbound": "direct"
},
{
"action": "route",
"outbound": "proxy"
}
]
}
}
Maybe I didn’t explain it clearly. Some of the internal network traffic needs to go out via Tunnelblick’s proxy interface (utun6), while most of the other internal traffic should use the default utun7.
So if I simply set "outbound": "direct", Tunnelblick will stop working. This is also why, even though everything is using the "direct" outbound, I still configure bind_interface to make certain internal networks use utun6.
Also, clients like Clash can actually solve this problem out of the box. But with sing-box, I haven’t found a better way so far.
As a result, if I use some sing-box clients that auto-update, they overwrite my rules and break the Tunnelblick configuration. The only way to fix it is to disable updates, but then my “airport” / subscription stops working when the server addresses change.
I’d like to clarify my setup and what I’ve observed, because my earlier comments might have created some confusion around TUN vs system proxy.
In both Clash and sing-box, I am using system proxy mode, not TUN mode. The comparison and all the issues I’m seeing are based on that.
What confused me initially is that with the same basic networking environment (Tunnelblick + macOS), Clash “just works” with my corporate VPN, while sing-box required extra configuration (like bind_interface + ip_cidr). After digging into it, I think I understand why.
1. What actually happens in system-proxy mode on macOS
In system-proxy mode, there are really two layers deciding where traffic goes:
(1) The OS decides whether to send traffic to the proxy
The GUI (Clash or sing-box) calls macOS APIs (like networksetup) to set HTTP/HTTPS/SOCKS proxy to something like:
127.0.0.1:PORT
—that’s usually an inbound like mixed / http / socks in the config.
At the same time, the GUI can also configure a bypass list, e.g.:
127.0.0.1, localhost, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, <local>, ...
Anything matching the bypass list never touches the proxy at all. The app connects directly, and the OS routing table decides whether it goes through utun6 (Tunnelblick), a physical NIC, etc.
(2) Once traffic hits the proxy, the proxy decides: “Proxy or DIRECT?”
If the OS does send a connection to the local proxy, then the app’s connection hits an inbound (e.g. mixed), and now the proxy’s own routing logic (rules, rule-sets, etc.) decides:
- send it through a remote outbound (proxy), or
- treat it as DIRECT (connect directly to the target from the local machine).
In sing-box, that might look like:
{
"route": {
"rules": [
{
"ip_cidr": [
"172.20.0.0/16",
"172.31.0.0/16"
],
"outbound": "direct-vpn"
},
{
"ip_is_private": true,
"outbound": "direct"
}
]
}
}
Both direct and direct-vpn are “local connects”; they call connect() from the local machine, and then the OS routing table + interface binding decide which NIC (e.g. en7, utun6) actually sends the packets.
The problem I hit is specifically inside step (2): how sing-box chooses the NIC for “DIRECT” outbounds.
2. Why Clash “automatically” works with Tunnelblick
From what I can tell, there are two reasons why Clash “just works” in my environment.
2.1 OS-level bypass: Clash often never sees corporate VPN traffic
Many Clash macOS clients (Clash Verge, older ClashX, etc.) automatically add a bunch of private ranges into the system proxy bypass list when system proxy is enabled, for example:
-
10.0.0.0/8 -
172.16.0.0/12 -
192.168.0.0/16 -
<local>
These already cover a large chunk of “internal” ranges.
In my case, when I access something like 172.20.x.x / 172.31.x.x (corporate VPN subnets), the system may simply decide:
Don’t use the HTTP/SOCKS proxy for this. Connect directly.
Then macOS looks at its routing table, sees that those prefixes are routed via utun6 (Tunnelblick), and sends traffic that way. Clash never even sees those connections. From my point of view, it looks like “Clash handled corporate VPN traffic automatically”, but in reality:
The OS + Tunnelblick handled it; Clash was bypassed.
2.2 Even when traffic reaches Clash, DIRECT usually doesn’t bind a NIC
On top of that, Clash’s DIRECT outbound, in common configs, doesn’t usually bind a specific interface. It just does a normal connect(dst) and lets the OS routing table decide per destination.
For example, on my machine:
route get 172.10.8.6 # corporate VPN host
# -> interface: utun6
route get www.baidu.com
# -> interface: en7
If Clash does:
-
DIRECTto172.10.8.6→ OS choosesutun6. -
DIRECTtowww.baidu.com→ OS choosesen7.
So even if some 172.x traffic does go into Clash, as long as Clash doesn’t override the interface, the actual behavior still matches the OS routing table. That again feels “automatic”.
3. Why sing-box breaks Tunnelblick in system-proxy mode
The key difference I ran into is sing-box’s routing feature:
route.auto_detect_interface / route.default_interface.
The official docs explain auto_detect_interface like this (simplified):
Bind outbound connections to the default NIC by default, to prevent routing loops when using TUN.
This makes perfect sense if sing-box is running its own TUN inbound and wants to avoid traffic looping back into itself.
However, in my setup:
- I am not using a sing-box
tuninbound at all. - I’m using Tunnelblick as the VPN (utun6) for some internal ranges.
- I’m using sing-box only as a system proxy (mixed/http inbound +
set_system_proxy).
On macOS in this configuration:
- The default route (0.0.0.0/0) is through my main NIC, e.g.
en7. - Tunnelblick just adds a few more specific routes like
172.20.0.0/16→utun6.
With auto_detect_interface: true enabled:
-
sing-box detects the default NIC (the interface used for 0.0.0.0/0) — in my case,
en7. -
Any outbound without an explicit
bind_interface(including<type: "direct">) gets bound toen7. -
Now when I visit
172.20.8.6:- My routing rules say:
ip_is_private: true→outbound: direct. - But
directhas been bound toen7byauto_detect_interface. - There is no valid route to
172.20.8.6viaen7, because that subnet is only reachable viautun6. - Result: timeouts like
i/o timeoutin the log.
- My routing rules say:
So in system-proxy mode, the thing that broke Tunnelblick for me wasn’t the routing rules themselves, but:
route.auto_detect_interface: trueforcing DIRECT traffic onto the default NIC, ignoring the OS routing table’s more specific routes for the corporate VPN ranges.
That’s a great safety feature for sing-box TUN scenarios, but in my “Tunnelblick + system proxy only” setup, it actually works against the OS routing.
4. Why my manual ip_cidr + bind_interface workaround fixed it (before)
Before I realized the auto_detect_interface issue, I tried this workaround:
"outbounds": [
{
"tag": "direct",
"type": "direct"
},
{
"tag": "direct-vpn",
"type": "direct",
"bind_interface": "utun6"
}
],
"route": {
"rules": [
{
"ip_cidr": ["172.10.0.0/16", "172.11.0.0/16"],
"outbound": "direct-vpn"
},
{
"ip_is_private": true,
"outbound": "direct"
}
]
}
From sing-box’s point of view this means:
-
For corporate VPN internal subnets (
172.10/16,172.11/16):- Match the first rule → use outbound
direct-vpn. -
direct-vpnis explicitlybind_interface: utun6. - Once an outbound has its own
bind_interface, the globalauto_detect_interfacedoesn’t override it. - So these ranges always go through Tunnelblick (
utun6).
- Match the first rule → use outbound
-
For other private IPs (
192.168.x.x,10.x.x.x, other172.*ranges that don’t match the first rule):- Match the second rule →
ip_is_private: true→outbound: direct. -
directstill gets bound byauto_detect_interfaceto the default NIC (e.g.en7). - That’s acceptable for my local/private but non-VPN subnets.
- Match the second rule →
-
Public IPs fall back to whatever outbound I assign in
final(usually some proxy node).
Effectively, I was re-implementing the OS routing logic inside sing-box:
“These specific 172.x ranges belong to Tunnelblick → bind them to utun6. Other internal ranges use the default NIC.”
This worked, but it meant I had to maintain corporate subnets manually in my sing-box config.
5. The key experiment: disabling auto_detect_interface in pure system-proxy mode
Then I tried the obvious experiment:
- Keep using only system-proxy mode in sing-box (no sing-box TUN).
- Disable
route.auto_detect_interface.
In other words:
"route": {
"rules": [
{
"ip_is_private": true,
"outbound": "direct"
}
// plus other usual rules: CN direct, others proxy, etc.
],
"final": "proxy",
"auto_detect_interface": false
}
With this change:
-
directno longer has a forced NIC. -
It simply does
connect(dst)and lets the OS routing table decide the interface — just like Clash’sDIRECTin most cases. -
For corporate VPN ranges like
172.20.x.x:- The OS knows they should go to
utun6(Tunnelblick). - sing-box doesn’t override that anymore.
- The OS knows they should go to
-
For normal public traffic:
- The OS chooses the normal default NIC.
At that point, I no longer needed ip_cidr + bind_interface: utun6 at all in pure system-proxy mode.
The behavior became very similar to what I see with Clash.
6. My current conclusions and suggested configuration
Based on all this, here’s how I understand it and how I plan to configure things.
A. If I’m using sing-box only as system proxy (no sing-box TUN)
In this scenario:
- Tunnelblick owns its own TUN (
utun6) and routes some internal subnets. - sing-box just listens on
127.0.0.1:PORTand is set as the system HTTP/HTTPS/SOCKS proxy.
What I will do:
-
Disable
route.auto_detect_interface:"route": { "rules": [ { "ip_is_private": true, "outbound": "direct" } // ...other rules... ], "final": "proxy", "auto_detect_interface": false } -
Keep
directas a normal outbound without anybind_interface."outbounds": [ { "type": "direct", "tag": "direct" }, { "type": "socks", "tag": "proxy", "server": "your-server", "server_port": 12345 } ]
This way:
- Any DIRECT (including internal VPN ranges) is handed back to the OS routing table.
- The OS + Tunnelblick decide whether the traffic goes out via
utun6or a physical NIC. - The behavior matches my expectations and is very close to Clash in system-proxy mode.
I no longer need bind_interface: utun6 or per-subnet ip_cidr rules inside sing-box for the corporate VPN case.
B. If I ever use sing-box TUN plus Tunnelblick (dual tunneling)
This is a different scenario (I haven’t fully switched to it, but conceptually):
- sing-box runs its own TUN inbound and wants to capture most traffic.
- Tunnelblick is another TUN (
utun6) for a subset of routes.
In that case, I now understand:
-
It does make sense to keep
auto_detect_interface: trueto avoid loops. -
And for the subnets that must go through Tunnelblick, I should:
- create a dedicated outbound with
bind_interface: "utun6", and - route specific
ip_cidrranges there with higher-priority rules.
- create a dedicated outbound with
For example:
"outbounds": [
{
"type": "direct",
"tag": "direct"
},
{
"type": "direct",
"tag": "direct-vpn",
"bind_interface": "utun6"
}
],
"route": {
"rules": [
{
"ip_cidr": [
"172.10.0.0/16",
"172.11.0.0/16"
],
"outbound": "direct-vpn"
},
{
"ip_is_private": true,
"outbound": "direct"
}
],
"final": "proxy",
"auto_detect_interface": true
}
In short:
-
System-proxy only (my current real-world setup):
- Turn off
auto_detect_interface. - Don’t bind interfaces on
direct. - Let macOS + Tunnelblick handle which NIC to use.
- Turn off
-
TUN mode (if I ever switch to it):
- Keep
auto_detect_interfaceon. - Use
bind_interface+ip_cidron special outbounds for Tunnelblick (utun6).
- Keep
TL;DR
-
The reason Clash “just worked” with Tunnelblick while sing-box did not was not that Clash magically understood my corporate VPN, but that:
- Clash either bypassed those subnets at the system-proxy level, or
- didn’t bind a specific NIC for DIRECT and simply respected the OS routing table.
-
In my sing-box setup,
route.auto_detect_interface: trueforced all DIRECT traffic onto the default NIC, effectively ignoring the OS’s more specific routes toutun6. -
After disabling
auto_detect_interfacein pure system-proxy mode, sing-box now behaves correctly without anyip_cidr + bind_interfacehacks, and Tunnelblick works as expected. @Mahdi-zarei