发布于2小时前2小时 背景:一道贴近授权攻防的 CTF 场景这类题通常把密码学缺陷和社会工程放在同一条链路里:目标站点使用 OAuth 登录,攻击者不能直接获取口令,但可以诱导测试账号点击伪装成“文档授权确认”的链接。真正的漏洞不在 OAuth 协议本身,而在客户端将可预测值当作 state,且回调阶段没有把 state 与发起登录的浏览器会话绑定。本文场景限定在本地靶场或书面授权环境。靶场由 portal.lab、idp.lab 和审计日志组成,测试目标是验证是否存在登录 CSRF 或会话置换,同时保留可供蓝队复盘的证据。不要在真实第三方 OAuth 客户端上复用下述验证请求。问题定位:先区分回调错误和 state 校验缺失正常授权请求应包含 redirect_uri、client_id、response_type、scope 和 state。state 的安全要求不只是“看起来随机”,还包括一次性、短时有效、与发起授权的浏览器会话绑定,并在成功或失败后立即作废。抓取一组自己账号的授权流量,重点检查三处:连续发起两次登录,state 是否重复、递增,或仅为时间戳、用户名摘要。更换浏览器或清空 Cookie 后,旧浏览器生成的 state 是否仍能在新浏览器回调中通过。同一个 state 是否可以重复提交;失败回调是否也会消耗 state。常见错误实现是 state=MD5(timestamp) 或 state=Base64(user_id:timestamp)。编码不是签名,普通哈希也无法证明值由服务端生成。即便改为 HMAC,如果没有保存 nonce 的消费状态,仍可能被重放。技术分析:社工只是触发器,根因是权限边界失配在会话置换场景中,测试者先以自己的低权限账号完成 IdP 授权,截获尚未使用的回调 URL,再让受测浏览器访问该 URL。如果客户端只验证授权码有效,却不确认 state 属于当前浏览器,受测浏览器可能被登录为测试者账号。随后受测人员上传的内容、填写的数据会落入错误账户。验证标准不是“链接能否被点击”,而是跨会话回调能否改变认证主体。社工话术、邮件投递和绕过安全网关不应成为复现重点;在靶场中用浏览器 A、浏览器 B 模拟不同人员即可。还要区分两种结果:登录 CSRF 会让受害浏览器进入攻击者账号;授权码注入则可能让攻击者获得受害者会话。后者通常还涉及 redirect_uri 校验、PKCE 缺失或授权码绑定错误,风险更高,不能仅凭页面显示判断。操作步骤:最小化、可审计地验证浏览器 A 登录测试账号 red-low,启动 OAuth,但在回调前暂停。记录 A 的 Cookie 标识哈希、state、授权开始时间和服务端 request_id。日志中不要保存完整 Cookie、授权码或令牌。浏览器 B 使用全新配置文件,不登录 portal.lab。把 A 的回调请求仅在浏览器 B 中提交一次,观察响应状态和认证主体。回到浏览器 A 再次提交相同回调,验证 state 或授权码是否可重放。立即撤销测试账号授权、清理会话,并保存脱敏日志。下面的命令只用于本地靶场。先固定 Host,避免误发往外部环境;不要把真实 code、Cookie 或 token 写入 shell history。export TARGET=' /> export CALLBACK='/oauth/callback?code=LAB_CODE_A&state=LAB_STATE_A' # B 浏览器对应空 Cookie 容器;只观察响应头,不自动跟随跳转 curl --resolve portal.lab:443:127.0.0.1 \ --cookie /tmp/browser-b.cookies \ --cookie-jar /tmp/browser-b.cookies \ --dump-header /tmp/callback.headers \ --output /tmp/callback.body \ --max-redirs 0 \ \"${TARGET}${CALLBACK}\" sed -n '1,20p' /tmp/callback.headers grep -Eo 'request_id=[A-Za-z0-9_-]+' /tmp/callback.body || true # 复测结束后销毁本地敏感材料 rm -f /tmp/browser-b.cookies /tmp/callback.headers /tmp/callback.body若响应创建了新会话,应访问只返回当前主体的低风险端点确认身份,不要进入管理功能或读取他人数据。例如 /api/me 返回 red-low,足以证明浏览器 B 被置换到 A 的账户。证据应记录响应码、主体 ID、request_id 和时间,不保存访问令牌正文。安全实现:随机 state、会话绑定和一次性消费推荐服务端保存 state 记录,而不是把全部状态塞进可读参数。至少使用 256 位 CSPRNG 随机数,数据库只保存 state 的 SHA-256 摘要,同时绑定 session_id 摘要、redirect_uri、创建时间和 used_at。校验与消费必须放在同一事务内,避免并发重放。# Python/Flask 风格伪代码:关键点是服务端存储、会话绑定、原子消费 import hashlib, secrets, time from flask import session, abort def digest(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() def issue_state(store, redirect_uri): raw = secrets.token_urlsafe(32) store.insert({ 'state_hash': digest(raw), 'session_hash': digest(session['sid']), 'redirect_uri': redirect_uri, 'created_at': int(time.time()), 'used_at': None, }) return raw def consume_state(store, raw, redirect_uri): # UPDATE ... WHERE used_at IS NULL 用于原子消费,不能先查再改 matched = store.consume_once( state_hash=digest(raw), session_hash=digest(session['sid']), redirect_uri=redirect_uri, not_before=int(time.time()) - 300, ) if not matched: abort(400, 'invalid_oauth_state')使用 OIDC 时还应校验 nonce、iss、aud、exp 和签名算法白名单;使用授权码流程时启用 PKCE,服务端只接受 S256,拒绝 plain。redirect_uri 应做完整字符串精确匹配,不能使用 startswith,也不能在回调中接受任意 next 参数跳往外域。日志留痕与排查OAuth 回调日志建议包含 event、request_id、client_id、state_hash 前 12 位、session_hash 前 12 位、结果、失败原因、来源网段、User-Agent 摘要和时间。禁止记录 code、access_token、id_token、完整 Cookie。为了识别跨会话注入,可按 state_hash 聚合不同 session_hash。# 假设日志为 JSON Lines,查找同一 state 被多个会话使用的情况 jq -r ' select(.event == \"oauth_callback\") | [.state_hash, .session_hash, .result, .request_id] | @tsv ' /var/log/portal/oauth.jsonl \ | sort \ | awk -F '\\t' ' { sessions[$1][$2]=1; lines[$1]=lines[$1] $0 \"\\n\" } END { for (s in sessions) { n=0; for (x in sessions[s]) n++; if (n > 1) printf \"%s\", lines[s]; } }'还应告警以下组合:同一 state 多次回调、state 失败后紧接成功、授权开始与回调的 User-Agent 或网段异常变化、五分钟内大量 invalid_oauth_state。IP 变化只能作为弱信号,移动网络和代理会造成误报,不能单独用于封禁。风险定级与修复建议发现风险修复state 可预测但仍严格绑定会话设计缺陷,需结合其他条件利用替换为 CSPRNG,并缩短有效期state 不绑定浏览器会话可导致登录 CSRF、会话置换服务端保存绑定关系,回调原子消费state 可重复使用回调重放,扩大诱导链接有效窗口成功和失败路径都执行一次性失效授权码未绑定 client、redirect_uri 或 PKCE可能升级为授权码注入启用 PKCE S256,并严格校验客户端和回调地址修复后回归测试至少覆盖:正常登录成功;跨浏览器 state 失败;过期 state 失败;重复回调失败;并发提交仅一次成功;redirect_uri 细微变化失败;日志不包含敏感令牌。若系统此前长期未绑定 state,应撤销存量会话并检查异常账户绑定,而不是只上线代码。总结这类 CTF 题的价值在于把密码学参数、社会工程触发和认证边界放到一条可验证链路里。复现时不需要制作钓鱼页面,更不需要扩大投递范围;用两个隔离浏览器即可证明跨会话问题。最终修复应同时覆盖高熵随机数、服务端会话绑定、原子一次性消费、PKCE、精确回调校验和脱敏日志,否则只把 MD5 换成 SHA-256 并不能消除风险。 网络请求、日志与边界流量分析示意 代码审计、调用链与关键函数定位示意
1小时前1小时 这条链里最关键的不是单独把 state 换成强随机数,而是确认它是否同时满足“不可预测、一次性、绑定发起登录的浏览器会话”。只修随机源但不做会话绑定,攻击者仍可能先发起一条自己的授权流程,再诱导受害者完成回调,形成登录 CSRF / 会话置换。 建议按下面顺序复盘: 从访问日志中按 /authorize 与 /callback 配对,检查同一 state 是否跨 IP、UA、会话 Cookie 出现,或被重复消费; 确认发起授权时生成的 state 是否落在服务端会话中,而不是仅靠回调参数自证; 回调成功后立即删除 state,并在绑定本地账号前轮换会话 ID,避免沿用匿名阶段被攻击者预置的会话; 若使用 OIDC,同时校验 nonce、iss、aud、签名和授权码的 redirect_uri;公有客户端或链路复杂时加 PKCE,但不要把 PKCE 当成 state 的替代品。 服务端逻辑可以收敛成下面这样: import secrets import time import hmac # 发起授权 state = secrets.token_urlsafe(32) # 约 256 bit 随机性 session["oauth_tx"] = { "state": state, "created_at": int(time.time()), "used": False, } # 同时生成 PKCE verifier/challenge,此处略 # OAuth 回调 tx = session.get("oauth_tx") if not tx: abort(400) if int(time.time()) - tx["created_at"] > 300: abort(400) if tx["used"] or not hmac.compare_digest(tx["state"], request.args.get("state", "")): abort(400) # 先标记一次性消费;换取 token 失败也不要让旧 state 无限重放 tx["used"] = True session["oauth_tx"] = tx # 校验 token / OIDC claims 并完成账号映射后: old_data = {"user_id": mapped_user_id} session.clear() rotate_session_id() # 按框架使用 regenerate/renew session.update(old_data) 如果业务允许多标签页并行登录,不要只在 Session 中保存一个 state;应保存一个有上限的事务集合,以 state 的哈希为键并分别记录过期时间,否则后一次登录会覆盖前一次,容易把正常失败误判成攻击。集合仍需一次性删除和数量限制。 留痕方面不建议把原始 state、授权码或 token 写入日志。可以记录带独立日志密钥的 HMAC 指纹,用于跨节点关联: state_fp = hmac.new(LOG_KEY, state.encode(), "sha256").hexdigest()[:20] logger.info("oauth_callback state_fp=%s sid_fp=%s result=%s", state_fp, session_fingerprint, result) 排查现有日志时可先找重复回调和异常时间差: # 字段示例:timestamp state_fp sid_fp ip ua result grep 'oauth_callback' access.log \ | awk '{print $2}' \ | sort | uniq -c | sort -nr | head -30 修复验收至少覆盖四个负向用例:猜测或篡改 state 被拒绝;把 A 浏览器生成的回调放到 B 浏览器被拒绝;同一回调重放被拒绝;授权完成后旧匿名会话 ID 失效。若“社工诱导链”中还携带可控跳转地址,跳转目标应使用服务端白名单或事务记录中的相对路径,不能直接信任回调里的 next/redirect 参数,否则 OAuth 修好后仍可能留下钓鱼跳转入口。
创建帐户或登录后发表意见