OPNsense: LDAP Injection via Unsanitized Login Username

Apr 9, 2026 min read

OPNsense is a popular open-source firewall and routing platform built on FreeBSD. It handles network perimeter security for a huge range of environments, from home labs to enterprise edge routers, and it supports LDAP and Active Directory integration for centralized authentication. That makes the WebGUI login, the captive portal, and VPN auth all capable of delegating to an external directory.

I found a classic injection vulnerability in that LDAP path: the login username was passed directly into LDAP filter strings without any escaping. That’s been true since the LDAP connector was first introduced in 2015.


Overview

GitHub Advisory: GHSA-jpm7-f59c-mp54

CVE: CVE-2026-34578

CVSS 3.0 Score: 8.2 High CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N

CWE: CWE-90 (Improper Neutralization of Special Elements used in an LDAP Query)

Affected software: OPNsense, all versions since the initial LDAP implementation (~15.1) until 26.1.6

Fixed in: 26.1.6 - Commit 016f66cb4 (April 8, 2026)


Root Cause

The vulnerable code lives in src/opnsense/mvc/app/library/OPNsense/Auth/LDAP.php, in the searchUsers() method around line 408. It builds an LDAP filter by directly interpolating the username string:

// Lines 408-412, before the fix
if (empty($extendedQuery)) {
    $searchResults = $this->search("({$userNameAttribute}={$username})");
} else {
    $searchResults = $this->search("(&({$userNameAttribute}={$username})({$extendedQuery}))");
}

There is no call to ldap_escape() anywhere in this method. The $username value comes straight from the login form:

// authgui.inc:170-172
if (isset($_POST['login']) && !empty($_POST['usernamefld']) && !empty($_POST['passwordfld'])) {
    $authFactory = new \OPNsense\Auth\AuthenticationFactory();
    $is_authenticated = $authFactory->authenticate("WebGui", $_POST['usernamefld'], $_POST['passwordfld']);

So $_POST['usernamefld'] flows into searchUsers() with no sanitization between them. PHP has had ldap_escape() since version 5.6, specifically for this problem. It just was never called.

This code path is reached any time an LDAP server is configured in System > Access > Servers and wired into a login endpoint, including the WebGUI, the captive portal, and VPN authentication.


Attack 1: Username Enumeration via Wildcard Injection

LDAP filter syntax treats * as a wildcard. Submitting admin* as the username causes OPNsense to send the filter (uid=admin*) to the LDAP server, which returns every entry whose uid starts with admin. OPNsense then takes the first result’s DN and attempts a bind with whatever password was supplied.

This creates a timing oracle: if the LDAP server finds a match, OPNsense makes a second connection to attempt the bind. If there is no match, it returns immediately without binding.

  • Username found: two LDAP connections, bind attempted, slower response
  • Username not found: one LDAP connection, immediate rejection, faster response

An attacker can binary-search the directory using prefix wildcards (a*, ad*, adm*, etc.) to reconstruct valid usernames one character at a time without any credentials.

Running the timing scan against a vulnerable instance:

$ python3 poc_opnsense_ldap_injection.py --target https://192.168.1.1 --enumerate

[*] Target        : https://192.168.1.1
[*] Mode          : Timing-based LDAP user enumeration
[*] Password used : wrongpassword (constant -- we only care about timing)

    Username                       Time(ms)   Result
    ------------------------------ ---------- --------------------
    zzznobodyhasthisname999        8          AUTH_FAILED
    *                              24         AUTH_FAILED          (+16ms vs baseline)
    admin                          21         AUTH_FAILED          (+13ms vs baseline)
    admin*                         19         AUTH_FAILED          (+11ms vs baseline)

The delta between a nonexistent user (8ms) and a matching wildcard (19-24ms) is the LDAP bind round-trip. The bigger that delta is, the more confidently you can say the username prefix exists.

One important caveat: In November 2024, commit 4cb1f6d57 added a 2-second constant-time delay after every failed authentication in Auth/Base.php:285-297. That substantially masks the timing signal from the WebGUI and captive portal login pages, since both “user found, wrong password” and “user not found” cases now take about 2 seconds. The wildcard still reaches the LDAP server unescaped, but the timing-based enumeration technique against the current codebase is not straightforward to pull off. The root cause was still there, just harder to exploit via this specific channel.


Attack 2: Group Membership Bypass via Filter Injection

This one is more interesting. OPNsense lets admins configure an Extended Query under System > Access > Servers to restrict login to members of a specific LDAP group. A typical setting looks like:

memberOf=cn=vpn-users,dc=example,dc=com

With that in place, OPNsense constructs:

(&(uid={username})(memberOf=cn=vpn-users,dc=example,dc=com))

The intent is that only members of vpn-users can log in. An attacker who has a valid LDAP password for a user who is NOT in that group can bypass the restriction by injecting filter metacharacters into the username field. Submitting this as the username:

targetuser)(|(uid=targetuser

Transforms the filter into:

(&(uid=targetuser)(|(uid=targetuser)(memberOf=cn=vpn-users,dc=example,dc=com)))

The inner |(uid=targetuser) is always true for that user, so the memberOf condition is short-circuited. OPNsense finds the DN and proceeds to bind. If the password is correct, authentication succeeds regardless of group membership.

$ python3 poc_opnsense_ldap_injection.py \
    --target https://192.168.1.1 \
    --username "targetuser)(|(uid=targetuser" \
    --password <known password> \
    --bypass

[*] Payload  : 'targetuser)(|(uid=targetuser'
    Strategy : Unclosed filter -- attempt OR injection
    With extendedQuery, filter becomes:
      (&(uid=targetuser)(|(uid=targetuser)(memberOf=cn=vpn-users,dc=example,dc=com)))
    HTTP 302 | 487ms | AUTHENTICATED

    [+] CONFIRMED: Authentication bypassed with payload

One nuance on the WebGUI case: When this bypass is used against the WebGUI login, $_SESSION['Username'] gets set to the injected filter string rather than the real username. The subsequent ACL lookup fails for that malformed value, so you do not actually end up with management access. The bypass is fully effective for the captive portal and VPN services, where authentication is pass/fail and the session username is not used for downstream ACL checks.


Why a Full Credential-Free Bypass Is Not Possible

OPNsense explicitly guards against anonymous LDAP binds:

// LDAP.php:505-507
if (empty($password)) {
    // prevent anonymous bind
    return false;
}

The bind step always uses the attacker-supplied password against the DN found by the search. A correct password is required to authenticate. The injection enables enumeration and group bypass but not credential-free access, as long as the LDAP server itself rejects empty-password binds.


Impact

ScenarioRequiresImpact
Username enumerationNo credentialsReveals valid LDAP usernames to any network attacker
Group membership bypassValid LDAP password for any userBypasses group-based login restrictions

Affected login surfaces:

  • WebGUI (POST /, usernamefld field) – group bypass does not grant WebGUI admin access due to ACL behavior described above
  • Captive portal (POST /api/captiveportal/access/logon/) – group bypass is fully effective here
  • VPN authentication and any other service calling LDAP::authenticate() or LDAP::searchUsers()
  • LDAP+TOTP (ldap-totp) is also affected: LDAPTOTP extends LDAP and does not override searchUsers(), so it inherits the injection

The vulnerability has been present since commit 6fee1e3e0 on 2015-07-27, when the LDAP connector was first introduced. That is over ten years of deployments.


The Fix

The fix is a single line. Commit 016f66cb4 adds a call to ldap_escape() with LDAP_ESCAPE_FILTER before the username is interpolated into the search string:

// After the fix
$username_safe = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
if (empty($extendedQuery)) {
    $searchResults = $this->search("({$userNameAttribute}={$username_safe})");
} else {
    $searchResults = $this->search("(&({$userNameAttribute}={$username_safe})({$extendedQuery}))");
}

LDAP_ESCAPE_FILTER encodes the characters *, (, ), \, and NUL as their \XX hex equivalents. After escaping, a submitted username of admin* becomes admin\2a, which the LDAP server treats as a literal asterisk in the username rather than a wildcard. The injected filter metacharacters in the group bypass payload are similarly neutralized.

After patching, the same wildcard test should produce:

filter="(uid=admin\2a)"   <- asterisk escaped as \2a
nentries=0                <- treated as literal, no match

When checking it against a live LDAP server, the log output is verified:

69cadec0 conn=1007 fd=12 ACCEPT from IP=172.17.0.1:40732 (IP=0.0.0.0:389)
69cadec0 conn=1007 op=0 BIND dn="cn=admin,dc=example,dc=com" method=128
69cadec0 conn=1007 op=0 BIND dn="cn=admin,dc=example,dc=com" mech=SIMPLE ssf=0
69cadec0 conn=1007 op=0 RESULT tag=97 err=0 text=
69cadec0 conn=1007 op=1 SRCH base="dc=example,dc=com" scope=1 deref=1 filter="(uid=admin\2A)"
69cadec0 conn=1007 op=1 SRCH attr=displayName cn name mail uid
69cadec0 conn=1007 op=1 SEARCH RESULT tag=101 err=0 nentries=0 text=
69cadec2 conn=1007 op=2 UNBIND
69cadec2 conn=1007 fd=12 closed

Timeline

DateEvent
2026-03-27Vulnerability discovered during LDAP connector code review
2026-03-28Reported to OPNsense security team via GitHub private advisory
2026-03-30Remediation in GitHub private fork supplied by Matt Andreko
2026-04-08Fix committed by Franco Fichtner (016f66cb4)
2026-04-09Public disclosure
2026-04-09Follow-up fix commited by Matt Andreko (PR#10111)

Closing Thoughts

This is a straightforward case of a missing sanitization call that has been sitting there for a decade. The function signature of ldap_escape() is simple and PHP has had it for a long time, so there is no framework complexity or API awkwardness that explains why it was not there.

The timing normalization added in late 2024 is worth noting because it shows the team was aware of timing-based issues in the auth path and took a reasonable step to reduce exposure. It helped, but it addressed a symptom rather than the root cause. The underlying injection still reached the LDAP server on every login attempt.

If you are running OPNsense with LDAP authentication, pull the latest commit or watch for the next release that includes 016f66cb4. If you also have an Extended Query group restriction configured and you are using LDAP for anything beyond the WebGUI, the group bypass is worth treating as urgent since it is fully effective against captive portal and VPN authentication endpoints.


Resources