Series
Breaking OpenEMR 8.0.0.3 (unpatched through 8.2.0) — A Five-Part Research Series — View all write-ups

TL;DR — OpenEMR 8.0.0.3's backup configuration import feature pipes uploaded SQL files directly to the mysql command-line client without any content validation. An authenticated admin can execute arbitrary SQL against the application database — extracting credential hashes, modifying data, and establishing persistent backdoors. In environments without secure_file_priv restrictions, file writes are also possible.


WHAT I WAS LOOKING FOR IN BACKUP.PHP

After mapping the pre-auth surface and the OAuth2 layer, I turned my attention to the admin interface. Specifically, I was looking for features that blurred the line between "configuration" and "arbitrary code execution" — features that trusted admin input more than they should.

The backup and restore functionality is always worth examining in PHP applications. Backup systems tend to be written with operational convenience as the primary concern, and security as a secondary thought. They often handle files in ways that other parts of the application do not, and they frequently have a "just make it work" quality to the implementation.

/interface/main/backup.php manages OpenEMR database backups. It has a restore/import feature at form_step=202 that accepts an uploaded SQL file and applies it to the database. I found the critical code at lines 969–992:

$mysql_cmd = "mysql -u " . escapeshellarg($db_user)
           . " -p" . escapeshellarg($db_pass)
           . " " . escapeshellarg($db_name)
           . " --host " . escapeshellarg($db_host);

$cmd = escapeshellcmd($mysql_cmd)
     . " < "
     . escapeshellarg($EXPORT_FILE);

shell_exec($cmd . " 2>&1");

escapeshellcmd() and escapeshellarg() are both present. The command is constructed correctly for its parameters. The developer was thinking about shell injection.

What they were not thinking about was the content of $EXPORT_FILE. That file is the SQL file the user just uploaded. Once it is passed to mysql via stdin redirection (< $EXPORT_FILE), its contents execute as SQL statements with the full privileges of the application database user. There is no parsing. No allowlisting of statement types. No restriction on DDL versus DML. No filtering for LOAD_FILE, INTO OUTFILE, or any of the other SQL features that make database access interesting from an attacker's perspective.

The code properly prevents shell injection. It does nothing to prevent SQL injection-via-upload.


CONFIRMING THE PRIMITIVE

To confirm this, I needed to authenticate as admin, get a CSRF token from the backup page, and then upload a SQL file. The CSRF token is straightforward — backup.php embeds a csrf_token_form hidden field in its form HTML.

There is one non-obvious requirement that tripped me up during initial testing: the form_import parameter. If you POST form_import=1 alongside your data, backup.php interprets this as "show me the import form" and overrides form_step back to 201 — presenting the upload form rather than executing the import. Only form_step=202 with form_import absent triggers the actual execution at lines 969–992.

Once I understood that, the mechanism was straightforward:

def exec_sql(sql, desc):
    csrf = get_csrf()  # GET /interface/main/backup.php, extract csrf_token_form
    files = {'userfile': ('import.sql', sql.encode(), 'application/sql')}
    data = {
        'csrf_token_form': csrf,
        'form_step': '202',
        'form_status': ''
        # NOTE: form_import must be absent
    }
    resp = session.post(f"{TARGET}/interface/main/backup.php", data=data, files=files)
    return "Applying" in resp.text

The response body includes the text "Applying" when the import executes, making confirmation unambiguous.


WHAT ARBITRARY SQL GETS YOU

I started with the obvious target: the credential store. OpenEMR stores user credentials in the users_secure table as bcrypt hashes (using $2y$12$, twelve rounds). The username and hash are stored separately from the main users table, which stores display information.

My first payload created a staging table, populated it with the hashed credentials, and then made them visible through the application itself:

DROP TABLE IF EXISTS _poc_exfil;
CREATE TABLE _poc_exfil (id INT AUTO_INCREMENT PRIMARY KEY, data TEXT);

INSERT INTO _poc_exfil(data)
  SELECT CONCAT(us.id, '|', us.username, '|', us.password)
  FROM users_secure us LIMIT 10;

-- Make the data visible by overwriting the application's site name global
UPDATE globals
  SET gl_value = (SELECT GROUP_CONCAT(data SEPARATOR ' :: ') FROM _poc_exfil)
  WHERE gl_name = 'openemr_name';

DROP TABLE IF EXISTS _poc_exfil;

After executing this SQL, the login page title changed to display the contents of users_secure. The admin's bcrypt hash appeared in the browser title bar.

A bcrypt hash with twelve rounds is computationally expensive to crack, but the point here is not the crackability of the hash. The point is that arbitrary SQL runs verbatim, with no filtering, against the application database. The UPDATE globals exfiltration trick is just one way to extract data. I could equally have exfiltrated through error messages, side channels, or — in this particular case — through the eval() sink in the category tree that I will describe in Part 4.


EXPANDING THE IMPACT: WHAT ELSE ARBITRARY SQL CAN DO

Credential extraction is the headline, but the complete surface of arbitrary SQL execution is much larger:

ACL table manipulation. The OpenEMR access control model is stored in database tables. An attacker with arbitrary SQL can insert rows that grant themselves or any other account elevated privileges, without going through any application-level access control checks.

Database triggers. SQL files can create AFTER INSERT or AFTER UPDATE triggers on commonly-used tables. These triggers execute automatically whenever any user performs a matching database operation — providing persistent code execution that survives application restarts and does not require further attacker interaction.

Stored procedures. Similarly, stored procedures can be created and then invoked from subsequent SQL files, enabling multi-stage attacks where the initial SQL import establishes a backdoor and later imports use it.

Credential injection. A SQL file can directly insert a new row into users and users_secure — creating a backdoor admin account without touching the web interface.

INTO OUTFILE. In environments where secure_file_priv is not set or is set to a permissive path, SELECT ... INTO OUTFILE '/var/www/html/shell.php' writes a PHP webshell directly to the web root. This was blocked in the test environment (MySQL's secure_file_priv was set), but it is not blocked by the application itself.


THE PERSISTENCE TECHNIQUE I DID NOT RUN

There is one technique I documented as a hypothesis but did not execute in the test environment, because it would leave persistent modifications that could not be fully cleaned up without DBA intervention: a BEFORE INSERT trigger on users_secure that exfiltrates plaintext passwords as users change them.

Whenever a user changes their password in OpenEMR, the plaintext is briefly available in the application layer before being hashed. A trigger on the users_secure table that fires before the hash is stored could capture the hash before it is computed — or, more practically, write the username and new hash to an attacker-controlled table for later retrieval. In a long-term persistence scenario, this is more valuable than a one-time hash dump.

I'm documenting this as a theoretical escalation path, not something I confirmed against the live system.


WHY THIS IS HARD TO FULLY REMEDIATE

The backup import feature exists for a legitimate operational reason: database administrators need to restore backups. The question is how to preserve that functionality while preventing arbitrary SQL execution.

The ideal fix is a combination of three layers:

1. Validate statement types. Parse the uploaded SQL and reject any file containing DDL statements (CREATE TABLE, ALTER TABLE, DROP, TRUNCATE) or dangerous data-access features (LOAD_FILE, INTO OUTFILE, INTO DUMPFILE). Only permit INSERT and UPDATE statements targeting a predefined set of tables. This is not trivial to implement correctly in a SQL parser, but it is the right long-term approach.

2. Use a restricted database user. The application database user (openemr) should have the minimum privileges required for normal operation — SELECT, INSERT, UPDATE, DELETE on application tables. Import operations that need DDL (for schema migrations) should use a separate, separately-credentialed process that runs only during planned maintenance windows.

3. Add a second confirmation step. For an operation as consequential as "execute this SQL file against the production database," a single form submission with a CSRF token is insufficient. Out-of-band confirmation (a second admin must approve, or an email confirmation is required) would significantly raise the bar for an attacker who has compromised one admin account.


AN IMPORTANT NOTE ON SCOPE

This finding is rated High rather than Critical because it requires admin authentication. OpenEMR's admin account is, in the test environment, configured with default credentials (admin/pass). In Part 2 of this series, I showed how to obtain an API access token from default credentials. In production environments with properly managed credentials, an attacker would need to first compromise an admin account through other means — phishing, credential stuffing, or exploiting another vulnerability.

That prerequisite matters. But it also does not make this finding academic. In my experience assessing healthcare systems, admin credentials are frequently shared among staff, frequently set to weak passwords, and frequently managed with less rigor than their privilege level warrants. Default credentials are surprisingly common.

More importantly: Part 4 of this series shows what happens when you chain this SQL import primitive with something else that already exists in the OpenEMR codebase. Spoiler: the "requires admin authentication" requirement does not change what the end result looks like.


Update — Vendor Disclosure & Response

I reported this issue to the OpenEMR maintainers on 2026-04-13 through GitHub’s private advisory process (GHSA-26g3-9466-c639). The maintainers validated the finding but declined to ship a code-level fix, taking the position that executing the contents of an uploaded SQL backup is intrinsic to the restore feature rather than an implementation flaw, and closed the advisory on 2026-05-01 as out of scope for a CVE. They noted that interface/main/backup.php is slated for eventual removal (tracked upstream in #11905) and that the import path additionally requires the non-default configuration_import_export global to be enabled. On that last point: I confirmed by both source review and dynamic testing that this global gates only whether the Import button renders in the backup UI — backup.php checks it in a single place, on the form-display path (form_step == 0), while the form_step=202 execution handler does not check it at all. A direct POST therefore executes the uploaded SQL whether the global is enabled or not; the only genuine preconditions are the admin/super ACL and a valid CSRF token, both of which an authenticated administrator already holds.

As of publication no fix has shipped and the vulnerable code is still present and reachable in the current release (8.2.0). CVE-2026-39931 was assigned by VulnCheck (CNA), independent of the vendor’s decision to close the advisory without a fix. I am publishing this write-up now that the coordinated-disclosure window has elapsed.

DISCLOSURE TIMELINE

Date Event
2026-04-12 Vulnerability identified via source review of backup.php:969-992
2026-04-12 Arbitrary SQL execution confirmed; admin bcrypt hash exfiltrated via globals table
2026-04-12 Vulnerability documented (High, CVSS 7.2)
2026-04-12 Remediation recommendations documented for coordinated disclosure
2026-04-13 Advisory reported to the OpenEMR maintainers; CVE-2026-39931 assigned by VulnCheck (CNA)
2026-05-01 Advisory closed by the maintainers as out of scope for a CVE; no code-level fix planned (backup.php slated for removal, #11905)
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 3 of a five-part series on vulnerabilities in OpenEMR 8.0.0.3. Part 4 is where this SQL import primitive gets used to achieve something considerably more severe: operating system command execution via a PHP eval() in the document category tree. Part 5 covers stored XSS in the patient portal.

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