
译者的话:开发CUBA平台时,我们在此框架中具有执行自定义脚本的能力,以便更灵活地配置应用程序业务逻辑。 这个机会是好是坏(我们不仅在谈论CUBA)已经争论了很长时间,但是必须控制用户脚本的执行这一事实并没有引起任何疑问。 CédricChampeau的此翻译介绍了Groovy管理自定义脚本执行的有用功能之一。 尽管事实上他最近离开了Groovy开发团队,但程序员社区似乎在很长一段时间内一直在利用他的工作。
使用Groovy的最常用方法之一是通过脚本编写,因为Groovy使得在运行时动态地执行代码变得容易。 取决于应用程序,脚本可以位于不同的位置:文件系统,数据库,远程服务...,但是最重要的是,执行脚本的应用程序开发人员不一定要编写它们。 此外,脚本可以在有限的环境(内存有限,文件描述符数量限制,运行时...)中工作,或者您可能希望阻止用户使用脚本中的所有语言功能。
这篇文章会告诉你。
- 为什么groovy适合编写内部dsl
- 就您的应用程序的安全性而言,其功能是什么
- 如何配置编译以改善DSL
- 关于
SecureASTCustomizer
的价值 - 关于类型控制扩展
- 如何使用类型控制扩展使沙盒有效
例如,想象一下您需要做什么,以便用户可以计算数学表达式。 一种实现方式是嵌入内部DSL,创建解析器,最后是这些表达式的解释器。 当然,要做到这一点,就必须工作,但是如果需要提高生产率,例如,通过为表达式生成字节码而不是在解释器中计算它们或使用运行时生成的类的缓存,那么Groovy是一个不错的选择。
文档中介绍了许多选项,但是最简单的示例只是使用Eval
类:
Example.java
int sum = (Integer) Eval.me("1+1");
Groovy在运行时将1+1
代码解析,编译为字节码,加载并执行。 当然,此示例中的代码非常简单,您将需要添加参数,但是想法是可执行代码可以是任意的。 而这可能并不是您真正需要的。 在计算器中,您需要允许以下内容:
1+1 x+y 1+(2*x)**y cos(alpha)*r v=1+x
但肯定不是
println 'Hello' (0..100).each { println 'Blah' } Pong p = new Pong() println(new File('/etc/passwd').text) System.exit(-1) Eval.me('System.exit(-1)') // a script within a script!
困难从这里开始,而且很明显,我们需要解决一些问题:
- 将语言的语法限制为其功能的子集
- 防止用户执行未提供的代码
- 防止执行恶意代码
带有计算器的示例非常简单,但是对于更复杂的DSL,人们可能不会注意到他们正在编写有问题的代码,尤其是在DSL如此简单以至于开发人员无法使用它的情况下 。
几年前,我处于这种情况。 我开发了一种引擎,该引擎运行由语言学家编写的Groovy“脚本”。 例如,一个问题是它们可能会无意间造成无限循环。 该代码在服务器上执行,并且出现了占用100%CPU的线程,此后有必要重新启动应用程序服务器。 我必须寻找一种解决问题的方法,而又不影响DSL,工具或应用程序的性能。
实际上,许多人也有类似的需求。 在过去的4年中,我一直在与很多有相同问题的人交谈: 如何防止用户在Groovy脚本中胡说八道?
定制编译器
那时,我已经有了自己的决定,而且我知道其他人也开发了类似的东西。 最后,Guillaume Laforge建议我在Groovy内核中创建一种机制来帮助解决这些问题。 它在Groovy 1.8.0中作为编译定制器出现 。
编译定制器是一组类,可修改Groovy脚本的编译过程。 您可以编写自己的定制程序,但是Groovy提供了:
- 导入自定义程序,可将导入内容隐式添加到脚本中,因此用户无需添加导入说明
- 定制程序AST(抽象语法树)转换,使您可以将AST转换直接添加到脚本中
- 限制语言的语法和语法构造的安全AST定制程序
AST转换的定制器帮助我解决了@ThreadInterrupt
转换带来的无限循环问题,但是在大多数情况下, SecureASTCustomizer可能是最容易被误解的东西。
我对此表示歉意。 然后我想不出一个更好的名字。 名称“ SecureASTCustomizer”中最重要的部分是AST 。 该机制的目的是限制对某些AST功能的访问。 标题中的“安全”一词通常是多余的,我将解释原因。 甚至还有詹金斯(Jenkins)著名的川口浩介(Kosuke Kawaguchi)的博客文章,标题为“ Fatal Groovy SecureASTCustomizer” 。 那里的所有内容都写得非常正确。 SecureASTCustomizer不是为沙盒设计的。 创建它是为了在编译时限制语言,而不是执行时。 现在我认为最好的名字应该是GrammarCustomizer 。 但是,正如您肯定知道的那样,计算机科学中存在三个困难:缓存失效,创建名称和每单位错误。
现在想象一下,您正在考虑使用安全的AST定制器作为确保脚本安全性的一种方法,而您的任务是防止用户从脚本中System.exit
。 该文档说,可以通过创建黑名单或白名单来禁止特殊接收器中的呼叫。 如果需要安全性,我总是建议严格列出允许使用的白名单,而不建议禁止使用任何东西的黑名单。 因为黑客总是思考您可能没有考虑的内容。 我举一个例子。
这是使用SecureASTCustomizer
设置原始沙箱脚本引擎的方法。 尽管我可以用Groovy编写它们,但我给出了Java配置示例以使集成代码和脚本之间的区别更加明显。
public class Sandbox { public static void main(String[] args) { CompilerConfiguration conf = new CompilerConfiguration(); SecureASTCustomizer customizer = new SecureASTCustomizer(); customizer.setReceiversBlackList(Arrays.asList(System.class.getName())); conf.addCompilationCustomizers(customizer); GroovyShell shell = new GroovyShell(conf); Object v = shell.evaluate("System.exit(-1)"); System.out.println("Result = " +v); } }
- 创建编译器配置
- 创建安全的AST定制器
- 声明将
System
类作为方法调用的接收者列入黑名单 - 将定制程序添加到编译器配置
- 将配置与shell脚本绑定,即尝试创建沙箱
- 运行“坏”脚本
- 显示运行脚本的结果
如果运行此类,则在脚本执行期间将发生错误:
General error during canonicalization: Method calls not allowed on [java.lang.System] java.lang.SecurityException: Method calls not allowed on [java.lang.System]
该结论由具有安全AST定制器的应用程序发布,该应用程序不允许执行System
类的方法。 成功! 因此,我们已经保护了脚本! 但是等一下...
SecureASTCustomizer被黑了!
保护,说什么? 但是,如果我这样做:
def c = System c.exit(-1)
如果再次运行该程序,您将看到它崩溃而没有错误,并且没有在屏幕上显示结果。 进程退出代码为-1,表示用户脚本已运行! 发生什么事了 在编译时,安全AST定制器c.exit
能识别c.exit
是对System
方法的调用,因为它在AST级别上起作用! 它分析方法调用,在这种情况下,方法调用为c.exit(-1)
,然后确定接收方并检查其是否在白名单(或黑名单)中。 在这种情况下,接收方为c
,此变量通过def声明 ,这与将其声明为Object
,并且安全AST定制程序将认为变量c
的类型为Object
,而不是System
!
通常,有很多方法可以解决在安全AST定制器上创建的各种配置。 这里有一些很酷的:
((Object)System).exit(-1) Class.forName('java.lang.System').exit(-1) ('java.lang.System' as Class).exit(-1) import static java.lang.System.exit exit(-1)
还会有更多。 Groovy的动态特性排除了在编译时修复这些问题的能力。 但是,确实存在解决方案。 一种选择是依赖标准的JVM安全管理器。 但是,这对于整个系统来说是一个庞大而庞大的解决方案,这等效于向麻雀发射大炮。 此外,它并非在所有情况下都有效,例如,如果您要禁止读取文件,但不希望创建文件,则无法执行...
这种局限性(对我们许多人而言是一种烦恼)导致基于运行时检查的解决方案的创建。 这种检查没有这种问题。 例如,因为在开始验证方法调用之前,您将知道消息的实际接收者类型。 以下实现是特别令人感兴趣的:
但是,这些实现都不是完全可靠和安全的。 例如,Kosuke的版本基于对缓存调用站点的内部实现的破解。 问题在于它与Groovy的invokedynamic版本不兼容,并且这些内部类将不在Groovy的未来版本中。 另一方面,Simon的版本基于AST转换,但是留下了许多潜在的漏洞。
结果,我的朋友Corinne Crisch,Fabrice Matrat和Sebastian Blanc,我决定在运行时创建一种新的沙箱机制,不会出现这些项目这样的问题。 我们开始在尼斯的黑客马拉松上实施它,在去年的Greach会议上,我们就此做了报告 。 该机制基于AST转换,本质上是重写代码以在每次方法调用之前进行检查,尝试访问类字段,增加变量,二进制表达式等。此实现仍未准备好,还没有完成很多工作,因此当我意识到通过“隐式this”调用的方法和参数的问题尚未解决时,例如在构建器中:
xml { cars { // cars is a method call on an implicit this: "this".cars(...) car(make:'Renault', model: 'Clio') } }
到目前为止,由于Groovy中的元对象协议的体系结构,我仍然没有找到解决此问题的方法,该体系基于以下事实:接收方在切换到另一个接收方之前无法找到该方法时会抛出异常。 简而言之,这意味着您无法在实际方法调用之前找出接收器的类型。 如果通话已通过,那就太迟了...
直到最近,对于可执行脚本使用语言的动态属性的情况,我还没有针对此问题的最佳解决方案。 但是现在是时候解释一下,如果您准备牺牲一点语言的活力,那么如何可以大大改善这种情况。
类型检查
让我们回到SecureASTCustomizer的主要问题:它与抽象语法树一起使用,并且没有有关特定消息类型和接收者的信息。 但是对于Groovy 2,Groovy添加了编译,在Groovy 2.1中,我们添加了用于类型检查的扩展 。
类型检查的扩展功能非常强大:它们使Groovy DSL开发人员可以帮助编译器进行类型推断,并且还可以在通常不发生错误的情况下生成编译错误。 例如,在实现traits或标记模板引擎时,Groovy在内部使用这些扩展来支持静态编译器。
如果我们可以依靠类型检查机制的信息来代替解析器的结果,该怎么办? 采取我们的黑客试图编写的代码:
((Object)System).exit(-1)
如果激活类型检查,则代码不会编译:
1 compilation error: [Static type checking] - Cannot find matching method java.lang.Object#exit(java.lang.Integer). Please check if the declared type is right and if the method exists.
因此,此代码不再编译。 如果我们采用以下代码,该怎么办:
def c = System c.exit(-1)
如您所见,它通过类型检查,包装在方法中并使用groovy
命令执行:
@groovy.transform.TypeChecked
类型检查器检测到从System
类调用了exit
方法,并且该方法是有效的。 这对我们没有帮助。 但是我们知道的是,如果此代码通过类型检查,则意味着编译器可以识别对类型为System
的接收者的调用。 通常,该想法是禁止带有扩展名的呼叫进行类型检查。
用于类型检查的简单扩展
在详细研究沙箱之前,让我们尝试在标准扩展的帮助下“保护”脚本的类型,以进行类型检查。 注册这样的扩展很容易:只需为@TypeChecked
注释设置extensions
参数(如果使用静态编译,则设置@TypeChecked
):
@TypeChecked(extensions=['SecureExtension1.groovy']) void foo() { def c = System c.exit(-1) } foo()
扩展搜索将以源代码格式在类路径中进行(您可以进行预编译的扩展以进行类型检查,但在本文中我们将不考虑它们):
SecureExtension1.groovy
onMethodSelection { expr, methodNode -> if (methodNode.declaringClass.name=='java.lang.System') { addStaticTypeError("Method call is not allowed!", expr) } }
- 当类型检查器选择要调用的方法时
- 如果该方法属于
System
类 - 然后让类型检查器生成错误
这就是您所需要的。 现在再次运行代码,您将看到编译错误!
/home/cchampeau/tmp/securetest.groovy: 6: [Static type checking] - Method call is not allowed! @ line 6, column 3. c.exit(-1) ^ 1 error
这次,由于类型检查器的帮助, c
识别为System
类的一个实例,我们可以禁止该调用。 这是一个非常简单的示例,并且在配置方面没有演示安全AST定制程序可以完成的所有操作。 在我们编写的扩展中,检查是硬编码的 ,但是最好使它们可定制。 因此,让示例变得更加复杂。
假设您的应用程序为文档计算某些指标,并允许用户自定义它们。 在这种情况下,DSL:
- 将操作(至少)
score
变量 - 允许用户执行数学运算(包括调用cos , abs ,...方法)
- 必须禁止所有其他方法
样本用户脚本:
abs(cos(1+score))
此DSL易于配置。 这是我们上面定义的变体:
Sandbox.java
CompilerConfiguration conf = new CompilerConfiguration(); ImportCustomizer customizer = new ImportCustomizer(); customizer.addStaticStars("java.lang.Math"); conf.addCompilationCustomizers(customizer); Binding binding = new Binding(); binding.setVariable("score", 2.0d); GroovyShell shell = new GroovyShell(binding,conf); Double userScore = (Double) shell.evaluate("abs(cos(1+score))"); System.out.println("userScore = " + userScore);
- 添加导入定制程序,这会将
import static java.lang.Math.*
添加到所有脚本 - 使
score
变量可用于脚本 - 执行脚本
有一些方法可以缓存脚本,而不是每次都解析和编译脚本。 有关详细信息,请参见文档。
因此,我们的脚本可以工作,但是没有什么可以阻止黑客启动恶意代码。 由于我们计划使用类型检查,因此我建议使用@CompileStatic
转换:
- 它会激活脚本中的类型检查,由于扩展了类型检查功能,我们将能够执行其他检查
- 提高脚本性能
将@CompileStatic
注释隐式添加到脚本非常简单。 您只需要更新编译器配置:
ASTTransformationCustomizer astcz = new ASTTransformationCustomizer(CompileStatic.class); conf.addCompilationCustomizers(astcz);
现在,如果您尝试再次运行该脚本,将看到编译错误:
Script1.groovy: 1: [Static type checking] - The variable [score] is undeclared. @ line 1, column 11. abs(cos(1+score)) ^ Script1.groovy: 1: [Static type checking] - Cannot find matching method int#plus(java.lang.Object). Please check if the declared type is right and if the method exists. @ line 1, column 9. abs(cos(1+score)) ^ 2 errors
发生什么事了 如果您从编译器的角度阅读脚本,那么很明显,他对变量“分数”一无所知。 但是作为开发人员,您知道这是一个double
变量,但是编译器无法输出它。 为此,将创建用于类型检查的扩展:您可以为编译器提供其他信息,然后编译将正常进行。 在这种情况下,我们需要指出score
变量的类型为double
。
因此,您可以稍微更改@CompileStatic
批注的方式:
ASTTransformationCustomizer astcz = new ASTTransformationCustomizer( singletonMap("extensions", singletonList("SecureExtension2.groovy")), CompileStatic.class);
这“模拟”了@CompileStatic(extensions=['SecureExtension2.groovy'])
注释的代码@CompileStatic(extensions=['SecureExtension2.groovy'])
。 现在,当然,我们需要编写一个扩展程序来识别score
变量:
SecureExtension2.groovy
unresolvedVariable { var -> if (var.name=='score') { return makeDynamic(var, double_TYPE) } }
- 如果类型检查器无法确定变量
- 如果变量名称是
score
- 让编译器使用
double
类型动态定义变量
可以在文档的此部分中找到用于类型检查的DSL扩展的完整说明,但是有一个组合编译模式的示例:编译器无法定义score
变量。 作为DSL开发人员,您知道变量实际上是其类型makeDynamic
,因此对makeDynamic
的调用在这里说:“好吧,不用担心,我知道我在做什么,可以使用double
类型动态定义此变量。 ” 仅此而已!
首次完成的“安全”扩展
现在,让我们把它们放在一起。 我们编写了一个类型检查扩展程序,它一方面防止调用System
类的方法,另一方面又定义了score
变量。 因此,如果我们将它们连接起来,我们将获得第一个完整的扩展以进行类型检查:
SecureExtension3.groovy
记住要更新Java类中的配置,以使用新的扩展名进行类型检查:
ASTTransformationCustomizer astcz = new ASTTransformationCustomizer( singletonMap("extensions", singletonList("SecureExtension3.groovy")), CompileStatic.class);
再次运行代码-它仍然有效。 现在尝试这个:
abs(cos(1+score)) System.exit(-1)
脚本的编译将因错误而崩溃:
Script1.groovy: 1: [Static type checking] - Method call is not allowed! @ line 1, column 19. abs(cos(1+score));System.exit(-1) ^ 1 error
恭喜,您刚刚编写了第一个防止恶意代码运行的类型检查扩展程序!
增强的扩展配置
因此,一切进展顺利,我们可以禁止调用System
类的方法,但是似乎很快就会发现新的漏洞,并且我们将需要防止启动恶意代码。 因此,我们将尝试使扩展名通用且可定制,而不是对扩展名中的所有内容进行硬编码。 这可能是最困难的,因为没有直接方法将上下文传递给扩展进行类型检查。 因此,该思想基于使用线程局部变量(曲线方法,是)将配置数据传递给类型检查器。
首先,我们将使变量列表可定制。 Java代码如下所示:
Sandbox.java
public class Sandbox { public static final String VAR_TYPES = "sandboxing.variable.types"; public static final ThreadLocal<Map<String, Object>> COMPILE_OPTIONS = new ThreadLocal<>(); public static void main(String[] args) { CompilerConfiguration conf = new CompilerConfiguration(); ImportCustomizer customizer = new ImportCustomizer(); customizer.addStaticStars("java.lang.Math"); ASTTransformationCustomizer astcz = new ASTTransformationCustomizer( singletonMap("extensions", singletonList("SecureExtension4.groovy")), CompileStatic.class); conf.addCompilationCustomizers(astcz); conf.addCompilationCustomizers(customizer); Binding binding = new Binding(); binding.setVariable("score", 2.0d); try { Map<String,ClassNode> variableTypes = new HashMap<String, ClassNode>(); variableTypes.put("score", ClassHelper.double_TYPE); Map<String,Object> options = new HashMap<String, Object>(); options.put(VAR_TYPES, variableTypes); COMPILE_OPTIONS.set(options); GroovyShell shell = new GroovyShell(binding, conf); Double userScore = (Double) shell.evaluate("abs(cos(1+score));System.exit(-1)"); System.out.println("userScore = " + userScore); } finally { COMPILE_OPTIONS.remove(); } } }
ThreadLocal
,- —
SecureExtension4.groovy
variableTypes
— “ → ”score
options
—- "variable types" VAR_TYPES
- thread local
- , , thread local
:
import static Sandbox.* def typesOfVariables = COMPILE_OPTIONS.get()[VAR_TYPES] unresolvedVariable { var -> if (typesOfVariables[var.name]) { return makeDynamic(var, typesOfVariables[var.name]) } }
- thread local
- , ,
- type checker
thread local, , type checker . , unresolvedVariable
, , , type checker, . , . !
. , .
. , . , , . , System.exit
, :
java.lang.System#exit(int)
, Java, :
public class Sandbox { public static final String WHITELIST_PATTERNS = "sandboxing.whitelist.patterns";
java.lang.Math
:
import groovy.transform.CompileStatic import org.codehaus.groovy.ast.ClassNode import org.codehaus.groovy.ast.MethodNode import org.codehaus.groovy.ast.Parameter import org.codehaus.groovy.transform.stc.ExtensionMethodNode import static Sandbox.* @CompileStatic private static String prettyPrint(ClassNode node) { node.isArray()?"${prettyPrint(node.componentType)}[]":node.toString(false) } @CompileStatic private static String toMethodDescriptor(MethodNode node) { if (node instanceof ExtensionMethodNode) { return toMethodDescriptor(node.extensionMethodNode) } def sb = new StringBuilder() sb.append(node.declaringClass.toString(false)) sb.append("#") sb.append(node.name) sb.append('(') sb.append(node.parameters.collect { Parameter it -> prettyPrint(it.originType) }.join(',')) sb.append(')') sb } def typesOfVariables = COMPILE_OPTIONS.get()[VAR_TYPES] def whiteList = COMPILE_OPTIONS.get()[WHITELIST_PATTERNS] onMethodSelection { expr, MethodNode methodNode -> def descr = toMethodDescriptor(methodNode) if (!whiteList.any { descr =~ it }) { addStaticTypeError("You tried to call a method which is not allowed, what did you expect?: $descr", expr) } } unresolvedVariable { var -> if (typesOfVariables[var.name]) { return makeDynamic(var, typesOfVariables[var.name]) } }
MethodNode
- thread local
- ,
, :
Script1.groovy: 1: [Static type checking] - You tried to call a method which is not allowed, what did you expect?: java.lang.System#exit(int) @ line 1, column 19. abs(cos(1+score));System.exit(-1) ^ 1 error
, ! , , . , ! , , . , ( foo.text
, foo.getText()
).
, type checker' "property selection", , . , , . , , — . .
SandboxingTypeCheckingExtension.groovy
import groovy.transform.CompileStatic import org.codehaus.groovy.ast.ClassCodeVisitorSupport import org.codehaus.groovy.ast.ClassHelper import org.codehaus.groovy.ast.ClassNode import org.codehaus.groovy.ast.MethodNode import org.codehaus.groovy.ast.Parameter import org.codehaus.groovy.ast.expr.PropertyExpression import org.codehaus.groovy.control.SourceUnit import org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys import org.codehaus.groovy.transform.stc.ExtensionMethodNode import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport import org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport import static Sandbox.* class SandboxingTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL { @CompileStatic private static String prettyPrint(ClassNode node) { node.isArray()?"${prettyPrint(node.componentType)}[]":node.toString(false) } @CompileStatic private static String toMethodDescriptor(MethodNode node) { if (node instanceof ExtensionMethodNode) { return toMethodDescriptor(node.extensionMethodNode) } def sb = new StringBuilder() sb.append(node.declaringClass.toString(false)) sb.append("#") sb.append(node.name) sb.append('(') sb.append(node.parameters.collect { Parameter it -> prettyPrint(it.originType) }.join(',')) sb.append(')') sb } @Override Object run() {
结论
Groovy JVM. , . , , , . , Groovy, sandboxing' (, , ).
, , . , . , , .
, sandboxing', , — SecureASTCustomizer
. , , : secure AST customizer , (, ), ( , ).
, : , , . Groovy . Groovy, , - pull request, - !