OpenEMR is a widely deployed open-source electronic health record and practice-management system. Its Fee Sheet is the form clinical staff use to record billable items during a visit, and it checks drug inventory as part of that. The query behind that check parameterizes its values, the standard defense against SQL injection. But one part of a SQL query can never be a bind parameter: the ORDER BY sort target. That part is assembled from a request field, prod[N][warehouse], by string interpolation, so a caller who controls the field controls the sort clause. A low-privileged user, such as a nurse whose role passes aclCheckForm('fee_sheet'), can inject SQL there and read the entire database out through a timing side channel. Reported to OpenEMR and fixed in 8.3.0 (GHSA-cg8c-2v5q-29g7, CWE-89, Critical).
IMPORTANT
Who’s exposed. Two things have to hold: you run OpenEMR before 8.3.0, and untrusted or low-trust users hold an ordinary clinical role with Fee Sheet access. That is a routine clinical role, not an admin one, so the set of accounts that can reach it is usually much wider than the set you would trust with the whole database. If every account with Fee Sheet access is already fully trusted with the whole database, the injection buys an attacker nothing new; if some are not, it hands them everything, including the login credentials of the accounts that are.
One slot the parameters don’t cover
The inventory lookup runs when the Fee Sheet is saved: interface/forms/fee_sheet/new.php posts the line items, library/FeeSheet.class.php walks them through checkInventory() and save(), and the query itself is built in src/Services/DrugSalesService.php::sellDrug(). The WHERE clause that selects the inventory rows is parameterized and safe. The problem is next to it: the warehouse value from the request is concatenated straight into the ORDER BY clause (DrugSalesService.php, around lines 308-310) instead of being checked against the columns it is allowed to name.
Past the zero-row barrier
An injection point inside ORDER BY is usually read through the ordering it produces: sort by a CASE expression and watch whether the rows come back in one order or another. That technique needs rows to sort, and it needs the attacker to be able to count on them. Here they cannot. A classic blind injection would just widen the WHERE with OR 1=1 to guarantee a full result set, but this WHERE is parameterized, so there is no way into it; the attacker is left with whatever the legitimate lookup returns, which for a crafted probe is often nothing. With zero rows there is no ordering to observe. That is the barrier, and it is why the parameterized half of the query still matters even though it is not the bug.
The finding steps around it with a non-correlated subquery. A subquery in the ORDER BY clause that does not reference the outer query’s rows is a constant as far as the outer query is concerned, so the database evaluates it once, independently of how many rows come back, zero included. Put a conditional heavy operation inside it, in the shape the advisory gives:
ORDER BY (SELECT IF(<condition about a hidden value>, <expensive operation>, 0))
and the request is slow when the condition is true and fast when it is false. Each request answers one yes-or-no question about data the caller was never served, and the answer is read off the clock rather than off the response. Asking the right sequence of questions, comparison by comparison for numbers and prefix by prefix for text, reconstructs any value in the database.
What it reaches
Because the oracle is not scoped to the Fee Sheet’s own tables, its ceiling is the whole schema. That includes users_secure, where OpenEMR keeps its bcrypt password hashes. Extracting the hashes lets an attacker crack them offline, at their own pace, and return as any user, including administrators, so a read-only foothold turns into full account takeover without ever needing a write.
OpenEMR’s advisory rates the finding Critical. Scored the way SQL injection usually is, where a foothold in the query implies read, write, and availability all at once, it is CVSS 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H). What we demonstrated end to end is narrower: blind, read-only extraction through the timing oracle, which taken on its own is CVSS 7.7 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N). The advisory carries both numbers. They differ on integrity and availability, not on confidentiality.
Remediation
If you maintain the code. The sort target cannot be a bind parameter, so it has to be validated instead of interpolated. Map the incoming warehouse field to a known column or to a fixed set of allowed sort keys, and build the ORDER BY only from a value that passed that check, rejecting anything else. An allowlist of literal column names is enough; the request field should never reach the clause verbatim. This is the shape of the fix in 8.3.0.
If you operate it. Upgrade to 8.3.0. There is no clean interim toggle, because the entry point is the Fee Sheet itself, a tool ordinary clinical staff are meant to use; tightening who holds fee_sheet access narrows the reachable set but does not close the hole for the users who legitimately keep it. Treat the upgrade as the fix. And because the reachable data includes the password store, if an affected version was reachable by anyone you would not trust with the full database, treat the hashes as potentially exposed and rotate credentials.
Disclosure
- Software: OpenEMR, all versions before 8.3.0.
- Class: CWE-89, SQL injection (into an
ORDER BYclause). - Severity: Critical, CVSS 9.9 (
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H); the demonstrated read-only technique scores 7.7 (.../C:H/I:N/A:N) on its own. - Advisory: GHSA-cg8c-2v5q-29g7, public 2026-08-18. No CVE assigned at time of writing.
- Fix: 8.3.0. Reported privately to OpenEMR ahead of the advisory.
All testing was against an instance we controlled, with synthetic patients and records.
Bind parameters don’t cover the whole query
Parameterizing every value is the right habit, and it is the one most injection advice teaches. It is also not the whole query. A bind parameter can stand in for a value, but not for a sort target, a table or column identifier, or in some drivers a LIMIT. Those slots have to be assembled by the code around the parameters, and an otherwise clean, parameterized codebase can still carry an injection in exactly one of them. So the check that finds this class is not “are the values parameterized” but “which slots in this query can a parameter not fill, and is each of those validated against a fixed set of allowed values.” List those slots first, then read each one. This Fee Sheet query had done the values correctly and left the one slot a parameter can’t reach open, which is the whole finding.
This work sits in our web application security practice and is part of our ongoing vulnerability research into the open-source software that everyone else builds on.