GraphQL API安全攻防实战:从攻击路径枚举到权限控制加固

本文深入探讨GraphQL API的常见安全漏洞,包括权限绕过、信息泄露等攻击手法,并提供针对性的防御策略。通过实际案例和工具演示,帮助开发者和安全研究人员更好地理解和防护GraphQL接口。

闭环实战:GraphQL API的攻击与防御

引言

GraphQL是一种现代化的API查询语言,由Facebook和GraphQL基金会支持。它迅速成长并进入技术采用周期的早期多数阶段,Shopify、GitHub和Amazon等主要行业参与者都已加入。

随着任何新技术的兴起,使用GraphQL也带来了成长中的痛苦,特别是对于首次实施GraphQL的开发人员。虽然GraphQL承诺比传统REST API具有更大的灵活性和功能,但它可能增加访问控制漏洞的攻击面。开发人员在实施GraphQL API时应注意这些问题,并在生产环境中依赖安全默认值。同时,安全研究人员在测试GraphQL API的漏洞时应关注这些弱点。

在REST API中,客户端向各个端点发出HTTP请求。例如:

1
2
3
4
GET /api/user/1:获取用户1
POST /api/user:创建用户
PUT /api/user/1:编辑用户1
DELETE /api/user/1:删除用户1

GraphQL取代了标准的REST API范式。相反,GraphQL只指定一个端点,客户端向该端点发送查询或变更请求类型。这些分别执行读写操作。第三种请求类型订阅后来被引入,但使用频率要低得多。

在后端,开发人员定义一个GraphQL模式,包括对象类型和字段以表示不同的资源。例如,用户将被定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type User {
  id: ID!
  name: String!
  email: String!
  height(unit: LengthUnit = METER): Float
  friends: [User!]!
  status: Status!
}

enum LengthUnit {
  METER
  FOOT
}

enum Status {
  FREE
  PREMIUM
}

这个简单示例展示了GraphQL的几个强大功能。它支持其他对象类型的列表(friends)、变量(unit)和枚举(status)。此外,开发人员编写解析器,定义后端如何从数据库获取GraphQL请求的结果。

为了说明这一点,假设开发人员在模式中定义了以下查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "name": "getUser",
  "description": null,
  "args": [
    {
      "name": "id",
      "description": null,
      "type": {
        "kind": "SCALAR",
        "name": "ID",
        "ofType": null
      },
      "defaultValue": null
    }
  ],
  "type": {
    "kind": "OBJECT",
    "name": "User",
    "ofType": null
  },
  "isDeprecated": false,
  "deprecationReason": null
}

在客户端,用户将进行getUser查询,并通过以下POST请求检索name和email字段:

1
2
3
4
5
6
7
8
9
POST /graphql
Host: example.com
Content-Type: application/json

{
  "query":"query getUser($id:ID!) { getUser(id:$id) { name email }}",
  "variables":{"id":1},
  "operationName":"getUser"
}

在后端,GraphQL层将解析请求并将其传递给匹配的解析器:

1
2
3
4
5
6
7
Query: {
  user(obj, args, context, info) {
    return context.db.loadUserByID(args.id).then(
      userData => new User(userData)
    )
  }
}

这里,args指的是提供给GraphQL查询中字段的参数。在这种情况下,args.id是1。

最后,请求的数据将返回给客户端:

1
2
3
4
5
6
7
8
{
  "data": {
    "user": {
      "name": "John Doe",
      "email": "johndoe@example.com"
    }
  }
}

您可能已经注意到,User对象类型还包括friends字段,它引用其他User对象。客户端可以使用它来查询相关User对象上的其他字段。

1
2
3
4
5
6
7
8
9
POST /graphql
Host: example.com
Content-Type: application/json

{
  "query":"query getUser($id:ID!) { getUser(id:$id) { name email friends { email }}}",
  "variables":{"id":1},
  "operationName":"getUser"
}

因此,开发人员无需手动定义各个API端点和控制器函数,而是可以利用GraphQL的灵活性在客户端构建复杂查询,而无需修改后端。这使得GraphQL在无服务器实现(如带有AWS Lambda的Apollo Server)中很受欢迎。

天堂中的麻烦

还记得那句熟悉的话——能力越大,责任越大?虽然GraphQL的灵活性是一个强大的优势,但它可能被滥用以利用访问控制和信息泄露漏洞。

考虑简单的User对象类型和查询。您可能合理地期望用户可以查询其朋友的电子邮件。但是他们朋友的朋友的电子邮件呢?未经授权,攻击者可以轻松使用以下方式获取二级和三级连接的电子邮件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
query Users($id: ID!) {
  user(id: $id) {
    name
    friends {
      friends {
        email
        friends {
          email
        }
      }
    }
  }
}

在经典的REST范式中,开发人员为每个单独的控制器或模型钩子实现访问控制。虽然可能违反"不要重复自己"(DRY)原则,但这使开发人员能够更好地控制每个调用的访问控制。

GraphQL建议开发人员将授权委托给业务逻辑层,而不是GraphQL层。

因此,授权逻辑位于GraphQL解析器之下。例如,在这个来自GraphQL的示例中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
//授权逻辑位于postRepository内部
var postRepository = require('postRepository');

var postType = new GraphQLObjectType({
  name: 'Post',
  fields: {
    body: {
      type: GraphQLString,
      resolve: (post, args, context, { rootValue }) => {
        return postRepository.getBody(context.user, post);
      }
    }
  }
});

postRepository.getBody在业务逻辑层验证访问控制。

然而,这并非由GraphQL规范强制执行。GraphQL认识到开发人员可能"诱惑"将授权逻辑错误地放置在GraphQL层。不幸的是,开发人员经常陷入这个陷阱,在访问控制层创建漏洞。

图形思维

那么安全研究人员应该如何处理GraphQL API?GraphQL建议开发人员在建模数据时"以图形方式思考",研究人员也应该这样做。我们可以将其与我在经典REST范式中称为"二阶不安全直接对象引用(IDOR)“的情况进行类比。

例如,在REST API中,虽然以下API调用可能得到适当保护:

1
GET /api/user/1

但"二阶"API调用可能没有得到充分保护:

1
GET /api/user/1/photo/6

后端逻辑可能已验证请求用户1的用户具有该用户的读取权限。但它未能检查他们是否也应该有权访问照片6。

这同样适用于GraphQL调用,只是使用图形模式时,可能的路径数量呈指数级增长。以社交媒体照片为例:如果攻击者查询喜欢某张照片的用户,进而访问他们的照片怎么办?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
query Users($id: ID!) {
  user(id: $id) {
    name
    photos {
      image
      likes {
        user {
          photos {
            image
          }
        }
      }
    }
  }
}

那些照片上的点赞呢?链条继续。简而言之,安全研究人员应寻求在图中"闭合循环"并找到通往目标对象的路径。GitLab的Dominic Couture在他关于graphql-path-enum工具的帖子中全面解释了这一点。

开始实战

在大多数GraphQL API的实现中,您应该能够快速识别GraphQL端点,因为它们往往是简单的/graphql或/graph。您还可以根据向这些端点发出的请求来识别它们。

1
2
3
4
5
POST /graphql
Host: example.com
Content-Type: application/json

{"query": "query AllUsers { allUsers{ id } }"}

您应该注意诸如query和mutation等关键词。此外,一些GraphQL实现使用如下所示的GET请求:GET /graphql?query=…

一旦识别出端点,您应该提取GraphQL模式。幸运的是,GraphQL规范支持此类"内省"查询,返回整个模式。这使开发人员能够快速构建和调试GraphQL查询。这些内省查询执行与REST API中的API调用文档工具(如Swagger)类似的功能。

我们可以从这个gist调整内省查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
query IntrospectionQuery {
  __schema {
    queryType {
      name
    }
    mutationType {
      name
    }
    subscriptionType {
      name
    }
    types {
      FullType
    }
    directives {
      name
     description
      args {
        InputValue
      }
      locations
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args {
      InputValue
    }
    type {
      TypeRef
    }
    isDeprecated
    deprecationReason
  }
  inputFields {
    InputValue
  }
  interfaces {
    TypeRef
  }
  enumValues(includeDeprecated: true) {
    name
    description
    isDeprecated
    deprecationReason
  }
  possibleTypes {
    TypeRef
  }
}

fragment InputValue on __InputValue {
  name
  description
  type {
    TypeRef
  }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
      }
    }
  }
}

当然,您必须根据调用方法对此进行编码。要匹配标准的POST /graphql JSON格式,请使用:

1
2
3
4
5
POST /graphql
Host: example.com
Content-Type: application/json

{"query": "query IntrospectionQuery {__schema {queryType { name },mutationType { name },subscriptionType { name },types {…FullType},directives {name,description,args {…InputValue},locations}}}\\nfragment FullType on __Type {kind,name,description,fields(includeDeprecated: true) {name,description,args {…InputValue},type {…TypeRef},isDeprecated,deprecationReason},inputFields {…InputValue},interfaces {…TypeRef},enumValues(includeDeprecated: true) {name,description,isDeprecated,deprecationReason},possibleTypes {…TypeRef}}\\nfragment InputValue on __InputValue {name,description,type { …TypeRef },defaultValue}\\nfragment TypeRef on __Type {kind,name,ofType {kind,name,ofType {kind,name,ofType {kind,name}}}}"}

希望这将返回整个模式,以便您可以开始寻找通往所需对象类型的不同路径。一些GraphQL框架(如Apollo)认识到暴露内省查询的危险,并在生产环境中默认禁用它们。在这种情况下,您必须通过耐心暴力破解和枚举可能的对象类型和字段来摸索前进。对于Apollo,服务器会返回有用的错误信息:Error: Unknown type "X". Did you mean "Y"?,用于接近实际值的类型或字段。

安全研究人员应尽可能多地揭示原始模式。如果您拥有完整模式,可以随意通过graphql-path-enum等工具运行它,以枚举从查询到目标对象类型的不同路径。在graphql-path-enum给出的示例中,如果模式中的目标对象类型是Skill,研究人员应运行:

1
2
3
4
5
6
7
8
$ graphql-path-enum -i ./schema.json -t Skill
Found 27 ways to reach the "Skill" node from the "Query" node:
 — Query (assignable_teams) -> Team (audit_log_items) -> AuditLogItem (source_user) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill
 — Query (checklist_check) -> ChecklistCheck (checklist) -> Checklist (team) -> Team (audit_log_items) -> AuditLogItem (source_user) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill
 — Query (checklist_check_response) -> ChecklistCheckResponse (checklist_check) -> ChecklistCheck (checklist) -> Checklist (team) -> Team (audit_log_items) -> AuditLogItem (source_user) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill
 — Query (checklist_checks) -> ChecklistCheck (checklist) -> Checklist (team) -> Team (audit_log_items) -> AuditLogItem (source_user) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill
 — Query (clusters) -> Cluster (weaknesses) -> Weakness (critical_reports) -> TeamMemberGroupConnection (edges) -> TeamMemberGroupEdge (node) -> TeamMemberGroup (team_members) -> TeamMember (team) -> Team (audit_log_items) -> AuditLogItem (source_user) -> User (pentester_profile) -> PentesterProfile (skills) -> Skill

结果返回模式中通过嵌套查询和链接对象类型到达Skill对象的不同路径。

安全研究人员还应手动检查模式,以发现graphql-path-enum可能遗漏的路径。由于该工具还需要GraphQL模式才能工作,无法提取完整模式的研究人员也必须依赖手动检查。为此,请考虑攻击者可以访问的各种对象类型,找到它们链接的对象类型,并沿着这些链接找到受保护的资源。接下来,测试这些查询的访问控制问题。

对于变更,方法类似。除了测试直接访问控制问题(对您不应有权访问的对象进行变更)之外,您还需要检查变更的返回值以获取链接的对象类型。

结论

GraphQL通过图形范式查询对象,为API增加了更大的灵活性和深度。然而,它并非访问控制漏洞的万能药。GraphQL API容易受到影响REST API的相同授权和身份验证问题的影响。此外,其访问控制仍然依赖于开发人员定义适当的业务逻辑或模型钩子,增加了人为错误的可能性。

开发人员应将其访问控制尽可能靠近持久化(模型)层,并在有疑问时依赖具有合理默认值的框架(如Apollo)。特别是,Apollo建议在数据模型中执行授权检查:

从一开始,我们就建议将实际的数据获取和转换逻辑从解析器移动到集中式模型对象,每个对象代表应用程序中的一个概念:User、Post等。这使您能够使解析器成为薄路由层,并将所有业务逻辑放在一个地方。

例如,User的模型将如下所示:

1
2
3
4
5
6
7
export const generateUserModel = ({ user }) => ({
  getAll: () => {
    if(!user || !user.roles.includes('admin')) return null;
    return fetch('http://myurl.com/users');
  },
  
});

通过将授权逻辑移动到模型层而不是将其分散在不同的控制器中,开发人员可以定义单一的"真相来源”。

从长远来看,随着GraphQL获得更大的采用并达到技术采用周期的后期多数阶段,更多的开发人员将首次实施GraphQL。开发人员必须仔细考虑其GraphQL模式的攻击面,并实施安全的访问控制以保护用户数据。

延伸阅读

  • GraphQL简介
  • GraphQL路径枚举以进行更好的权限测试
  • GraphQL内省和内省查询
  • 保护GraphQL安全
  • 艰难之路:从真实世界GraphQL中学习安全经验

特别感谢Dominic Couture、Kenneth Tan、Medha Lim、Serene Chan和Teck Chung Khor的贡献。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计