一、SSO 基础架构

1.1 核心角色

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   User       │     │   IdP        │     │   SP         │
│  (浏览器)    │◀──▶│ 身份提供方   │◀──▶│ 服务提供方   │
│              │     │ Google / Okta│     │ 内部系统     │
└──────────────┘     └──────────────┘     └──────────────┘

IdP: Identity Provider — 负责认证用户(如登录、MFA)
SP:  Service Provider — 受保护的应用(如 GitHub、内部系统)

SSO 让用户登录一次 IdP 就能访问所有关联 SP

1.2 SAML vs OIDC

特性 SAML 2.0 OpenID Connect
传输格式 XML JSON
认证方式 基于浏览器重定向 基于 OAuth 2.0 扩展
Token 格式 Assertion (XML) ID Token (JWT)
开发难度 较复杂 相对简单
适用场景 企业内部传统系统 现代 Web/Mobile 应用
规范成熟度 稳定(2005 年) 稳定(2014 年)

二、SAML 攻击一:XML 外部实体注入(XXE)

2.1 漏洞描述

SAML Assertion 使用 XML 格式,如果 IdP 没有禁用外部实体解析,攻击者可以通过构造恶意 Assertion 读取服务器文件或进行 SSRF。

2.2 脆弱实现

from lxml import etree

# 🔴 脆弱实现:直接解析用户提交的 XML 且不限制外部实体
@app.route('/saml/consume', methods=['POST'])
def saml_consume():
    saml_response = request.form.get('SAMLResponse')
    xml_data = base64.b64decode(saml_response)

    # 🔴 没有禁用 DTD / 外部实体
    assertion = etree.fromstring(xml_data)
    # 解析 Assertion ...

2.3 攻击 Payload

<!-- 攻击者构造的恶意 SAML Response -->
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
      <!-- 🔴 XXE 注入 -->
      <!DOCTYPE foo [
        <!ENTITY xxe SYSTEM "file:///etc/passwd">
      ]>
      <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
        <saml:AttributeStatement>
          <saml:Attribute Name="email">
            <saml:AttributeValue>&xxe;</saml:AttributeValue>
            <!-- 解析后 /etc/passwd 内容会出现在 email 字段中 -->
          </saml:Attribute>
        </saml:AttributeStatement>
      </saml:Assertion>
    </samlp:Response>
  </soapenv:Body>
</soapenv:Envelope>

2.4 修复

from defusedxml import ElementTree as ET  # ✅ 使用安全的 XML 解析库

@app.route('/saml/consume', methods=['POST'])
def saml_consume():
    saml_response = request.form.get('SAMLResponse')
    xml_data = base64.b64decode(saml_response)

    # ✅ defusedxml 默认禁用所有危险特性:
    # - 外部实体
    # - DTD
    # - XML 爆炸(Billion Laughs)
    try:
        assertion = ET.fromstring(xml_data)
    except ET.ParseError as e:
        return jsonify({'error': f'invalid XML: {e}'}), 400

    # 继续处理...

# 如果需要用原生 lxml,必须显式禁用
from lxml import etree

parser = etree.XMLParser(
    dtd_validation=False,
    load_dtd=False,
    no_network=True,
    resolve_entities=False,  # 🔴 关键!
)
assertion = etree.fromstring(xml_data, parser)

三、SAML 攻击二:签名校验缺失

3.1 漏洞描述

SP 没有校验 SAML Assertion 的签名,攻击者可以构造任意 Assertion 冒充任意用户。

3.2 脆弱实现

# 🔴 完全没有签名校验
def parse_saml_assertion(xml_data: bytes) -> dict:
    tree = ET.fromstring(xml_data)

    # 直接提取属性并信任
    email = tree.find('.//{urn:oasis:names:tc:SAML:2.0:assertion}Attribute[@Name="email"]')
    role = tree.find('.//{urn:oasis:names:tc:SAML:2.0:assertion}Attribute[@Name="role"]')

    return {
        'email': email.text,
        'role': role.text,  # 攻击者可以把 role 改成 admin
    }

3.3 攻击脚本

import base64
from lxml import etree

# 构造恶意 SAML Assertion
malicious_assertion = '''
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
  <saml:Subject>
    <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
      admin@example.com
    </saml:NameID>
  </saml:Subject>
  <saml:AttributeStatement>
    <saml:Attribute Name="email">
      <saml:AttributeValue>admin@example.com</saml:AttributeValue>
    </saml:Attribute>
    <saml:Attribute Name="role">
      <saml:AttributeValue>super_admin</saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
  <!-- 故意没有签名 -->
</saml:Assertion>
'''

# 直接提交给 SP
payload = base64.b64encode(malicious_assertion.encode()).decode()
response = requests.post(f'{SP_URL}/saml/consume', data={'SAMLResponse': payload})
print(f"[+] Response: {response.json()}")  # 如果没校验签名,登录成功!

3.4 安全实现

from saml2.config import Config as Saml2Config
from saml2.client import Saml2Client
from saml2 import BINDING_HTTP_POST

def verify_saml_response(saml_response_b64: str, relay_state: str) -> dict:
    """完整的 SAML 响应校验"""
    # 1. 解码
    saml_response_xml = base64.b64decode(saml_response_b64)

    # 2. 使用 PySAML2 库做完整校验
    # 校验内容包括:
    # - 签名是否有效(使用 IdP 的公钥)
    # - 断言的 Issuer 是否是可信的 IdP
    # - Audience 是否包含本 SP
    # - 断言是否过期 / 尚未生效
    # - InResponseTo 是否匹配我们发送的 AuthnRequest ID
    # - Destination 是否指向本 SP

    try:
        authn_response = saml_client.parse_authn_request_response(
            saml_response_b64,
            BINDING_HTTP_POST,
        )

        # 3. 检查状态
        if authn_response.status != 'Success':
            raise ValueError(f'SAML status: {authn_response.status}')

        # 4. 提取用户信息
        identity = authn_response.get_identity()
        subject = authn_response.get_subject()

        return {
            'name_id': subject.name_id.text,
            'name_id_format': subject.name_id.format,
            'attributes': identity,
            'session_index': authn_response.session_index,
        }

    except Exception as e:
        # PySAML2 在校验失败时会抛出异常
        # 这意味着签名无效 / 断言被篡改 / 断言过期等
        raise ValueError(f'SAML validation failed: {e}')

# 关键:SP 必须预先配置 IdP 的公钥
# SP 绝不能信任自己生成的签名或来自非可信来源的签名
SAML_CONFIG = {
    'metadata': {
        'local': ['/path/to/sp_metadata.xml'],
        'remote': [{'url': 'https://idp.example.com/metadata'}],
    },
    'service': {
        'sp': {
            'entityid': 'https://sp.example.com/metadata',
            'required_attributes': ['email'],
            'optional_attributes': ['role', 'name'],
            'allow_unsolicited': False,  # 🔴 关键:禁止未请求的断言
        },
    },
}

四、SAML 攻击三:XML 签名包装(Signature Wrapping)

4.1 漏洞描述

SAML 支持嵌套签名。如果 SP 只验证最外层签名但信任内层未签名的内容,攻击者可以构造双层 Assertion。

4.2 攻击原理

正常的 SAML 响应:
  Response → Assertion (已签名)

Signature Wrapping 攻击:
  Response → Assertion_A (攻击者伪造,无签名)
           → Assertion_B (攻击者截取的有效断言,已签名)

如果 SP 只解析 Assertion_A 但验证了 Assertion_B 的签名
→ 攻击者的 Assertion_A 被当作可信的!

4.3 修复

使用成熟的 SAML 库(如 PySAML2、onelogin/python3-saml),它们会正确处理签名范围。

五、SAML 攻击四:Open Redirect via RelayState

5.1 漏洞描述

SAML 协议使用 RelayState 参数在 IdP 和 SP 之间传递状态信息。如果 SP 没有校验 RelayState 的目标 URL,攻击者可以构造钓鱼链接。

5.2 攻击流程

攻击者构造钓鱼链接:
https://idp.example.com/sso/saml?SAMLRequest=...&RelayState=https://evil.com

受害者点击后:
1. IdP 要求受害者登录
2. 登录成功后 IdP 把 SAML Response 发送回 SP
3. SP 处理完 SAML 后,把用户重定向到 RelayState 指定的 URL(evil.com)
4. evil.com 可能是钓鱼页面,仿造真实 SP 欺骗用户

如果 SP 自己就存在开放重定向漏洞,攻击者也可以把 RelayState 指向 SP 的重定向接口:
https://sp.example.com/callback?RelayState=/redirect?url=https://evil.com

5.3 修复

ALLOWED_RELAY_STATE_HOSTS = [
    'https://sp.example.com',
    'https://app.example.com',
    'https://admin.example.com',
]

def validate_relay_state(relay_state: str) -> bool:
    """校验 RelayState 只能跳转到可信域名"""
    try:
        parsed = urlparse(relay_state)

        # 只允许 https
        if parsed.scheme != 'https':
            return False

        # 检查是否在白名单中
        for allowed in ALLOWED_RELAY_STATE_HOSTS:
            if relay_state.startswith(allowed):
                return True

        # 或允许同域的相对路径
        if relay_state.startswith('/') and not relay_state.startswith('//'):
            return True

        return False
    except Exception:
        return False

@app.route('/saml/callback', methods=['POST'])
def saml_callback():
    relay_state = request.form.get('RelayState')

    # 🔑 SP 发起登录时就应该校验和存储 relay_state
    # 回调时检查收到的 relay_state 是否与我们存储的一致
    expected_relay_state = session.pop('saml_relay_state', None)

    if relay_state != expected_relay_state:
        return jsonify({'error': 'relay state mismatch'}), 400

    # 处理完 SAML 后再做最终跳转
    if validate_relay_state(relay_state):
        return redirect(relay_state)
    else:
        return redirect('/dashboard')  # 默认跳转到安全页面

六、OIDC 攻击一:OpenID 提供者冒充(OP Impersonation)

6.1 漏洞描述

OIDC 允许动态注册客户端,但如果没有校验 issuer,攻击者可以注册一个冒充的 OIDC provider。

6.2 修复

from authlib.integrations.flask_client import OAuth

oauth = OAuth(app)

# 🔑 硬编码 issuer,不信任动态发现
google = oauth.register(
    name='google',
    client_id=os.environ['GOOGLE_CLIENT_ID'],
    client_secret=os.environ['GOOGLE_CLIENT_SECRET'],
    access_token_url='https://oauth2.googleapis.com/token',
    authorize_url='https://accounts.google.com/o/oauth2/v2/auth',
    api_base_url='https://www.googleapis.com/oauth2/v3/',
    client_kwargs={'scope': 'openid email profile'},
    # 🔑 关键:明确指定 issuer
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    # 从 metadata 中读取 issuer,确保它符合预期
    authorize_client_kwargs={'code_challenge_method': 'S256'},
)

七、OIDC 攻击二:nonce 缺失

7.1 漏洞描述

OIDC 中的 nonce 用于防止重放攻击。客户端应该在请求中发送 nonce,并在收到的 ID Token 中验证它。

7.2 脆弱实现

// 🔴 没有 nonce
async function login() {
  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
    response_type: 'code id_token',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid email',
    // 缺少 nonce!
  })}`;
  window.location.href = authUrl;
}

7.3 修复

// ✅ 始终生成并发送 nonce
async function login() {
  const nonce = crypto.randomUUID();
  sessionStorage.setItem('oidc_nonce', nonce);

  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
    response_type: 'code id_token',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid email',
    nonce,  // 发送给 IdP
  })}`;
  window.location.href = authUrl;
}

// 回调时验证
async function handleCallback(idToken) {
  const payload = decodeJWT(idToken);
  const expectedNonce = sessionStorage.getItem('oidc_nonce');

  if (!expectedNonce || payload.nonce !== expectedNonce) {
    throw new Error('nonce mismatch - possible replay attack');
  }

  sessionStorage.removeItem('oidc_nonce');
  // ...
}

八、OIDC 攻击三:客户端认证绕过

8.1 漏洞描述

公共客户端(SPA)错误地使用 confidential client 的凭据(client_secret),或者在 token 交换时不校验 client_id 是否匹配。

8.2 场景:token 端点接受任意 client_id

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=abc123
&client_id=evil_client  # 🔴 攻击者换了一个 client_id
&client_secret=evil_secret
&redirect_uri=https://evil.com/callback

# 如果 token 端点不校验 code 所属的 client_id
# 攻击者就能用别人的 code 换到 token

8.3 修复

# 存储授权码时绑定 client_id
auth_codes[code] = {
    'client_id': original_client_id,  # 🔑 关键
    'redirect_uri': original_redirect_uri,
    'user_id': user.id,
    'scope': scope,
    'expires': now + 600,
}

@app.route('/oauth2/token', methods=['POST'])
def token_endpoint():
    code = request.form.get('code')
    client_id = request.form.get('client_id')

    stored = auth_codes.get(code)
    if not stored:
        return jsonify({'error': 'invalid_grant'}), 400

    # 🔑 校验 client_id 必须匹配
    if stored['client_id'] != client_id:
        return jsonify({'error': 'invalid_grant - client mismatch'}), 400

    # 🔑 校验 redirect_uri 必须匹配
    if stored['redirect_uri'] != request.form.get('redirect_uri'):
        return jsonify({'error': 'invalid_grant - redirect_uri mismatch'}), 400

    # code 只能用一次
    del auth_codes[code]

    # ... 正常签发 token

九、SSO 攻击五:会话固定在 SSO 流程中

9.1 场景

1. 用户访问 SP → SP 重定向到 IdP 登录
2. 攻击者事先在 IdP 上设置了一个会话 cookie
3. 用户在攻击者的会话上下文中登录 → 会话被攻击者和用户共享
4. 攻击者的 IdP 会话现在绑定了受害者的认证信息
5. 攻击者用自己的浏览器访问 IdP → 自动登录为受害者

9.2 修复

# IdP 登录成功后必须重新生成会话
@app.route('/idp/login', methods=['POST'])
def idp_login():
    username = request.form.get('username')
    password = request.form.get('password')

    user = authenticate(username, password)
    if user:
        # 🔑 IdP 也必须 regenerate session
        session.clear()
        session.regenerate_id()
        session['user_id'] = user.id
        # ...

# 或者在每次认证请求中,检查当前会话是否已被认证
@app.before_request
def check_session_integrity():
    if 'user_id' in session and request.endpoint == 'idp_sso_init':
        # 用户已经在 IdP 上登录了
        # 但要确认这个会话确实属于当前用户(没有被固定)
        pass

十、SSO 攻击六:单点登出(SLO)失效

10.1 问题

用户在 IdP 登出后,各个 SP 并没有被通知,仍保持登录状态。攻击者可以用受害者遗留在 SP 上的会话直接访问。

10.2 SAML SLO 实现

# IdP 发起登出时,向所有 SP 发送 LogoutRequest
def initiate_global_logout(user_id: str):
    # 获取用户当前登录的所有 SP 会话
    active_sp_sessions = db.get_user_sp_sessions(user_id)

    for sp_session in active_sp_sessions:
        # 构造 LogoutRequest
        logout_request = build_saml_logout_request(
            sp_entity_id=sp_session.sp_entity_id,
            name_id=sp_session.name_id,
            session_index=sp_session.session_index,
        )

        # 绑定(后端直接请求 SP 的 SingleLogoutService 端点)
        try:
            resp = requests.post(
                sp_session.slo_url,
                data={'SAMLRequest': base64.b64encode(logout_request).decode()},
                timeout=5,
            )
            db.mark_sp_session_logged_out(sp_session.id)
        except Exception as e:
            log.warning(f'SLO failed for {sp_session.sp_entity_id}: {e}')

    # 最后清除 IdP 自己的会话
    session.clear()

十一、SSO 攻击七:密钥管理不当

11.1 问题

SAML/OIDC 的安全核心是密钥对:

  • IdP 用私钥签名 Assertion / ID Token
  • SP 用 IdP 的公钥验证签名

如果私钥泄露 → 攻击者可以伪造任意 Assertion 冒充任意用户。

11.2 密钥轮换

# 使用 JWKS (JSON Web Key Set) 实现平滑密钥轮换
import json

# IdP 发布两个密钥:当前生效 + 下一个
JWKS = {
    "keys": [
        {
            "kty": "RSA",
            "kid": "key-2024-q4",  # 当前密钥
            "n": "...",
            "e": "AQAB",
            "use": "sig",
            "alg": "RS256",
        },
        {
            "kty": "RSA",
            "kid": "key-2025-q1",  # 即将启用的密钥(给 SP 预热)
            "n": "...",
            "e": "AQAB",
            "use": "sig",
            "alg": "RS256",
        },
    ]
}

@app.route('/.well-known/jwks.json')
def jwks_endpoint():
    """SP 通过这个端点获取公钥集合"""
    return jsonify(JWKS)

# SP 侧:实现 JWKS 缓存 + 刷新
from cryptography.hazmat.primitives import serialization
import requests as http_requests
import time

class JWKSCache:
    def __init__(self, jwks_url: str):
        self.jwks_url = jwks_url
        self.keys = {}
        self.last_fetch = 0
        self.ttl = 300  # 5 分钟刷新一次

    def get_key(self, kid: str) -> dict | None:
        if time.time() - self.last_fetch > self.ttl:
            self._refresh()

        return self.keys.get(kid)

    def _refresh(self):
        try:
            resp = http_requests.get(self.jwks_url, timeout=5)
            data = resp.json()
            self.keys = {k['kid']: k for k in data['keys']}
            self.last_fetch = time.time()
        except Exception as e:
            log.error(f'JWKS refresh failed: {e}')

# 在 token 校验中使用缓存的 key
jwks_cache = JWKSCache('https://idp.example.com/.well-known/jwks.json')

def verify_jwt(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    kid = header.get('kid')

    key_data = jwks_cache.get_key(kid)
    if not key_data:
        # 强制刷新再试一次
        jwks_cache._refresh()
        key_data = jwks_cache.get_key(kid)

    if not key_data:
        raise ValueError(f'key not found: {kid}')

    # 从 JWK 构造公钥
    public_key = load_jwk_to_public_key(key_data)

    return jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience='sp.example.com',
        issuer='https://idp.example.com',
    )

十二、SSO 安全实现清单

12.1 SAML SP 审计清单

  • 是否使用了成熟库(PySAML2 / python3-saml)
  • 是否校验了 Assertion 签名
  • 是否禁用了 XXE / 外部实体
  • 是否校验了 Issuer、Audience、NotBefore、NotOnOrAfter
  • 是否校验了 InResponseTo(CSRF 防护)
  • 是否校验了 Destination
  • 是否校验了 RelayState(开放重定向防护)
  • 是否允许未请求的 Assertion(allow_unsolicited = false)

12.2 OIDC Relying Party 审计清单

  • 是否使用了成熟库(authlib / oidc-client-ts)
  • 是否硬编码了 issuer(不信任动态发现)
  • 是否生成并校验了 nonce
  • 是否启用了 PKCE(公共客户端必须)
  • 是否校验了 ID Token 的 exp、iat、aud、iss、nonce、at_hash
  • 是否用 Redis/cache 实现了 JWKS 缓存
  • 是否做了密钥轮换准备
  • 是否禁止了 token 端点接收任意 client_id

12.3 SSO 通用清单

  • 单点登出(SLO)是否真正通知了所有 SP
  • SSO 流程中是否重新生成了 IdP 会话
  • 是否有会话绑定 / 设备指纹
  • 是否有异常登录检测
  • IdP 私钥是否安全存储(HSM / Secret Manager)
  • 是否定期做密钥轮换(建议 1-2 年)
  • 是否监控 SSO 失败日志(暴力破解 / 钓鱼)

十三、总结

SSO 的安全关键在于信任链的完整性

  1. 协议级校验:签名、nonce、PKCE、Audience、Issuer —— 一个都不能少
  2. 实现级防御:XXE 防护、开放重定向防护、会话固定防护
  3. 密钥管理:安全存储、定期轮换、JWKS 缓存
  4. 防御纵深:SSO 之上叠加 MFA、设备指纹、行为分析

SSO 让认证"一次登录,处处通行",但也让"一次攻破,处处沦陷"。在享受便利的同时,必须确保 IdP 是整个体系中最安全的环节。

十四、参考资料

  • SAML 2.0 Core Specification
  • OpenID Connect Core 1.0
  • OWASP SAML Security Cheat Sheet
  • OWASP OIDC Security Cheat Sheet
  • RFC 8414: OAuth 2.0 Authorization Server Metadata
  • RFC 7517: JSON Web Key (JWK)
  • PortSwigger SAML Vulnerabilities
  • CVE-2018-0489: Shibboleth XXE
  • CVE-2017-11427: LemonLDAP::NG SAML Wrapping