关于 saml-sso-attack
面向授权 SAML SSO 测试的安全技能,涵盖元数据与请求分析、常见 SAML 弱点检查,以及基于响应时间的用户枚举和验证流程。
主要功能
- 01IdP 元数据提取与分析
- 02解码 SAMLRequest
- 03测试 XML 签名包装
- 04检查签名剥离与重放
- 05基于响应时间的用户枚举
适用场景
- 01梳理 IdP 端点与签名元数据
- 02测试 SAML 响应验证
- 03评估 SSO 用户枚举风险
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill saml-sso-attack安装后在 Claude Code 中输入/saml-sso-attack确认已加载。
详细介绍
SAML SSO 攻击技能
SAML 单点登录攻击方法论——IdP 元数据分析、XML 签名包装(XSW)、移除签名、在 NameID 中注入注释,以及基于 SSO 响应时间的用户枚举。已在 TARGET_ORG_A(SimpleSAMLphp IdP,WordPress SP 上有 79 个 XMLRPC 方法)、TARGET_ORG_B(Ory Kratos + OIDC)和 TARGET_ORG_C(暴露了 ADFS WS-Trust)上确认。
适用场景
- 目标使用 SSO(重定向到
idp.、sso.、login.、auth.子域名)。 - URL 包含
SAMLRequest=或SAMLResponse=参数。 - 元数据端点可通过
/saml2/idp/metadata.php或/FederationMetadata/2007-06/FederationMetadata.xml访问。 exchange-owa-attack发现 ADFS 后。
前置条件
- curl、python3。
- 目标 SAML 端点 URL(来自侦察或元数据)。
- 用于交互式测试的 SAML Raider Burp 扩展(可选)。
运行方法
# Discover SAML IdP metadata
curl --max-time 30 --connect-timeout 10 -sk "https://TARGET/saml2/idp/metadata.php" | python3 -c "
import sys, base64, zlib
from xml.etree import ElementTree as ET
content = sys.stdin.read()
if 'EntityDescriptor' in content:
root = ET.fromstring(content)
for el in root.iter():
if 'entityID' in el.attrib:
print(f'entityID: {el.attrib[\"entityID\"]}')
"
# Decode SAMLRequest from URL
echo "SAMLREQUEST_BASE64" | python3 -c "
import sys, base64, zlib
raw = base64.b64decode(sys.stdin.read().strip())
decompressed = zlib.decompress(raw, -15)
print(decompressed.decode())
"
快速参考
| 攻击 | 前置条件 | 影响 |
|---|---|---|
| XML 签名包装(XSW) | 任意用户的有效签名断言 | 冒充任意用户 |
| 移除签名 | 服务器不验证签名是否存在 | 完全伪造身份 |
| 在 NameID 中注入注释 | NameID 格式允许注释 | 冒充用户 |
| SAML 响应重放 | 不验证 InResponseTo | 劫持会话 |
| 密钥混淆 | 元数据中有多个签名证书 | 使用不同密钥对断言签名 |
| 绕过受众限制 | 不验证 Audience | 跨 SP 重用令牌 |
| 提取元数据 | 公开的 IdP 元数据 | 发现证书、端点和绑定 |
| Golden SAML(漏洞利用后) | 窃取的 ADFS 令牌签名证书 | 伪造令牌、冒充任意用户 |
流程
阶段 1 — 发现 SAML 端点
TARGET="$1"
echo "[*] SAML endpoint discovery on $TARGET"
# Common SAML paths
declare -A SAML_PATHS
SAML_PATHS["/saml2/idp/metadata.php"]="SimpleSAMLphp IdP"
SAML_PATHS["/saml2/sp/metadata.php"]="SimpleSAMLphp SP"
SAML_PATHS["/FederationMetadata/2007-06/FederationMetadata.xml"]="ADFS"
SAML_PATHS["/adfs/ls/IdpInitiatedSignOn.aspx"]="ADFS Login"
SAML_PATHS["/adfs/services/trust"]="ADFS WS-Trust"
SAML_PATHS["/auth/realms/master/protocol/saml"]="Keycloak SAML"
SAML_PATHS["/.well-known/openid-configuration"]="OIDC"
SAML_PATHS["/sso/saml"]="Generic SAML"
SAML_PATHS["/idp/shibboleth"]="Shibboleth"
SAML_PATHS["/simplesamlphp"]="SimpleSAMLphp root"
for path in "${!SAML_PATHS[@]}"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 --connect-timeout 5 "https://$TARGET$path")
[[ "$code" == "200" || "$code" == "302" ]] && echo " [FOUND] $path — ${SAML_PATHS[$path]} (HTTP $code)"
sleep 1
done
阶段 2 — 提取 IdP 元数据
METADATA_URL="$1" # e.g., https://idp.target.com/saml2/idp/metadata.php
echo "[*] Extracting SAML metadata from $METADATA_URL"
METADATA=$(curl -sk --max-time 10 --connect-timeout 10 "$METADATA_URL" 2>/dev/null)
if [[ -z "$METADATA" ]]; then
echo "[-] No metadata accessible"
exit 1
fi
# Parse with Python
echo "$METADATA" | python3 -c "
import sys
from xml.etree import ElementTree as ET
content = sys.stdin.read()
root = ET.fromstring(content)
# Namespaces
ns = {'md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'ds': 'http://www.w3.org/2000/09/xmldsig#'}
# Entity ID
entity_id = root.get('entityID', 'unknown')
print(f'Entity ID: {entity_id}')
# Signing certificates
for cert_el in root.iter('{http://www.w3.org/2000/09/xmldsig#}X509Certificate'):
cert = cert_el.text.strip()
print(f'Signing Cert ({len(cert)} chars): {cert[:60]}...')
# SSO endpoints
for el in root.iter():
if 'Binding' in el.attrib:
binding = el.attrib['Binding']
location = el.attrib.get('Location', '')
if 'HTTP-Redirect' in binding or 'HTTP-POST' in binding:
print(f'Endpoint: {location} [{binding.split(\":\")[-1]}]')
# NameID formats
for el in root.iter('{urn:oasis:names:tc:SAML:2.0:metadata}NameIDFormat'):
print(f'NameID Format: {el.text}')
" 2>/dev/null
阶段 3 — 解码并分析 SAMLRequest
SAML_B64="$1" # from URL parameter or Burp
echo "[*] Decoding SAMLRequest"
echo "$SAML_B64" | python3 -c "
import sys, base64, zlib
from xml.etree import ElementTree as ET
raw = sys.stdin.read().strip()
decoded = base64.b64decode(raw)
try:
decompressed = zlib.decompress(decoded, -15)
except:
decompressed = decoded
xml = decompressed.decode('utf-8', errors='replace')
print(xml[:3000])
root = ET.fromstring(xml)
print()
print('=== Analysis ===')
# Request ID
req_id = root.get('ID', 'none')
print(f'Request ID: {req_id}')
# Issuer
issuer_el = root.find('.//{urn:oasis:names:tc:SAML:2.0:assertion}Issuer')
if issuer_el is not None:
print(f'Issuer: {issuer_el.text}')
# ForceAuthn
force = root.get('ForceAuthn', 'false')
print(f'ForceAuthn: {force}')
# NameIDPolicy
policy_el = root.find('.//{urn:oasis:names:tc:SAML:2.0:protocol}NameIDPolicy')
if policy_el is not None:
allow_create = policy_el.get('AllowCreate', 'false')
fmt = policy_el.get('Format', 'unspecified')
print(f'NameIDPolicy: AllowCreate={allow_create}, Format={fmt}')
" 2>/dev/null
阶段 4 — 基于 SSO 响应时间枚举用户
TARGET="$1" # SSO login endpoint
USERS_FILE="$2" # List of usernames/emails to test
echo "[*] SSO timing-based user enumeration"
# The technique: valid users produce a different response time than invalid users
# because the server checks LDAP/AD before returning the SAML response
while read -r user; do
START=$(date +%s%N)
curl -sk -o /dev/null --max-time 15 --connect-timeout 10 \
"https://$TARGET/sso/login?username=$user&password=WRONG_PASS" 2>/dev/null
END=$(date +%s%N)
ELAPSED=$(( (END - START) / 1000000 ))
echo " $user: ${ELAPSED}ms"
sleep 1
done < "$USERS_FILE" | sort -t: -k2 -rn | head -20
echo "[*] Users with significantly higher response times likely exist"
阶段 5 — XML 签名包装(XSW)测试
TARGET="$1"
echo "[*] XSW vulnerability analysis"
# Check if IdP signs only the Assertion (good) or the entire Response (better)
# If only the Assertion is signed, XSW is possible:
# 1. Capture a valid SAML Response with signed Assertion
# 2. Create a new Response containing the original signed Assertion + a forged Assertion
# 3. If the SP validates the forged Assertion instead of the signed one → impersonation
echo "[*] Manual XSW test steps:"
echo " 1. Capture SAML Response from browser (Burp/DevTools)"
echo " 2. Decode SAMLResponse (base64 + inflate)"
echo " 3. Check: is Signature on Response or Assertion level?"
echo " 4. If Assertion-level: wrap original Assertion + forged Assertion in new Response"
echo " 5. Submit forged SAMLResponse to SP ACS endpoint"
echo " 6. If SP accepts → XSW confirmed"
注意事项
- XSW 较为复杂。 需要理解 XML 命名空间、规范化和 SAML 响应结构。
- SAML 消息较大。 URL 中的 SAMLResponse 可长达 4000 个字符以上。响应更常使用 POST 绑定。
- SP 可能会验证 InResponseTo。 如果会验证,重放攻击将失败。可通过连续两次发送相同的 SAMLResponse 进行检查。
- 移除签名只对存在缺陷的 SP 有效。 大多数现代 SP 都会拒绝未签名的断言。
- 速率限制。 SSO 响应时间枚举和端点发现可能触发账户锁定或 IP 封禁。务必在请求之间加入
sleep(≥1s),并在生产环境的测试任务中使用一组源 IP。
验证
- 元数据必须至少显示:实体 ID、签名证书、SSO 端点和 NameID 格式。
- SAMLRequest 必须能解码为包含 Issuer、ID 和 NameIDPolicy 元素的有效 XML。
- SSO 响应时间枚举必须显示有效用户与无效用户之间存在统计显著差异(>200ms)。
- XSW:伪造的 SAMLResponse 必须被 SP 接受并创建有效会话。
- 必须记录所有 SAML 端点:IdP 元数据 URL、SP ACS URL、绑定类型和证书详情。
现代 SAML CVE 与技术
| CVE | 受影响对象 | 影响 | 年份 |
|---|---|---|---|
| CVE-2025-25291 | ruby-saml ≤ 1.17.0 | 通过 XSW / 签名混淆绕过身份验证 | 2025 |
| CVE-2025-25292 | ruby-saml ≤ 1.17.0 | 通过解析器差异绕过身份验证 | 2025 |
| CVE-2024-45428 | GitLab(ruby-saml) | 绕过 SAML 身份验证——完全接管账户 | 2024 |
| CVE-2024-45409 | ruby-saml ≤ 1.16.0 | 通过签名包装(XSW)绕过身份验证 | 2024 |
| CVE-2023-2813 | GitLab CE/EE | SAML 组声明验证错误 | 2023 |
Golden SAML 攻击(后渗透)
获得 ADFS 服务的访问权限或提取令牌签名证书后:
- 提取 ADFS 令牌签名证书(.pfx 或私钥)。
- 使用 AADInternals 等工具或自定义 Python 脚本伪造 SAML 令牌。
- 冒充任意用户(包括仅限云端的身份),无需密码或 MFA。
- 适用于 Azure AD / Entra ID 联合域——伪造的令牌会被无限期信任。
SAML 工具
- SAML Raider(Burp 扩展)——编码/解码、XSW、证书操作
- SAMLReQuest(Burp 扩展)——轻量级 SAML 请求编辑器
- saml2aws ——通过 SAML 使用 AWS SSO 的 CLI
- AADInternals ——用于 Azure AD / Entra ID 的 PowerShell 工具包(Golden SAML)
- ESPOOR ——SAML 消息操作工具
评论
支持 Markdown