一、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 的安全关键在于信任链的完整性:
- 协议级校验:签名、nonce、PKCE、Audience、Issuer —— 一个都不能少
- 实现级防御:XXE 防护、开放重定向防护、会话固定防护
- 密钥管理:安全存储、定期轮换、JWKS 缓存
- 防御纵深: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