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组成。H264NALU的封装有两种方式,一种是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帧

IDR帧的信息,slice\_type写了I slice only

其他

  • 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 # 把关键帧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的无符号指数哥伦布熵编码是0b17的无符号指数哥伦布熵编码是0b0001000,比特流应当是

1
0b 1 0001000 xxxxxxx

找一个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为

1
88 84 00 .....

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
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参数,使其不重新编码,而是只做封装。

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 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修改为损坏的视频即可。

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 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 参考