6 minutes
π¬π§ CVE-2026-34036 - When your CRM’s autocomplete reads more than it should
Table of Contents
- Introduction
- What is selectobject.php?
- The Vulnerability
- Impact & Exploitation
- Proof of Concept
- Conclusion
- PoC Script
Introduction
Dolibarr is a widely used open-source ERP/CRM written in PHP, with over 7k stars on GitHub and more than 780 contributors at the time of writing. It has a solid security posture: a built-in WAF that filters XSS and SQL Injections, and a centralized ACL framework that gates access to every module.
With XSS and SQLi well-defended, I turned to Local File Inclusion, a classic PHP vulnerability class that often survives in mature applications because it hides in dynamic include/require patterns rather than user-facing inputs.
This led me to CVE-2026-34036: an authenticated LFI in Dolibarr 22.0.4 and earlier that turns the application’s autocomplete endpoint into an arbitrary file reader.
What is selectobject.php
Throughout Dolibarr’s interface, form fields that reference other objects such as linking an invoice to a company, or a manufacturing order to a product rely on an autocomplete search powered by jQuery. When a user starts typing in one of these fields, the browser sends an AJAX request to /core/ajax/selectobject.php, which searches the database and returns matching results as JSON.
This endpoint accepts a key parameter called objectdesc, which describes what type of object to search for. The expected format is: ClassName:path/to/class.php:0:filter
For example, searching for a company would produce: Societe:societe/class/societe.class.php:0:t.status=1
Under the hood, the endpoint follows this flow:
- Parse the
objectdescstring to extract the class name and the path to its PHP file - Resolve the object by calling
fetchObjectByElement(), which loads the corresponding class - Fall back to
dol_include_once($classpath)if the resolution fails, this directly includes the file from the webroot - Check permissions via
restrictedArea(), Dolibarr’s centralized ACL function - Query the database through
selectForFormsList()and return the results
This endpoint is used everywhere in the application. It requires a valid session but no specific module permissions by default.
Two things worth noting: the $classpath in step 3 comes directly from user input, and the permission check in step 4 depends entirely on the resolved object to know what to check permissions against, so what happens when that object doesn’t exist?
The vulnerability
Crafting the payload
In normal usage, objectdesc contains something like Societe:societe/class/societe.class.php:0. But since the value comes straight from the request, nothing stops us from sending:
GET /core/ajax/selectobject.php?outjson=0&htmlname=x&objectdesc=A:conf/.htaccess:0
Here, A is a fake class name and conf/.htaccess is the file we want to read. Let’s walk through what the application does with this.
Failed object resolution
The endpoint parses our input and calls fetchObjectByElement(0, "A"). Since A doesn’t match any known Dolibarr object, the function returns 0, not null, but the integer 0. This distinction matters.
Because $objecttmp is falsy, the code enters the fallback branch:
if (empty($objecttmp) && !empty($classpath)) {
dol_include_once($classpath);
}
dol_include_once() resolves $classpath relative to DOL_DOCUMENT_ROOT and calls include_once on it. Since conf/.htaccess is a plain text file, PHP can’t parse it as code, so it dumps the raw contents straight into the HTTP response buffer.
At this point, the file has already been read. But the request should still fail at the permission check… right?
The fail-open: tricking restrictedArea()
This is where it gets interesting. After the include, the code reaches the ACL gate:
if ($objecttmp !== null && !empty($objecttmp->module) && !in_array($objecttmp->module, $allowModules)) {
restrictedArea($user, $objecttmp->module, $id, $objecttmp->table_element, $objecttmp->element)
} else {
restrictedArea($user, $objecttmp !== null ? $objecttmp->element : '', $id);
}
Remember: $objecttmp is 0, not null. In PHP, 0 !== null evaluates to true. But $objecttmp->module on an integer is empty, so we fall into the else branch:
restrictedArea($user, $objecttmp !== null ? $objecttmp->element : '', $id);
Again, 0 !== null is true, so PHP evaluates $objecttmp->element, accessing a property on an integer returns null, which gets casted to an empty string. The actual call becomes:
restrictedArea($user, '', $id);
Now let’s look inside restrictedArea() in core/lib/security.lib.php. The function iterates over the $features parameter to check if the user has the required module permissions:
} elseif (!empty($feature) && ($feature != 'user' && $feature != 'usergroup')) {
if (!$user->hasRight($feature, 'lire') && !$user->hasRight($feature, 'read') && !$user->hasRight($feature, 'run')) {
$readok = 0;
$nbko++;
}
}
The key condition is !empty($feature). Since we passed an empty string, this entire block is skipped. The $readok variable, initialized to 1, is never set to 0. The function concludes that the user has permission to read the file, and the request completes with HTTP 200.
The file contents we included earlier? They’re already in the response body.
Chain summary
objectdesc=A:conf/.htaccess:0fetchObjectByElement("a")β returns0(notnull)dol_include_once("conf/.htaccess")β file contents dumped to responserestrictedArea($user, '', $id)β empty$featuresβ check skipped β$readok = 1- HTTP 200 and file contents in response body
Impact & Exploitation
What can be read
The dol_include_once() function resolves paths relative to DOL_DOCUMENT_ROOT, the htdocs/ directory. Any non-PHP text file under this root is fair game:
conf/.htaccess: Apache access rules, internal path restrictions.env: environment variables, potentially containing API keys or credentialscomposer.lock/installed.json: exact versions of every installed dependency, useful for targeting known CVEs.sql,.log,.bakfiles : database dumps, debug logs, or forgotten backups left in the webroot
What canβt be read
PHP files are included and executed, not displayed. Since the fake class A doesn’t exist, the script hits a fatal error on getEntity() and since PHP buffers the included PHP code’s execution rather than its source, no source code is leaked.
Required conditions
The barrier to exploitation is very low:
- A valid session, any authenticated user account
- No specific permissions required : the fail-open in
restrictedArea()makes module rights irrelevant - No interaction needed
A newly created user with zero permissions assigned can exploit this on their first login.
Proof of concept
The simplest way to trigger the vulnerability is a single authenticated GET request:
GET /core/ajax/selectobject.php?outjson=0&htmlname=x&objectdesc=A:conf/.htaccess:0
The response body will contain the raw contents of conf/.htaccess, followed by PHP warnings and a fatal error which can simply be ignored.
With curl, assuming a valid session cookie:
curl -b 'DOLSESSID_=<session_cookie>' 'http://TARGET_URL/core/ajax/selectobject.php?outjson=0&htmlname=x&objectdesc=A:conf/.htaccess:0'
A full Python PoC is available at the end of this post.
Conclusion
This vulnerability is a good reminder that access control bugs don’t always live where you expect them. The real issue here wasn’t a missing filter or a lack of input sanitization, Dolibarr has plenty of both. It was a subtle logic flaw: a function designed to enforce permissions that allows everything when it receives an empty argument.
The fix is available in Dolibarr v23. If you’re running 22.0.4 or earlier, you should update.
Thanks to the Dolibarr team for the triage and patch. Click here to access the Github Advisory.
PoC Script
A complete PoC is available on my github: https://github.com/cnf409/CVE-2026-34036