MSL Logger

Decrypts Netflix MSL messages and logs them

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MSL Logger
// @namespace    http://greasyfork.org/
// @version      0.1.1
// @description  Decrypts Netflix MSL messages and logs them
// @author       DevLARLEY
// @license      CC BY-NC-ND 4.0
// @noframes
// @match        *://*/*
// @run-at       document-start
// ==/UserScript==

(async () => {
    const proxy = (object, method, handler) => {
        if (Object.hasOwnProperty.call(object, method)) {
            const original = object[method];
            Object.defineProperty(object, method, {
                value: new Proxy(original, {apply: handler})
            });
        }
    };

    const decode = {
        b64: s => Uint8Array.from(atob(s), c => c.charCodeAt(0)),
        b64Text: s => atob(s),
        utf8: s => new TextDecoder("utf-8").decode(s)
    };

    /* https://github.com/Netflix/msl/blob/63721e8fa2fee08b1e194b15cf906933590b6467/core/src/main/javascript/util/LzwCompression.js */
    function lzwUncompress(data, maxDeflateRatio) {
        let BYTE_SIZE = 8;
        let BYTE_RANGE = 256;

        let UNCOMPRESS_DICTIONARY = [];
        for (let ui = 0; ui < BYTE_RANGE; ++ui) {
            UNCOMPRESS_DICTIONARY[ui] = [ui];
        }

        let dictionary = UNCOMPRESS_DICTIONARY.slice();

        let codeIndex = 0;
        let codeOffset = 0;
        let bits = BYTE_SIZE;
        let uncompressed = new Uint8Array(Math.ceil(data.length * 1.5));
        let index = 0;
        let nextIndex = 0;
        let prevvalue = [];

        while (codeIndex < data.length) {
            let bitsAvailable = (data.length - codeIndex) * BYTE_SIZE - codeOffset;
            if (bitsAvailable < bits)
                break;

            let code = 0;
            let bitsDecoded = 0;
            while (bitsDecoded < bits) {
                let bitlen = Math.min(bits - bitsDecoded, BYTE_SIZE - codeOffset);
                let msbits = data[codeIndex];

                msbits <<= codeOffset;
                msbits &= 0xff;
                msbits >>>= BYTE_SIZE - bitlen;

                bitsDecoded += bitlen;
                codeOffset += bitlen;
                if (codeOffset === BYTE_SIZE) {
                    codeOffset = 0;
                    ++codeIndex;
                }

                code |= (msbits & 0xff) << (bits - bitsDecoded);
            }

            let value = dictionary[code];

            if (prevvalue.length === 0) {
                ++bits;
            } else {
                if (!value) {
                    prevvalue.push(prevvalue[0]);
                } else {
                    prevvalue.push(value[0]);
                }

                dictionary[dictionary.length] = prevvalue;
                prevvalue = [];

                if (dictionary.length === (1 << bits))
                    ++bits;

                if (!value)
                    value = dictionary[code];
            }

            nextIndex = index + value.length;

            if (nextIndex > maxDeflateRatio * data.length)
                throw new Error("Deflate ratio " + maxDeflateRatio + " exceeded. Aborting uncompression.");

            if (nextIndex >= uncompressed.length) {
                let u = new Uint8Array(Math.ceil(nextIndex * 1.5));
                u.set(uncompressed);
                uncompressed = u;
            }

            uncompressed.set(value, index);
            index = nextIndex;

            prevvalue = prevvalue.concat(value);
        }

        return uncompressed.subarray(0, index);
    }

    const db = indexedDB.open('netflix.player');
    db.onsuccess = e => {
        const namedatapairs = e.target.result.transaction('namedatapairs').objectStore('namedatapairs');
        namedatapairs.get('mslstore').onsuccess = e => {
            db.encryptionKey = e.target.result.data.encryptionKey;
        }
    };

    const parseObjects = (str) => {
        return str.split('}{')
            .map((s, i, arr) => {
                if (i > 0) s = '{' + s;
                if (i < arr.length - 1) s = s + '}';
                return JSON.parse(s);
            });
    }

    async function decryptCipherEnvelope(envelope) {
        const plaintext = await crypto.subtle.decrypt(
            {
                name: "AES-CBC",
                iv: decode.b64(envelope.iv)
            },
            db.encryptionKey,
            decode.b64(envelope.ciphertext)
        );

        return JSON.parse(decode.utf8(plaintext));
    }

    async function decryptPayloadChunks(chunks) {
        const parts = [];

        for (const chunk of chunks) {
            const chunkJson = JSON.parse(decode.b64Text(chunk.payload));
            const decryptedJson = await decryptCipherEnvelope(chunkJson);

            let appData = decode.b64(decryptedJson.data);
            if (decryptedJson.compressionalgo === "LZW") {
                appData = decode.utf8(lzwUncompress(appData, 200));
            } else if (decryptedJson.compressionalgo === "GZIP") {
                const ds = new DecompressionStream("gzip");
                const stream = new Blob([appData]).stream().pipeThrough(ds);
                const buffer = await new Response(stream).arrayBuffer();
                appData = decode.utf8(new Uint8Array(buffer));
            } else {
                appData = decode.utf8(appData);
            }

            parts.push(appData);
        }

        return JSON.parse(parts.join(""));
    }

    async function decryptHeader(header) {
        const tokendata = JSON.parse(decode.b64Text(header.mastertoken.tokendata));

        const headerdataCipher = JSON.parse(decode.b64Text(header.headerdata));
        const headerdata = await decryptCipherEnvelope(headerdataCipher);

        return { tokendata, headerdata }
    }

    async function handleCadmiumData(type, data, url) {
        if (!db.encryptionKey)
            return;

        const mslObjects = parseObjects(data);
        const header = await decryptHeader(mslObjects[0]);
        const payload = await decryptPayloadChunks(mslObjects.slice(1));

        console.groupCollapsed(
            `%c MSL %c [%s]%c Message ID: %c%s%c, Sender: %c%s%c, Time: %c%s%c`,
            `background: #0F5257; color: white;`, `color: white; font-weight: bold;`, type, "color: white",
            "color: #8AA2A9", header.headerdata.messageid, "color: white",
            "color: #8AA2A9", header.headerdata.sender, "color: white",
            "color: #8AA2A9", (new Date(header.headerdata.timestamp * 1000)).toLocaleString(), "color: white",
        );

            console.groupCollapsed("[HEADER]");
            console.log(header.headerdata);

                console.groupCollapsed("[MASTER TOKEN]");
                console.log(header.tokendata);
                console.groupEnd();

            console.groupEnd();

            console.groupCollapsed("[PAYLOAD]");
            console.log(payload);
            console.groupEnd();

        console.groupEnd();
    }

    proxy(XMLHttpRequest.prototype, "open", (target, thisArg, args) => {
        const [method, url] = args;

        thisArg.requestMethod = method;
        thisArg.requestURL = url;

        return Reflect.apply(target, thisArg, args);
    });

    proxy(XMLHttpRequest.prototype, "send", async (target, thisArg, args) => {
        const body = args[0];

        if (thisArg.requestMethod === "POST" && thisArg.requestURL.includes("cadmium") && !!body && typeof body === "string") {
            thisArg.addEventListener("readystatechange", async () => {
                if (thisArg.readyState === 4) {
                    await handleCadmiumData("RESPONSE", thisArg.responseText, thisArg.requestURL);
                }
            });
            await handleCadmiumData("REQUEST", body, thisArg.requestURL);
        }

        return Reflect.apply(target, thisArg, args);
    });
})();