IncusOS forwarding packets between bridges by default?

I’m running OPNsense as an IncusOS VM. I have four network interfaces which are pertinent to this post, which are connected to: the WAN (uplink), the server segment of the network (serverside), the personal devices segment of the network (homeside), and the management interface (management).

IncusOS itself is set to only get addresses on serverside (through which it connects to OPNsense via dhcp/slaac, which is how its default route is configured) and management (which it has a static IP on). This does work; I wasn’t able to get IncusOS to start up for the first time without a network connection, but temporarily making it use DHCP on the uplink on the install seed, then rewriting the network policy after it had connected to check for updates at least once seems to be fine as far as making sure IncusOS can start up later.

But I noticed that the numbers on OPNsense were all wrong, and that an alarming number of WAN addresses were being blocked for coming from the server LAN. After some fiddling, I worked out that IncusOS is forwarding packets it receives on other interfaces (including the WAN and home LAN sides, on which it doesn’t have an IP address) to its default route; I was able to access server IP addresses from the home network, and when I moved the default route to be on the management port instead, the flood of incoming WAN packets moved from the server LAN to the management port from the VM’s point of view. This means it’s routing packets on its own, and as a result, basically circumventing the OPNsense firewall.

I see that the firewall_rules are only meant for access to IncusOS itself, and that the recommendation is to use network ACLs for controlling what packets are forwarded. But the Incus documentation says that network ACLs are not applicable to non-managed and external (physical) managed networks, and Incus forbids editing the configuration of a non-managed network. What’s going on here?

For the time being, keeping the default route on the management port (where no devices except the IncusOS hypervisor and OPNsense VM exist) and firmly tightening what can be routed by the OPNsense box from the management port has at least prevented the immediate threat to the network. But this is, to say the least, not the desired state of affairs. Have I misconfigured something? Is there a way to limit the forwarding that I’m missing out on? Let me know!

IncusOS network config:

confirmation_timeout: 5m
time:
  timezone: UTC
interfaces:
  - name: "management"
    hwaddr: "..."
    addresses:
      - ".../24"
    roles:
      - "management"
      - "instances"
  - name: "uplink"
    hwaddr: "..."
    roles:
      - "instances"
    required_for_online: "no"
  - name: "unused"
    hwaddr: "..."
    roles:
      - "instances"
    required_for_online: "no"
  - name: "serverside"
    hwaddr: "..."
    addresses:
      - "dhcp4"
      - "slaac"
    required_for_online: "no"
    roles:
      - "management"
      - "cluster"
      - "storage"
      - "instances"
  - name: "homeside"
    hwaddr: "..."
    roles:
      - "instances"
    required_for_online: "no"
dns:
  domain: "..."
  hostname: "..."

OPNsense VM config:

architecture: "x86_64"
name: "..."
ephemeral: false
description: "OPNsense-based router/load balancer"
type: "vm"
config:
  "boot.autostart": true
  "boot.host_shutdown_action": "stop"
  "boot.host_shutdown_timeout": 30
  "limits.cpu": "sockets=1,cores=6,threads=1"
  "limits.disk.priority": 10
  "limits.memory": "8GiB"
  "limits.memory.hotplug": false
  "limits.memory.oom_priority": -1000
  "security.agent.metrics": true
  "security.csm": true
  "security.guestapi": true
  "security.iommu": true
  "security.protection.delete": true
  "security.secureboot": false
  "snapshots.expiry": "30d"
  "snapshots.pattern": "{{ creation_date|date:'2006-01-02_15-04-05' }}"
  "snapshots.schedule": "@hourly"
  "snapshots.schedule.stopped": false
  "volatile.uuid": "..."
  "volatile.uuid.generation": "..."
devices:
  root:
    "type": "disk"
    "size": "16GiB"
    "pool": "local"
    "path": "/"
  eth0:
    "type": "nic"
    "nictype": "bridged"
    "parent": "uplink"
    "hwaddr": "..."
  eth1:
    "type": "nic"
    "nictype": "bridged"
    "parent": "homeside"
    "hwaddr": "..."
  eth2:
    "type": "nic"
    "nictype": "bridged"
    "parent": "serverside"
    "hwaddr": "..."
  eth3:
    "type": "nic"
    "nictype": "bridged"
    "parent": "management"
    "hwaddr": "..."
  eth4:
    "type": "nic"
    "nictype": "bridged"
    "parent": "unused"
    "hwaddr": "..."

So yes, IncusOS is 100% doing this. Any network that IncusOS knows how to route and is connected to will receive packets from any interface IncusOS has connected, even if the host IncusOS is not using that interface.

I have a simple reproduction for this, involving an IncusOS VM sitting opposite a Kali VM on two bridges:

  • Bridge A gives addresses in the 192.168.122.0/24 subnet and is NATted to the rest of the world; this is how the client connects to it.
  • Bridge B is completely isolated from the world and only consists of the two VMs.

testbed’s network config (comments added by me):

$ incus admin os system network show testbed:
WARNING: The IncusOS API and configuration is subject to change

config:
  interfaces:
    - addresses:
        - dhcp4 # 192.168.122.128/24, default gateway 192.168.122.1
      hwaddr: 52:54:00:e3:b0:ba # on bridge A
      name: enp1s0
      required_for_online: 'no'
      routes:
        - to: 10.0.0.0/8
          via: 192.168.122.19
    - hwaddr: 52:54:00:de:97:8a # on bridge B
      name: connected 
      required_for_online: 'no'
  time:
    timezone: UTC

Remote “testbed” is configured to talk to that VM: (output truncated to only the relevant entry)

$ incus remote list
+-----------------+---------------------------------------------+---------------+-------------+--------+--------+--------+
|      NAME       |                     URL                     |   PROTOCOL    |  AUTH TYPE  | PUBLIC | STATIC | GLOBAL |
+-----------------+---------------------------------------------+---------------+-------------+--------+--------+--------+
| testbed         | https://192.168.122.128:8443                | incus         | tls         | NO     | NO     | NO     |
+-----------------+---------------------------------------------+---------------+-------------+--------+--------+--------+

I’ve deleted the nic from the default profile and deleted the default network (incusbr0), so there are no managed networks on this machine:

$ incus network list testbed:
+------+------+---------+------+------+-------------+---------+-------+
| NAME | TYPE | MANAGED | IPV4 | IPV6 | DESCRIPTION | USED BY | STATE |
+------+------+---------+------+------+-------------+---------+-------+

No instances either:

$ incus list testbed: 
+------+-------+------+------+------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+------+-------+------+------+------+-----------+

The kali box is connected to bridge A via eth0 and bridge B via eth1.

Use scapy on the kali box’s eth1, writing to the bridge B and writing to routes that the above network configuration thinks should exit via bridge A:

$ sudo scapy
>>> sendp(Ether(dst="52:54:00:de:97:8a")/IP(dst="192.168.122.19",src="192.168.122.80")/ICMP(), iface="eth1")
.
Sent 1 packets.
>>> sendp(Ether(dst="52:54:00:de:97:8a")/IP(dst="10.0.10.5",src="192.168.122.80")/ICMP(), iface="eth1")
.
Sent 1 packets.

… and it shows up from the testbed’s enp1s0 in tcpdump on the kali box’s eth0, sniffing bridge A:

$ sudo tcpdump -i eth0 -s0 -Un ether src 52:54:00:e3:b0:ba
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
20:34:59.345491 ARP, Request who-has 192.168.122.19 tell 192.168.122.128, length 28
20:34:59.346077 IP 192.168.122.80 > 192.168.122.19: ICMP echo request, id 0, seq 0, length 8
20:36:08.726671 IP 192.168.122.80 > 10.0.10.5: ICMP echo request, id 0, seq 0, length 8

Pause the IncusOS VM and it pauses the packets, and they’re delivered when the VM is unpaused.

So this is, to say the least, not what I would expect, and an incredibly powerful security footgun from an OS that otherwise makes a decent effort to keep things secure. I’ll file a bug with this information and a link to this post.