H264编码分析及隐写实践
目录
CTF竞赛中经常出现图片隐写,视频作为更高量级的信息载体,应当有更大的隐写空间。本文就简单介绍一下H264编码以及一次校赛相关出题经历。
1 视频数据层级
平常我们生活中遇到的大部分是FLV、AVI
等等的视频格式,但实际上这只是一种封装,实际的数据还是要看视频的编码,就比如H264
。像我们平时在视频网站看到的视频就是通过HTTP
协议传输的,直播则是RTMP
协议,协议承载的一般是封装后的视频数据。
下图就很好的展示了视频数据的各个层级。
2 H264裸流
得到最原始的视频数据,需要提取H264
裸流。
简单介绍一下
ffmpeg
的用法,不指定格式的情况下,ffmpeg
会识别给定文件名的后缀自动进行转换,比如ffmpeg input.flv output.mp4
就会自动转换为
mp4
如何提取一个H264编码的视频裸流呢。
使用以下命令。
ffmpeg -vcodec copy -i input.flv output.h264
默认不加参数的情况,ffmpeg
会把视频重新编码,视频数据会发生变化,所以要加上-vcodec copy
,指示ffmpeg
直接复制视频流,而不是重新编码。
这样得到的h264
裸流就是封装格式中的原始数据。
有了H264
裸流,可以使用H264Analyzer的工具查看裸流信息。
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
,所以采用了一些防竞争的策略。
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
还原防竞争字节后的数据
一段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
定义
还是拿之间的数据做例子
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
),那么
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
重新编码一下,不然太清晰裸流的文件大小很大
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
裸流可以用h26xparser库
H26xParser = H26xParser(ORIGIN_H264, verbose=False)
H26xParser.parse()
nalu_list = H26xParser.nalu_pos
nalu_pos
方法 返回的是一个元组列表,前两个表示的是nalu
数据的开始字节和结束字节
然后获取rbsp
数据,用getRSBP
方法,这个方法返回的数据是包含NALU
头部的。
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帧的帧类型
if nal_unit_type == 5:
origin_data[start + 1] = origin_data[start + 1] | 0x4 # 把关键帧slice_type改为11
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
,比特流应当是
0b 1 0001000 xxxxxxx
找一个IDR帧的数据来验证一下
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 84 00 .....
88
恰好对应0b10001000
所以我直接对这个字节byte | 0x4
,让这个字节变成0b10001100
,于是slice_type
就变成了11
。这里主要是为了好处理数据,所以直接用二进制运算,实际上slice_type
想改多少都可以。
修改后IDR的信息如下
6.2 修改其他帧类型
关于其他帧类型的修改,题目是将所有帧类型都改为B
帧,然后记录下原来的帧类型,存放在每个IDR帧之后的SEI帧里,供后续修复。
if nal_unit_type == 1: #修改slice
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 # 非关键帧全部变为B帧
效果如下
SEI内容
6.3 重新封装
由于ffmpeg
的转换会重新编码,所以还是一样要加上-vcodec copy
参数,使其不重新编码,而是只做封装。
os.system(f"ffmpeg -i {OUTPUT_H264} -vcodec copy {OUTPUT_MP4}")
最后的视频成了这样
放出完整的出题脚本,只需要修改FLAG_VIDEO
就可以生成。
import os
from h26x_extractor.h26x_parser import H26xParser
from uuid import uuid4
SLICE_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)
#print(nalu_body[:3])
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
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 # 非关键帧全部变为B帧
elif nal_unit_type == 5:
origin_data[start + 1] = origin_data[start + 1] | 0x4 # 把关键帧slice_type改为11
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
修改为损坏的视频即可。
import os
from h26x_extractor.h26x_parser import H26xParser
from uuid import uuid4
SLICE_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)
#print(nalu_body[:3])
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: #修改slice
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 参考
https://github.com/yistLin/H264-Encoder/blob/master/doc/ITU-T%20H.264.pdf
https://www.itu.int/rec/T-REC-H.264-202108-I/en
什么是 NALU-ZigZagSin 解释什么是 NALU https://www.zzsin.com/article/avc_0_5_what_is_nalu.html
标签:编码,00,slice,H264,data,隐写,type From: https://www.cnblogs.com/Joooook/p/18165395