原文来自SecIN社区—作者:tkswifty
相关背景
文件上传是系统中比较常见的业务需求,例如上传头像、简历、报表等。但是如果在业务实现过程中没有考虑相关的安全问题(例如没有对用户上传的文件类型做校验或者校验不充分,导致用户可以上传恶意脚本到服务器)便会导致相关的风险。
Java文件类File以抽象的方式代表文件名和目录路径名。该类主要用于文件和目录的创建、文件的查找和文件的删除等。
一般新建文件是通过将给定路径名字符串转换成抽象路径名来创建一个新File实例:
File file = new File("path")
使用File创建文件时,若路径处path写入…/…/穿越符号,是可以跨目录新建文件的:
看一个例子,下面是通过引入…/…/穿越符进行跨目录在上级目录Desktop创建文件:
结合该特点,结合特定的利用场景可以完成相关的权限获取操作。例如:
linux写入定时任务、ssh公钥
windows写入自启动脚本、恶意dll
…
挖掘过程
一般针对文件上传业务,主要判断是否有检查后缀名,同时要查看配置文件是否有设置白名单或者黑名单,如果没有的话,那么攻击者利用该缺陷上传类似webshell等恶意文件。
目标系统主要是通过commons-fileupload组件来实现文件上传,具体实现如下:
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setRepository(new File(System.getProperty("java.io.tmpdir")));
ServletFileUpload upload = new ServletFileUpload(factory);
// 设置上传文件大小的上限
upload.setSizeMax(100 * 1024 * 1024);
List<FileItem> items = new ArrayList<>();
// 上传解析
try {
items = upload.parseRequest(request);
Iterator it = items.iterator();
while (it.hasNext()) {
FileItem fit = (FileItem) it.next();
String fileName = fit.getName();
String suffix = fileName.substring(fileName.lastIndexOf("."));
if(blackSuffix.contains(suffix)) {
return (new OpenAPIResponse()).getFailResp("禁止上传恶意文件");
}
File file = new File("/resource/upload/"+ fileName);
fit.write(file);
}
} catch (FileUploadException fe) {
fe.printStackTrace();
}
该实现方式可以简单概述为:
通过黑名单对上传的后缀进行了检查
未对上传文件进行重命名
针对后缀名检查,尝试上传jsp/jspx等恶意文件应该是没戏了,第一时间想到了%00截断。但是JDK1.7.0_40(7u40)开始对\00进行了检查,相关代码如下:
final boolean isInvalid(){
if(status == null){
status=(this.path.indexOf('\u0000')<0)?PathStatus.CHECKED:PathStatus.INVALID;
}
return status == PathStatus.INVALID;
}
所以%00截断应该是无法成功的。
查看commons-fileupload组件具体获取文件名的方法看看有没有利用的可能:
首先是解析multipart上传请求的实现:
public List parseRequest(RequestContext ctx) throws FileUploadException
{
List items = new ArrayList();
boolean successful = false;
Iterator iterator;
try
{
FileItemIterator iter = getItemIterator(ctx);
FileItemFactory fac = getFileItemFactory();
if (fac == null) {
throw new NullPointerException("No FileItemFactory has been set.");
}
FileItemStream item;
while (iter.hasNext())
{
item = iter.next();
String fileName = ((FileUploadBase.FileItemIteratorImpl.FileItemStreamImpl)item).name;
FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(), item.isFormField(), fileName);
items.add(fileItem);
这里直接获取上传的文件名然后封装到FileItemStream对象里方便后续操作。查看后续获取上传文件名的方法:
public String getName()
{
return Streams.checkFileName(this.fileName);
}
进一步查看Streams.checkFileName()的具体实现:
public static String checkFileName(String pFileName)
{
if ((pFileName != null) && (pFileName.indexOf(0) != -1))
{
StringBuffer sb = new StringBuffer();
for (int i = 0; i < pFileName.length(); i++)
{
char c = pFileName.charAt(i);
switch (c)
{
case '\000':
sb.append("\\0");
break;
default:
sb.append(c);
}
}
throw new InvalidFileNameException(pFileName, "Invalid file name: " + sb);
}
return pFileName;
}
同样的也对00截断进行了处理。但是整个过程中没有对路径穿越符…/进行处理。也就是说可以尝试构造特殊的文件名,尝试进行穿越目录上传。同时并没有重命名上传的文件名,那么可以尝试讲文件名命名为../../../../../../../../../var/spool/cron/root
,写入定时任务尝试反弹shell。
相关数据包如下,尝试写入root的定时任务,每分钟反弹shell到相关主机的8888端口:
上传成功后等待定时任务执行,比较幸运,成功反弹shell:
整个缺陷利用过程首先是黑名单的局限性,然后没有对上传的文件名进行重命名操作,结合目录穿越上传最终导致通过上传"定时任务"获取系统权限。
文件目录穿越上传的处理方式
针对上面的场景,那么如何对文件目录穿越上传进行相应的防护呢?这里参考了一下Spring的做法。
spring-web项目包含Web应用开发时,用到Spring 框架时所需的核心类,包括自动载入Web Application Context 特性的类、Struts 与JSF 集成类、文件上传的支持类、Filter 类和大量工具辅助类。
其内部实现也是通过集成commons-fileupload组件来实现文件上传解析的:
查看其获取上传文件名的方法:
相关代码:
public String getOriginalFilename()
{
String filename = this.fileItem.getName();
if (filename == null) {
return "";
}
int pos = filename.lastIndexOf("/");
if (pos == -1) {
pos = filename.lastIndexOf("\\");
}
if (pos != -1) {
return filename.substring(pos + 1);
}
return filename;
}
这里分别对linux、windows的情况进行了处理,只截取/
或者\\
最终的文件名,那么类似../../../../../../../../../var/spool/cron/root
的文件名最终取得的的应该是root,这样就没法进行目录穿越上传了,从而一定程度上解决了某些场景下的安全风险。当然了,对上传的文件名随机命名,如UUID、GUID,不允许用户自定义也是一种不错的防护手段。