H264编码分析及隐写实践 目录
CTF竞赛中经常出现图片隐写,视频作为更高量级的信息载体,应当有更大的隐写空间。本文就简单介绍一下H264编码以及一次校赛相关出题经历。
1 视频数据层级 平常我们生活中遇到的大部分是FLV、AVI
等等的视频格式,但实际上这只是一种封装,实际的数据还是要看视频的编码,就比如H264
。像我们平时在视频网站看到的视频就是通过HTTP
协议传输的,直播则是RTMP
协议,协议承载的一般是封装后的视频数据。
下图就很好的展示了视频数据的各个层级。
2 H264裸流 得到最原始的视频数据,需要提取H264
裸流。
简单介绍一下ffmpeg
的用法,不指定格式的情况下,ffmpeg
会识别给定文件名的后缀自动进行转换,比如
1 ffmpeg input.flv output.mp4
就会自动转换为mp4
如何提取一个H264编码的视频裸流呢。
使用以下命令。
1 ffmpeg -vcodec copy -i input.flv output.h264
默认不加参数的情况,ffmpeg
会把视频重新编码,视频数据会发生变化,所以要加上-vcodec copy
,指示ffmpeg
直接复制视频流,而不是重新编码。
这样得到的h264
裸流就是封装格式中的原始数据。
有了H264
裸流,可以使用
的工具查看裸流信息。
3 NALU H.264
裸流是由⼀个接⼀个NALU
组成。H264
对NALU
的封装有两种方式,一种是AnnexB
,一种是 avcC
。
这里仅介绍AnnexB
,对avcC
感兴趣的可以看
。
AnnexB
的封装很简单,以00 00 00 01
或者00 00 01
开头作为一个新NALU
的标志,为了防止竞争,即 NALU
数据中出现00 00 00 01
导致解码器误认为是一个新的NALU
,所以采用了一些防竞争的策略。
1 2 3 4 00 00 00 => 00 00 03 00 00 00 01 => 00 00 03 01 00 00 02 => 00 00 03 02 00 00 03 => 00 00 03 03
看一眼下面的图就很清楚了。
那么也就是说当我们把数据从H264
裸流中提取出来之后,还需要对防竞争字节进行还原。
这里的话对这些类型的数据有些定义,详细可以去看
。
NALU
:去除00 00 00 01
标志符的数据
EBSP
:去除NALU header
(通常是第一个字节)但未还原防竞争字节的数据
RBSP
:将EBSP
还原防竞争字节后的数据
1 2 3 4 5 6 7 8 一段AnnexB封装的NALU: 00 00 00 01 67 64 00 1F AC D9 40 50 05 BB 01 6A 02 02 02 80 00 00 03 00 80 00 00 1E 07 8C 18 CB NALU: 67 64 00 1F AC D9 40 50 05 BB 01 6A 02 02 02 80 00 00 03 00 80 00 00 1E 07 8C 18 CB EBSP: 64 00 1F AC D9 40 50 05 BB 01 6A 02 02 02 80 00 00 03 00 80 00 00 1E 07 8C 18 CB RBSP: 64 00 1F AC D9 40 50 05 BB 01 6A 02 02 02 80 00 00 00 80 00 00 1E 07 8C 18 CB
4 RBSP 现在有了NALU
数据,我们就可以对着
上的内容来一步步解码了。
直接看到手册7.3节,这里是表格式的语法,右边的Descriptor
描述了数据的格式及占用的bit数,比如第一个f(1)
表示1bit fixed-pattern bit string
。
可以在7.2节找到所有的Descriptor
定义
还是拿之间的数据做例子
1 2 67 64 00 1F AC D9 40 50 05 BB 01 6A 02 02 02 80 00 00 00 80 00 00 1E 07 8C 18 CB
第一个字节为0b01100111
(这部分称为NALU header
),那么
1 2 3 4 forbidden_zero_bit= (byte >> 7) & 0x1 = 0 nal_ref_idc = (byte >> 5) & 0x3 = 3 nal_unit_type = byte & 0x1F = 7
有了nal_unit_type
,可以在7.4节的Table 7-1找到对应的类型和对RBSP
数据的解析。
4.1 指数哥伦布熵编码 在Descriptor中有以下几种特殊的编码
无符号指数哥伦布熵编码 ue(v)
有符号指数哥伦布熵编码 se(v)
映射指数哥伦布熵编码 me(v)
截断指数哥伦布熵编码 te(v)
这部分建议跟着
来自己复现一下。
5 NALU种类 NALU种类有很多,简单介绍几个重要的
SPS(Sequence Paramater Set):序列参数集, 包括一个图像序列的所有信息,如图像尺寸、视频格式等。
PPS(Picture Paramater Set):图像参数集,包括一个图像的所有分片的所有相关信息,包括图像类型、序列号等。
在传输视频流之前,必须要传输这两类参数,不然无法解码 。为了保证容错性,每一个 I 帧前面,都会传一遍这两个参数集合。
一个流由多个帧序列组成,一个序列由以下三种帧组成。
I帧(Intra-coded picture帧内编码图像帧):不参考其他图像帧,只利⽤本帧的信息进⾏编码。
P帧(Predictive-codedPicture预测编码图像帧):利⽤之前的I帧或P帧,采⽤运动预测的⽅式进⾏帧间预测编码。
B帧(Bidirectionallypredicted picture双向预测编码图像帧):提供最⾼的压缩⽐,它既需要之前的图像帧(I帧或P帧),也需要后来的图像帧(P帧),采⽤运动预测的⽅式进⾏帧间双向预测编码。
这些个帧组成一个序列,每个序列的第一个帧是IDR帧
IDR(Instantaneous Decoding Refresh,即时解码刷新):⼀个序列的第⼀个图像叫做 IDR 图像(⽴即刷新图像),IDR 图像都是 I 帧图像。
IDR帧必须是I帧,但是I帧可以不是IDR帧 。
其他
SEI(Supplemental Enhancement Information辅助增强信息):SEI是H264标准中一个重要的技术,主要起补充和增强的作用。 SEI没有图像数据信息,只是对图像数据信息或者视频流的补充,有些内容可能对解码有帮助.
6 实践 在BUAACTF2024
中出了一道H264
编码的视频题,思路如下。
首先有一个正常的带flag
的视频
希望把视频损坏,但是是可修复的损坏。
首先用ffmpeg
重新编码一下,不然太清晰裸流的文件大小很大
1 os.system('ffmpeg -i flag.mp4 -c:v libx264 -crf 18 -preset medium -c:a aac -b:a 128k encode-origin.h264 ' )
并生成一个H264
裸流文件,接下来就是对H264
裸流进行操作。
python中操作H264
裸流可以用
库
1 2 3 4 H26xParser = H26xParser(ORIGIN_H264, verbose=False ) H26xParser.parse() nalu_list = H26xParser.nalu_pos
nalu_pos
方法 返回的是一个元组列表,前两个表示的是nalu
数据的开始字节和结束字节
然后获取rbsp
数据,用getRSBP
方法,这个方法返回的数据是包含NALU
头部的。
1 2 3 4 5 6 for tu in nalu_list: start, end, _, _, _, _ = tu rbsp = bytes (H26xParser.getRSBP(start, end)) nalu_header = rbsp[0 ] nal_unit_type = nalu_header & 0x1F nalu_body = rbsp[1 :]
6.1 修改IDR帧类型 前面提到,IDR帧的类型必须是I帧 ,所以可以将他的类型进行改变。改变IDR帧的帧类型
1 2 if nal_unit_type == 5 : origin_data[start + 1 ] = origin_data[start + 1 ] | 0x4
nal_unit_type == 5
意味着这是一个IDR帧,然后看IDR的解析语法
找到slice_layer_without_partitioning_rbsp()
找到slice_header()
ue(v)
就是我们前面提到的无符号指数哥伦布编码 。
来看看如何使用无符号指数哥伦布进行编码:
先把要编码的数字加 1,假设我们要编码的数字是 4,那么我们先对 4 加 1,就是 5。
将加 1 后的数字 5 先转换成二进制,就是: 101。
转化成二进制之后,我们看转化成的二进制有多少位,然后在前面补位数减一个 0 。例如,101 有 3 位,那么我们应该在前面补两个 0。
最后,4 进行无符号指数哥伦布编码之后得到的二进制码流就是 0 0 1 0 1。
而前面的first_mb_in_slice
表示该slice
的第一个宏块在图像中的位置,涉及到一些更深入的知识,但是这里不用关心,因为我们的情况中first_mb_in_slice
始终为0。
slice_type
就是我们的帧类型,同样在7.4节给出了不同类型对应的值。
观察我们正常的h264裸流,这个slice_type
的值都是被设置为7。
所以从RBSP
的第一个字节开始,0
的无符号指数哥伦布熵编码是0b1
,7
的无符号指数哥伦布熵编码是0b0001000
,比特流应当是
找一个IDR帧的数据来验证一下
1 00 00 01 65 88 84 00 6F F9 C3 AB 0F 3B E0 BC 1E 03 54 39 CD 48 64 95 22 F4 6E AA 45 2F E6 8A 4F A2 1D 61 88 5C B2 0F 61 41 11 81 69 27 E5 93 DE D3 15 0D A2 97 F7 9A 41 E7 DF D5 B0 BD 50 57 D9 30 65 42 D9
RBSP为
88
恰好对应0b10001000
所以我直接对这个字节byte | 0x4
,让这个字节变成0b10001100
,于是slice_type
就变成了11
。这里主要是为了好处理数据,所以直接用二进制运算,实际上slice_type
想改多少都可以。
修改后IDR的信息如下
6.2 修改其他帧类型 关于其他帧类型的修改,题目是将所有帧类型都改为B
帧,然后记录下原来的帧类型,存放在每个IDR帧之后的SEI帧里,供后续修复。
1 2 3 4 5 if nal_unit_type == 1 : slice_type = extract_slice_type(nalu_body) origin_slice_type_list.append(SLICE_TYPES[slice_type % 5 ]) print (SLICE_TYPES[slice_type % 5 ], end=' ' ) origin_data[start + 1 ] = origin_data[start + 1 ] | 0x4
效果如下
SEI内容
6.3 重新封装 由于ffmpeg
的转换会重新编码,所以还是一样要加上-vcodec copy
参数,使其不重新编码,而是只做封装。
1 2 os.system(f"ffmpeg -i {OUTPUT_H264} -vcodec copy {OUTPUT_MP4} " )
最后的视频成了这样
放出完整的出题脚本,只需要修改FLAG_VIDEO
就可以生成。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 import osfrom h26x_extractor.h26x_parser import H26xParserfrom uuid import uuid4SLICE_TYPES = {0 : 'P' , 1 : 'B' , 2 : 'I' , 3 : 'SP' , 4 : 'SI' } FLAG_VIDEO = 'flag.mp4' ORIGIN_H264='encode-origin.h264' OUTPUT_H264='encode-new.h264' OUTPUT_MP4='encode-output.mp4' OUTPUT_FLV='encode-output.flv' class BitStream : def __init__ (self, buf ): self .buffer = buf self .bit_pos = 0 self .byte_pos = 0 def ue (self ): count = 0 while self .u(1 ) == 0 : count += 1 res = ((1 << count) | self .u(count)) - 1 return res def u1 (self ): self .bit_pos += 1 res = self .buffer[self .byte_pos] >> (8 - self .bit_pos) & 0x01 if self .bit_pos == 8 : self .byte_pos += 1 self .bit_pos = 0 return res def u (self, n: int ): res = 0 for i in range (n): res <<= 1 res |= self .u1() return res def extract_slice_type (nalu_body ): body = BitStream(nalu_body) first_mb_in_slice = body.ue() slice_type = body.ue() return slice_type def generate_sequence_data (origin_slice_type_list: list ): sei_data = b'\x00\x00\x01\x06\x05' sei_payload_len = len (origin_slice_type_list) + 16 uuid = uuid4().bytes while sei_payload_len > 255 : sei_payload_len -= 255 sei_data += b'\xFF' sei_payload = uuid + '' .join(origin_slice_type_list).encode() sei_data += int .to_bytes(sei_payload_len, 1 , 'big' ) sei_data += sei_payload sei_data += b'\x80' return sei_data if __name__ == '__main__' : os.system(f'ffmpeg -i {FLAG_VIDEO} -c:v libx264 -crf 18 -preset medium -c:a aac -b:a 128k {ORIGIN_H264} ' ) f = open (ORIGIN_H264, 'rb' ) origin_data = list (f.read()) f.close() H26xParser = H26xParser(ORIGIN_H264, verbose=False ) H26xParser.parse() nalu_list = H26xParser.nalu_pos print (nalu_list) data = H26xParser.byte_stream origin_slice_type_list = [] sei_data_list = [] for tu in nalu_list: start, end, _, _, _, _ = tu rbsp = bytes (H26xParser.getRSBP(start, end)) nalu_header = rbsp[0 ] nal_unit_type = nalu_header & 0x1F nalu_body = rbsp[1 :] if nal_unit_type == 1 : slice_type = extract_slice_type(nalu_body) origin_slice_type_list.append(SLICE_TYPES[slice_type % 5 ]) print (SLICE_TYPES[slice_type % 5 ], end=' ' ) origin_data[start + 1 ] = origin_data[start + 1 ] | 0x4 elif nal_unit_type == 5 : origin_data[start + 1 ] = origin_data[start + 1 ] | 0x4 elif nal_unit_type == 7 and origin_slice_type_list: sei_data_list.append(generate_sequence_data(origin_slice_type_list)) origin_slice_type_list = [] sei_data_list.append(generate_sequence_data(origin_slice_type_list)) origin_slice_type_list = [] new_data = b'' start_pos = 0 count = 0 for start, end, _, _, _, _ in nalu_list: rbsp = bytes (H26xParser.getRSBP(start, end)) nalu_header = rbsp[0 ] nal_unit_type = nalu_header & 0x1F if nal_unit_type == 5 : new_data += bytes (origin_data[start_pos:end]) + sei_data_list[count] count += 1 start_pos = end new_data += bytes (origin_data[start_pos:]) f = open (OUTPUT_H264, 'wb' ) f.write(bytes (new_data)) f.close() os.system(f"ffmpeg -i {OUTPUT_H264} -vcodec copy {OUTPUT_MP4} " ) os.system(f"ffmpeg -i {OUTPUT_MP4} -vcodec copy {OUTPUT_FLV} " )
6.4 修复 理解了出题思路,解题就比较简单。将EXTRACT_VIDEO
修改为损坏的视频即可。
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 import osfrom h26x_extractor.h26x_parser import H26xParserfrom uuid import uuid4SLICE_TYPES = {0 : 'P' , 1 : 'B' , 2 : 'I' , 3 : 'SP' , 4 : 'SI' } EXTRACT_VIDEO = 'final/extract.flv' ORIGIN_H264 = 'decode-extract.h264' OUTPUT_H264 = 'decode-origin.h264' OUTPUT_MP4 = 'decode-origin.mp4' class BitStream : def __init__ (self, buf ): self .buffer = buf self .bit_pos = 0 self .byte_pos = 0 def ue (self ): count = 0 while self .u(1 ) == 0 : count += 1 res = ((1 << count) | self .u(count)) - 1 return res def u1 (self ): self .bit_pos += 1 res = self .buffer[self .byte_pos] >> (8 - self .bit_pos) & 0x01 if self .bit_pos == 8 : self .byte_pos += 1 self .bit_pos = 0 return res def u (self, n: int ): res = 0 for i in range (n): res <<= 1 res |= self .u1() return res def extract_slice_type (nalu_body ): body = BitStream(nalu_body) first_mb_in_slice = body.ue() slice_type = body.ue() return slice_type def read_sei (nalu_body ): payload_type = nalu_body[0 ] payload_size = 0 i = 1 while nalu_body[i] == 0xff : payload_size+=255 i+=1 payload_size += nalu_body[i] return [chr (i) for i in nalu_body[i+1 +16 :i+1 +payload_size]] if __name__ == '__main__' : os.system(f'ffmpeg -i {EXTRACT_VIDEO} -vcodec copy {ORIGIN_H264} ' ) f = open (ORIGIN_H264, 'rb' ) origin_data = list (f.read()) f.close() H26xParser = H26xParser(ORIGIN_H264, verbose=False ) H26xParser.parse() nalu_list = H26xParser.nalu_pos data = H26xParser.byte_stream origin_slice_type_list = [] prev_unit_type = 0 count=0 for tu in nalu_list: start, end, _, _, _, _ = tu rbsp = bytes (H26xParser.getRSBP(start, end)) nalu_header = rbsp[0 ] nal_unit_type = nalu_header & 0x1F nalu_body = rbsp[1 :] if nal_unit_type == 1 : if origin_slice_type_list[count]=='P' : origin_data[start + 1 ] = origin_data[start + 1 ] ^ 0x4 elif nal_unit_type == 5 : origin_data[start + 1 ] = origin_data[start + 1 ] ^ 0x4 elif nal_unit_type == 6 and prev_unit_type == 5 : count=0 print (read_sei(nalu_body)) origin_slice_type_list = read_sei(nalu_body) prev_unit_type = nal_unit_type new_data = bytes (origin_data) f = open (OUTPUT_H264, 'wb' ) f.write(bytes (new_data)) f.close() os.system(f"ffmpeg -i {OUTPUT_H264} -vcodec copy {OUTPUT_MP4} " )
7 总结 关于视频编码的隐写还有很多待发掘的地方,本文仅抛砖引玉,比如YUV像素信息就可以尝试LSB隐写。希望对你有些启发。
8 参考