【CTF】强网杯2025 Web题目writeup
WEB
SecretVault
User(id=0, username=‘admin’),并为该用户插入了一个 VaultEntry,label=‘flag’,密码字段是用 Fernet 加密的 flag(文件 /flag 的内容)。
只要以 id=0 的身份访问 dashboard,就能看到并解出 flag。
题目有一个中间件代理,使用Go的reverseproxy做代理转发接收的请求,然后发给flask,首先想到要伪造jwt,伪造一个uid为0的jwt token,但是没办法获取secretkey。另一种思路就是怎么让代理转发后的请求头没有X-User请求头,或者X-User请求头对应值为0,但是main.go中写死了,请求头中的X-User肯定会存在,要不为jwt解密出来的uid,要不为anonymous。
之后查看golang 的 reverseproxy 源码,发现如下代码片段
https://go.dev/src/net/http/httputil/reverseproxy.go
代码对应的功能是移除 HTTP 请求 / 响应中的 “逐跳头部”(Hop-by-Hop Headers),确保代理服务器(如反向代理、网关)在转发请求时符合 HTTP 协议规范,避免头部被不当传递到下游服务。
代码会遍历 Connection 头部的值(可能是逗号分隔的多个字段),然后分割逗号分隔的字段,之后删除该字段。
例如 Connection: keep-alive, X-Custom 表示 keep-alive 和 X-Custom 是逐跳头部,应被当前代理移除,不转发给下游。这段代码会解析 Connection 头部的值,将其中声明的所有字段从请求响应头中删除。
利用这个代理转发特性,将Connection头部值改成 Connection: close,x-user
然后就会将如下请求
GET /dashboard HTTP/1.1
Host: xxxxxxx
User-Agent: curl/7.68.0
Accept: */*
X-User: 1
Connection: close,x-user
修改成如下请求转发
GET /dashboard HTTP/1.1
Host: xxxxxxx
User-Agent: curl/7.68.0
Accept: */*# 以下头部被移除:
# - Connection: close,x-user(标准逐跳头部,被删除)
# - X-User: 1(被 Connection 声明为逐跳,被删除)
之后flask收到的请求头不包含X-User: 1,默认将0赋值给uid,然后输出flag
ezphp
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);
析构函数中可以直接调用函数,可以将$func赋值为函数名调用函数,也可以用数组回调对象方法,第一个参数为对象变量,第二个参数为类中的方法名称字符串。
然后看test类中的构造函数
新建了一个匿名类,赋值给$this->readflag。新的匿名类中包含一个构造函数,功能大致为可以向uploads文件夹任意上传文件,文件内容无限制,文件名称和后缀不可控。还包含一个readflag函数,其中又声明了readflag函数,主要功能,是先检查文件的内容,如果不包含危险字符,则包含对应的文件,本地测试发现,上传的文件在每分钟之内文件名固定,
那么就可以条件竞争,一边传执行命令的php脚本,一边传可通过过滤的文本。
执行test类的__construct函数,很好构造
这样就可以传任意内容的文件了。
之后就是要触发readflag函数,进行文件包含了。
exp中最后序列化的一个数组,依次分三个步骤,第一步,先去调用执行test类的__construct函数,然后得到test实例化后的对象,对应exp中的t变量,第二步,调用t变量,第二步,调用t变量,第二步,调用this->readflag的readflag方法,然后得到对readflag函数的声明,之后就可以调用readflag的方法,进行文件包含。构造好这3个对象,存于一个数组中,然后进行序列化,可执行readflag函数。
之后burp 条件竞争,先列目录
之后发现flag没权限读,需要提权,suid提权 find / -perm -u=s -type f 2>/dev/null
base64 可以提权