
OPNsense 26.7.6 - High - Remote - Network-access bypass - captive lastseen roam
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · opnsense-captive-lastseen-roam
Class: Network-access bypass Reach: Remote
OPNsense core 26.7.6 (a842fe48) - opnsense.org
Captive-portal roaming still inherits a session for the newest hostwatch IPv4 on a MAC, plus every IPv6. GHSA-864r (26.7.3) stopped "add every IPv4." The leftover IPv4 is list_hosts.py ORDER BY last_seen DESC. Primary IP is then assigned from unfiltered current_ips[0]. Gratuitous ARP attacker_IP is-at victim_MAC updates last_seen. After 26.7.3 that IPv4 is not one of many; it is the IPv4 the filter keeps if it is newer than the victim's own ARP. IPv6 NDP was never capped. Roaming and hostwatch default on.
This GitHub pack stays private until coordinated disclosure.
| ID | no CVE yet |
| Class | Network-access bypass |
| Reach | Remote (L2-adjacent, captive-portal zone) |
| CWE | CWE-345 / CWE-863 |
| CVSS | High: 7.4 CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:N |
| Product | OPNsense core |
| Affected | 26.7.6 (a842fe48c21288d11a422cb944678e8eb308becf) captive portal roaming |
| Auth | Unauth L2-adjacent host on a captive-portal zone; roaming on |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
I pinned tag 26.7.6 peel a842fe48. The lab is a Python gadget of the pinned _filter_addresses / list_hosts.py / arp.py lines, loopback wait on 127.0.0.1:18820.
| Method | Sink | Privilege |
|---|---|---|
| Newest hostwatch IPv4 for a MAC with roaming=1 | pin _filter_addresses / list_hosts.py ORDER BY last_seen DESC; primary from unfiltered current_ips[0] | Integrity (lab-proven): attacker 192.0.2.99 kept, victim 192.0.2.10 dropped, primary=192.0.2.99. Lab: attacker-ipv4-kept=192.0.2.99 victim-ipv4-dropped=yes roaming=1. |
| Same MAC, roaming=0 | _filter_addresses returns [addresses[0]] | Negative (lab-proven): first IPv4 192.0.2.10 kept. |
| IPv6 on the same MAC, roaming=1 | _filter_addresses keeps every non-v4 | Integrity (lab-proven): all IPv6 kept (2001:db8::10 and 2001:db8::99). |
| Forged GARP / NDP that hostwatch stores under the victim MAC | live PF.add_to_table / IPFW.add_accounting on __captiveportal_zone_N | Integrity (inferred): attacker source IP matches the portal pass table. Session username and quota stay the victim's. Live GARP on the wire and live zone-table pass are not run in this gadget. |
| Same table add | ipfw accounting rule number per session | Integrity (inferred): attacker bytes count on the victim session. |
| Victim IPv4 no longer first | _remove_client / pf delete of addresses not in list_session_ips | Availability (inferred): victim IPv4 can drop off the zone table while the attacker IPv4 stays. |
Lab-proven vs inferred: last_seen IPv4 keep/drop, roaming=0 first-IP, and IPv6-all are lab SUCCESS. Forged GARP on the wire, live pf/ipfw pass, and accounting smear are inferred. Gadget, not a full OPNsense FreeBSD VM.
Requires: L2-adjacent unauth host on a captive-portal zone. Roaming on (default). Hostwatch on (default). One already-authorized session on that zone.
| Surface | Lab |
|---|---|
| GHSA-864r add-every-IPv4 | closed on 26.7.3; this leftover is the last_seen IPv4 plus all IPv6 |
| roaming=0 | first IPv4 kept; attacker IPv4 not selected |
XFF / X-Real-Ip spoof of a different logon IP | zone proxy appends; not a clean pf CIDR |
Unauth logon of the caller IP | documented portal login of getClientIp() |
| Voucher brute on default 8+10 CSPRNG | not this leftover |
| Template zip-slip | realpath plus admin upload |
| Portal splash XSS to WebGUI origin | different port / chroot |
| WAN-unauth | L2-adjacent on the zone |
| WebGUI / root / voucher mint | stays closed |
No unauth WebGUI. No root. No WAN skip of the portal.
cd lab
./run.sh
Loopback wait 127.0.0.1:18820. Compose project opnsense-captive-lastseen-roam. Image tag from that compose. Witness OPNSENSE-CAPTIVE-LASTSEEN-ROAM-WITNESS. Last line:
SUCCESS opnsense-captive-lastseen-roam attacker-ipv4-kept=192.0.2.99 victim-ipv4-dropped=yes roaming=1 OPNSENSE-CAPTIVE-LASTSEEN-ROAM-WITNESS
Root launcher: python3 opnsense-captive-lastseen-roam-Abraxas-Labs.py.
Pick the IPv4 the session already authorized, not the newest hostwatch row for that MAC. Cap IPv6 the same way, or require NDP that the kernel still holds. Do not assign primary from unfiltered current_ips[0] when the filter already chose a different address. Treat a GARP that only updates last_seen as untrusted observation, which is the GHSA-864r invariant.
a842fe48c21288d11a422cb944678e8eb308becfcp-background-process.py _filter_addresses and primary-IP rewritelist_hosts.py ORDER BY last_seen DESClib/arp.py get_all_addresses_by_mac