初遇XOR加密: ximalaya加密算法分析

今年2月,我针对喜马拉雅www2接口进行了一次JS逆向,成功取回了解密的方法。一般来说前端加密很多时候都是找个加密库,调API来实现加解密,但喜马拉雅这次却用原生JS手撸了加解密。正好我最近正打算换语言重构这个项目,于是又捡起了被我遗忘了一段时间的旧代码。我看着从喜马拉雅生扒下来的一串解密函数,寻思着能不能参透其中的加密方法。

一、解密函数一瞥

const r = new Uint8Array([188, 174, 178, 234, 171, 147, 70, 82, 76, 72, 192, 132, 60, 17, 30, 127, 184, 233, 48, 105, 38, 232, 240, 21, 47, 252, 41, 229, 209, 213, 71, 40, 63, 152, 156, 88, 51, 141, 139, 145, 133, 2, 160, 191, 11, 100, 10, 78, 253, 151, 42, 166, 92, 22, 185, 140, 164, 91, 194, 175, 239, 217, 177, 75, 19, 225, 94, 107, 125, 138, 242, 31, 182, 150, 15, 24, 226, 29, 80, 116, 168, 118, 28, 1, 186, 220, 158, 79, 59, 244, 119, 9, 189, 161, 74, 130, 221, 56, 216, 241, 212, 26, 218, 170, 85, 165, 153, 69, 238, 93, 255, 142, 3, 159, 215, 67, 33, 249, 53, 176, 77, 254, 222, 25, 115, 101, 148, 16, 13, 237, 197, 5, 58, 157, 135, 248, 223, 61, 198, 211, 110, 44, 54, 111, 52, 227, 4, 46, 205, 7, 219, 136, 14, 87, 114, 64, 104, 50, 39, 203, 81, 196, 43, 163, 173, 109, 108, 187, 102, 195, 37, 235, 65, 190, 113, 149, 143, 8, 27, 155, 207, 134, 123, 224, 129, 245, 62, 66, 172, 122, 126, 12, 162, 214, 90, 247, 251, 124, 201, 236, 117, 183, 73, 95, 89, 246, 181, 179, 83, 228, 193, 99, 6, 45, 112, 32, 154, 128, 230, 131, 206, 243, 57, 84, 146, 0, 35, 96, 250, 137, 36, 208, 103, 34, 68, 204, 231, 144, 120, 98, 202, 49, 210, 23, 200, 18, 86, 55, 121, 20, 199, 97, 167, 180, 169, 106])
    , n = new Uint8Array([20, 234, 159, 167, 230, 233, 58, 255, 158, 36, 210, 254, 133, 166, 59, 63, 209, 177, 184, 155, 85, 235, 94, 1, 242, 87, 228, 232, 191, 3, 69, 178])
    , o = new Uint8Array([183, 174, 108, 16, 131, 159, 250, 5, 239, 110, 193, 202, 153, 137, 251, 176, 119, 150, 47, 204, 97, 237, 1, 71, 177, 42, 88, 218, 166, 82, 87, 94, 14, 195, 69, 127, 215, 240, 225, 197, 238, 142, 123, 44, 219, 50, 190, 29, 181, 186, 169, 98, 139, 185, 152, 13, 141, 76, 6, 157, 200, 132, 182, 49, 20, 116, 136, 43, 155, 194, 101, 231, 162, 242, 151, 213, 53, 60, 26, 134, 211, 56, 28, 223, 107, 161, 199, 15, 229, 61, 96, 41, 66, 158, 254, 21, 165, 253, 103, 89, 3, 168, 40, 246, 81, 95, 58, 31, 172, 78, 99, 45, 148, 187, 222, 124, 55, 203, 235, 64, 68, 149, 180, 35, 113, 207, 118, 111, 91, 38, 247, 214, 7, 212, 209, 189, 241, 18, 115, 173, 25, 236, 121, 249, 75, 57, 216, 10, 175, 112, 234, 164, 70, 206, 198, 255, 140, 230, 12, 32, 83, 46, 245, 0, 62, 227, 72, 191, 156, 138, 248, 114, 220, 90, 84, 170, 128, 19, 24, 122, 146, 80, 39, 37, 8, 34, 22, 11, 93, 130, 63, 154, 244, 160, 144, 79, 23, 133, 92, 54, 102, 210, 65, 67, 27, 196, 201, 106, 143, 52, 74, 100, 217, 179, 48, 233, 126, 117, 184, 226, 85, 171, 167, 86, 2, 147, 17, 135, 228, 252, 105, 30, 192, 129, 178, 120, 36, 145, 51, 163, 77, 205, 73, 4, 188, 125, 232, 33, 243, 109, 224, 104, 208, 221, 59, 9])
    , a = new Uint8Array([204, 53, 135, 197, 39, 73, 58, 160, 79, 24, 12, 83, 180, 250, 101, 60, 206, 30, 10, 227, 36, 95, 161, 16, 135, 150, 235, 116, 242, 116, 165, 171])
    , i = "function" == typeof atob
    , u = "function" == typeof e;
"function" == typeof TextDecoder && new TextDecoder,
"function" == typeof TextEncoder && new TextEncoder;

const f = i ? e => atob(e.replace(/[^A-Za-z0-9\+\/]/g, "")) : u ? t => e.from(t, "base64").toString("binary") : e => {
    if (e = e.replace(/\s+/g, ""),
        !s.test(e))
        throw new TypeError("malformed base64.");
    e += "==".slice(2 - (3 & e.length));
    let t, r, n, o = "";
    for (let a = 0; a < e.length;)
        t = c[e.charAt(a++)] << 18 | c[e.charAt(a++)] << 12 | (r = c[e.charAt(a++)]) << 6 | (n = c[e.charAt(a++)]),
            o += 64 === r ? l(t >> 16 & 255) : 64 === n ? l(t >> 16 & 255, t >> 8 & 255) : l(t >> 16 & 255, t >> 8 & 255, 255 & t);
    return o
};

function p(e, t, r) {
    let n = Math.min(e.length - t, r.length);
    for (let o = 0; o < n; o++)
        e[o + t] = e[o + t] ^ r[o]
};

function decrypt(e) {
    // console.log(e);
    const i = 'www2', t = e;
    // const { link: t = "", deviceType: i = "www2" } = e;
    // console.log(e);
    let u = o
    , c = a;
    ["www2", "mweb2"].includes(i) || (u = r,
    c = n);
    try {
        let e = f(t.replace(/_/g, "/").replace(/-/g, "+"));
        if (null === e || e.length < 16)
            return t;
        let r = new Uint8Array(e.length - 16);
        for (let t = 0; t < e.length - 16; t++)
            r[t] = e.charCodeAt(t);
        let n = new Uint8Array(16);
        for (let t = 0; t < 16; t++)
            n[t] = e.charCodeAt(e.length - 16 + t);
        for (let e = 0; e < r.length; e++)
            r[e] = u[r[e]];
        for (let e = 0; e < r.length; e += 16)
            p(r, e, n);
        for (let e = 0; e < r.length; e += 32)
            p(r, e, c);
        return function (e) {
            var t, r, n, o, a, i;
            for (t = "",
                n = e.length,
                r = 0; r < n;)
                switch ((o = e[r++]) >> 4) {
                    case 0:
                    case 1:
                    case 2:
                    case 3:
                    case 4:
                    case 5:
                    case 6:
                    case 7:
                        t += String.fromCharCode(o);
                        break;
                    case 12:
                    case 13:
                        a = e[r++],
                            t += String.fromCharCode((31 & o) << 6 | 63 & a);
                        break;
                    case 14:
                        a = e[r++],
                            i = e[r++],
                            t += String.fromCharCode((15 & o) << 12 | (63 & a) << 6 | (63 & i) << 0)
                }
            return t
        }(r)
    } catch (e) {
        return console.warn(e, "secret failed"),
            ""
    }
};

这里的解密主函数decrypt,其实是被我改写过的getSoundCryptLink。那么就先从这个主函数看起,decrypt所接收的参数e,是密文URL。函数体内先定义常量i为www2,再将密文URL的值e赋值给常量t。接下来定义两个变量u和c,将常量o和a分别赋值给它们。

这些相当于函数内的初始化操作,真正的好戏,就在接下来的try…catch的语句中。

二、解密第一步,进制转换

let e = f(t.replace(/_/g, "/").replace(/-/g, "+"));

try…catch内先定义了一个变量e,先将t(密文URL)中的“_”和“-”分别替换为“/”和“+”,使其符合base64规范,再将替换好的t传给常量f做进一步处理。

再看下对字符串做进一步加工的常量f是什么:

const f = i ? e => atob(e.replace(/[^A-Za-z0-9\+\/]/g, "")) : u ? t => e.from(t, "base64").toString("binary") : e => {
    if (e = e.replace(/\s+/g, ""),
        !s.test(e))
        throw new TypeError("malformed base64.");
    e += "==".slice(2 - (3 & e.length));
    let t, r, n, o = "";
    for (let a = 0; a < e.length;)
        t = c[e.charAt(a++)] << 18 | c[e.charAt(a++)] << 12 | (r = c[e.charAt(a++)]) << 6 | (n = c[e.charAt(a++)]),
            o += 64 === r ? l(t >> 16 & 255) : 64 === n ? l(t >> 16 & 255, t >> 8 & 255) : l(t >> 16 & 255, t >> 8 & 255, 255 & t);
    return o
};

常量f内是层层嵌套的三目运算符:如果i为真,则调用i后的箭头函数进行字符串处理;如果i为假,u为真,就调用u后的箭头函数;如果i和u都不为真,则另有一个箭头函数来处理字符串。

这三个箭头函数的功能都是等价的。结合上下文来看,在实际操作中执行的都是i后的逻辑,即atob(e.replace(/[^A-Za-z0-9\+\/]/g, “”))。这个表达式用于将base64字符转换成二进制。

所以变量e的值是一串二进制数值。

理解了常量f的作用后,其实可以小小改一下u后面的逻辑t => e.from(t, “base64”).toString(“binary”)。因为这里的e没有定义,如果你直接设置i为假,u为真,运行到这里会报错ReferenceError: e is not defined。但既然已经知道了f就是将base64转为二进制,其实可以做如下改动:

t => Buffer.from(t, "base64").toString("binary")

混淆的字符串——清洗使其符合base64编码规范——将base64转为二进制,这一套倒腾完了,你以为进制转换就此打住了吗?二进制无法直接打印,打印出来是乱码,那么就到了下一步,将二进制转为Unicode编码:

let r = new Uint8Array(e.length - 16);
for (let t = 0; t < e.length - 16; t++)
    r[t] = e.charCodeAt(t);

这里定义了一个长度为e.length – 16的空Uint8Array,即变量r,用于储存进一步转码后的数据。接下来用一个for循环遍历e中除去最后16个字符的部分,用charCodeAt将e中每一个二进制值转为对应的Unicode值,并存入r中。

最后16个字符去哪儿了?别急,先来看接下来的代码:

let n = new Uint8Array(16);
for (let t = 0; t < 16; t++)
    n[t] = e.charCodeAt(e.length - 16 + t);

操作和上面一模一样,只不过这次循环的对象换成了e最后16个字符。for循环将这16个字符的Unicode值存入了一个长度为16的Uint8Array,变量n中。这最后16个字符和变量n到底有何作用,留待第三部分细讲。

接下来是字符替换:

for (let e = 0; e < r.length; e++)
    r[e] = u[r[e]];

因为在函数体开头的位置,已经将常量o赋值给了u。这个for循环是将r中的元素作为索引,替换为常量o中对应索引的值。自此,又一轮换血完成。

三、解密第二步,XOR运算

下来的两个for循环才是耐人寻味:

for (let e = 0; e < r.length; e += 16)
    p(r, e, n);
for (let e = 0; e < r.length; e += 32)
    p(r, e, c);

这两个for循环一个步长为16,另一个是32,一般不都是自增++吗。这个for循环为何要这样设置,得先来看看函数p:

function p(e, t, r) {
    let n = Math.min(e.length - t, r.length);
    for (let o = 0; o < n; o++)
        e[o + t] = e[o + t] ^ r[o]
};

函数p非常简短,接受3个参数,从调用上我们可以得知e就是换血后的r,t是for循环的索引变量,r是由那最后16个字符的Unicode值组成的变量u。

p的函数体内,先定义变量n,用Math.min函数算出e.length – t, r.length两个参数中的最小值。实践中,n的值其实等于r的长度。

接下来的for循环,循环变量o从0开始,一直到n-1,遍历数组e中[o + t]位置的值,将其和数组r中[o]位置的值做异或运算(^是javascript里的异或运算符,什么是异或请参考百科)。

用文字表述可能比较难理解,下面我代入一些变量解释一下:

for (let e = 0; e < r.length; e += 16)
    p(r, e, n);

// 循环变量为0时,已知n长度为16,假设r长度为104
p(r, 0, n);

function p(e, t, r) {
    let n = Math.min(e.length - t, r.length); // Math.min(104-0, 16) 结果为16,因为for循环一跨为16,因此下次t为16,以此类推
    for (let o = 0; o < n; o++) // 循环0~15
        e[o + t] = e[o + t] ^ r[o] // e[0 + 0] =  e[0 + 0] ^ r[0]
};

函数p里的变量n保证了,p每次只操作从索引变量t往后推n位的字符串。因为n基本上等于r的长度,这里就是每次只操作16个字符。至于为什么for循环步长是16,因为数组n的长度是16嘛!所以再通俗一点,就是每次截取长度为16的数组r,和长度为16的数组n进行异或运算。

那么到这里,你就明白了,为什么之前二进制转Unicode值的时候要特意将最后16位单独作为一个数组。因为最后16位,相当于key。

一次异或就够了吗?不够!

for (let e = 0; e < r.length; e += 32)
    p(r, e, c);

第二次异或,for循环的步长变为32。这次的key变成了变量c,回到开头,一个长度为32的数组常量a被赋值给了c。所以,这次就是每次截取长度为32的数组r,和秘钥c进行异或运算。

异或两次后,进入switch选择分支,将数组r中的Unicode值用String.fromCharCode逐个转为字符:

case 7:
    t += String.fromCharCode(o);
    break;

Bingo!五一快乐!

后记:我到网上查了一下,不少音频加密都选择异或加密,因为相对简单,解密速度快,很适合网页播放试听的场景,这种场景下要求简单快捷,对安全性反而不是那么看重。

发布者

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注