防御 GraphQL 攻击:深入剖析常见漏洞
本文深入介绍最常见的 GraphQL 漏洞,分析其产生原因以及相应的缓解方法。
防御 GraphQL 攻击:深入剖析常见漏洞
GraphQL 以灵活、高效的数据查询方式彻底改变了 API 开发。然而,与其他任何技术一样,它也有自身的一系列安全挑战。
本文总结了我们在 Ostorlab 自动化检测和测试 GraphQL 漏洞方面的经验。
我们将深入探讨最常见的 GraphQL 漏洞、它们产生的原因以及如何加以缓解。
什么是 GraphQL?
GraphQL 是一种面向 API 的查询语言,也是执行这些查询的运行时。它允许客户端精确地请求所需的数据,不多也不少,从而能够显著优化 API 交互。
GraphQL 最初由 Facebook 于 2012 年开发,并于 2015 年开源,其设计目的是克服传统 REST API 的局限性,例如数据获取过多或获取不足。
根据 Wappalyzer 的数据,目前已有超过 176,000 个网站在使用 GraphQL。随着越来越多的公司(包括 AWS、PayPal 和 GitHub)采用 GraphQL,这一数字还在快速增长。完整列表请查看 GraphQL Landscape。

query CurrentUser {
currentUser {
name
age
}
}
响应:
{
"currentUser":{
"name":"John Doe",
"age":23
}
}
主要特性
- 单一端点:GraphQL API 只暴露一个端点,所有交互都通过该端点进行。
- 精确的数据获取:客户端只请求其确切需要的数据,从而减少网络开销。
- 强类型 Schema:定义明确的 Schema 规定了类型及其关系,使强大的查询成为可能。GraphQL 内置的 Schema 完整性和类型安全消除了对数据版本管理的需求。

GraphQL 中的类型
GraphQL Schema 由三种主要类型构成:
- 根类型
- 标量类型
- 对象类型

根类型
1. 查询(Queries):从服务器检索或获取数据。
2. 变更(Mutations):修改或操作数据(创建、更新、删除)。
3. 订阅(Subscriptions):使客户端能够接收实时数据更新。

标量类型
标量类型表示简单的值,例如整数、字符串、布尔值等。它们是 Schema 的基本构建块。
对象类型
对象类型表示包含多个字段的复杂实体。这些字段可以是标量,也可以是其他对象类型。例如:
type Human {
id: String
name: String
homePlanet: Planet
}
type Planet {
id: String
name: String
}

GraphQL Schema 的发现与内省
GraphQL 提供了内省(introspection)功能,允许开发人员查询 Schema,以了解可用的类型、查询和变更(可以将其类比为 REST 中的 OPTIONS 请求)。
尽管内省本身并不是安全问题,并且在开发过程中很有用,但攻击者可以利用它更好地了解 API 的能力,进而可能滥用您的 GraphQL API。

内省处于启用状态
获取所有变更的内省查询示例:
请求
{
__schema {
mutationType {
kind
name
fields {
name
description
deprecationReason
}
}
}
}
响应
{
"__schema":{
"mutationType":{
"kind":"OBJECT",
"name":"Mutation",
"fields":[
{
"name":"createUser",
"description":"Create a new user"
}
]
}
}
}
可以导出整个 Schema(包括查询、变更和对象类型)的内省示例:
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name
locations
args {
...InputValue
}
}
}
}
fragment FullType on __Type {
kind
name
fields(includeDeprecated: true) {
name
args {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
type { ...TypeRef }
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}
}
}
绕过针对 `__schema` 的正则过滤
当开发人员禁用内省时,他们可能会使用正则表达式来排除查询中的 __schema 关键字。您可以尝试使用空格、换行符和逗号等字符,因为 GraphQL 会忽略这些字符,而存在缺陷的正则过滤器则不会。
例如,如果开发人员只排除了 __schema{,那么下面这个在 __schema 后带有换行符的内省查询就不会被排除:
{
"query": "query{__schema
{queryType{name}}}"
}
通过建议与错误处理获取 Schema
有时,即使禁用了内省,仍然可以利用服务器提供的建议和错误消息推断出 GraphQL Schema 的部分内容。例如,当请求中包含一个拼写错误但与现有字段相近的字段时,GraphQL 服务器可能会返回一条建议正确字段名的错误消息。

示例:

推断变更和参数的方法同样如此。

禁用内省
在可能的情况下,应在生产环境中禁用内省,仅在开发期间启用。 根据 GraphQL 规范:
“Schema 中定义的所有类型和指令的名称都不得以 __(两个下划线)开头,因为该前缀专门保留给 GraphQL 的内省系统使用。”
禁用内省的一种常见方法是排除以 __ 开头的字段。
这种方法可以有效地限制内省,但必须在所有环境中一致地应用该配置。
拒绝服务(DoS)攻击
在本节中,我们将深入探讨攻击者可能(而且一定会)用来攻击您应用的各种 DoS(拒绝服务)攻击。
GraphQL 以图的形式暴露应用数据,使客户端能够通过遍历节点(类型)之间的关系来检索数据。
下面列出的大多数漏洞都源于 GraphQL 中“图”的这一部分。
循环片段(低危)
GraphQL 片段允许您通过定义可复用的字段来复用查询逻辑。
然而,循环片段可被用来构造消耗过多服务器资源的查询。
循环片段示例:
fragment UserFields on User {
comments {
...CommentFields
}
}
fragment CommentFields on Comment {
owner {
...UserFields
}
}
如您所见,UserFields 片段引用了 CommentFields,而 CommentFields 又反过来引用 UserFields,这将导致无限循环。

GraphQL 服务器通常会识别并拒绝包含循环片段引用的查询,并返回错误消息。
然而,在某些情况下,服务器的实现可能并未严格遵循 GraphQL 规范。
因此,需要采取额外的措施:
循环片段检测:使用 GraphQL-ESLint 等 Schema 分析工具来检测和防止循环片段。
实施查询成本分析:为字段和查询分配成本,并拒绝超过预定义上限的查询。例如,如果您使用的是 Apollo Server,可以使用 graphql-query-complexity 库:
const {
queryComplexity,
simpleEstimator
} = require('graphql-query-complexity');
const server = new ApolloServer({
schema,
validationRules: [
queryComplexity({
estimators: [
simpleEstimator({
defaultComplexity: 1
})
],
maximumComplexity: 1000, // Reject queries that exceed this cost
}),
],
});
别名过载(低危)
GraphQL 中的别名过载是指攻击者在一个查询中使用大量别名,以压垮服务器的处理能力。
在 GraphQL 中,别名允许客户端以不同的名称多次请求同一个字段。然而,在单个查询中过度使用别名可能会耗尽服务器资源,从而导致拒绝服务(DoS)攻击。这种攻击可能降低性能,甚至导致服务完全中断。
例如:
query AliasOverLoading {
alias1: __typename
alias2: __typename
alias3: __typename
alias4: __typename
alias5: __typename
}
别名过载的安全影响:
别名过载给 GraphQL API 带来了重大的安全风险。一个主要后果是拒绝服务(DoS):包含过多别名的查询会迫使服务器分配不成比例的资源来处理和响应。这可能导致服务变慢甚至崩溃,从而破坏可用性。此外,服务器还可能出现资源耗尽,因为同时处理大量别名可能导致内存耗尽、CPU 飙升或性能严重下降。
可以采取多种策略来缓解别名过载的风险。首先,强制设置查询超时是一种有效的措施。通过设置最长执行时间,服务器可以自动终止解析耗时过长的查询,防止恶意查询耗尽资源并导致 DoS。
另一项重要措施是限制别名数量,具体内容已在上一节中介绍。
循环引用(中危)

攻击者可以利用这一点构造递归查询,压垮服务器资源,造成拒绝服务和资源耗尽。
我们在测试该漏洞的影响时(针对一个真实目标),使用了 Django、graphene-python 以及一个配备 42 GB 内存、8 个 vCPU 和 2.2 TB SSD 存储的 Cloud SQL 实例。
我们成功构造了一个递归查询,使我们的 Cloud SQL 实例的 CPU 利用率达到 100%,并且问题一直持续,直到我们不得不手动终止那些挂起的 SQL 事务。

假设我们希望使用以下类型来暴露一个用户及其发表的评论列表:
type User {
id: ID!
comments: Comment
username: String
}
type Comment {
id: ID
owner: User!
content: String!
}
类型 User 引入了循环引用,可被用来创建复杂的或递归的查询。
循环查询示例:
query {
user(id: 1) {
id
comments {
id
owner {
id
comments {
id
owner {
id
... # can go forever
}
}
}
}
}
}

要缓解 GraphQL 中循环引用的风险,您可以:
重构 Schema,尽可能避免直接的循环引用。例如,在 `Comment` 类型中使用一个新的 `LimitedUser` 类型来打破循环:
type User {
id: ID!
username: String!
comments: [Comment]!
}
type LimitedUser {
id: ID!
username: String!
}
type Comment {
id: ID!
owner: LimitedUser!
content: String!
}

不过,这种方案未必有效,因为它可能会破坏客户端,并需要对代码库进行重大修改。
强制限制查询深度:对查询的嵌套深度设置上限,以防止过深的查询。
大多数 GraphQL 服务器实现默认提供此功能,例如在 Apollo Server 中:
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
schema,
validationRules: [depthLimit(10)], // Set maximum query depth to 10
});
利用别名批处理进行登录暴力破解(中危)
在 GraphQL 中利用别名批处理进行登录暴力破解,是指攻击者借助别名功能自动化登录尝试,从而更容易在单个查询中提交大量凭据组合。
在 GraphQL 中,别名允许客户端以不同的名称发送同一查询的多个版本。攻击者利用这一点,在单个查询中为每次尝试使用不同的别名来批量发送登录请求。这可以形成一种高效的暴力破解攻击,绕过传统的速率限制防护,并用大量登录尝试压垮身份验证系统。
示例:
query loginBatch {
login1: login(username: "user1", password: "password1") {
token
}
login2: login(username: "user2", password: "password2") {
token
}
login3: login(username: "user3", password: "password3") {
token
}
...
}
要缓解此问题,您应当限制单个查询中允许的别名数量。通过配置服务器端限制或使用 GraphQL Armor 等工具,您可以为每个请求的别名数量设置上限。
限制每个用户的登录失败次数也能有效解决该问题。
GraphQL 中的授权配置错误(高危)
在 GraphQL API 中,如果访问控制检查不一致,可能出现一种严重漏洞:对敏感数据的访问在某一条查询路径中受到了正确限制,却在另一条路径中暴露在外。攻击者可以通过另一条绕过限制的查询路径获取未经授权的数据。
这类漏洞很容易出现。
下面是一个简单的示例(使用 Django 和 django-graphene):
我们有一个小型社交网络平台,用户可以在其中相互交流,也可以发布帖子。
from django.db import models
from django.contrib.auth.models import AbstractUser
class SocialUser(AbstractUser):
pass
class Post(models.Model):
user = models.ForeignKey(SocialUser, related_name='posts', on_delete=models.CASCADE)
content = models.TextField()
class Discussion(models.Model):
user = models.ForeignKey(SocialUser, related_name='discussions', on_delete=models.CASCADE)
content = models.TextField()
使用 `graphene_django`,我们定义了以下类型:
class DiscussionType(DjangoObjectType):
class Meta:
model = models.Discussion
class PostType(DjangoObjectType):
class Meta:
model = models.Post
class UserProfileType(DjangoObjectType):
class Meta:
model = models.SocialUser
我们声明了以下查询:
class Query(graphene.ObjectType):
my_discussions = graphene.List(DiscussionType)
posts = graphene.List(PostType)
my_profile = graphene.Field(UserProfileType)
def resolve_my_discussions(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return user.discussions.all() # Only the discussions of the logged in user
def resolve_posts(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return models.Post.objects.all() # All posts are public of course.
def resolve_my_profile(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return user
乍一看,这里没有任何问题。用户只能看到自己的讨论

您也可以检索其他用户的公开帖子列表。

但如果仔细查看类型 `PostType`,就会发现它有一个 UserProfileType 字段。

这是因为模型 Post 确实有一个指向模型 SocialUser 的 `ForeignKey`,而 django-graphene 会使用它找到的第一个对象类型来暴露该字段。

因此,我们可以执行以下查询,未经授权访问其他用户的讨论。

GraphQL 扩展:以调试模式为例
什么是 GraphQL 扩展?
GraphQL 扩展是为您的 GraphQL 环境添加新功能的代码片段。它们可以帮助您完成 GraphQL 本身通常无法完成的工作,例如添加新的对象类型(ObjectType)以及暴露调试信息。
尽管这是一项有用的功能,但 Graphene-Django 和 graphql-ruby 等部分 GraphQL 服务器默认实现的某些扩展可能会引发严重的安全问题。
GraphQL 调试(高危)
在排查 GraphQL 中的问题时,开发人员会借助应用的调试信息。
启用调试模式后,GraphQL 服务器在响应客户端请求时,会针对后端服务器错误提供通常不会显示的详细消息。
例如,客户端收到的可能不是标准错误消息,而是堆栈跟踪以及有关错误的详细信息。
许多 GraphQL 实现默认都实现了 GraphQL 调试模式,但并非全部如此(参见下表)。
这在开发期间非常有价值,但如果在生产环境中保持启用,就可能暴露有关服务器内部结构和实现细节的敏感信息。
启用调试模式后,错误响应可能包含:
- 详细的堆栈跟踪
- 数据库查询信息(包括 SQL 查询)
- 服务器内部路径和文件名
- 敏感的配置细节
在 Django Graphene(它封装了 Django 的调试功能)中启用调试模式时的响应示例:
{
query {
nonexistentField {
id
}
}
_debug {
sql {
sql
transId
transStatus
isoLevel
encoding
}
}
}
{
"errors":[
{
"message":"Cannot query field \"nonexistentField\" on type \"Query\".",
"locations":[
{
"line":3,
"column":3
}
],
"path":[
"nonexistentField"
]
}
],
"data":null,
"extensions":{
"debug":{
"sql":[
{
"sql":"SELECT \"auth_user\".\"id\", \"auth_user\".\"password\", \"auth_user\".\"last_login\", \"auth_user\".\"is_superuser\", \"auth_user\".\"username\", \"auth_user\".\"first_name\", \"auth_user\".\"last_name\", \"auth_user\".\"email\", \"auth_user\".\"is_staff\", \"auth_user\".\"is_active\", \"auth_user\".\"date_joined\" FROM \"auth_user\" WHERE \"auth_user\".\"id\" = %s LIMIT 21",
"time":"0.000",
"params":[
"1"
]
}
],
"python_version":"3.8.5",
"django_version":"3.1.3",
"graphene_version":"2.1.8",
"graphql_version":"3.0.0"
}
}
}
GraphQL 调试模式的安全影响:
-
信息泄露:调试信息可能暴露 GraphQL 服务器的结构、依赖项和潜在漏洞等内部细节,攻击者可借此策划更有针对性的攻击。
-
敏感数据泄露:堆栈跟踪、错误消息,尤其是 SQL 查询,可能会无意中包含数据库结构、内部文件路径或环境变量等敏感信息。
-
更易于利用:详细的错误消息和 SQL 查询可以即时反馈恶意查询中哪些有效、哪些无效,从而帮助攻击者改进攻击。
-
性能信息泄露:SQL 查询所附带的耗时信息可能被攻击者用来推断数据库结构或实施计时攻击。
要缓解与 GraphQL 调试模式相关的风险,首要的一点是:
必须在生产环境中禁用调试模式。例如,如果您将 Django 与 Graphene-Django 一起使用,GraphQL 中的调试模式由与 Django 调试相同的设置控制。
settings.py
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
结论
GraphQL 为 API 开发带来了灵活的查询和高效的数据获取等显著优势,但同时也引入了独特的安全挑战。应对这些挑战不仅需要深入了解潜在漏洞,还需要深入了解您所使用的特定 GraphQL 服务器及其支持的安全功能。
以下是最流行的 GraphQL 服务器及其所支持功能的列表:
✅ - 默认启用
⚠️ - 默认禁用
❌ - 不支持
