文章总结: 本文复盘某校园教务系统从邮箱泄露到账号接管的完整攻击链。核心漏洞包括PC端修改邮箱无验证、CSRF绕过Origin校验、移动端API签名校验缺失及角色校验缺失,导致约16815条学生信息泄露。攻击者可修改受害者绑定邮箱后通过密码重置实现账号完全接管。文章提供了详细的技术分析、CVSS评估及传统漏扫盲区分析,对移动端API安全测试具有实战参考价值。
综合评分: 85
文章分类: 移动安全,漏洞分析,实战经验,代码审计,安全意识
某校园教务系统移动端API,从邮箱泄露到账号接管
可乐治好我的牙龈
可乐治好我的牙龈
菜鸟学信安
2026年9月20日 08:30
重庆
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
作者:感谢可乐治好我的牙龈
原文:https://xz.aliyun.com/news/92552
前言
这件事的起点比较常规。前段时间挖了个职业学院的SRC,PC端教务系统,Struts2那套,交了五个洞。其中两个高危和一个中危指向同一个根因:修改绑定邮箱不需要任何验证。
提交之后我琢磨了一下——PC端有三个地方能改这个邮箱(个人信息页表单、saveEmail接口直接POST、绕过Origin的CSRF),那移动端呢?
这个想法后来让我在移动端逆向出了整个API鉴权体系,发现了一处签名形同虚设的问题,并进一步验证发现学生角色可访问课程成员接口,导致约16815条学生基础信息泄露。这篇文章复盘完整过程,写写方法论。
0x01 目标环境
G学院(脱敏代称)有三套关联系统:
- PC教务系统(
xxx.edu.cn):Java Struts2,服务端渲染 - 统一认证系统(
xxx-cas.edu.cn):CAS Server - 移动端系统(
xxx-app.edu.cn):Angular 8 SPA,/app-web/路径,微信公众号H5入口
PC端和移动端都挂在同一台openresty后面,后端是同一套Spring Boot,走同一个数据库。这个信息后面会用到。
攻击链总览
敏感字段: user.email (数据库 user 表) | +---------------+---------------+ | | PC Web Mobile H5 (xxx.edu.cn) (xxx-app.edu.cn) | | +---------+---------+ +---------+---------+ | | | | | 表单提交 CSRF绕过 直接POST API签名校验缺失 角色校验缺失 | | | | | +----+----+----+----+ +---------+---------+ | | +-----------+-------------------+ | 邮箱被修改为攻击者邮箱 | PC端密码重置功能 sendResetMail.action | 重置邮件 → 攻击者邮箱 | 账号完全接管
辅助链路:
课程学生查询接口 (角色校验缺失) | 遍历 lesson_id 参数 | 16815条学生学籍信息泄露 | 提供精准攻击目标
0x02 PC端的发现——同一个字段的三个入口
PC端挖出来的五个洞里,最核心的是这两个高危:
漏洞1:任意邮箱绑定。 个人信息页有个邮箱字段,前端用blur事件触发AJAX提交到saveEmail.action。参数就一个newEmail,没有验证码、没有旧邮箱确认、没有密码二次认证。改成攻击者的邮箱之后,用密码重置功能(sendResetMail.action)就能劫持账号。
漏洞2:CSRF绕过Origin校验。 同一个saveEmail.action接口,服务端做了一个Origin头检查——带Origin且值不是xxx.edu.cn就返回403。但如果不带Origin头直接POST,直接放行,Referer也不看。用一个自动提交的form表单就能让受害者在不知情的情况下改掉邮箱。
两个洞的根因完全一样——服务端对”修改邮箱”这个操作没有做任何安全校验。区别只是请求从哪发出来的。
我当时画了张草稿:
改邮箱的三种方式:1. 同源Session → 直接POST → 改2. 跨域无Origin → CSRF → 改3. ???
移动端有没有这个功能?如果移动端也能改邮箱,会不会是第四条路?
0x03 登录移动端
移动端地址是xxx-app.edu.cn/app-web/#/login,Angular SPA。打开F12看了一眼,页面标题叫”Eams App”,加载了一堆webpack打包的JS,包括main.xxx.js(1.4MB)、runtime.xxx.js、polyfills.xxx.js。
PC端的学生账号(25510207134)在上面登不上。这个应用是给微信H5用的,正常流程是微信OAuth授权→拿到code→换token。我没有微信OAuth环境,得走另一条路。
观察根域名xxx-app.edu.cn/——直接302跳转到了/eams/home.action。说明easm下面也部署了PC端的Struts2应用。既然后端是同一套,认证体系应该也是通的。
目标系统走CAS统一认证,PC端和移动端共享登录态。我在PC端已经有了合法测试账号,通过CAS登录后拿到了移动端所需的Token和DynamicSecret(签名密钥),存于localStorage:
token: xxxxxdynamicSecret: xxxxx
CAS层面的DES加密不是本文重点,略过。关键是——在合法获取测试账号Token后,接下来的工作是在这个授权范围内验证移动端接口的权限边界是否与预期一致。
0x04 逆向Angular应用
提取API端点
main.js是webpack打包产物,1.4MB单文件。直接用文本编辑器打开会卡死,但用grep或者Node脚本按模式匹配就够了。
先找API端点。搜apiUrlKey,命中了一个巨大对象:
apiUrlKey={ get_dynamic_secret:"/dynamic/secret", login:"/login", check_token:"/check-token", user_reset_passwd:"/user/reset-passwd", user_change_role:"/user/change-role", user_modify_contact_info:"/user/modify-contact-info", user_get_base_info:"/user/get-base-info", course_schedule_lesson_get_students:"/course/schedule/lesson/get-students", // ...后面总共150+个端点}
顺手搜了搜Secret,在中段位置发现了硬编码的App Secret:
Secret="supwisdom_eams_app_secret"
URL拼接规则
getWholeUrl函数的实现:
getWholeUrl = function(e, t) { return t ? this.baseUrl + this.commonUrl + t + this.apiUrlKey[e] : this.baseUrl + this.commonUrl + this.apiUrlKey[e]}
代入常量:
baseUrl = "https://xxx-app.edu.cn/app-ws"commonUrl = "/ws/app-service"
所以完整URL是 https://xxx-app.edu.cn/app-ws/ws/app-service/{身份前缀}/{端点路径}。比如学生查个人信息就是 /student/user/get-base-info。
这里扔了个坑让我踩了几小时。一开始我没注意到那个第二参数t,所有请求都直接拼/app-ws/ws/app-service/user/detail,全部返回405。调试了快两个小时才发现URL中间缺了身份前缀——传/student、/teacher、/admin才能通。而且这三个前缀返回的数据范围不一样:/admin路径下返回的用户信息比/student下少好几个字段(生日、性别、手机号都不返回),但都能用同一个学生Token访问。
这说明路径前缀只是用来区分视图,不是权限控制。真正的权限控制是在数据库层——Token里带的identity和roleId会在SQL查询时被用作过滤条件。这意味着横向越权(读其他学生的数据)这条路大概率走不通,后面验证也确实如此。
签名算法
httpClientService里找到了签名函数:
getMd5Sign = function(e, t, n, r) { var i = t + "|"; Object.keys(e).sort().forEach(function(t) { i += e[t] + "|" }); return MD5(i += n + "|" + r).toUpperCase()}
其中e是POST参数字典,t是dynamicSecret,n是时间戳,r是随机数。所以签名字符串是:
MD5(dynamicSecret | value1 | value2 | ... | timestamp | random)
每个请求的body需要额外带上sign、timestamp、random三个字段。我用Node的CryptoJS照着写了一个签名函数,调接口一切正常,200返回数据。
0x05 关键发现——客户端完整性校验机制失效
写完签名函数后我在做批量测试,懒得每次都算MD5。顺手试了一个不带sign的请求。
结果200。数据正常返回。
我当时以为是某些接口不需要签名。换了一个POST修改操作——modify-contact-info——也不带sign。
还是200。邮箱被改了。
不信邪,做了三组对照:
| | | |
| — | — | — |
| 请求 | sign字段 | 结果 |
| A | 正确的MD5签名 | 200,改成功 |
| B | 瞎填的字符串”sdfsdfsdf” | 200,改成功 |
| C | 不传sign/timestamp/random | 200,改成功 |
结论很清楚:签名字段服务端根本没看。 timestamp也不校验(没有防重放),random也不看。客户端完成了完整的签名计算流程,但服务端未执行对应的校验逻辑。
实测终端输出:
[A] 正确签名 | Status: 200 | business_data 正常返回[B] 错误签名 | Status: 200 | business_data 正常返回[C] 无签名 | Status: 200 | business_data 正常返回结论: A/B/C 均返回200 + 相同数据, 服务端不校验签名
这个漏洞到底叫什么
刚开始我只把它描述成”签名绕过”,但事后复盘觉得不够准确。”绕过”暗示签名校验是存在的、只是被绕开了——实际上这里压根就没校验。更准确的表述应该是客户端完整性校验机制失效,或者API请求签名校验缺失。
这类缺陷的核心问题是:服务端信任了客户端传来的所有参数(含身份标识),却没有一个独立的机制来验证请求的完整性和合法性。签名存在的意义就是防止参数被篡改——签名不校验,等于这层防御不存在。
危害评级
单独看”签名不校验”,危害有限——毕竟Token本身还是有效的,没Token的人也调不了接口。但在本场景中,它和另一个缺陷叠加后构成了完整攻击链:
攻击者获取普通用户Token(社工/钓鱼/XSS/同网段嗅探) ↓签名校验缺失 → 调用modify-contact-info ↓修改受害者绑定邮箱为攻击者邮箱(无需验证码/密码/旧邮箱) ↓PC端密码重置 → 邮件发到攻击者邮箱 ↓重置成功 → 账号完全劫持
整个链路能走通的关键节点有二:一是邮箱修改无验证(业务逻辑缺陷),二是签名校验缺失(安全机制缺陷)。两者缺一不可。综合评定:高危。
CVSS 评估
| | | |
| — | — | — |
| 指标 | 值 | 说明 |
| 攻击向量 (AV) | 网络 (N) | 通过公网可达的API接口 |
| 攻击复杂度 (AC) | 低 (L) | 无需签名,单次POST即可 |
| 权限要求 (PR) | 低 (L) | 需普通学生账号Token |
| 用户交互 (UI) | 无 (N) | 攻击全程无需受害者参与 |
| 影响范围 (S) | 未变更 (U) | 普通学生Token仅对自身账号生效,未涉及水平越权 |
| 机密性 (C) | 高 (H) | 可获取学生姓名、学号、院系、班级等学籍信息 |
| 完整性 (I) | 高 (H) | 可篡改账号绑定邮箱,进而重置密码 |
| 可用性 (A) | 低 (L) | 单用户账号被接管,不影响系统整体可用性 |
注:以上CVSS评估基于”攻击者已通过社工、钓鱼、XSS等方式获取受害者的有效登录Token”这一前提。Token窃取本身属于另一类攻击向量,不在本文漏洞范围内。本文聚焦的是——在攻击者获得Token之后,签名校验缺失使得后续的邮箱修改和密码重置操作失去了应有的安全屏障。
为什么传统漏扫发现不了
这个案例里的几个核心缺陷,用常规自动化扫描器基本跑不出来。原因在于:
| | |
| — | — |
| 问题 | 扫描器盲区 |
| 邮箱修改无验证 | 属于业务逻辑缺陷。扫描器不知道”修改邮箱”这个操作应该有什么前置校验——它只能发现SQL注入或XSS等可模式匹配的技术漏洞 |
| API签名校验缺失 | 扫描器不认识自定义的MD5签名体系。sign/timestamp/random这些字段对扫描器来说就是普通POST参数,它无法判断”服务端应该校验但不校验” |
| 移动端API隐藏 | 150+个REST端点的URL全部封装在webpack打包的main.js里,没有Swagger文档暴露,没有API目录遍历。扫描器用字典爆破的方式根本碰不到这些路径 |
| 数据泄露接口 | get-students这类接口需要传入一个合法的课程ID才能返回数据,本身不存在注入点,也没有报错回显。它暴露数据是因为权限控制的缺失,不是技术漏洞 |
这个案例里最核心的两步——从webpack产物里逆向出完整API列表、然后验证签名校验是否生效——都属于理解业务上下文后才能完成的操作。Payload探测解决不了这类问题。
0x06 移动端的第四条路
有了上面的结论,在移动端改邮箱就只剩一行curl:
POST /app-ws/ws/app-service/student/user/modify-contact-infoHost: xxx-app.edu.cnContent-Type: application/x-www-form-urlencoded
token=xxxxx&mobile_phone=13800000000&[email protected]
响应:
{ "business_data": "eyJzdWNjZXNzIjp0cnVlfQ==", "err_code": "00000", "err_msg": ""}
base64 decode:{"success": true}。
刷新个人信息页,邮箱已经更新了。这条请求不需要Origin头(本来就不是浏览器请求)、不需要CSRF Token、不需要sign、不需要验证码——只要Token。攻击成本和PC端那三条路比起来,低了一个数量级。
实测终端输出(无签名修改 + 读回验证):
修改前邮箱: [email protected]修改请求(无sign参数): Status: 200响应: {"business_data":"eyJzdWNjZXNzIjp0cnVlfQ==","err_code":"00000"}base64解码: {"success":true}修改后邮箱: [email protected]验证通过: 邮箱修改成功
同时确认PC端密码重置表单包含邮箱字段 user.mail,移动端与PC端操作的是同一数据字段。
四条路画一起:
同一个邮箱字段 (数据库 user.mail)├── PC直连: POST xxx.edu.cn/eams/security/my!saveEmail.action├── PC跨域: POST 同上,但不带Origin头绕过CSRF├── 移动端: POST xxx-app.edu.cn/app-ws/.../modify-contact-info ← 最简单└── 移动端无签名: 同上,sign字段都不用传 ← 更简单
攻击链的后半段还是PC端那条——调sendResetMail.action重置密码,邮件发到攻击者邮箱,点链接设新密码,原账号彻底丢失。PC端报告里的攻击场景描述完全适用于这里,我就不重复写了。
0x07 意外发现——16815条学生数据
改邮箱的攻击链需要知道受害者学号。我本来想找有没有什么”学生列表”接口能看到全校学号——一般来说这种接口得有管理员权限。
浏览API列表时看到一个接口:course/schedule/lesson/get-students。功能是按课程ID返回选课学生名单。这明显是教师端的接口——我自己做学生的时候从来没在前端见过。
试了第一个lesson_id=1,返回了92个学生:
[{ "code": "201700120101", "name": "白xx", "gender": "女", "depart_name": "经济管理学院", "major_name": "酒店管理", "adminclass_name": "17酒店1班", "direction_name": ""}]
7个字段:学号,姓名,性别,院系,专业,班级,方向。没有手机、身份证、邮箱。但作为学籍名册,足够精准。
接着换lesson_id。2返回55人,3返回59人,4返回218人。每个ID返回几十到两百多人。使用普通学生身份认证后即可调用该接口,服务端未进行有效的角色权限校验。
lesson_id从1到10万+范围内均有数据返回。经验证接口存在批量返回学生名单的能力,测试范围内(约500个有效lesson_id,去重后)暴露了约16815条唯一学生记录。 跨2009级到2025级共17个年级,其中2025级2795人最多。学号有两种格式:2021级之前用四位年份开头(如201700120101),2022级之后用两位(如25510207134)。
这本质上就是一个全校名册。单独看危害没有手机号身份证那么直接,但和前面改邮箱的攻击链路串起来——从名册里挑目标、定向改邮箱、密码重置、劫持账号——整个攻击链的起点问题解决了。
数据泄露影响评估
虽然单条记录只含学号、姓名、性别、院系、专业、班级七个字段,不包含手机号或身份证等直接个人隐私标识,但批量泄露带来的风险仍然不容忽视:
- 学籍信息的定向利用:16815条记录覆盖全校17个年级,攻击者可从中筛选特定院系、特定年级的学生群体,进行精准的社工攻击
- 账号劫持辅助:学号是密码重置流程的必要输入。拿到完整学号列表后,结合邮箱修改漏洞,攻击者不再需要猜测受害者学号
- 钓鱼攻击前置:知道目标学生的真实姓名、院系、专业、班级后,可以伪装成辅导员或教务处发送高度针对性的钓鱼邮件/消息,成功率远高于泛化钓鱼
- 学号规律反推:两套学号编码格式的规律已被摸清,攻击者可推算出不在本次泄露范围内的其他学生的学号
0x08 漏洞关系与权限模型
三个漏洞的关联关系
本次发现的三个漏洞并非孤立存在,单独看各自的危害都不算致命,但组合之后构成了完整的攻击链:
| | | |
| — | — | — |
| 漏洞 | 单独影响 | 在攻击链中的角色 |
| 邮箱修改无验证 | 任意修改当前用户联系方式 | 攻击链终点——账号恢复渠道劫持 |
| API签名校验缺失 | 请求完整性保护失效 | 攻击链入口——大幅降低移动端攻击门槛 |
| 学生信息泄露 | 批量获取学籍基础信息 | 攻击链前置——提供精准目标选择 |
如果把整个攻击链比作一次精准打击:数据泄露负责选定目标,签名缺失负责降低门槛,邮箱修改负责完成劫持。三者缺任何一个,要么无法定位受害者、要么攻击操作过于复杂、要么最终目标无法达成。
权限控制模型分析
前面提到URL路径中的/student、/teacher、/admin前缀不决定权限。这里展开讲一下这个系统实际的权限模型——它反映了很多企业应用的共性问题。
请求的完整处理链路:
POST /student/user/modify-contact-info ↓ Controller(路由层) ↓ 根据URL前缀分发,但此处无权限拦截 Service(业务层) ↓ 从Token中提取identity/roleId SQL查询 ↓ WHERE条件中拼接用户身份过滤 返回结果
问题在于:只有数据层的用户身份过滤,没有接口层的角色权限校验。
/admin路径和/student路径的区别仅仅是返回字段的多少——Controller层只是决定”长什么样”,Service层才是”能看到什么数据”。但Service层不做角色校验,只做身份绑定(”你是谁”)。这就导致一个学生Token可以访问教师和管理员的路径,只是因为数据库里查不到对应的教师/管理员记录,所以返回空或者报错。
这个概念在很多企业系统里都能看到——前端通过隐藏菜单来”做权限”,但后端API只要Token有效就接收,实际权限约束完全依赖SQL的WHERE条件。这个做法的致命缺陷是:一旦业务逻辑变更、新增了某个跨角色的查询场景,权限控制就会漏。
修复建议
1. 敏感操作增加二次验证
修改邮箱、手机号、密码等敏感字段时,要求用户提供二次凭证:
原密码验证 or短信验证码 or邮箱验证码(向旧邮箱发送)
不要仅依赖Token——Token是登录态证明,不是操作授权证明。
2. 服务端强制校验签名
当前流程:
客户端计算sign → 服务端直接接受
应改为:
客户端提交请求(含sign、timestamp、random) ↓服务端用服务端存储的secret重新计算sign ↓比较客户端sign与服务端计算结果 ↓验证timestamp是否在有效时间窗口内(如±5分钟) ↓验证nonce(timestamp+random)是否已使用(防重放)
注意:计算sign所用的secret必须仅存储在服务端,当前硬编码在JS中的supwisdom_eams_app_secret应移至后端配置文件。
3. API层增加RBAC权限控制
不要依赖URL路径前缀区分权限。改为基于角色的访问控制:
Token → 解析角色(roleId/identity) ↓ 查询权限映射表 ↓ 匹配接口+角色授权 ↓ 放行 or 403
/course/schedule/lesson/get-students这类教师专属接口,应在Controller层就做角色判断,而非在Service层靠数据库记录是否存在来间接限制。
0x09 方法论
回过头看这次挖掘,能复用到其他目标的东西大概有这么几点。
敏感字段驱动的跨场景测试模型
这次挖掘的核心驱动力不是某个具体漏洞类型,而是一个简单的原则:以敏感数据字段为中心,穷举所有修改入口。
操作模型如下:
发现敏感字段(邮箱、手机号、密码、身份证等) ↓穷举该字段的所有读写入口├── PC Web端(Struts2/JSP/ASPX等传统架构)├── 移动H5(Angular/Vue/React SPA)├── 微信小程序/支付宝小程序├── 原生App(Android/iOS,抓包分析)├── API网关(开放平台/Swagger)└── 第三方对接接口 ↓对每个入口验证四个维度├── 认证(需要什么凭证?Token/Cookie/Signature?)├── 授权(当前角色能否操作?能否操作他人?)├── 完整性(参数能否篡改?签名是否校验?)└── 业务逻辑(是否需要二次验证?有无频率限制?) ↓串联独立缺陷构造攻击链
这个模型的优势在于:你把攻击面检测从”按漏洞类型检索”转变为”按数据字段检索”。一个敏感字段可能有10个修改入口,每个入口缺失的防御各不相同——但只要有一个入口保护不足,整个字段的数据完整性就被打破了。
跨场景的攻击面拓展
在PC端挖改邮箱漏洞的时候,关注点是”这个接口本身缺什么防御”——缺验证码、缺CSRF Token、缺旧邮箱验证。但更关键的问题是”这个数据字段还存在哪些修改入口”。
移动端是用了一套独立的前端和API路由,但后台是同一个Spring Boot、读写同一张表。PC端可能修了表单提交的校验逻辑,但如果移动端API的Controller没有同步修复呢?这种情况在企业系统里非常常见——多终端的前端团队各自开发,后端接口是同一个团队维护的,但安全加固往往只覆盖了”报漏洞的那个入口”。
具体做法不复杂:抓到PC端一个漏洞→记下它操作的数据表和字段→反查其他端(H5、小程序、App、API网关)有没有对应功能→用同一套凭证去调。
SPA的逆向套路
工程化打包后的Angular/Vue/React应用,直接读源码不现实,但提取结构信息完全够用:
- 按
apiUrlKey、baseUrl、commonUrl关键字找API端点映射表 - 按
Secret、secret、key、token找硬编码凭据 - 按
encrypt、decrypt、sign、MD5找鉴权函数 - 按
getWholeUrl或类似的URL构造方法找路由拼接规则 - 按
httpClientService或axios、fetch的封装找请求发送方式
函数名被混淆了也不怕——只要找得到apiUrlKey这个大对象,API列表就到手了。签名算法则靠特征字符串定位:MD5、sign、timestamp、random这些关键词组合搜索一般都能命中。
签名校验的试验优先原则
遇到带签名的API,别急着逆向签名算法。先做三件事:
- 去掉sign参数发一次
- sign填个固定值发一次
- 把timestamp改成一个过期的时间发一次
如果都过了,说明签名是摆设。逆向这步直接跳过。
参数空间的遍历思维
lesson_id这个参数没有什么规律可循,就是自增序列。但”自增序列”本身就是规律——说明可以通过遍历穷举。开发人员觉得”前端不会把这个接口暴露给学生”,但REST API不受前端路由控制。任何在API列表里看到的端点,不管前端页面长什么样,直接用Token调一次再说。
0x0A 写在最后
这次在G学院的收获:PC端5个洞,移动端2个洞(API签名校验缺失+邮箱修改无验证做成一个报告,学生数据泄露做成另一个)。7份报告指向同一个核心问题——修改邮箱不需要验证。只不过这次证明了这个问题存在于4个不同的入口。
很多SRC的审核标准是”同一根因不重复计分”。但作为攻击者视角,多一个入口就多一条路——厂商可能修了PC端的三个入口,只要漏掉移动端的那个,攻击链依然完整。
安全修复不应只针对漏洞触发入口,而应围绕业务对象建立完整的数据流安全模型。以本文案例为例:user.email是一个敏感业务对象,正确的做法不是分别在PC表单、saveEmail接口、移动端API三个地方各自加校验,而是在Service层对”修改邮箱”这个业务操作设置统一的、不可绕过的安全约束。无论请求来自哪个前端、经过哪个Controller、走哪条API路由,最终都会汇聚到同一个Service方法——在那里做一次校验,比在十个入口分别修补要可靠得多。
漏洞已提交相关平台并协助修复。文中涉及目标已做脱敏处理。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:菜鸟学信安 可乐治好我的牙龈
可乐治好我的牙龈《某校园教务系统移动端API,从邮箱泄露到账号接管》