H264編碼分析及隱寫實踐
目錄
- 1 影片資料層級
- 2 H264裸流
- 3 NALU
- 4 RBSP
- 4.1 指數哥倫布熵編碼
- 5 NALU種類
- 6 實踐
- 6.1 修改IDR幀型別
- 6.2 修改其他幀型別
- 6.3 重新封裝
- 6.4 修復
- 7 總結
- 8 參考
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 參考
H264之NALU解析! - 知乎 前言: 大家晚上好,今天給大家分享一篇技術文章,廢話不多說,咋們直接“起飛”吧,哈哈哈! 一、H264簡介: H.264從1999年開始,到2003年形成草案,最後在2007年定稿有待核實。在ITU的標準⾥稱 為H.264,在MPEG… https://zhuanlan.zhihu.com/p/409527359
21、H264 NALU分析 - 知乎 影片編碼在流媒體和⽹絡領域佔有重要地位;流媒體編解碼流程⼤致如下圖所示: 1、H264簡介 H.264從1999年開始,到2003年形成草案,最後在2007年定稿有待核實。在ITU的標準稱為H.264,在MPEG的標準⾥是MPEG-4的一個… https://zhuanlan.zhihu.com/p/419901787?utm_id=0
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
GitHub - slhck/h26x-extractor: Extracts NAL units from H.264 bitstreams and decodes their type and content Extracts NAL units from H.264 bitstreams and decodes their type and content - slhck/h26x-extractor https://github.com/slhck/h26x-extractor