Research/opnsense-lockout-factory-ip
0-dayNo CVEHighPublic

OPNsense 26.7.6 factory LOG_AUTH plants sshlockout

OPNsense core 26.7.6 unauth WebGUI login still drives lockout_handler from a factory LOG_AUTH line with no socket IP. Last-token pick, the GHSA-2v2x hardening, plants an attacker-chosen address into sshlockout. Labbed.

Name
OPNsense 26.7.6 factory LOG_AUTH plants sshlockout
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
OPNsense (Deciso B.V.)
Affected
OPNsense core 26.7.6 (a842fe48) AuthenticationFactory syslog plus lockout_handler last-IP. Default lockout on, LAN GUI. Unauth login POST.
Published
13 Oct 2026
Updated
13 Oct 2026
Tags
0-day, opnsense, lockout, privilege-leftover, cwe-117, cwe-863

Last-IP saved authgui. It did not save the factory line.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/opnsense-lockout-factory-ip (loopback client). The lab stack is lab/. Authorized lab only. It talks to loopback.

This is OPNsense core 26.7.6 (a842fe48c21288d11a422cb944678e8eb308becf). No CVE yet. CWE-117 / CWE-863. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). Remote unauthenticated privilege leftover. Default: lockout on, LAN GUI.

GHSA-2v2x closed CRLF split. syslog-ng rewrites CR/LF to space. IP pick moved to the last token so authgui.inc's trailing from REMOTE_ADDR wins. That is the right hardening for the authgui template. AuthenticationFactory::authenticate() still syslog(LOG_WARNING, "user %s could not authenticate for %s. [using %s + %s]", $username, ...) with no socket IP. A username that matches a lockout False phrase plus an IPv4 token is the last (only) address on that line. After default 5 spaced attempts: pfctl -t sshlockout -T add and pfctl -k.

This is not CRLF. One PHP syslog record stays one handler line. Last-IP was the wrong hardening for the factory template.

Why OPNsense

Same pin as the rest of this 26.7.6 pass. I wanted leftovers of public GHSAs, not a re-file of the named walk. Lockout is default-on. Unauth login POST is the front door. If the factory line still plants a table, that is High availability of SSH and GUI from a chosen host. Not a login skip. Not RCE.

Pinning 26.7.6

Tag 26.7.6 peel a842fe48c21288d11a422cb944678e8eb308becf. Not master. Not 27.1.a. Not EOL 26.1. 26.7.6 also changed pfctl -k str(ip) (Omar Habra). That TypeError is already on this pin. I did not keep it.

The skip list is half the hunt

I did not re-file:

  • GHSA-2v2x itself. CRLF extra readline(). First IPv4 wins. Closed. IP pick is last token. authgui still ends with REMOTE_ADDR.
  • CVE-2026-44195 / GHSA-h3vx success-keyword counter reset. Failure rules now precede Accepted / Successful login. Username Accepted on the authgui line matches Web GUI authentication error first.
  • GHSA-x3q7 empty-password Save. 26.7.5.
  • GHSA-jjm2 audit log injection via login username. Write-side LF. Integrity of the audit file is Medium. The High pf-table walk is this leftover, not that GHSA.
  • CVE-2026-34578 LDAP filter injection. ldap_escape. Empty password returns false before bind.
  • Empty-password login. authgui.inc requires a non-empty password.
  • 26.7.6 Omar Habra pfctl -k str(ip) TypeError.

Twenty-eight hunts. This is G2. Default WebGUI. Unauth.

How the factory line plants an IP

Unauth POST /index.php with login=1, non-empty usernamefld / passwordfld. CSRF cookie plus token from GET /. authgui.inc calls AuthenticationFactory::authenticate("WebGui", username, password). On failure the factory always syslog's the raw username after openlog(..., LOG_AUTH). That string has no REMOTE_ADDR.

syslog-ng facility(auth) feeds lockout_handler. Handler walks tokens, keeps the last ipaddress.ip_address. First matching all_rules key wins. .*Web GUI authentication error.* is False (block). The factory message is not an authgui line, but a username that contains that phrase still matches re.match from the start because of the leading .*.

authgui's own error log still ends from {REMOTE_ADDR}, so last-IP on that line is the attacker. The factory line's last IP is whatever token sat in the username. Counts are per IP. Default --attempts 5. Timestamps more than 2s apart. Six spaced POSTs add the planted address.

pf then blocks TCP from that table to (self) ports 22 and WebGUI and kills states sourced from that host.

Wrong turns, in order

I first tried to re-file GHSA-2v2x. CRLF is gone. Then I assumed last-IP closed every plant. It closed the authgui plant of a foreign IP. It opened the factory plant of a foreign IP. Then I sent a username that was only the IPv4. No False phrase. No table add. Then I looked at empty-password login. Closed. Then I looked at 44195 success-keyword reset on the authgui line. Failure rules first. Then Omar Habra's str(ip) TypeError. Already on this pin.

Anti-lockout pass on the primary interface may still let a LAN operator reconnect after state kill. I did not treat that as a full close. WAN or non-primary-interface admins stay behind the table. pfctl -k still drops the live session.

The lab

Pinned lockout_handler copied unchanged. PATH stub pfctl. syslog-ng and WebGUI POST stubbed. Loopback identity 127.0.0.1:18800. Compose project opnsense-lockout-factory-ip. Image tag opnsense-lockout-factory-ip:26.7.6.

CONTROL: username is only 203.0.113.77. No table add. negative-ip-only=no. TRIGGER: username Web GUI authentication error 203.0.113.77. Six spaced factory lines. Table add plus kill argv. AUTHGUI control: trailing from 198.51.100.10 stays last-IP.

Last run:

Plain text
SUCCESS opnsense-lockout-factory-ip planted=203.0.113.77 table=sshlockout negative-ip-only=no authgui-last-ip=198.51.100.10 OPNSENSE-LOCKOUT-FACTORY-IP-WITNESS

Live pf drop of TCP 22/GUI and live session kill stay inferred. Gadget, not a full OPNsense VM.

What an attacker can do

Method Sink Privilege
Unauth factory LOG_AUTH username with a lockout False phrase plus an IPv4 token (6 spaced lines) pin lockout_handler -> stub pfctl -t sshlockout -T add and pfctl -k Integrity (lab-proven): table sshlockout plus state-kill argv. Lab: planted=203.0.113.77.
same plant, live pf filter.lib.inc:293-314 from <sshlockout> to (self) ports 22/GUI Availability (inferred): drop new TCP 22/GUI from the planted host; pfctl -k tears down existing states from that IP.
same username with only the IPv4 (no False phrase) lockout_handler Negative (lab-proven): no table add. negative-ip-only=no.
authgui line with trailing from REMOTE_ADDR last-IP parser Integrity of own IP (lab-proven): authgui last token stays 198.51.100.10.
Failed-login body none Recon (lab-proven closed): existing "Wrong username or password." No user enum.

Requires: Unauth WebGUI login POST. CSRF cookie + token from GET /. Lockout on (system.no_sshlockout unset). Default install.

Adjacent surface that stays closed

Unauth session / root / config.xml write. authgui line planting a foreign IP (last token is REMOTE_ADDR). CRLF extra readline(). Empty-password login. CVE-2026-44195 success-keyword reset on the authgui line. 26.7.6 Omar Habra pfctl -k str(ip) TypeError. Does not block the planted IP as a DNS/gateway destination (rules are from <sshlockout> to (self) ports 22/GUI only). Does not mint a session.

The fix

Append the socket IP to the factory syslog(LOG_AUTH) template, same trailing from REMOTE_ADDR as authgui.inc. Or ignore lockout IPs that did not come from the handler's own peer. Do not treat last-token as a substitute for "the client address."

Replay:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18800. Witness OPNSENSE-LOCKOUT-FACTORY-IP-WITNESS.

References

Abraxas Labs https://abraxaslabs.tech https://github.com/abraxas https://x.com/abraxas_null (@abraxas_null) abraxas.null@proton.me