說明:
這文章并不是對算法的分析,而僅僅是為了實現(xiàn)cKey9.1(因為有Nodejs了,所以就不需要擔心JS腳本的運行)。
而其中的算法就是取自于騰訊視頻的9.1加密版本的cKey,所以本文章僅僅是對騰訊視頻cKey生成算法的提取。
項目實現(xiàn)
前提
建議
在閱讀本文章之前先閱讀我的上一篇文章 愛奇藝視頻H5解析分析過程 ,因為這一文章將基于默認已知上一篇的一些使用操作方法的前提下進行。
這文章只對cKey生成的實現(xiàn)和分析,其他所需提交的參數(shù)不會在這里分析說明。
預(yù)備知識
以下所需要的知識不需要精通,只需要了解和知道基本的語法即可。
正文
關(guān)于騰訊視頻cKey
- 騰訊視頻的cKey是解析視頻直接地址的核心關(guān)鍵,只有通過該算法的加密才能夠請求到要解析的數(shù)據(jù)。
- 目前我所知的騰訊算法有8.1和9.1兩個版本,8.1版本是為了兼容不支持WebAssembly的那一部分用戶。所以實際上這兩者在PC網(wǎng)頁端里面所做的工作基本一致。因為8.1版本的cKey基本上和愛奇藝那一部分算法的分析過程差不多所以我就不在這里分析了(騰訊視頻cKey的那一部分解析可能要繁瑣一些)。
- 所以這文章將對9.1版本進行分析實現(xiàn)。
關(guān)于工具
-
這一文章將不使用Fiddler來抓包,而是全程使用chromium核的開發(fā)者工具進行分析。
-
一說到chromium,可能大家第一反應(yīng)就是使用谷歌瀏覽器。但是很遺憾,在這里不使用谷歌瀏覽器,因為谷歌瀏覽器所用到的cKey是8.1版本的。(我這里的瀏覽器版本是74.0.3729.131, 以后的版本可能會使用9.1)
-
除了谷歌瀏覽器外還有很多使用chromium核的瀏覽器,360瀏覽器(未知),搜狗瀏覽器(ckey8.1),,我這里將使用百分瀏覽器(ckey9.1),并且在騰訊視頻解析里面他使用到的是cKey9.1版本。這正是我們所要分析的對象。
-
下面將以https://v.qq.com/x/cover/bzfkv5se8qaqel2/j002024w2wg.html 為分析例子。
初次分析
-
F12打開開發(fā)者工具。
-
打開鏈接:https://v.qq.com/x/cover/bzfkv5se8qaqel2/j002024w2wg.html 。
-
切換Network查看抓包。
-
搜索proxyhttp,找到兩項 https://vd.l.qq.com/proxyhttp 。(這里搜proyhttp是因為前面省略了從視頻到解析鏈接的尋找過程,如果不知道怎么做,就先看上文的前提建議)
-
既然知道了請求視頻的鏈接是proxyhttp,那么在proxyhttp發(fā)送前中斷如何?
- 轉(zhuǎn)到
Sources 頁面,在XHR/fetch Breakpoints 的+ 進行添加條件斷點 proxyhttp ,意思就是在包含proxyhttp字串的請求鏈接時進行中斷。
- [圖1.1]
- 按F5刷新,等待中斷發(fā)生。
- [圖1.2]
- 之后看到右邊的調(diào)用棧信息
Call Stack ,可以看到調(diào)用函數(shù)的右邊表明了被調(diào)用函數(shù)所在的JS鏈接。
- [圖1.3]
- 為什么要看這些呢,因為對于一個具有龐大的JS腳本鏈接的視頻網(wǎng)站來說,找準加密所在的JS算法所在的鏈接是第一步。首先要知道的是,在POST
https://vd.l.qq.com/proxyhttp 之前肯定先需要先收集所要發(fā)送的data,所以必然這將調(diào)用到獲取data的函數(shù),而獲取部分必然會與加密部分有聯(lián)系,所以可以通過這樣的方式來找到加密部分。
- (事實上你可以直接在Network頁面搜索
proxyhttp 來定位到目標鏈接(注意這不是一定的),但是由于在愛奇藝分析過程中使用了這一方法,我在這里用一下別的方法來解決。)
- 由[圖1.3]可以知道的是
tvx.core.js 是用來對發(fā)送請求的。所以大概可以估計這文件就是對請求函數(shù)的集合,既然已經(jīng)到了發(fā)送的地步了,那么data肯定是已經(jīng)獲取完成了。
- 第二個JS文件
pecker.js ,點擊他,然后往下滾看到Scope 項,看到e,f兩項就是要發(fā)送的請求的所有數(shù)據(jù),展開發(fā)現(xiàn)data中cKey已經(jīng)存在,所以這里Call Stack 往上走(往上一層調(diào)用走)。
- [圖1.4][圖1.5][圖1.6]
- 到
e.requestPostCgi 位于htmlframe....... (關(guān)于Call Stack看圖1.3),粗看函數(shù)名似乎就是提交data的獲取。將其作為重點深找一下。
- 進入
e.requestPostCgi 后往下滾看到Scope ,下圖,本地變量c 就是要提交的data,圖1.7的中間紅框部分就是本地變量c 的獲取,發(fā)現(xiàn)vinfoparam 是由62455行 生成的數(shù)據(jù)。f.param(b.vinfoparam) ,發(fā)現(xiàn)該函數(shù)傳入了參數(shù)b.vinfoparam ,鼠標停在該參數(shù)出現(xiàn)了數(shù)據(jù)cKey。所以可以斷定重點在于b.vinfoparam ,而不是函數(shù)f.param 。
- [圖1.7]
- 發(fā)現(xiàn)
b.vinfoparam 中的變量b是調(diào)用e.requestPostCgi 時傳入的參數(shù)(位于62446 )
- 既然這樣,看【圖1.3】Call Stack,往上一層調(diào)用棧走,進入調(diào)用棧
c 。
- [圖1.8]
- 傳入的是
{
vinfoparam: g,
adparam: e,
domain: v,
method: w
}
- 我們關(guān)注的對象是
vinfoparam: g ,往前找g的生成代碼??础緢D1.8】的62742 進入函數(shù)f.getInfoConfig 。卻沒有發(fā)現(xiàn)cKey 的蹤跡,既然我們無法直接知道,不如放個斷點走一走。
- [圖1.9][圖1.10][圖1.11]
- 看上圖1.11,我們進入了
getInfoConfig 的調(diào)試中。
- [圖1.12][圖1.13]
- 一直往下走【看圖1.12、圖1.13】都發(fā)現(xiàn)cKey還沒獲取,一直到了
e(h) 。【圖1.14】【圖1.15】
- [圖1.14][圖1.15]
a.cKey = b || "" 這就是cKey生成的地方。就是變量b ,也就是
f ? (a.encryptVer = "9.1",
b = f(a.platform, a.appVer, a.vids || a.vid, "", a.guid, a.tm)) : (a.encryptVer = "8.1",
b = i(a.vids || a.vid, a.tm, a.appVer, a.guid, a.platform)),
a.cKey = b || ""
- 從這里可以看到8.1版本和9.1版本的控制是由
f() 參數(shù)控制的。但這不是我們的重點,既然我們分析的是9.1版本,那么進入函數(shù)f() 。
重點分析
function i(a, b, c, d, e) {
// 注意下面的函數(shù)k(a, b)是函數(shù)f(a)里面的k函數(shù),為了方便起見直接在合起來寫了
function k(a, b) {
if (0 === b || !a)
return "";
for (var c, d = 0, e = 0; ; ) {
if (g(a + e < db),
c = Ga[a + e >> 0],
d |= c,
0 == c && !b)
break;
if (e++,
b && e == b)
break
}
b || (b = e);
var f = "";
if (d < 128) {
for (var h, i = 1024; b > 0; )
h = String.fromCharCode.apply(String, Ga.subarray(a, a + Math.min(b, i))),
f = f ? f + h : h,
a += i,
b -= i;
return f
}
return m(a)
}
function f(a) {
return "string" === b ? k(a) : "boolean" === b ? Boolean(a) : a
}
var i = h(a)
, j = []
, l = 0;
if (g("array" !== b, 'Return type should not be "array".'),
d)
for (var m = 0; m < d.length; m++) {
var n = $a[c[m]];
n ? (0 === l && (l = Ub()),
j[m] = n(d[m])) : j[m] = d[m]
}
var o = i.apply(null, j);
return o = f(o),
0 !== l && Tb(l),
o
}
- 由上面找到的【圖1.15】開始。
- 斷點繼續(xù)往下走,進入【圖2.1】【圖2.2】
- [圖2.1][圖2.2][圖2.3]
- 返回的是變量
o ,那么我們重點關(guān)注他,走到o ,64084行 ,進去,【圖2.3】看到ua._getckey ,可以知道看來是找對地方了。
ua._getckey = function() {
return g(ib, "you need to wait for the runtime to be ready (e.g. wait for main() to be called)"),
g(!jb, "the runtime was exited (use NO_EXIT_RUNTIME to keep it alive after main() exits)"),
ua.asm._getckey.apply(null, arguments)
}
- 進去
ua.asm._getckey.apply(null, arguments) ,???wocao這是什么鬼【圖2.4】
- [圖2.4][圖2.5]
- 這函數(shù)名怎么是個數(shù)字???而且發(fā)現(xiàn)也進不去,而且提示的是
native code ,這說明了這不是JS的原生代碼,可能是其他語言實現(xiàn)的方法。
- 事實上這是
WebAssembly ,這是一種JS的一種可以理解成是交叉編程的一種方式,目的是為了提高JS運行效率,這是由C或者其他編程語言生成的代碼,生成*.wasm然后交給WebAssembly加載處理運行。
- 可以通過【圖2.5】看到加載的wasm文件,而其中的函數(shù)名29就是對應(yīng)
wasm-0005098e-29 ,你點進去查看就看反匯編到具體的指令。
- 好了,基本說明了這一種JS的技術(shù),如果要了解更多就百度谷歌把。
- 那么重要的是要找到這被加載的
wasm 文件。
- 一個最簡單的方法就是直接在
Sources 頁面搜索wasm 就能找到加載的wasm文件。
- [圖2.6]
- 對于找wasm也可以使用其他方法實現(xiàn),但是既然是請求GET到的,當然能抓包到了,所以這里就偷懶不通過代碼分析了。(不然篇幅會很長)
- 要知道的是,我們雖然得到了wasm文件,但是任何交叉編程類的東西,都需要有接口,而這些接口或者必須提供的,所以我們還需要找到wasm接口部分,但這里先放一邊,待會再進行。
- 通過【圖2.4】可以看到的是傳了參數(shù)
arguments ,雖然我們得到了wasm,但是我們還是需要知道參數(shù)arguments 才能實現(xiàn)算法。
- 而
arguments 就是前面【圖2.3】傳遞的參數(shù)j ,我們要得到j 。
- 看【圖2.2】進入函數(shù)
Ub() 和n() ,而n() 是由var n = $a[c[m]]; 提供的。所以我們F5刷新下頁面在【圖2.2】重新斷點。為的就是單步執(zhí)行,找所需。
- [圖2.7]
- 由【圖2.7】出單步走,你會發(fā)現(xiàn)有兩種
n ,一種是undefined 和
stringToC: function(a) {
var b = 0;
if (null !== a && void 0 !== a && 0 !== a) {
var c = (a.length << 2) + 1;
b = Sb(c),
o(a, b, c)
}
return b
}
- 往下一直找能找到
Sb() , o() , n() ,其中包括了循環(huán)中的Ub 還有f() 函數(shù)中的k() 然后你能整理出來
Ub = function() {
return g(ib, "you need to wait for the runtime to be ready (e.g. wait for main() to be called)"),
g(!jb, "the runtime was exited (use NO_EXIT_RUNTIME to keep it alive after main() exits)"),
ua.asm.stackSave.apply(null, arguments)
}
Sb = function() {
return g(ib, "you need to wait for the runtime to be ready (e.g. wait for main() to be called)"),
g(!jb, "the runtime was exited (use NO_EXIT_RUNTIME to keep it alive after main() exits)"),
ua.asm.stackAlloc.apply(null, arguments)
}
function o(a, b, c) {
return g("number" == typeof c, "stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!"),
n(a, Ga, b, c)
}
function n(a, b, c, d) {
if (!(d > 0))
return 0;
for (var e = c, f = c + d - 1, g = 0; g < a.length; ++g) {
var h = a.charCodeAt(g);
if (h >= 55296 && h <= 57343) {
var i = a.charCodeAt(++g);
h = 65536 + ((1023 & h) << 10) | 1023 & i
}
if (h <= 127) {
if (c >= f)
break;
b[c++] = h
} else if (h <= 2047) {
if (c + 1 >= f)
break;
b[c++] = 192 | h >> 6,
b[c++] = 128 | 63 & h
} else if (h <= 65535) {
if (c + 2 >= f)
break;
b[c++] = 224 | h >> 12,
b[c++] = 128 | h >> 6 & 63,
b[c++] = 128 | 63 & h
} else if (h <= 2097151) {
if (c + 3 >= f)
break;
b[c++] = 240 | h >> 18,
b[c++] = 128 | h >> 12 & 63,
b[c++] = 128 | h >> 6 & 63,
b[c++] = 128 | 63 & h
} else if (h <= 67108863) {
if (c + 4 >= f)
break;
b[c++] = 248 | h >> 24,
b[c++] = 128 | h >> 18 & 63,
b[c++] = 128 | h >> 12 & 63,
b[c++] = 128 | h >> 6 & 63,
b[c++] = 128 | 63 & h
} else {
if (c + 5 >= f)
break;
b[c++] = 252 | h >> 30,
b[c++] = 128 | h >> 24 & 63,
b[c++] = 128 | h >> 18 & 63,
b[c++] = 128 | h >> 12 & 63,
b[c++] = 128 | h >> 6 & 63,
b[c++] = 128 | 63 & h
}
}
return b[c] = 0,
c - e
}
- 大家應(yīng)該發(fā)現(xiàn)了上面的函數(shù)
o(a, b, c) 調(diào)用了方法n(a, Ga, b, c) ,其中a, b,c 我們都知道,但是Ga 是什么東西?
- 既然在Locan變量無法找到,那么網(wǎng)上一級找??聪聢D2.8
- [圖2.8][圖2.9]
- 發(fā)現(xiàn)上一級有
Ga ,所以,我們找到他了,看【圖2.9】
- 既然知道了要找
Ga 的緣由,那么把所有對于給Ga 賦值的東西聯(lián)系起來。
- 這將是個漫長的過程。
// 只要知道ArrayBuffer的都知道這將導(dǎo)致下面的 Fa,Ha, Ja, Ga, Ia, Ka, La, Ma綁在了Ea下
// 也就是說Ea是數(shù)據(jù),而Fa,Ha, Ja, Ga, Ia, Ka, La, Ma就是描述這個數(shù)據(jù)的方式,所有的改變只是對Ea操作。所以對其中一個改變都會改變我們的目標Ga
function w() {
Fa = new Int8Array(Ea),
Ha = new Int16Array(Ea),
Ja = new Int32Array(Ea),
Ga = new Uint8Array(Ea),
Ia = new Uint16Array(Ea),
Ka = new Uint32Array(Ea),
La = new Float32Array(Ea),
Ma = new Float64Array(Ea);
}
function d(a) {
var b = Oa;
return Oa = Oa + a + 15 & -16,
b
}
function e(a, b) {
b || (b = Da);
var c = a = Math.ceil(a / b) * b;
return c
}
var Da = 16;
var Ea, Fa, Ga, Ha, Ia, Ja, Ka, La, Ma, Na, Oa, Pa, Qa, Ra, Sa, Ta, Ua, Va = {
"f64-rem": function(a, b) {
return a % b
},
"debugger": function() {}
}, Wa = (new Array(0), 1024) ;
Na = Oa = Qa = Ra = Sa = Ta = Ua = 0,
Pa = !1;
var cb = 5242880 , db = 16777216, ab = 65536;
var wasmMemory = new WebAssembly.Memory({
initial: db / ab,
maximum: db / ab
});
Ea = wasmMemory.buffer;
w();
Ja[0] = 1668509029;
Ha[1] = 25459;
var eb = []
, fb = []
, gb = []
, hb = []
, ib = !1
, jb = !1;
Na = Wa,
Oa = Na + 6928,
fb.push();
Oa += 16;
Ua = d(4),
Qa = Ra = e(Oa),
Sa = Qa + cb,
Ta = e(Sa),
Ja[Ua >> 2] = Ta,
Pa = !0;
- 以上解決了
Ga 的初始化。
- 目前為止解決了循環(huán)這一部分了。
for (var m = 0; m < d.length; m++) {
var n = $a[c[m]];
n ? (0 === l && (l = Ub()),
j[m] = n(d[m])) : j[m] = d[m]
}
var o = i.apply(null, j);
return o = f(o),
0 !== l && Tb(l),
o
- 前面我們已經(jīng)說了
i.apply(null, j); ,他的代碼位于wasm中。
- 所以目前我們需要的是正確加載wasm,只要完成這一步,所有函數(shù)都可以串起來實現(xiàn)cKey了。
- 我們先看下如下代碼
var ub = ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea)
var Cb = ub._getckey;
ub._getckey = function() {
return g(ib, "you need to wait for the runtime to be ready (e.g. wait for main() to be called)"),
g(!jb, "the runtime was exited (use NO_EXIT_RUNTIME to keep it alive after main() exits)"),
Cb.apply(null, arguments)
}
- 也就是說,我們先知道
ub 也就是ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea) 。
- 調(diào)試進去,找到以下代碼。
ua.asm = function(a, b, c) {
if (!b.table) {
var d = ua.wasmTableSize;
void 0 === d && (d = 1024);
var f = ua.wasmMaxTableSize;
"object" == typeof WebAssembly && "function" == typeof WebAssembly.Table ? void 0 !== f ? b.table = new WebAssembly.Table({
initial: d,
maximum: f,
element: "anyfunc"
}) : b.table = new WebAssembly.Table({
initial: d,
element: "anyfunc"
}) : b.table = new Array(d),
ua.wasmTable = b.table
}
b.memoryBase || (b.memoryBase = ua.STATIC_BASE),
b.tableBase || (b.tableBase = 0);
var h;
return h = e(a, b, c),
g(h, "no binaryen method succeeded. consider enabling more options, like interpreting, if you want that: http://kripken./emscripten-site/docs/compiling/WebAssembly.html#binaryen-methods"),
h
}
- 這里面就是對wasm的加載了。而這一切加載的前提是知道參數(shù)
a,b,c ,所以再回到ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea)
- 也就是
ua.asmGlobalArg, ua.asmLibraryArg, Ea ,而其中Ea 我們已經(jīng)在前面說過了,跟Ga 有關(guān)系。
- 很容易找到
ua.wasmTableSize = 99,
ua.wasmMaxTableSize = 99,
ua.asmGlobalArg = {},
ua.asmLibraryArg = {
abort: sa,
assert: g,
enlargeMemory: B,
getTotalMemory: C,
abortOnCannotGrowMemory: A,
abortStackOverflow: z,
nullFunc_ii: ca,
nullFunc_iiii: da,
nullFunc_v: ea,
nullFunc_vi: fa,
nullFunc_viiii: ga,
nullFunc_viiiii: ha,
nullFunc_viiiiii: ia,
invoke_ii: ja,
invoke_iiii: ka,
invoke_v: la,
invoke_vi: ma,
invoke_viiii: na,
invoke_viiiii: oa,
invoke_viiiiii: pa,
__ZSt18uncaught_exceptionv: Q,
___cxa_find_matching_catch: S,
___gxx_personality_v0: T,
___lock: U,
___resumeException: R,
___setErrNo: ba,
___syscall140: V,
___syscall146: X,
___syscall54: Y,
___syscall6: Z,
___unlock: $,
_abort: _,
_emscripten_memcpy_big: aa,
_get_unicode_str: P,
flush_NO_FILESYSTEM: W,
DYNAMICTOP_PTR: Ua,
tempDoublePtr: rb,
STACKTOP: Ra,
STACK_MAX: Sa
};
var ub = ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea)
- 可以看到wasm的加載連接了很多接口,但是我在這里只說其中比較重要的方法
P ,也就是_get_unicode_str: P, 中的P ,對應(yīng)如下
function P() {
function a(a) {
return a ? a.length > 48 ? a.substr(0, 48) : a : ""
}
function b() {
var b = document.URL
, c = window.navigator.userAgent.toLowerCase()
, d = "";
document.referrer.length > 0 && (d = document.referrer);
try {
0 == d.length && opener.location.href.length > 0 && (d = opener.location.href)
} catch (e) {}
var f = window.navigator.appCodeName
, g = window.navigator.appName
, h = window.navigator.platform;
return b = a(b),
d = a(d),
c = a(c),
b + "|" + c + "|" + d + "|" + f + "|" + g + "|" + h
}
var c = b()
, d = p(c) + 1
, e = Pb(d);
return o(c, e, d + 1),
e
}
- 為什么這個重要呢?當你把剛開始重點分析后面的那一個函數(shù)單步走一遍你就會發(fā)現(xiàn)了,當在執(zhí)行
_getckey() 的時候,他會call 20 ,也就是wasm文件中的編號20的函數(shù) ,但是你仔細看【圖2.5】會發(fā)現(xiàn)缺少缺少了20號 函數(shù),這是因為他會上面在鏈接接口的時候鏈接了函數(shù)P() ,而函數(shù)P() 就是20號 函數(shù)。
- 而除此之外其他的函數(shù)對我們來說用處不大,所以你大可以使用空函數(shù)來鏈接。
- 所以我如下處理了接口鏈接和wasm環(huán)境的配置
var fun_ = function(){};
wasm_env = {
abort: fun_,
assert: fun_,
enlargeMemory: fun_,
getTotalMemory: C,
abortOnCannotGrowMemory: fun_,
abortStackOverflow: fun_,
nullFunc_ii: fun_,
nullFunc_iiii: fun_,
nullFunc_v: fun_,
nullFunc_vi: fun_,
nullFunc_viiii: fun_,
nullFunc_viiiii: fun_,
nullFunc_viiiiii: fun_,
invoke_ii: fun_,
invoke_iiii: fun_,
invoke_v: fun_,
invoke_vi: fun_,
invoke_viiii: fun_,
invoke_viiiii: fun_,
invoke_viiiiii: fun_,
__ZSt18uncaught_exceptionv: fun_,
___cxa_find_matching_catch: fun_,
___gxx_personality_v0: fun_,
___lock: fun_,
___resumeException: fun_,
___setErrNo: fun_,
___syscall140: fun_,
___syscall146: fun_,
___syscall54: fun_,
___syscall6: fun_,
___unlock: fun_,
_abort: fun_,
_emscripten_memcpy_big: fun_,
_get_unicode_str: P, // function 20( ) => P( )
flush_NO_FILESYSTEM: fun_,
DYNAMICTOP_PTR: 7968, //Ua
tempDoublePtr: 7952, //rb
STACKTOP: 7984, //Ra
STACK_MAX: 5250864, //Sa
memoryBase: 1024,
tableBase: 0,
memory: wasmMemory,
table: new WebAssembly.Table({
initial: 99,
maximum: 99,
element: "anyfunc"
})
};
var importObject = {
'env': wasm_env,
'asm2wasm': {
"f64-rem": function(a, b) {
return a % b
},
"debugger": function() {}
},
'global': {
NaN: NaN,
Infinity: 1 / 0
},
"global.Math": Math,
// "parent": {};
};
- 到目前為止,已經(jīng)完成了對接口的鏈接,也就是可以進行加載wasm了,再然后就可以對cKey進行測試了。
- 注意前面花了很大篇幅來再次回顧了對變量或函數(shù)的定位方法,所以后面很大一部分我會省略這一步驟,只是直接一步帶過,直說個結(jié)果。
結(jié)束分析
參考和說明
- Github項目鏈接: 點這
- 本文章僅用于技術(shù)交流。
|