在刚结束不久的全国 EDU 护网中,我有幸作为红队成员参与了对某国家级重点教育机构的定向测试。在这个 AI 浪潮席卷的时代,各大高校纷纷上线了自己的“AI 助手”、“AI 聊天小程序”。表面上看,这些系统集成了大模型,显得极其高大上;但在白帽子的眼里,越是新潮的业务,往往越容易在最基础的鉴权逻辑上“翻车”。
今天,我就来带大家复盘一个极其“戏剧性”的漏洞。没有复杂的反序列化,没有花里胡哨的 RCE,仅仅是因为我在发包时多按了一个“退格键”(Backspace),就直接击穿了目标系统的防线,导致全量用户的 AI 聊天隐私数据彻底裸奔。
0x01 故事背景
当你打开一个校园 AI 聊天小程序,向它倾诉你的学习烦恼,甚至询问一些私密问题时,你一定以为这些对话只有你和服务器知道。
但如果在平行的网络世界里,另一个人只需要注册一个最普通的学生账号,就能像看“朋友圈”一样,实时翻阅所有用户的 AI 聊天记录,你会作何感想?
这就是我在这次护网中发现的致命漏洞——一个由参数置空引发的史诗级 BOLA(Broken Object Level Authorization,对象级越权)漏洞。
0x02 信息收集
项目开始后,我拿到了一批目标的小程序资产。其中一个名为“某某大学 AI 聊天系统”的目标引起了我的注意。
我按常规流程抓包配置了 Burp Suite,用手机号注册了一个普通测试账号,随便在小程序里和 AI 聊了几句(比如:“请给我推荐几本好书”)。
此时,我紧盯着 Burp 的 HTTP 历史记录,寻找任何可以利用的蛛丝马迹。在翻阅历史记录时,一个获取聊天会话列表的 API 引起了我的极度舒适
GET /v0/chat/paging?userId=5020414033383795×tamp=1779240425758&signature=xxx...
专业直觉告诉我,事情不简单:
看到 userId=5020414033383795 这种直接暴露在 GET 参数里的明文 ID,我作为安全研究员的“DNA 瞬间动了”。
绝大多数后端开发在写接口时,习惯于通过前端传来的 ID 去数据库查数据。
- 此时,普通的渗透思路是:我能不能把 ID 改成 5020414033383796,去越权看别人的会话?(这叫水平越权)。
然而,我决定玩点更骚的。
0x03 漏洞利用
很多时候,开发者会对传入的 ID 进行权限校验(比如验证传入的 ID 是否与当前 Token 绑定的 ID 一致)。如果一致,放行;不一致,拦截。
逻辑转换的瞬间:
我突然想到一个问题:如果我根本不传这个 ID 呢?
在很多后端 ORM 框架(如 MyBatis)的动态 SQL 拼接中,经常会有这样的逻辑:
if (userId != null && userId != “”) { 拼接 AND user_id = #{userId} }
这就意味着,如果我把 userId 参数置空,后端的 SQL 语句可能就少了一个 WHERE 条件,从而变成了 SELECT * FROM chats!
心动不如行动,我立刻在 Repeater 中构造了我的“退格键” Payload。
步骤一:参数“滞空”,获取全量会话 ID (Session Leak)
我拦截了获取聊天列表的接口,将 userId 等号后面的数字全部删除,保留空参数,发送请求:
GET /v0/chat/paging?userId=×tamp=1779081671971&tenantId=&signature=[Redacted] HTTP/1.1
Host: api.target-edu.edu.cn
Accept: text/event-stream
Authorization: Bearer 08dde9e4-[Redacted]-2a3bda99c65e
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 MicroMessenger/7.0.20
Connection: keep-alive
服务器不仅没有报错,反而返回了 200 OK!
在长长的 JSON 响应体中,赫然躺着成百上千个不属于我的聊天会话对象!

步骤二:二次越权,看穿 AI 的“悄悄话”
拿到全量用户的会话 ID (id 字段) 后,这其实还只是个半成品,因为这只证明我能看到别人“有”聊天记录。
作为红队,必须拿到敏感数据本身才算高危。
我顺藤摸瓜,找到了查看聊天详情的接口 /v0/chat/{会话ID}/record/paging。
我将刚才越权偷到的其他用户的会话 ID (08deb527-c761…) 填入了这个接口的 URL 路径中,并发起请求:
GET /v0/chat/08deb527-c761-4a0a-8e7e-48a0bed6df54/record/paging?pageNum=1&pageSize=1000 HTTP/1.1
Host: api.target-edu.edu.cn
Authorization: Bearer 08dde9e4-[Redacted]-2a3bda99c65e
随着一记回车,服务器毫无防备地交出了底牌:
响应包中,完整地返回了陌生用户向 AI 提出的所有问题,以及 AI 的所有回复!

至此,渗透链条彻底闭合。
0x04 危害影响
我将这个漏洞在护网平台上提交,毫无悬念地拿下了高分(自评满分 1500 分,涉及全量用户会话数据)。
我们来场景化地评估一下这个漏洞的影响:
这不仅仅是一次普通的“水平越权”,而是一次降维级别的业务数据脱库。
在 AI 聊天系统中,用户的提问往往包含大量的隐私信息:
- 可能有学生在询问心理疾病的建议。
- 可能有老师在输入未发表的科研论文摘要让 AI 润色。
- 可能有用户上传了包含个人 PII(个人身份信息)的简历让 AI 评估。
攻击者只需写一个极其简单的 Python 脚本,不断循环步骤一和步骤二,就能在没有任何黑客暴力破解痕迹的情况下,静悄悄地把目标教育机构的“全量 AI 交互语料库”全部打包带走。这种级别的隐私泄露,对业务方来说是灾难性的。
0x05 防御与反思
狂欢过后,作为有建设性思维的安全研究员,我们来剖析一下产生这种低级且致命漏洞的根源(Root Cause):
核心根源在于:“隐式信任了客户端传入的身份标识” 加上 “脆弱的动态 SQL 拼接逻辑”。
后端系统虽然配置了 Authorization: Bearer Token 进行登录校验,但业务逻辑在获取数据时,却偷懒使用了 URL 里的 userId。当 userId 为空时,缺乏保底的参数非空校验,导致越权查询发生。
给开发师傅们的加固建议:
- 废弃客户端传入的 userId: 永远不要相信前端传来的用户 ID。当前用户的身份标识应该且仅应该从后端的 Session 或经过验签的 JWT Token 中提取(即:userId = token.getUserId())。
- 落实对象级访问控制(BOLA 纵深防御): 在查询 /v0/chat/{id}/record 时,SQL 语句必须强制带上当前用户的鉴权条件。例如:SELECT * FROM records WHERE chat_id = ? AND owner_id = ?。绝对不能仅靠一个 chat_id 就返回数据。
- 严格的参数非空校验: 对于关键的过滤参数,不要随意使用类似于 if(userId != null) 的动态 SQL 省略逻辑。如果业务要求必须带有特定用户的过滤条件,参数为空时应直接抛出 400 Bad Request,而不是查全表。
一次惊心动魄的护网实战就分享到这里。有时候,打穿一个坚固的系统,不需要用牛刀,可能只需要多按一次退格键