다음에 필요한 지식은 숙달할 필요는 없고, 기본적인 문법을 이해하고 알면 충분합니다.
본문
Tencent Video cKey에 대하여
- 텐센트 비디오의 cKey는 비디오 직접 주소를 해석하는 핵심 키이며, 이 알고리즘의 암호화를 통해서만 요청할 수 있는 데이터를 해석할 수 있습니다.
- 현재 제가 아는 텐센트 알고리즘에는 8.1과 9.1 두 가지 버전이 있으며, 8.1 버전은 WebAssembly를 지원하지 않는 사용자와의 호환성을 위해 만들어졌습니다. 그래서 실제로 이 두 가지는 PC 웹에서 수행하는 작업이 기본적으로 일치합니다. 8.1 버전의 cKey는 기본적으로 아이치이의 일부 알고리즘 분석 과정과 비슷하기 때문에 여기서 분석하지 않겠습니다(텐센트 비디오 cKey의 그 부분 해석은 다소 복잡할 수 있습니다).
- 따라서 이 글에서는 9.1 버전에 대한 분석을 진행하겠습니다.
도구에 대하여
- 이 글에서는 Fiddler를 사용하여 패킷을 잡지 않고, 전 과정에서 chromium 기반의 개발자 도구를 사용하여 분석합니다.
- 일단 chromium에 대해 이야기하면, 아마 모두가 첫 반응으로 구글 크롬을 떠올릴 것입니다. 하지만 안타깝게도 여기서는 구글 크롬을 사용하지 않습니다. 왜냐하면 구글 크롬에서 사용되는 cKey는 8.1 버전이기 때문입니다. (여기서의 브라우저 버전은 74.0.3729.131이며, 이후 버전에서는 9.1을 사용할 수 있습니다.)
- 구글 크롬 외에도 많은 크로미움 기반 브라우저가 있으며, 360 브라우저(미확인), 소구 브라우저(ckey8.1)가 있습니다. 여기서는 백분 브라우저(ckey9.1)를 사용할 것이며, 텐센트 비디오 분석에서는 cKey9.1 버전을 사용합니다. 이것이 바로 우리가 분석할 대상입니다.
- 아래는 https://v.qq.com/x/cover/bzfkv5se8qaqel2/j002024w2wg.html을 분석 예제로 사용합니다.
초기 분석
- F12를 눌러 개발자 도구를 엽니다.
- 링크 열기:
https://v.qq.com/x/cover/bzfkv5se8qaqel2/j002024w2wg.html
。
- 네트워크 전환하여 패킷 캡처 보기.
- 검색 proxyhttp, 두 개의 항목
https://vd.l.qq.com/proxyhttp
를 찾습니다. (여기서 proxyhttp를 검색하는 이유는 앞서 비디오에서解析 링크를 찾는 과정을 생략했기 때문입니다. 방법을 모른다면 먼저 위의 전제 제안을 참고하세요.)
- 既然知道了请求视频的链接是proxyhttp,那么在proxyhttp发送前中断如何?
* 转到`Sources`页面,在`XHR/fetch Breakpoints`的`+`进行添加条件断点 `proxyhttp`,意思就是在包含proxyhttp字串的请求链接时进行中断。
* \[图1.1\]![[图1.1]](https://i-blog.csdnimg.cn/blog_migrate/183be0e9c80cd214b3b9a01bc78ba1a1.png)
* 按F5刷新,等待中断发生。
* \[图1.2\]![[图1.2]](https://i-blog.csdnimg.cn/blog_migrate/dd554f45c7318437620d85a84ba127b2.png)
* 之后看到右边的调用栈信息`Call Stack`,可以看到调用函数的右边表明了被调用函数所在的JS链接。
* \[图1.3\]![[图1.3]](https://i-blog.csdnimg.cn/blog_migrate/f8ddfd0c25bfe66591d14c17da51d580.png)
* 为什么要看这些呢,因为对于一个具有庞大的JS脚本链接的视频网站来说,找准加密所在的JS算法所在的链接是第一步。首先要知道的是,在POST`https://vd.l.qq.com/proxyhttp`之前肯定先需要先收集所要发送的data,所以必然这将调用到获取data的函数,而获取部分必然会与加密部分有联系,所以可以通过这样的方式来找到加密部分。
* (事实上你可以直接在Network页面搜索`proxyhttp`来定位到目标链接(注意这不是一定的),但是由于在爱奇艺分析过程中使用了这一方法,我在这里用一下别的方法来解决。)
* 由\[图1.3\]可以知道的是`tvx.core.js`是用来对发送请求的。所以大概可以估计这文件就是对请求函数的集合,既然已经到了发送的地步了,那么data肯定是已经获取完成了。
* 第二个JS文件`pecker.js`,点击他,然后往下滚看到`Scope`项,看到e,f两项就是要发送的请求的所有数据,展开发现data中cKey已经存在,所以这里`Call Stack`往上走(往上一层调用走)。
* \[图1.4\]![[图1.4]](https://i-blog.csdnimg.cn/blog_migrate/f425a09bd70e963208881d32dd6c522f.png)\[图1.5\]![[图1.5]](https://i-blog.csdnimg.cn/blog_migrate/ef93da6f5ddba17b662d9e5dc8335d8a.png)\[图1.6\]![[图1.6]](https://i-blog.csdnimg.cn/blog_migrate/4866f8f2877e0d4e4e7b078a7947bf79.png)
* 到`e.requestPostCgi`位于`htmlframe.......`(关于Call Stack看图1.3),粗看函数名似乎就是提交data的获取。将其作为重点深找一下。
* 进入`e.requestPostCgi`后往下滚看到`Scope`,下图,本地变量`c`就是要提交的data,图1.7的中间红框部分就是本地变量`c`的获取,发现`vinfoparam`是由`62455行`生成的数据。`f.param(b.vinfoparam)`,发现该函数传入了参数`b.vinfoparam`,鼠标停在该参数出现了数据cKey。所以可以断定重点在于`b.vinfoparam`,而不是函数`f.param`。
* \[图1.7\]![[图1.7]](https://i-blog.csdnimg.cn/blog_migrate/eba8f7894948cfe2e64ce504c445cb2b.png)
* 发现`b.vinfoparam`中的变量b是调用`e.requestPostCgi`时传入的参数(位于`62446`)
* 既然这样,看【图1.3】Call Stack,往上一层调用栈走,进入调用栈`c`。
* \[图1.8\]![[图1.8]](https://i-blog.csdnimg.cn/blog_migrate/642cc2cf6d5b1747fe5b59d3d87cb5a0.png)
* 传入的是
{ vinfoparam: g, adparam: e, domain: v, method: w
}
* 我们关注的对象是`vinfoparam: g`,往前找g的生成代码。看【图1.8】的`62742`进入函数`f.getInfoConfig`。却没有发现`cKey`的踪迹,既然我们无法直接知道,不如放个断点走一走。
* \[图1.9\]![[图1.9]](https://i-blog.csdnimg.cn/blog_migrate/d2b8b9b58befcdb4159718d3526734fb.png)\[图1.10\]![[图1.10]](https://i-blog.csdnimg.cn/blog_migrate/bffef21c1de62b06a391e0588194b96a.png)\[图1.11\]![[图1.11]](https://i-blog.csdnimg.cn/blog_migrate/1d2ba6e1a7391d4d1a229e101a026526.png)
* 看上图1.11,我们进入了`getInfoConfig`的调试中。
* \[图1.12\]![[图1.12]](https://i-blog.csdnimg.cn/blog_migrate/a06f974d703786292a728a11674a0426.png)\[图1.13\]![[图1.13]](https://i-blog.csdnimg.cn/blog_migrate/2c9f6175355e78280378eb8078330ac8.png)
* 一直往下走【看图1.12、图1.13】都发现cKey还没获取,一直到了`e(h)`。【图1.14】【图1.15】
* \[图1.14\]![[图1.14]](https://i-blog.csdnimg.cn/blog_migrate/c0c141705065e50a88d305865c77eaf6.png)\[图1.15\]![[图1.15]](https://i-blog.csdnimg.cn/blog_migrate/4657a0fbdc645f19c571edd28b9e42ca.png)
* `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()` 매개변수에 의해 제어된다는 것을 알 수 있습니다. 하지만 이것은 우리의 초점이 아닙니다. 우리가 분석하고 있는 것은 9.1 버전이므로 함수 `f()`로 들어갑니다.
주요 분석
function i(a, b, c, d, e) { 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】开始。
- 断点继续往下走,进入【图2.1】【图2.2】
- [图2.1][图2.2][图2.3]
- 返回的是变量
o
,那么我们重点关注他,走到o
,64084行
,进去,【图2.3】看到ua._getckey
,可以知道看来是找对地方了。
ua._getckey = function() { return g(ib, "런타임이 준비될 때까지 기다려야 합니다 (예: main()이 호출될 때까지 기다리세요)"), g(!jb, "런타임이 종료되었습니다 (main()이 종료된 후에도 계속 실행되도록 NO_EXIT_RUNTIME을 사용하세요)"), ua.asm._getckey.apply(null, arguments)
}
- 进去
ua.asm._getckey.apply(null, arguments)
,???wocao这是什么鬼【图2.4】 - [图2.4][图2.5]
- 这函数名怎么是个数字???而且发现也进不去,而且提示的是
native code
,这说明了这不是JS的原生代码,可能是其他语言实现的方法。 - 事实上这是
WebAssembly
,这是一种JS的一种可以理解成是交叉编程的一种方式,目的是为了提高JS运行效率,这是由C或者其他编程语言生成的代码,生成*.wasm然后交给WebAssembly加载处理运行。 - 可以通过【图2.5】看到加载的wasm文件,而其中的函数名29就是对应
wasm-0005098e-29
,你点进去查看就看反汇编到具体的指令。 - 好了,基本说明了这一种JS的技术,如果要了解更多就百度谷歌把。
- 那么重要的是要找到这被加载的
wasm
文件。 - 一个最简单的方法就是直接在
Sources
页面搜索wasm
就能找到加载的wasm文件。 - [图2.6]
- 对于找wasm也可以使用其他方法实现,但是既然是请求GET到的,当然能抓包到了,所以这里就偷懒不通过代码分析了。(不然篇幅会很长)
- 那么重要的是要找到这被加载的
- 要知道的是,我们虽然得到了wasm文件,但是任何交叉编程类的东西,都需要有接口,而这些接口或者必须提供的,所以我们还需要找到wasm接口部分,但这里先放一边,待会再进行。
- 通过【图2.4】可以看到的是传了参数
arguments
,虽然我们得到了wasm,但是我们还是需要知道参数arguments
才能实现算法。 - 而
arguments
就是前面【图2.3】传递的参数j
,我们要得到j
。 - 看【图2.2】进入函数
Ub()
和n()
,而n()
是由var n = $a[c[m]];
提供的。所以我们F5刷新下页面在【图2.2】重新断点。为的就是单步执行,找所需。 - [图2.7]
- 由【图2.7】出单步走,你会发现有两种
n
,一种是undefined
和
- 通过【图2.4】可以看到的是传了参数
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()
를 찾을 수 있으며, 여기에는 반복문 내의Ub
와f()
함수 내의k()
도 포함되어 있습니다. 그러면 정리할 수 있습니다.
- 大家应该发现了上面的函数
o(a, b, c)
调用了方法n(a, Ga, b, c)
,其中a, b,c
我们都知道,但是Ga
是什么东西? - 既然在Locan变量无法找到,那么网上一级找。看下图2.8
- [图2.8][图2.9]
- 发现上一级有
Ga
,所以,我们找到他了,看【图2.9】 - 既然知道了要找
Ga
的缘由,那么把所有对于给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
의 초기화를 해결했습니다. - 현재까지는 루프 부분을 해결했습니다.
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
- 앞서 우리는
i.apply(null, j);
에 대해 이야기했으며, 그의 코드는 wasm에 위치해 있습니다. - 따라서 현재 우리가 필요한 것은 wasm을 올바르게 로드하는 것이며, 이 단계를 완료하기만 하면 모든 함수가 연결되어 cKey를 구현할 수 있습니다.
- 먼저 아래 코드를 살펴보겠습니다.
var ub = ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea) var Cb = ub._getckey;
ub._getckey = function() { return g(ib, "실행 시간이 준비될 때까지 기다려야 합니다 (예: main()이 호출될 때까지 기다리세요)"), g(!jb, "실행 시간이 종료되었습니다 (main()이 종료된 후에도 계속 유지하려면 NO_EXIT_RUNTIME을 사용하세요)"), Cb.apply(null, arguments)
}
- 즉, 우리는 먼저
ub
가ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea)
임을 알게 됩니다. - 디버깅을 진행하여 다음 코드를 찾습니다.
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.github.io/emscripten-site/docs/compiling/WebAssembly.html#binaryen-methods"), h }
- 여기에는 wasm의 로드에 대한 내용이 있습니다. 그리고 이 모든 로드의 전제는 매개변수
a,b,c
를 아는 것입니다. 그래서 다시ua.asm(ua.asmGlobalArg, ua.asmLibraryArg, Ea)
로 돌아갑니다. - 즉,
ua.asmGlobalArg, ua.asmLibraryArg, Ea
이며, 그 중Ea
는 우리가 앞에서 언급한 바와 같이Ga
와 관련이 있습니다. - 쉽게 찾을 수 있습니다.
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
는 다음과 같습니다.
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
}
- 왜 이게 중요할까요? 처음에 후속 함수에 대한 집중 분석을 진행하면,
_getckey()
를 실행할 때call 20
이 발생한다는 것을 알 수 있습니다. 즉, wasm 파일의20번 함수
를 호출하는 것입니다. 하지만 【그림 2.5】를 자세히 보면20번
함수가 누락된 것을 발견할 수 있습니다. 이는 위에서 링크 인터페이스를 설정할 때 함수P()
를 링크했기 때문이며, 함수P()
가 바로20번
함수입니다. - 그 외의 다른 함수는 우리에게 큰 도움이 되지 않으므로, 빈 함수를 사용하여 링크할 수 있습니다.
- 그래서 저는 인터페이스 링크와 wasm 환경 설정을 다음과 같이 처리했습니다.
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, flush_NO_FILESYSTEM: fun_, DYNAMICTOP_PTR: 7968, tempDoublePtr: 7952, STACKTOP: 7984, STACK_MAX: 5250864, 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, };
- 지금까지 인터페이스의 연결이 완료되었으며, 즉 wasm을 로드할 수 있게 되었습니다. 그 다음에는 cKey에 대한 테스트를 진행할 수 있습니다.
- 앞서 변수나 함수의 위치를 찾는 방법에 대해 많은 분량을 할애했으므로, 이후에는 이 단계를 생략하고 바로 결과를 말씀드리겠습니다.
분석 종료
원래 이 글이 길기 때문에 전체 JS 코드는 여기서 붙여넣지 않습니다. JS 구현 코드를 보려면 _https://github.com/ZSAIm/iqiyi-parser/blob/master/js/tencent.js_에 들어가면 됩니다.
참고 및 설명
- Github 프로젝트 링크: 점 여기
- 본 기사는 기술 교류를 위한 것입니다.