Table of Contents

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 objectdesc string 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:0
  • fetchObjectByElement("a") β†’ returns 0 (not null)
  • dol_include_once("conf/.htaccess") β†’ file contents dumped to response
  • restrictedArea($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 credentials
  • composer.lock / installed.json : exact versions of every installed dependency, useful for targeting known CVEs
  • .sql, .log, .bak files : 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