png图片格式

PNG图片格式

  • 图种jpg 图片有文件尾标识 FF D9,而图片查看器只读取 FF D9 之前的数据显示为图片,因此 FF D9 之后的空间能够存放隐藏数据;png 的末尾 00 00 00 00 49 45 4e 44 ae 42 60 82 充当文件尾标识,情况类似
  • LSB隐写:将数据藏匿在最低有效位,因此只适合 无损压缩 png无压缩 bmp 图片格式

很显然,想要更深入的了解图片隐写,就必须对常见的图片格式有所了解

本篇介绍 png 图片格式


数据块(Chunk)

文件头标识 ——(8 bytes)89 50 4E 47 0D 0A 1A 0A,ASCII码为 .PNG....

png 文件由数据块(Chunk)组成,每个Chunk都必须包含:

  • 长度(Length):4 bytes

  • 数据块类型(Chunk Type):4 bytes

  • 数据块数据(Chunk Data)Length bytes

  • CRC校验码:4 bytes,由Chunk Type和Chunk Data计算而来

    CRC循环冗余校验(Cyclic Redundancy Check)的缩写,它能根据数据产生简短的固定位数校验码,能够用来校验数据传输前后的完整性

    数据中哪怕是 1 bit 的改动,也会导致完全不一样的CRC校验码

    数据传输过程中难免会出现差错,那么可以在传输前对数据进行CRC计算、传输后再计算,如果两个CRC结果不一致,就应该要求重新传输数据

我们来重点分析 png 的第一个Chunk:IHDR Chunk


IHDR Chunk

介绍

IHDR Chunk又称为图片文件头数据块(image header chunk),是 png 图片的第一个chunk,一张 png 图片有且仅有一个IHDR Chunk

IHDR Chunk包含了 png 图片的基本信息:

  • 宽、高
  • 颜色深度
  • 颜色类型
  • 压缩方法
  • ......

我们假设用十六进制查看器打开一张 png 图片,数据如下:

1
2
3
4
0000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR
0000010: 0000 0010 0000 0010 0800 0000 003a 98a0 .............:..
0000020: bd00 0000 0774 494d 4507 da0a 0a07 150b .....tIME.......
......

8个固定字节属于文件头标识:89 50 4e 47 0d 0a 1a 0a

紧接着的4个字节是 00 00 00 0d,它属于chunk的Length域,也就是说这个chunk的Chunk Data域的长度应该为 0x0d = 13 个字节

从Length开始已经进入了 png 文件的第一个chunk——IHDR Chunk了

再往后取4个字节,49 48 44 52,对应chunk的Chunk Type,其对应的ASCII字符为 IHDR

接下来应该是Chunk Data域了,它的长度由Length确定,所以有 0x0d = 13 个字节

就如之前所说的,IHDR Chunk包含 png 文件的许多信息,这些信息就包含在IHDR Chunk的Chunk Data里面。这 13 个字节分别代表:

  • 宽度(Width)4 bytes

  • 高度(Height)4 bytes

  • 颜色深度(Bit Depth)1 byte

    注意

    颜色深度(Bit Depth)应该与另一个概念位深度一起理解

    • 位深度表示RGBA通道中,每个通道的位数
    • 颜色深度表示RGBA四个通道总共的位数,有 4 * 位深度 = 颜色深度

    不要被英文翻译弄晕了

    不同的位图软件会以不同的角度去解释图像的位深度,有可能同一张图片,在十六进制查看器中显示颜色深度为8 bytes;而查看图片属性却显示位深度为32 bytes

  • 颜色类型(Color Type)1 byte

  • 压缩方法(Compression Method)1 byte

  • 过滤方法(Filter Method)1 byte

  • 扫描方法(Interlace Method)1 byte

跳过Chunk Data的13个字节,IHDR Chunk的最后4个字节 3a 98 a0 bd 是由Chunk Type和Chunk Data计算得到的CRC校验码


CRC计算方法(可跳过)

任意一个二进制数都可以与一个系数仅为 01 的多项式一一对应

例如,1010111 对应 $x^6+x^4+x^2+x+1$,而$x^5+x^3+x^2+x+1$对应 101111

在计算CRC码之前,都要选定一个参数模型参数模型就是上面的多项式

我们可以将$x^6+x^4+x^2+x+1$称之为CRC-6;将$x^5+x^3+x^2+x+1$称之为CRC-5;但是光CRC-6就有许多种——只要最高项指数为6、系数为1的,都是

科学家对CRC的研究延伸出生成多项式,专门用于研究如何选定一个多项式为CRC-6,使得CRC-6的性能提升(CRC类似哈希算法,理应避免哈希碰撞)

就目前而言,广泛使用的CRC-5是$x^5+x^2+1$、而不是$x^5+x^3+x^2+x+1$;广泛使用的CRC-6是$x^6+x+1$,而不是$x^6+x^4+x^2+x+1$

选定CRC的多项式是有数学依据的,但这篇文章不讨论这个

我们用自定义的CRC-3来快速演示一下CRC的计算过程:

假设选定多项式为:$x^3+x+1$

假设原始数据为 1010,求 1010 经过我们自定义的CRC-3编码后,得到的CRC码:

  1. 多项式$x^3+x+1$对应的二进制数是 1011

  2. 我们把最高项指数称为R,在这里,$R = 3$

    R是CRC的命名依据,并且也是生成CRC码的二进制位数)

    把原始数据 1010 左移R位,得到移位数据 1010000

  3. 移位数据与多项式对应二进制数进行模2除法

    模2除法

    普通除法过程中的加减法全部改成异或运算

    得到 1011011

  4. 异或运算结果的后R位,也就是 011,这个就是生成的CRC码

具体可以参见文章https://blog.csdn.net/Kj1501120706/article/details/73330526


回到我们 png 的IHDR Chunk的CRC码 3a 98 a0 bd,它是由Chunk Type和Chunk Data计算得到的CRC校验码

Chunk Type和Chunk Data的数据为 4948 4452 0000 0010 0000 0010 0800 0000 00,这就是CRC计算中的原始数据

对于 png,默认使用CRC-32
$$
x^{32}+x^{26}+x^{23}+x^{22}+x^{16}+x^{12}+x^{11}+x^{10}+x^8+x^7+x^5+x^4+x^2+x+1
$$

Python 2计算CRC-32

使用Python 2是借助zlib库可以快速计算CRC-32的,以上面的原始数据为例:

1
2
3
4
5
import zlib
s = "\x49\x48\x44\x52\x00\x00\x00\x10\x00\x00\x00\x10\x08\x00\x00\x00\x00"
c = zlib.crc32(s) & 0xffffffff
print hex(c)
# 0x3a98a0bd

可以看到,得到的结果的确是我们想要的 3a 98 a0 bd

原始数据必须转换成 \x 形式,它表示单字节编码;而 0x 是不行的

\x 表示一个不能直接显示的单字节字符的编码;0x 便是一个标准十六进制的字符串

补充

Python 2中 crc32() 的值域为 $[-2^{31},2^{31}-1]$之间的整数,而更为广泛使用的CRC码(如Python 3、C语言)的取值范围为 $[0,2^{32}-1]$

为了修正Python 2中的CRC码,必须与 0xffffffff 进行运算

Python 3计算CRC-32

Python 3同样有zlib库

1
2
3
4
5
import zlib
s = b"\x49\x48\x44\x52\x00\x00\x00\x10\x00\x00\x00\x10\x08\x00\x00\x00\x00"
c = zlib.crc32(s)
print(hex(c))
# 0x3a98a0bd

注意Python 3的 zlib.crc32() 的参数必须是 bytes类型,而Python 3强制区分bytesstring的使用,为此在字符串 s 前面多加一个 b


在CTF中,有一类题目就是人为减小了IHDR Chunk中Chunk Data的WidthHeight的值,这就导致图片查看器在解析 png 图片数据的时候,按照修改后的WidthHeight会缺少图片部分区域的显示,这部分区域往往就隐藏着flag

为此,我们需要在十六进制编辑器中将WidthHeight修改回来,而正确的WidthHeight就隐藏在CRC码

注意

胡乱修改IHDR Chunk中的WidthHeight,在某些操作系统中会导致CRC校验报错,从而无法打开 png 图片

比如Linux下的图片查看器就不会忽略错误的CRC校验码,因此用Linux打开修改过WidthHeightpng 图片,会出现打不开的情况

下图为Kali中打开CRC校验报错的 png 图片:

但是Windows是会忽略的,它会按照修改后的WidthHeigth来显示图片

BugKu的杂项题目隐写就是这一类题目   点击下载图片

下载得到 2.png,用十六进制编辑器打开,查看IHDR Chunk的数据:

png 文件头标识 89 50 4E 47 0D 0A 1A 0A 没有问题

图片宽度为 00 00 01 f4,即 500 像素;高度为 00 00 01 a4,即 420 像素;CRC码为 cb d6 df 8a

我们用Chunk Type和Chunk Data的数值来计算一下IHDR Chunk的CRC码:(Python 3)

发现计算出来的CRC校验码与文件数据中的CRC校验码不同,猜测为更改了图片的宽度或高度

由于图片的宽度和高度的取值都在一个比较小的范围内,因此可以根据CRC码,爆破出正确的宽度或高度

png CRC爆破

CRC码可以利用Python的 zlib.crc32() 函数求得,反过来,也可以用该函数进行爆破

由于图片宽度为 500像素、高度为 420 像素,所以假设高度被改小了,那么就基于CRC码IHDR Chunk中其它数据,爆破出正确的高度值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import struct
import zlib

# 十六进制字符串转bytes:两两分割字符串,对子串使用struct.pack()转换成bytes类型,拼接在一起
def hexStr2bytes(s):
b = b""
for i in range(0,len(s),2):
temp = s[i:i+2]
b += struct.pack("B", int(temp, 16))
return b

# str1和str2分别是待爆破的Height前面的数据、后面的数据,呈现为16进制字符串
str1 = "49484452000001f4"
str2 = "0806000000"
bytes1 = hexStr2bytes(str1)
bytes2 = hexStr2bytes(str2)

crc32 = "0xcbd6df8a"

# 遍历高度h从0到1000
for h in range(1000):
str = hex(h)[2:].rjust(8, '0') # 将十进制int转化为8位十六进制数,rjust()用于补齐
bytes_temp = hexStr2bytes(str)

if hex(zlib.crc32(bytes1+bytes_temp+bytes2)) == crc32:
print(h)

# 脚本是自己写的,跟网上常见的binascii有所不同

上面的Python 3代码主要应用了zlib.crc32()struct.pack()函数来爆破Height

执行代码的运行结果为: 500,所以对应CRC码的正确高度值应该为 500 像素

500 对应十六进制 1f4,那么在十六进制编辑器中将 png 的高度修改回 1f4

再次打开 2.png,发现将高度修正后,图片查看器将显示出被遮掩的部分,flag出现

补充一篇网上的 png CRC爆破代码,修改过适用于本题:

1
2
3
4
5
6
7
8
9
10
import binascii
import struct

misc = open("2.png","rb").read()

for h in range(1024):
data = misc[12:20] + struct.pack('>i',h)+ misc[24:29]
crc32 = binascii.crc32(data) & 0xffffffff
if crc32 == 0xcbd6df8a:
print h

对比

我原先的代码之所以写得这么长,主要有两个原因:

  • 没熟悉 struct.pack() 的使用方法

    struct库只要用于处理二进制数据,pack() 的API为 pack(fmt, v1, v2)

    第一个参数 fmt 表示 pack() 对数据处理的格式,Bunsigned char的缩写,占1 bytes;而 iinteger的缩写,占4 bytes

    我傻傻地将高度 h 转换成8位十六进制数,两两拆分后,把每个单独的2为十六进制数再转换成int,最后使用 B 参数指定 pack() 生成1个bytes,再把4个的1 bytes拼接起来

    而使用 i 参数就不用这么麻烦,该参数直接指定生成4 bytes

    留意到 pack() 接收的都是十进制数

    此外,pack() 提供 > < 来改变生成字节的顺序,默认为 <,因此需要指定为 >

    详细部分可以参考文章:https://www.cnblogs.com/gala/archive/2011/09/22/2184801.html

  • Python 2不像Python 3一样,对bytesstring有严格区分

    留意到上面代码中,struct.pack() 返回的bytesmisc 的切片string相加,这在Python 3中只会给你一个报错信息:TypeError: can only concatenate str (not "bytes") to str,而这在Python 2中是允许的,节省了不少功夫

  • 0xffffffff

    首先得指出,binascii库zlib库crc32() 函数都是一样的  ←存疑

    在Python 2中,crc32() 计算处理的CRC值域为$[-2^{31}, 2^{31}]$之间的有符号整数,为了与一般的CRC结果作对比,需要将其转换为无符号整数,所以与 0xffffffff 进行&运算来进行转换

    Python 3的计算结果是$[0, 2^{32}-1]$间的无符号整数,无需额外的转换

    因此这个 0xffffffff 的出现是因为Python的版本问题

直接修改

Thanks to Windows对错误的CRC校验码的忽略,我们可以直接将高度修改为较大的值,可以很快解决掉这种类型的CTF题目

相比于CRC爆破,这种方法是最快的


All Chunks

png 文件主要由4大块(Chunk)组成:

  • 文件头数据块IHDR(header chunk)
  • 调色板数据块PLTE(palette chunk)
  • 图像数据块IDAT(data chunk)
  • 图像结束数据块IEND(trailer chunk)

上面4个数据块是 png 最主要的数据块

还有其它算不上重要的数据块,比如:

  • PLTEIDAT之前的:cHRM 基色和白色点数据块、gAMA 图像Y数据块、sBIT 样本有效位数据块
  • 介于PLTEIDAT之间的:bKGD 背景颜色数据块、hIST 图像直方图数据块、tRNS 图像透明数据块
  • 无位置限制的:tIME 图像最后修改时间数据块、tEXt 文本信息数据块 ...

每个数据块都是由LengthChunk TypeChunk DataCRC码构成


IEND Chunk

每个正常的 png 图片文件的IEND Chunk都是固定的:

1
00 00 00 00 49 45 4e 44 ae 42 60 82

按照chunk的结构进行分析:

  • Length4 bytes,因此 00 00 00 00 就是IEND Chunk的长度

    又因为Chunk Data的大小就取决于Length,因此可以看到:IEND ChunkChunk Data是空的,没有分配哪怕1 bytes的空间给它

  • Chunk Type也占4 bytes,而 49 45 4e 44 对应的ASCII字符恰好就是 IEND

  • 最后的4 bytes属于CRC,我们可以计算一下:

    1
    2
    3
    4
    import zlib
    s = b"\x49\x45\x4e\x44"
    print(hex(zlib.crc32(s)))
    # 0xae426082

由于IEND Chunk的长度为0,导致Chunk Data为空;又由于Chunk Type是固定的 IEND,导致计算出来的CRC码也是固定的

综上,每个 png 文件的IEND Chunk都是 00 00 00 00 49 45 4e 44 ae 42 60 82,占12字节,它作为 png 文件的文件尾标识

我们知道,图种的原理就是利用文件尾标识之后的数据不被图片查看器解析成图片,因此 pngjpg 一样可作为图种的载体

习题:格式为flag{xxx}

链接:https://pan.baidu.com/s/1Ze6bct3jA8qJt-zh0sNBKg
提取码:8vsh


IDAT Chunk

介绍

作为 png 图片中存储实际数据的数据块Data Chunk,它在 png 中的存在并不像IHDR Chunk那样是唯一的。相反,它可以有很多个,但所有的IDAT Chunk必须连续存在

总结一下,它有如下特点:

  • 一个 png 文件可能有多个连续IDAT Chunk,而IHDRPLTEIEND都是唯一存在的
  • IDAT Chunk的数据都是经过LZ77派生算法压缩过的,数据不可读;可以用zlib解压缩
  • IDAT Chunk只有上一个块充满,才会开启一个新的块

由于IDAT Chunk是多个存在的,我们就可以将隐写信息以IDAT Chunk的形式加入图片中,将信息隐藏起来

检索隐藏在IDAT Chunk中的信息比较麻烦,因此我们借用工具:pngcheck


pngcheck简介

pngcheck能够检查文件的结构以验证完整性(通过检查内部的32位CRC校验码、并解压缩图像数据),以人类可读的方式转储图像中的块级信息

pngcheck官网显示,pngcheck支持所有的 png 块、jng 块、mng


pngcheck安装

pngcheck貌似在kali上是自带的,在Linux系统上安装只需执行命令:

1
$ sudo apt install pngcheck

安装成功后输入 $ pngcheck,可以看到使用方法:

pngcheck在Windows上也可以安装,方法自寻


pngcheck使用

我们以sctf的一道题来演示这类题目的解题过程

链接:https://pan.baidu.com/s/1D6ORthAZH5TVsoeyBNZyfA
提取码:rrb8

建议自己按常规套路对图片进行一番信息检索

解答

使用pngchecksctf.png 进行检索,注意添加 -v 参数,使其罗列详细数据:

pngcheck首先检查了IHDR Chunk,并将里面的文件信息显示了出来:宽1000像素、高562像素、位深度32,无扫描方法(interlace method)

随后pngcheck依次检查了sRGB Chunk、gAMA Chunk、pHYs Chunk,并对每个检索到的chunk都显示名称偏移量offset、长度length

这里有两点关于pngcheck显示的数据的要点

  1. pngcheck检索每个块的length只是这个块的Chunk Data占用的字节数

    我们可以根据上图计算一下:

    比如IHDRsRGB,两者的偏移量相减:0x00025 - 0x0000c = 0x00019 = 25,也就是说IHDR总共占用25个字节,而pngcheck显示 length 13,很明显它不将IHDR中,Length4字节、Chunk Type4字节、CRC4字节——总共12字节统计在内,满足 13 + 12 = 25

  2. pngcheckoffset指到一个chunk的Chunk Type,而不是Length

    我们直接看IHDR Chunk,抛却 png 最开始的8字节的文件头标识,IHDR Chunk的开始应该是 0x00008(从 0x000000x00007 是文件头标识),而上图却显示为 0x0000c,两者相差的4个字节就是IHDR ChunkLength

然后到了连续的多个IDAT Chunk以及最后的IEND Chunk

最后的IEND ChunkChunk Data是空的,所以显示的 length 为0

但是在最后一个IDAT Chunk处出现了异常——我们知道,IDAT Chunk只有上一个块充满,才会开启一个新的块。按照前面的IDAT Chunk可知,正常的IDAT Chunk length65524时才满

我们发现倒数第二个IDAT Chunklength 只有45027,在它未满的情况下后面出现了 length 为138的新IDAT Chunk,我们可以断定,这个 length 为138的IDAT Chunk是人为添加进去的

关于IDAT Chunk的"满"

png 图片中,不一定是65524 bytes,这取决于图片本身

例如,我们打开一张正常的非隐写 png 图片,可以看到:

这里的32768 bytes,最后一个IDAT Chunk length 为22583,未满

如何判断IDAT Chunk是否已,根据大部分IDAT Chunklength 就可以得知

既然发现了异常数据,那么可以用十六进制编辑器打开 png,定位到偏移量 0x15aff7

图中紫蓝色的十六进制数据部分就是定位到的异常chunk的数据

区域的开始位置为pngcheck检索到的偏移量 0x0015aff7 + 4 = 0x0015affb(要跳过Chunk Type),结束位置为IEND Chunk的偏移量 0x0015b08d - 8 = `0x0015b085(跳过RCR以及IEND ChunkLength

之前有提到,IDAT Chunk中的数据都是经过LZ77派生算法压缩过的,不可直接读,但可以使用zlib解压缩。解压缩的Python 2代码为:

1
2
3
import zlib
IDAT = "789c5d...897667".decode('hex')
result = zlib.decompress(IDAT)

上面代码首先用 decode() 将十六进制数解码为bytes(注意Python 2不区分bytesstring),然后直接调用 zlib.decompress() 解压缩

得到的 result 为:

事实上到这里已经可以结束了,这道题结合的综合知识比较多,剩余部分超出了这篇文章的讲解范畴

以上就是pngcheck的介绍


总结

png 文件头标识(8 bytes) 89 50 4E 47 0D 0A 1A 0A,ASCII码为 .PNG....

依次为4个关键数据块:IHDRPLTEIDATIEND,每个数据块包含Length(4 bytes)Chunk Type(4 bytes)Chunk Data(Length bytes)CRC(4 bytes)

  • IHDR Chunk,Length = 13 bytes,Chunk Data中Width(4 bytes)Height(4 bytes)

    通过计算CRC码查看有没有修改宽高,计算代码:(Python 2,cmd快速使用版)

    1
    2
    3
    4
    5
    6
    str = "直接复制过来的十六进制字符串,形如 '49 28 0d a7  ' ,可以保留两端的空格"

    import zlib
    str = str.strip().split(" ")
    temp = reduce(lambda x,y: x+y, map(lambda x: chr(int(x, 16)), str))
    print hex(zlib.crc32(temp) & 0xffffffff)

    CRC码不匹配,宽高被修改,Windows下直接修正为较大的值;Linux下爆破出正确的宽度或高度

  • IDAT Chunk,执行命令 $ pngcheck -v temp.png,查看有无异常的IDAT Chunk

    IDAT Chunk中异常数据的解压缩代码:

    1
    2
    3
    import zlib
    IDAT = "789c5d...897667".decode('hex')
    result = zlib.decompress(IDAT)
  • IEND Chunk

    1
    00 00 00 00 49 45 4e 44 ae 42 60 82

    文件尾不是上面的数值,可能存在图种


随笔

理清了 png 的文件结构,顺便还拾起了堆在角落的Python知识

总结那里,计算CRC的代码中的那句:

1
temp = reduce(lambda x,y: x+y, map(lambda x: chr(int(x, 16)), str))

事实上是

1
2
3
temp = ""
for i in str:
temp += chr(int(i, 16))

缩写成一行而已

之所以把三行简单的语句弄成这么复杂的一句,是因为这是cmd快速使用版,只要从十六进制编辑器中复制异常数据出来,作为字符串赋值给 str,然后把 str 下面的4行代码复制到cmd中,就可以出结果了

之前写成三行代码的时候,发现 for 语句下面的那个缩进复制过去的时候会被cmd自动删去,得手动添加上一个缩进,这不符合所谓的"快速使用版",因此想办法将这个缩进干掉,借用lambda、map、reduce写成了一行代码

我还有一个 .py 文件是直接调用出结果的,但在cmd中切换目录和找到这篇文章复制代码,这两者之间孰快孰慢,不知道...

新工具:

  • pngcheck

新函数:

  • zlib.crc32() —— 计算CRC码
  • struct.pack() —— 字符串转换成Unicode码
  • zlib.decompress() —— 解压缩

end