背景使用浏览器可以找到m3u8链接,把ts文件下载下来播放,发现有声音但是花屏。
这里面可能是对TS帧加密。
上网搜索找到这篇文章,WEB前端逆向TS PES NALU解密 ,但这篇文章是针对视频的,我是想对直播解密,使用上述的脚本 ,解密出来,底下有一点花屏。
分析从其他文章知道这个网站是使用wasm加密,所以可以先找到wasm,然后找到对应的js文件,js文件比较重要的有
h5.worker%3Fv=220805
wasm文件,会构造一个对象 CNTVModule,这个js有一点点混淆,反混淆就好。可以在chrome浏览器里面Override反混淆后的代码。
liveplayer.js
可以根据CNTVModule找到CNTVH5PlayerModule = CNTVModule();
liveplayer_controls.js
根据CNTVH5PlayerModule找到,解密代码调用位置
先来到liveplayer_controls.js这个文件,主要的解密方法是在x.prototype._parseAVCPES里。
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
x . prototype . _parseAVCPES = function ( h , e ) {
var g ;
var y ;
var m ;
var v = this ;
var E = this . _avcTrack ;
var t = this . _parseAVCNALu ( h . data );
var _ = this . avcSample ;
var T = false ;
var S = false ;
var b = this . pushAccesUnit . bind ( this );
h . data = null ;
if ( _ && t . length && ! E . audFound ) {
b ( _ , E );
_ = this . avcSample = p ( false , h . pts , h . dts , "" );
}
...
function uint8ToHex ( uint8Array ) {
// 确保输入是 Uint8Array 或类数组
if ( ! ArrayBuffer . isView ( uint8Array ) && ! Array . isArray ( uint8Array )) {
throw new Error ( 'Input must be a Uint8Array or an array of numbers' );
}
return Array . from ( uint8Array )
. map ( byte => byte . toString ( 16 ). padStart ( 2 , '0' )) // 转为两位小写 hex
. join ( ' ' ); // 用空格分隔每个字节
}
t . forEach ( function ( e ) {
y = L = false ;
// 可以插入一些代码测试
console . log ( "e.type" , e . type , uint8ToHex ( e . data ));
if ( ! ( e . type !== 5 && e . type !== 1 )) {
if ( F && CNTVH5PlayerModule ) {
e . data = A ( e . data , 0 );
} else if ( M && CNTVH5PlayerModule ) {
e . data = A ( e . data , 1 );
}
}
...
return S ;
};
从代码看到 t = this._parseAVCNALu(h.data);
先把NALu解析出来,然后调用 t.forEach
遍历,如果类型是1或5才做解密。其中直播是调用 A(e.data, 0)
。
来到看解密方法
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
function A ( e , t ) {
var r = 0 ;
var i = 0 ;
var a = 0 ;
var n = new Uint8Array ( 0 );
try {
r = CNTVH5PlayerModule . _jsmalloc ( e . byteLength + 1024 );
for ( var o = 0 ; o < e . byteLength ; o ++ ) {
CNTVH5PlayerModule . HEAP8 [ r + o ] = e [ o ];
}
if ( ! v . signalUrlSend ) {
a = v . rootURL . length ;
for ( var s = 0 ; s < a ; s ++ ) {
CNTVH5PlayerModule . HEAP8 [ r + e . byteLength + s ] = v . rootURL . charCodeAt ( s );
}
v . signalUrlSend = true ;
}
if ( t == 0 ) {
i = CNTVH5PlayerModule . _nalplay2 ( r , e . byteLength , a );
} else if ( t == 1 ) {
i = CNTVH5PlayerModule . _vodplay ( r , e . byteLength , a );
}
n = new Uint8Array ( i );
for ( var l = 0 ; l < n . byteLength ; l ++ ) {
n [ l ] = CNTVH5PlayerModule . HEAP8 [ r + l ];
}
} catch ( e ) {}
CNTVH5PlayerModule . _jsfree ( r );
r = null ;
return n ;
}
解密方法看到前面把地址放到CNTVH5PlayerModule.HEAP8[r + e.byteLength]之后,直播调用 i = CNTVH5PlayerModule._nalplay2(r, e.byteLength, a);
解密,视频是调用 _vodplay
,实际上调用wasm里面的方法。
根据文档 ts帧加密案例(一) 我们知道如果直接调用这个方法的话,wasm里面会有很多env import,会针对浏览器很多检查,他第一次调用的时候发现还是有一些花屏,在第二篇文章里 ts帧加密案例(二) 发现是环境没补全。
但是作者跟踪代码可以绕过这个方法调用,跳过这些检查,这里不清楚作者是如何发现这个内部调用的。
如果想要分析,可以使用wabt工具 来反编译,可以编译成c或者wat格式。跟踪代码可以看到,直播调用的是func58_tea,和视频调用的func60_tea不一样。
根据文章我们可以把内部方法暴露出来,wasm是base64格式的文本,可以让AI写一个方法保存为文件
先转为wat格式
1
wasm2wat xxx.wasm -o xxx.wat
修改wat,在导出表里增加两行(搜索export)
(export "func58_TEA" (func 58))
(export "func60_TEA" (func 60))
最后再转回wasm,然后再转回base64的格式填回代码里面
1
wat2wasm xxx.wat -o xxx_out.wasm
TS 格式分析分析一下 WEB前端逆向TS PES NALU解密 文章里面的代码之前,先来简单理解一下TS格式,我也不太懂,根据自己理解描述一下。
TS每个Frame 188个字节,第一个字节固定是0x47,每个Frame包含Header和数据,里面可能是一些属性数据或者视频流、音频流,我们可以用010Editor来辅助分析。
在010Editor里面我们可以下载模板,在Templates > Templates Repository 里面下载 TS/H264 的模板,然后再选择Templates > Video > TS 就可以看到解析后的格式了。如果选择H264也可以找到H264的解析。
TSHeader里面有一个PID,表示一个编号,也是一个类型,我们需要根据PID找到哪个是视频流,我们只对视频流解密。文章作者说通过软件看到视频流的PID是0x100,在代码中也做了这个过滤,那这个是怎么看出来的呢?
可以先找到PAT,PAT是在PID=0的Frame里面,可以找到PMT的PID。
如果你的Value不是按16进制显示,可以在标题栏右键,选择Value > Hex。可以看到PMT的PID在0x1001,文件我看是在第二个Frame里面。
这里看到PMTElementary有多个,这里是定义stream_type对应的PID是什么,这里第一个stream_type是 0x1B ,这个是h264类型,我们可以看到PID是 0x100。但我实际运行下来,这个一般是0x100,但实际有时候会变的,所以需要动态的读取这个值。原作者是固定使用了这个值,会导致有部分时候花屏。
然后我们就可以根据前面得到的PID得到视频流,解析里面的NALu。
回到代码,代码Parse_TS_Packet里面会涉及到一些合并Frame,没了解很多TS结构,我不懂。然后再在这里面找NALu的开头,它是以0x 00 00 01
或者 0x 00 00 00 01
开头,FindNalUnitStart方法就是找这些开头。
但代码里面有种情况没有处理,就是如果payload里面有0x000003
,需要替换成 0x0000
,它是为了避免分隔符和数据混淆做了替换(H264的 Encapsulated Byte Sequence Payload编码),对数据做了替换。不过这种情况可能很少,偶尔出错一次应该也不太影响。
0x000000-->0x00000300
0x000001-->0x00000301
0x000002-->0x00000302
0x000003-->0x00000303
原有代码调用的是FUNC60,是针对视频的,如果是直播,内部调用的是FUNC58,可以断点进入wasm里面看到。
参考原有js代码实现,另外func58_TEA的参数和原代码有点不一样,可以断点调试对比对应的值是什么,也可以把xx.wat发给AI,让它给你分析,才发现第二个和第三个参数是一样的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function XOR ( e ) {
let r = 0 ;
let i = 0 ;
let a = 0 ;
let n = new Uint8Array ( 0 );
try {
r = CNTVH5PlayerModule . _jsmalloc ( e . byteLength + 1024 );
for ( var o = 0 ; o < e . byteLength ; o ++ ) {
CNTVH5PlayerModule . HEAP8 [ r + o ] = e [ o ];
}
i = CNTVH5PlayerModule . asm . func58_TEA ( r , e . byteLength , e . byteLength );
n = new Uint8Array ( i );
for ( var l = 0 ; l < n . byteLength ; l ++ ) {
n [ l ] = CNTVH5PlayerModule . HEAP8 [ r + l ];
}
} catch ( e ) {
console . log ( "err" , e );
}
CNTVH5PlayerModule . _jsfree ( r );
r = null ;
return n ;
}
排查问题改完这个发现可以直播观看了,但是后台还是时不时会报错,画面有部分花屏。
这时可以在network找到ts文件的下载,在后面找到调用的js地址,下断点,每次下载ts的时候,我们可以暂停,在解析的地方可以增加一些日志调试,也可以把对应的ts文件下载下来本地分析。
有一些报错是 RuntimeError: memory access out of bounds
,解密失败了,发现问题是原代码写死视频的PID是0x100,但实际上还是偶尔有不一样的。
我们可以从PMT里面找到视频流的PID,让AI帮我写了一些方法。这里吐槽一下qwen3 coder,让它改了好几次才正确。不过对于这个文件位运算的代码,我还真不保证我能写明白,当前AI还是牛的。
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
function Parse_PAT ( buf , index ) {
// PAT PID is always 0x0000
let PID = (( buf [ index + 1 ] & 0x1f ) << 8 ) + buf [ index + 2 ];
// console.log("Parsing PAT at index:", index, "PID:", PID.toString(16));
if ( PID !== 0x0000 ) {
return null ;
}
// Skip TS header (4 bytes) + adaptation field (if exists)
let AdaptationFieldControl = ( buf [ index + 3 ] & 0x30 ) >>> 4 ;
let payload_index = index + 4 ;
if ( AdaptationFieldControl === 2 || AdaptationFieldControl === 3 ) {
let AdaptationFieldLength = buf [ index + 4 ];
payload_index += 1 + AdaptationFieldLength ;
}
// Skip pointer_field
let pointer_field = buf [ payload_index ];
payload_index += 1 + pointer_field ;
// Check table_id
let table_id = buf [ payload_index ];
// console.log("PAT table_id:", table_id);
if ( table_id !== 0x00 ) {
console . log ( "Invalid PAT table_id:" , table_id );
return null ;
}
// Skip table_id
payload_index += 1 ;
// Get section_length (first 2 bits are reserved)
let section_length = (( buf [ payload_index ] & 0x0f ) << 8 ) | buf [ payload_index + 1 ];
// console.log("PAT section_length:", section_length);
payload_index += 2 ;
// Skip reserved, version_number, current_next_indicator, section_number, last_section_number
payload_index += 5 ;
// Get program_number and PMT PID
// program_number is 2 bytes
// PMT PID is in the next 2 bytes (with first 3 bits as reserved)
let program_number = ( buf [ payload_index ] << 8 ) | buf [ payload_index + 1 ];
let pmt_pid = (( buf [ payload_index + 2 ] & 0x1f ) << 8 ) | buf [ payload_index + 3 ];
// console.log("PAT program_number:", program_number, "PMT PID:", pmt_pid.toString(16));
return pmt_pid ;
}
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
function Parse_PMT ( buf , index ) {
// Parse PMT to find video stream PID
let PID = (( buf [ index + 1 ] & 0x1f ) << 8 ) + buf [ index + 2 ];
// console.log("Parsing PMT at index:", index, "PID:", PID.toString(16));
// Skip TS header (4 bytes) + adaptation field (if exists)
let AdaptationFieldControl = ( buf [ index + 3 ] & 0x30 ) >>> 4 ;
let payload_index = index + 4 ;
if ( AdaptationFieldControl === 2 || AdaptationFieldControl === 3 ) {
let AdaptationFieldLength = buf [ index + 4 ];
payload_index += 1 + AdaptationFieldLength ;
}
// Skip pointer_field
let pointer_field = buf [ payload_index ];
payload_index += 1 + pointer_field ;
// Check table_id
let table_id = buf [ payload_index ];
// console.log("PMT table_id:", table_id);
if ( table_id !== 0x02 ) {
console . log ( "Invalid PMT table_id:" , table_id );
return null ;
}
// Skip table_id
payload_index += 1 ;
// Get section_length (first 2 bits are reserved)
let section_length = (( buf [ payload_index ] & 0x0f ) << 8 ) | buf [ payload_index + 1 ];
// console.log("PMT section_length:", section_length);
payload_index += 2 ;
// Skip program_number (2 bytes)
payload_index += 2 ;
// Skip reserved (2 bits) + version_number (5 bits) + current_next_indicator (1 bit) = 1 byte
payload_index += 1 ;
// Skip section_number (1 byte) + last_section_number (1 byte) = 2 bytes
payload_index += 2 ;
// Skip reserved (3 bits) + PCR_PID (13 bits) = 2 bytes
payload_index += 2 ;
// Skip program_info_length (first 4 bits are reserved)
let program_info_length = (( buf [ payload_index ] & 0x0f ) << 8 ) | buf [ payload_index + 1 ];
// console.log("PMT program_info_length:", program_info_length);
payload_index += 2 ;
// Skip program descriptors
payload_index += program_info_length ;
// Parse elementary streams
let video_pid = null ;
let end_position = index + 4 + section_length ; // 4 bytes for TS header + section_length
// console.log("PMT end_position:", end_position, "payload_index:", payload_index);
while ( payload_index < end_position - 4 ) { // -4 to ensure we have enough bytes for stream_type and PID
// Ensure we have enough bytes for stream_type and PID
if ( payload_index + 2 >= buf . length ) {
console . log ( "Not enough bytes for stream_type and PID" );
break ;
}
let stream_type = buf [ payload_index ];
let stream_pid = (( buf [ payload_index + 1 ] & 0x1f ) << 8 ) | buf [ payload_index + 2 ];
// console.log("Stream type:", stream_type, "PID:", stream_pid.toString(16));
// H.264 stream type is 0x1b
if ( stream_type === 0x1b ) {
video_pid = stream_pid ;
// console.log("Found H.264 video stream PID:", video_pid.toString(16));
break ;
}
// Check if we have enough bytes for ES_info_length
if ( payload_index + 4 >= buf . length ) {
console . log ( "Not enough bytes for ES_info_length" );
break ;
}
// Skip ES_info_length (first 2 bits are reserved)
let es_info_length = (( buf [ payload_index + 3 ] & 0x0f ) << 8 ) | buf [ payload_index + 4 ];
// console.log("ES info length:", es_info_length);
payload_index += 5 + es_info_length ;
}
return video_pid ;
}
拿到视频PID之后,就可以在里面过滤了。
总结最终测试下来,虽然还是偶尔有报错,但总体基本可用了。
参考WEB前端逆向TS PES NALU解密
ts帧加密案例(一)
ts帧加密案例(二)