'Tencent Video cKey 9.1의 생성 분석 및 구현'

콘텐츠

다음에 필요한 지식은 숙달할 필요는 없고, 기본적인 문법을 이해하고 알면 충분합니다.

본문

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.1][图2.2][图2.2][图2.3][图2.3]
    • 返回的是变量o,那么我们重点关注他,走到o64084行,进去,【图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.4][图2.5][图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][图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]
    • 由【图2.7】出单步走,你会发现有两种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()를 찾을 수 있으며, 여기에는 반복문 내의 Ubf() 함수 내의 k()도 포함되어 있습니다. 그러면 정리할 수 있습니다.
  • 大家应该发现了上面的函数o(a, b, c)调用了方法n(a, Ga, b, c),其中a, b,c 我们都知道,但是Ga是什么东西?
  • 既然在Locan变量无法找到,那么网上一级找。看下图2.8
  • [图2.8][图2.8][图2.9][图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)
}
  • 즉, 우리는 먼저 ubua.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 프로젝트 링크: 점 여기
  • 본 기사는 기술 교류를 위한 것입니다.
요약하다
腾讯视频的cKey是解析视频地址的关键,当前有8.1和9.1两个版本。本文分析9.1版本,使用chromium开发者工具进行抓包,重点在于找到加密算法和请求数据的过程。通过设置断点和查看调用栈,逐步定位到cKey的生成过程,最终获取所需数据。