当"1+1"变成650美元——Groovy沙箱逃逸实战
为什么写这篇文章?
我喜欢讲述那些有趣但不会造成世界末日的漏洞故事。这个漏洞感觉是一个很好的组合:推理过程直接明了,沙箱失效的方式很微妙,而且具有教育意义,因为它展示了当你忘记考虑类加载器和反射时,黑名单是多么脆弱。
突破口——“它的行为就像代码”
我正在测试应用程序中基于Gremlin的图搜索功能。像往常一样,我尝试了一些奇怪的输入。其中一个返回了Groovy解析错误——不是JSON验证消息或应用程序定义的错误,而是实际的Groovy解析器报错:Unexpected character: '.'
。这立即告诉我服务器正在将我的查询参数解析为Groovy代码。
于是我尝试了一些简单愚蠢的东西:发送1+1
作为查询。端点愉快地返回了2
。这证实了:无论查询中包含什么,都会被Groovy解释器评估。
|
|
从那里开始,就是经典的"什么被阻止,什么没有被阻止"的游戏。
探测沙箱
该应用程序有一个语义检查器——类似于SecureASTCustomizer——可以防止明显危险的调用。尝试System.getProperties()
或直接Runtime调用会返回语义分析错误(沙箱明确禁止这些方法调用)。
这意味着作者知道动态评估是有风险的,并试图阻止直接的危险API使用。很好。但沙箱只能保护它们所保护的路径。
我开始考虑替代入口点。Groovy运行在JVM上。JVM有类加载器。Groovy有强大的运行时API,允许你通过类加载器动态编译代码。如果沙箱按名称阻止System和Runtime,但允许你获取类加载器并调用parseClass,你可以定义整个类体并从其中运行代码——有效地逃避基于名称的限制。
这正是我接下来测试的内容。
绕过方法
- 使用当前执行上下文的类加载器编译一个小的Groovy类(通过parseClass)
- 实例化该类并调用其方法
- 在该方法内部,执行沙箱之前阻止的任何操作(例如,读取文件、访问环境变量、执行更复杂的反射)
请求:
|
|
响应:
|
|
因为parseClass将代码编译成一个新的Class,实例在同一个JVM进程中执行,阻止直接System调用的语义检查器不会以相同方式介入已编译类的字节码——或者它没有捕获调用链。
我通过编译一个返回字符串的小类验证了这一点,然后继续读取/etc/passwd
和环境变量。
带有精心构造的JSON主体的POST /api/graph-search请求,包含使用当前类加载器解析新类的Groovy代码片段,允许我调用读取文件的方法。端点响应了文件内容。
简而言之:
通过以下方式编译类:
|
|
实例化它:.newInstance()
调用读取文件/环境变量的方法:.run()
/ .go()
JSON响应包含返回的数据:
|
|
这证明了:
- 进程内任意代码执行(定义和运行类)
- 数据泄漏(本地文件和环境变量)
- 沙箱不完整
我还尝试通过编译类内部的.execute()
执行shell命令。这条路径在这个目标上不可靠——它返回null/被阻止——可能是因为运行时受到容器控制的进一步限制。这种限制,加上每个客户的沙箱和网络出口控制,减少了真实世界的影响范围。
我能够读取的内容
/etc/passwd
——确认文件读取有效System.getenv()
——枚举环境变量,在这个环境中包括Kubernetes注入的值和进程可见的应用程序秘密
我尝试读取/var/run/secrets/kubernetes.io/serviceaccount/token
的服务账户令牌来测试Kubernetes渗透潜力;容器有挂载的令牌,但网络限制限制了进一步的利用尝试。
报告、供应商互动和结果
我通过HackerOne向项目报告了这个问题,提供了清晰的复现路径和影响解释。供应商反应迅速,要求我继续测试,并确认环境是网络受限的且每个客户都有沙箱。这个背景很重要:虽然漏洞允许进程内有限的代码执行,但这些环境控制减少了跨租户的影响。
最终奖励:650美元。快乐的日子。
沙箱失败的原因
供应商使用了语义AST检查,阻止了危险的名称和直接方法调用(例如,System、Runtime),这是一个良好的第一步。
然而,Groovy和JVM暴露了替代入口点——类加载器和反射——这允许我动态创建和执行代码,而基于名称的检查不一定能防范这些。
黑名单是脆弱的。攻击者会将允许的API链接起来以达到被禁止的功能。在运行在强大运行时之上的动态语言中,攻击面很广。
结束语
这是一个整洁的小发现——由于隔离和仅管理员访问,不是核弹级漏洞,但是一个很好的例子,说明像类加载器这样的运行时特性如何破坏天真应用的语义检查。我赞赏供应商的响应能力以及分类/重新测试的方式——全方位的良好合作。
狩猎愉快,保持安全
再见 ;)