ts帧加密案例(二)
本帖最后由 我是不会改名的 于 2024-1-19 22:42 编辑# ts帧加密案例(二)
## 一、前言
原本还有两篇案例的,基本都写完了,但考虑快过年了,加上最近...,所这篇主要综合两篇案例,讲讲怎么去生成一个加密的视频,并在线播放(web)。
## 二、前文回顾
### 2.1.bug修复
上一篇文章中第一次调用导出函数解密失败了,主要是这里长度忘记修改了,导致环境被检测了,修改一下就行了。当然都用c了,直接调用非导出函数也可以的。
### 2.2.nal Payload
在上一篇案例中,解密函数(f60 jeb)中有这样一段代码
含义很简单,就是判断是否存在000003,然后替换为0000。之所以这样的原因是,之前的文章中已经说明了,提取出来的帧,是由很多不同的nalu组成,而分割它们的是000001,那么如果nal原始的负载中出现000001,那么就会导致出错,所以引入了EBSP(Encapsulated Byte Sequence Payload)
- 0x000000-->0x00000300
- 0x000001-->0x00000301
- 0x000002-->0x00000302
- 0x000003-->0x00000303
所以需要注意这里和标准的代码并不完全一样,下面是直播部分(ida),这里和标准文档就一样了
## 三、基本环境配置
### 3.1.hls下载
**使用前温馨提示**
1. **调试web的时候刷新的时候请按住 Ctrl+F5 键,不要只按F5**
2. **有时候代码没问题,但就是播放失败,甚至封面都解析出来了,拖中间就不行,需要在设置中清除缓存,然后重启浏览器**
浏览器地址输入 edge://settings/clearBrowserData或者 chrome://settings/clearBrowserData
只需要清除缓存的图像和文件就行了
下载地址 :https://github.com/video-dev/hls.js
```
git clone https://github.com/video-dev/hls.js.git
cd hls.js
# After cloning or pulling from the repository, make sure all dependencies are up-to-date
npm install ci
# Run dev-server for demo page (recompiles on file-watch, but doesn't write to actual dist fs artifacts)
npm run dev
# After making changes run the sanity-check task to verify all checks before committing changes
npm run sanity-check
```
克隆下来以后先不要运行,改一下cdn,以及log替换为静态的,不然很卡,或者挂梯子。
同时修改一下这里,把worker关闭,方便调试一点
然后运行,播放最后一个视频,或者直接[点击](http://127.0.0.1:8080/demo/?src=https%3A%2F%2Fhlsjs-test-streams-wistia.s3.amazonaws.com%2Fstart-delimiter.m3u8&demoConfig=eyJlbmFibGVTdHJlYW1pbmciOnRydWUsImF1dG9SZWNvdmVyRXJyb3IiOnRydWUsInN0b3BPblN0YWxsIjpmYWxzZSwiZHVtcGZNUDQiOmZhbHNlLCJsZXZlbENhcHBpbmciOi0xLCJsaW1pdE1ldHJpY3MiOi0xfQ==),能正常播放并且配置文件没问题就行
### 3.2.bento4下载
下载网站:https://www.bento4.com/downloads/
下载源码因为需要简单分析修改: (https://www.bok.net/Bento4/source/Bento4-SRC-1-6-0-641.zip)
下载完成后,不出意外的话人家配置好了的,可以打开项目,修改一下工作目录,改成之前下载的hls项目中
然后简单修改一下开头代码,不用解析命令行部分,方便调试,手动或者自己用程序创建存放ts,key,m3u8位置,然后直接运行
```
Options.verbose = false;
Options.hls_version = 5;
Options.pmt_pid = 0x100;
Options.audio_pid = 0x101;
Options.video_pid = 0x102;
Options.audio_track_id = -1;
Options.video_track_id = -1;
Options.audio_format = AUDIO_FORMAT_TS;
Options.output_single_file = false;
Options.show_info = false;
Options.segment_duration = 10;
Options.segment_duration_threshold = DefaultSegmentDurationThreshold;
Options.pcr_offset = AP4_MPEG2_TS_DEFAULT_PCR_OFFSET;
AP4_SetMemory(Options.encryption_key, 0, sizeof(Options.encryption_key));
AP4_SetMemory(Options.encryption_iv,0, sizeof(Options.encryption_iv));
AP4_SetMemory(&Stats, 0, sizeof(Stats));
AP4_Result result;
Options.allow_cache="YES";
Options.encryption_key_hex="000102030405060708090a0b0c0d0e0f";
AP4_ParseHex(Options.encryption_key_hex, Options.encryption_key, 16);
Options.encryption_iv_mode = ENCRYPTION_IV_MODE_RANDOM;
AP4_System_GenerateRandomBytes(Options.encryption_iv, 16);
Options.index_filename="test.m3u8";
Options.encryption_key_uri = "./key/test.key";
Options.segment_filename_template="./ts/test-%d.ts";
Options.segment_url_template="./ts/test-%d.ts";
Options.encryption_mode=ENCRYPTION_MODE_SAMPLE_AES;
Options.input="10编译FFmpeg(WebAssembly版)库.mp4";
Options.hls_version = 3;
FILE* key_file = fopen(Options.encryption_key_uri, "wb");
if (key_file == NULL) {
fprintf(stderr, "ERROR: cannot open key file for writing\n");
return 1;
}
unsigned char key;
AP4_ParseHex(Options.encryption_key_hex, key, 16);
fwrite(key, 1, 16, key_file);
fclose(key_file);
AP4_ByteStream* input = NULL;
result = AP4_FileByteStream::Create(Options.input, AP4_FileByteStream::STREAM_MODE_READ, input);
if (AP4_FAILED(result)) {
fprintf(stderr, "ERROR: cannot open input (%d)\n", result);
return 1;
}
// open the file
AP4_File* input_file = new AP4_File(*input, true);
```
然后用hls去[播放视频](http://127.0.0.1:8080/demo/?src=http%3A%2F%2F127.0.0.1%3A8080%2Fdemo%2Ftest.m3u8),能正常播放说明没有问题
## 四、源码简要分析
### 4.1.Mp42Hls加密流程
这里主要分析和加解密相关的,解复用之类的就不过多介绍。
1. 首先是这里,设置了key以及随机的iv,然后设置了加密方式,目录支持不加密,整体aes加密以及SAMPLE_AES
```
Options.encryption_key_hex="000102030405060708090a0b0c0d0e0f";
AP4_ParseHex(Options.encryption_key_hex, Options.encryption_key, 16);
Options.encryption_iv_mode = ENCRYPTION_IV_MODE_RANDOM;
AP4_System_GenerateRandomBytes(Options.encryption_iv, 16);
Options.encryption_mode=ENCRYPTION_MODE_SAMPLE_AES;
```
(https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/HLS_Sample_Encryption/Encryption/Encryption.html#//apple_ref/doc/uid/TP40012862-CH2-SW8),不仅加密视频帧,也加密了音频帧,所以之前生成ts文件,你直接播放没有声音也没有画面,后续我们也主要修改这来实现自己的帧加密。
2. 然后就是解复用那一套,获取对应的码流,看不懂不重要,直接看怎么写入文件
3. 根据不同加密方式设置加密函数,本质上就是一个aes-cbc
4. 选择不同轨道来分别加密,主要看看视频加密部分
5. nal加密部分,只加密了nal=1或者5的部分数据,至于为啥不知道苹果这样规定的
6. 在后面就是写入文件了,先判断是h264还是h265,然后写入了sps/pps
7. 写入了加密后的nalu
8. 组装pes
```
WritePES(pes_data.GetData(), pes_data.GetDataSize(), dts, true, pts, with_pcr, output)
```
9. 生成pes头
```
unsigned int pes_header_size = 14+(with_dts?5:0);
AP4_BitWriter pes_header(pes_header_size);
.....................................................
pes_header.Write(1, 1); // market_bit
```
10. 切分nal按188字节组装ts,然后就写入文件
11. 一直循环上面操作,完成后就是生成m3u8文件,后面不重要了不分析了。
### 4.2.hls解密流程
1. 请求加载文件,一次加载m3u8文件,key文件,ts文件
其中key加载部分,loadKeyHTTP函数,请求成功后回调函数中把,key赋值给了keyInfo.decryptdata.key = frag.decryptdata.key,
如果key有加密,大部分都在这里解密了,好处是只需要解密一次key,不需要额外修改函数逻辑,坏处就是一眼就看出来了
loadKeyHTTP = function loadKeyHTTP(keyInfo, frag) {
..................
return keyInfo.keyLoadPromise = new Promise(function (resolve, reject) {
var loaderCallbacks = {
onSuccess: function onSuccess(response, stats, context, networkDetails) {
......
keyInfo.decryptdata.key = frag.decryptdata.key = new Uint8Array(response.data);
frag.keyLoader = null;
keyInfo.loader = null;
resolve({
frag: frag,
keyInfo: keyInfo
});
}
.....
2. 然后是ts回调函数,完全看不懂,唯一看起来眼熟的frag,key就存在这里面,然后就进入了transmuxer
function _handleFragmentLoadProgress(data) {
var _frag$initSegment;
var frag = data.frag,
.........
transmuxer.push(payload, initSegmentData, audioCodec, videoCodec, frag, part, details.totalduration, accurateTimeOffset, chunkMeta, initPTS);
};
3. 后面又是一大堆看不懂的,把key赋值给了decryptdata,然后判断是否开启了worker,就开始解复用了
```
unction push(data, initSegmentData, audioCodec, videoCodec, frag, part, duration, accurateTimeOffset, chunkMeta, defaultInitPTS) {
................
var decryptdata = frag.decryptdata;
this.frag = frag;
this.part = part;
if (this.workerContext) {
// post fragment payload as transferable objects for ArrayBuffer (no copy)
this.workerContext.worker.postMessage({
cmd: 'demux',
data: data,
decryptdata: decryptdata,
chunkMeta: chunkMeta,
state: state
}, data instanceof ArrayBuffer ? : []);
} else if (transmuxer) {
var _transmuxResult = transmuxer.push(data, decryptdata, chunkMeta, state);
if (isPromise(_transmuxResult)) {
transmuxer.async = true;
_transmuxResult.then(function (data) {
_this2.handleTransmuxComplete(data);
}).catch(function (error) {
_this2.transmuxerError(error, chunkMeta, 'transmuxer-interface push error');
});
} else {
transmuxer.async = false;
this.handleTransmuxComplete(_transmuxResult);
}
}
```
4. 解复用之前,首先判断是否是ts整体加密
```
var keyData = getEncryptionType(uintData, decryptdata);
if (keyData && keyData.method === 'AES-128') {
var decrypter = this.getDecrypter();
// Software decryption is synchronous; webCrypto is not
if (decrypter.isSync()) {
var decryptedData = decrypter.softwareDecrypt(uintData, keyData.key.buffer, keyData.iv.buffer);
uintData = new Uint8Array(decryptedData);
```
5. 然后判断是否是SAMPLE,进入不同解析流程,解复完成后再进行解密内容
```
function transmux(data, keyData, timeOffset, accurateTimeOffset, chunkMeta) {
var result;
if (keyData && keyData.method === 'SAMPLE-AES') {
result = this.transmuxSampleAes(data, keyData, timeOffset, accurateTimeOffset, chunkMeta);
} else {
result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta);
}
return result;
};
function demuxSampleAes(data, keyData, timeOffset) {
var demuxResult = this.demux(data, timeOffset, true, !this.config.progressive);
var sampleAes = this.sampleAes = new SampleAesDecrypter(this.observer, this.config, keyData);
return this.decrypt(demuxResult, sampleAes);
};
```
6. 解复用基本流程就不说了,主要看看这两个函数
if (videoData && (pes = this.parsePES(videoData))) {
this.videoParser.parseAVCPES(videoTrack, textTrack, pes, false, this._duration);
}
7. 首先是parsePES,这里传入的videoData包含了pes头,返回的数据才是纯nal内容
```
return {
data: pesData,
pts: pesPts,
dts: pesDts,
len: pesLen
};
```
8. parseAVCPES中,先拆分了nal,`var units = this.parseAVCNALu(track, pes.data);`然后根据不同nalu类型进行处理,主要关注一下等于7的时候,也就是sps,这里设置了分辨率,时长,以及解码器。
if (!track.sps || track.width !== config.width || track.height !== config.height || ((_track$pixelRatio = track.pixelRatio) == null ? void 0 : _track$pixelRatio) !== config.pixelRatio || ((_track$pixelRatio2 = track.pixelRatio) == null ? void 0 : _track$pixelRatio2) !== config.pixelRatio) {
track.width = config.width;
track.height = config.height;
track.pixelRatio = config.pixelRatio;
track.sps = ;
track.duration = duration;
var codecarray = sps.subarray(1, 4);
var codecstring = 'avc1.';
for (var _i = 0; _i < 3; _i++) {
var h = codecarray.toString(16);
if (h.length < 2) {
h = '0' + h;
}
codecstring += h;
}
track.codec = codecstring;
}
break;
9. 后面就是解密函数,基本就和c一样了,唯一区别就是,js直接把加密的数据单独拿出来`getAvcEncryptedData`,解密后重新写入`getAvcEncryptedData`
10. 后面的就和普通视频一样了,就不看了,看了也看不懂
## 五、实现帧加密
### 5.1 pes整体加密
前面已经说过了,加密时在WritePES这里传入了不含头的pes,解密时parsePES返回了不含头的pes,所以直接修改对应代码。
```
Options.encryption_mode=ENCRYPTION_MODE_NONE;//修改为不加密
func WritePES
unsigned int pes_header_size = 14+(with_dts?5:0)+1;//增加一字节,存放是否加密
.......
pes_header.Write(0x10, 8); // is_enc
bool first_packet = true;
```
简单写个异或,需要注意加密可能存在000001,所以需要填充03,不填充问题应该也不大,就怕运气好,所以还是填一下
```
unsigned int nalUnescape(uint8_t* nal, unsigned int nalSize, uint8_t* buffer)
{
unsigned int i = 0;
unsigned int j = 0;
while (i < nalSize) {
if (i < nalSize - 2 && nal == 0x00 && nal == 0x00 && nal <= 0x03) {
buffer = nal;
buffer = nal;
buffer = 0x03;
} else {
buffer = nal;
}
}
return j;
}
uint8_t * escapedBuffer = (uint8_t *)calloc(1, data_size * 2);
char xor_key={53,50,112,111,106,105,101,119,115,98,104,103,109,100,104,104};
for (unsigned int i=0; i<data_size; i++) {
escapedBuffer = data ^ xor_key;
}
unsigned int escapedSize = nalUnescape(escapedBuffer, data_size,escapedBuffer);
data_size = escapedSize;
data = escapedBuffer;
if (with_dts && (dts == pts)) {
with_dts = false;
}
```
可以看出来生成以后的文件,可以识别到pes头,但是负载全加密了
然后修改js中的parsePES函数
播放视频,可以看到和预期结果一样,视频也能正常播放。
### 5.2nal内容加密
上面已经提到了内置了一种帧加密模式所以直接在这基础上修改
首先把音频加密部分注释掉,不太熟悉音频
前面已经提到过了,加密nal时,只加密了1和5,那么简单看下为啥加密1和5,修改代码,分别只加密1或5
只加密1
只加密5
可以看出来,只加密5时,画面基本是完整的,但是并不连续,而只加密1时,画面基本不完整。具体涉及到了解码部分,主要是和i,p,b帧有关的,感兴趣可以去了解一下。这里只是展示一下有什么区别,方便快速定位是哪一帧出问题了。type=5就可以当做是i帧(idr),type=1就可以看做p或b帧。简单地讲,I 帧是一个完整的画面,而 P 帧和 B 帧记录的是相对于 I 帧的变化。
那么除了1和5,上面还提到了7,sps里面包含了分辨率以及解码器信息,尝试只加密一下7。这里需要修改一下加密长度,不然加密不了
```
AP4_UI08 nalu_type = nalu & 0x1F;
if (nalu_length > 16 && (nalu_type==7)) {
AP4_Size encrypted_size = 16*((nalu_length-1)/16);
if ((nalu_length%16) == 0) {
encrypted_size -= 16;
}
```
然后就离谱了,居然还能正常播放。
然后用[软件](https://www.elecard.com/products/video-analysis/stream-analyzer)去分析,发现多了一个sps和pps,第一个能正常解析出分辨率,第二个不行
回想起写入文件的时候先写入了sps/pps,在写入了加密后的nal,说明这里写入的sps是加密前的,或者是直接生成的,那么最简单方法直接注释掉。
这次视频分辨率就识别不出来了,画面也没有
然后尝试用hls去播放
首先修改一下src/demux/sample-aes.ts->decryptAvcSamples函数
```
if (
curUnit.data.length <= 16 ||
(curUnit.type !== 5||curUnit.type !== 1||curUnit.type !== 7)
) {
continue;
}
```
然后修改一下getAvcDecryptedUnit以及getAvcEncryptedData
然后,不出意外的出意外了,主要提示解码器不支持
之前的分析中,它是先解复用,在解密,而在解复用过程中就已经设置了解码器,所以在这里修改代码是不行的。
那就先修改c
```
if (nalu_length > 16 && (nalu_type ==5 ||nalu_type==7||nalu_type==1)) {
AP4_Size encrypted_size = 16*((nalu_length-1)/16);
if ((nalu_length%16) == 0) {
encrypted_size -= 16;
}
if (nalu_type==5){
m_StreamCipher->SetIV(m_IV);
for (unsigned int i=0; i<encrypted_size; i += 10*16) {
AP4_Size one_block_size = 16;
m_StreamCipher->ProcessBuffer(nalu+nalu_length_size+1+i, one_block_size,
nalu+nalu_length_size+1+i, &one_block_size);
}
}else if (nalu_type==7){
encrypted_size=nalu_length-3;
AP4_UI08 xor_mask={53,50,112,111,106,105,101,119,115,98,104,103,109,100,104,104};
for (unsigned int i=0; i<encrypted_size; i ++) {
nalu ^= xor_mask;
}
}
```
然后修改js src/demux/video/avc-video-parser.ts parseAVCPES
```
case 7:
.....
const stack = new Error().stack;
if (stack.includes('SampleAes')) {
const xor_mask = new Uint8Array();
const encrypted_size = sps.length - 3;
for (let i = 0; i < encrypted_size; i++) {
sps ^= xor_mask;
}
}
const expGolombDecoder = new ExpGolomb(sps);
..............
```
刷新网页,能正常播放视频了
## 六、补充知识
### 6.1.drm系统组成
看了上面内容,发现其实帧加密也不难,那么为什么drm那么难以破解,那么就不得不提整个流程了,下面是一张很完整的图,详细介绍在(https://www.w3.org/TR/encrypted-media/)
从图上可以看出来,我写的仅仅涉及了最右下角小小的一部分。而真正复杂的,生成请求信息,以及解密响应,以及它内部的解密帧算法,基本全是白盒算法,请求一般是rsa,解密帧是白盒的aes,而不是上面我们自己实现时,用的很明确的aes,在cdm中几乎不会直接出现key。而cdm分为软件和硬件层实现,又完全不是一个东西。所以任重而道远,我能力有限,只能写一点没什么用的东西。
### 6.2.sdt
在wv以及pr(谷歌和微软的drm),只需要key就能解密,因为他的iv隐藏在第一个文件中,而它们的格式是mp4,那么ts如果想要隐藏iv可以尝试放哪里。
在查看(https://github.com/ireader/media-server/blob/master/libmpeg/source/mpeg-ts-enc.c#L324)时发现,在写入pat,pmt之前,还有一个可能存在一个sdt表,但是无论是加密还是解密过程中,均没发现,说明没啥用,既然没啥用那我们就可以用一用
首先是sdt主要组成,可以看到字段很多,但没什么合适的,只有一个free_CA_mode可以来判断是否加密了
然后就是Descriptor,Descriptor也分很多种具体在[官方文档](https://www.etsi.org/deliver/etsi_en/300400_300499/300468/01.03.01_60/en_300468v010301p.pdf)可以找到,比较常见的是Service descriptor,下面就是其主要组成
而刚好从这张表的描述中可以看到,存在两个可以自定义长度的字符串,那么我们就可以把iv写入这里,前面又提到了这是写在ts开头的,那么还可以每个ts文件一个iv,还可以设置不同加密算法,cbc或者ctr模式切换用,除此之外加密的过程中,160字节只加密了,16字节,剩下的什么都不做太可惜了,还可以让他们和iv进行异或。甚至可以随机设置加密和异或块大小。当然上面只是吹牛,具体的可以尝试自行实现。
## 7、附件
附件没有代码,只是本地版文档,仅供备份。
https://nicaicai.lanzouo.com/i49kw1llmklc
楼主yyds,不知道楼主有研究过某盾,某蜂的高强度加密不 大牛是彻底吧流媒体搞明白了 大佬牛牛牛,感谢分享,学到了 学习,虽然目前看的不是很懂 大佬求助,有YUSUAN,留连系防S
页:
[1]