Skip to content

Commit ee67cc4

Browse files
mcuelenaereclaude
andcommitted
feat(usbgadget): drop inbound TCP/UDP on usb0 via nftables
Adds an `inet jetkvm` table with a single `input_usb0` chain that drops TCP and UDP arriving on usb0. ICMP/ICMPv6 (including NDP and ping) are untouched so the link stays debuggable. Installed by applyNcmFirewall when NCM is toggled on, removed by removeNcmFirewall on toggle off. Fail-closed: if modprobe or any nftables operation fails, the link is rolled back and bringUpNcmInterface returns the error -- no scenario where usb0 is up without the firewall in place. Idempotent: a stale table from a previous run is deleted before the fresh one is built. The rv1106 rootfs ships nf_tables.ko in /lib/modules but does not auto-load it (kernel module auto-load via NETLINK_NETFILTER never fires here), so applyNcmFirewall does a lazy `modprobe nf_tables` on first use. Match expressions (meta iifname, meta l4proto, cmp, drop) are all baked into nf_tables.ko on 5.10, so no additional modprobes are needed for the production rules. This closes the security gap that was previously documented as a known issue in PR #1470: with this in place, the web UI, mDNS, SSH and any other system services are unreachable from the target host over the CDC-NCM link, while ICMPv6 (NDP + ping) keeps the link itself debuggable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 017dd7f commit ee67cc4

4 files changed

Lines changed: 119 additions & 6 deletions

File tree

go.mod

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ require (
1010
github.com/coder/websocket v1.8.14
1111
github.com/coreos/go-oidc/v3 v3.16.0
1212
github.com/creack/pty v1.1.24
13+
github.com/eclipse/paho.mqtt.golang v1.5.1
1314
github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377
1415
github.com/fsnotify/fsnotify v1.9.0
1516
github.com/gin-contrib/logger v1.2.6
1617
github.com/gin-gonic/gin v1.10.1
1718
github.com/go-co-op/gocron/v2 v2.17.0
19+
github.com/google/nftables v0.3.0
1820
github.com/google/uuid v1.6.0
1921
github.com/guregu/null/v6 v6.0.0
2022
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
@@ -54,14 +56,14 @@ require (
5456
github.com/cloudwego/base64x v0.1.6 // indirect
5557
github.com/creack/goselect v0.1.2 // indirect
5658
github.com/davecgh/go-spew v1.1.1 // indirect
57-
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
5859
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
5960
github.com/gin-contrib/sse v1.1.0 // indirect
6061
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
6162
github.com/go-playground/locales v0.14.1 // indirect
6263
github.com/go-playground/universal-translator v0.18.1 // indirect
6364
github.com/go-playground/validator/v10 v10.27.0 // indirect
6465
github.com/goccy/go-json v0.10.5 // indirect
66+
github.com/google/go-cmp v0.7.0 // indirect
6567
github.com/gorilla/websocket v1.5.3 // indirect
6668
github.com/jonboulle/clockwork v0.5.0 // indirect
6769
github.com/josharian/native v1.1.0 // indirect
@@ -70,8 +72,9 @@ require (
7072
github.com/leodido/go-urn v1.4.0 // indirect
7173
github.com/mattn/go-colorable v0.1.14 // indirect
7274
github.com/mattn/go-isatty v0.0.20 // indirect
75+
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
7376
github.com/mdlayher/packet v1.1.2 // indirect
74-
github.com/mdlayher/socket v0.4.1 // indirect
77+
github.com/mdlayher/socket v0.5.0 // indirect
7578
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
7679
github.com/modern-go/reflect2 v1.0.2 // indirect
7780
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
7474
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
7575
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7676
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
77+
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
78+
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
7779
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7880
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7981
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -117,10 +119,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
117119
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
118120
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
119121
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
122+
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
123+
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
120124
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
121125
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
122-
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
123-
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
126+
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
127+
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
124128
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
125129
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
126130
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

internal/usbgadget/ncm_firewall.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package usbgadget
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
7+
"github.com/google/nftables"
8+
"github.com/google/nftables/expr"
9+
"golang.org/x/sys/unix"
10+
)
11+
12+
const (
13+
ncmFirewallTableName = "jetkvm"
14+
ncmFirewallChainName = "input_usb0"
15+
)
16+
17+
// applyNcmFirewall installs (or replaces) an nftables table that drops all
18+
// inbound TCP and UDP arriving on usb0. ICMP/ICMPv6 are intentionally not
19+
// touched so NDP and ping continue to work — host isolation, not full
20+
// blackhole. Idempotent: deletes any pre-existing table of the same name
21+
// first so a stale ruleset from a previous run can't accumulate.
22+
//
23+
// Loads nf_tables.ko on first call via modprobe; the rv1106 rootfs ships
24+
// the module but does not auto-load it.
25+
func (u *UsbGadget) applyNcmFirewall() error {
26+
if out, err := exec.Command("modprobe", "nf_tables").CombinedOutput(); err != nil {
27+
return fmt.Errorf("modprobe nf_tables: %w: %s", err, out)
28+
}
29+
30+
conn, err := nftables.New()
31+
if err != nil {
32+
return fmt.Errorf("open nftables conn: %w", err)
33+
}
34+
35+
// Wipe any stale table from a previous run before building fresh.
36+
table := &nftables.Table{Name: ncmFirewallTableName, Family: nftables.TableFamilyINet}
37+
conn.DelTable(table)
38+
// Ignore error — table may not exist, which is fine.
39+
_ = conn.Flush()
40+
41+
table = conn.AddTable(table)
42+
policy := nftables.ChainPolicyAccept
43+
chain := conn.AddChain(&nftables.Chain{
44+
Name: ncmFirewallChainName,
45+
Table: table,
46+
Hooknum: nftables.ChainHookInput,
47+
Priority: nftables.ChainPriorityFilter,
48+
Type: nftables.ChainTypeFilter,
49+
Policy: &policy,
50+
})
51+
52+
// One drop rule per L4 protocol. Each rule matches:
53+
// iifname == usb0 AND l4proto == <tcp|udp> => drop
54+
for _, proto := range []byte{unix.IPPROTO_TCP, unix.IPPROTO_UDP} {
55+
conn.AddRule(&nftables.Rule{
56+
Table: table,
57+
Chain: chain,
58+
Exprs: []expr.Any{
59+
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
60+
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifnameBytes(ncmInterfaceName)},
61+
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
62+
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{proto}},
63+
&expr.Verdict{Kind: expr.VerdictDrop},
64+
},
65+
})
66+
}
67+
68+
if err := conn.Flush(); err != nil {
69+
return fmt.Errorf("commit nftables ruleset: %w", err)
70+
}
71+
return nil
72+
}
73+
74+
// removeNcmFirewall deletes our nftables table. Best-effort: a missing table
75+
// is not an error (we may be called during teardown after a crash or during
76+
// rapid toggle off/on cycles).
77+
func (u *UsbGadget) removeNcmFirewall() {
78+
conn, err := nftables.New()
79+
if err != nil {
80+
u.log.Warn().Err(err).Msg("nftables open failed during teardown")
81+
return
82+
}
83+
conn.DelTable(&nftables.Table{Name: ncmFirewallTableName, Family: nftables.TableFamilyINet})
84+
if err := conn.Flush(); err != nil {
85+
// Most likely cause is "table not found", which we don't care about.
86+
u.log.Debug().Err(err).Msg("nftables flush during teardown")
87+
}
88+
}
89+
90+
// ifnameBytes pads or truncates name to IFNAMSIZ (16 bytes), the form nft
91+
// expects when comparing against the iifname meta key. A shorter slice
92+
// silently fails to match (the kernel memcmps the full register width).
93+
func ifnameBytes(name string) []byte {
94+
b := make([]byte, unix.IFNAMSIZ)
95+
copy(b, name)
96+
return b
97+
}

internal/usbgadget/ncm_iface.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,21 @@ func (u *UsbGadget) bringUpNcmInterface() error {
3131
if err := netlink.LinkSetUp(link); err != nil {
3232
return fmt.Errorf("link up %s: %w", ncmInterfaceName, err)
3333
}
34+
35+
// Install the host-isolation firewall before returning success. Fail closed:
36+
// if the firewall can't be installed, usb0 must not be exposed.
37+
if err := u.applyNcmFirewall(); err != nil {
38+
// Roll back the link so we don't leak an unfiltered interface.
39+
_ = netlink.LinkSetDown(link)
40+
return fmt.Errorf("apply NCM firewall: %w", err)
41+
}
3442
return nil
3543
}
3644

37-
// tearDownNcmInterface brings usb0 down before the gadget rebind drops the
38-
// netdev. Silent no-op if the interface is already gone.
45+
// tearDownNcmInterface removes the firewall and brings usb0 down before the
46+
// gadget rebind drops the netdev. Both steps are best-effort.
3947
func (u *UsbGadget) tearDownNcmInterface() {
48+
u.removeNcmFirewall()
4049
link, err := netlink.LinkByName(ncmInterfaceName)
4150
if err != nil {
4251
return

0 commit comments

Comments
 (0)