前端核心程式碼保護技術面面觀

eroszhao發表於2019-04-05

1、 前言

Web的開放與便捷帶來了極高速的發展,但同時也帶來了相當多的隱患,特別是針對於核心程式碼保護上,自作者從事Web前端相關開發的相關工作以來,並未聽聞到太多相關於此的方案,『前端程式碼無祕密』這句話好似一個業界共識一般在前端領域傳播。但在日常的開發過程中,我們又會涉及以及需要相當強度的前端核心程式碼的加密,特別是在於與後端的資料通訊上面(包括HTTP、HTTPS請求以及WebSocket的資料交換)。

考慮一個場景,在視訊相關的產品中,我們通常需要增加相關的安全邏輯防止被直接盜流或是盜播。特別是對於直播來說,我們的直播視訊流檔案通常會被劃分為分片然後通過協商的演算法生成對應的URL引數並逐次請求。分片通常以5至10秒一個間隔,如果將分片URL的獲取作為介面完全放置於後端,那麼不僅會給後端帶來極大的壓力外還會帶來直播播放請求的延遲,因此我們通常會將部分實現放置於前端以此來減少後端壓力並增強體驗。對於iOS或是Android來說,我們可以將相關的演算法通過C/C++進行編寫,然後編譯為dylib或是so並進行混淆以此來增加破解的複雜度,但是對於前端來說,並沒有類似的技術可以使用。當然,自從asm.js及WebAssembly的全面推進後,我們可以使用其進一步增強我們核心程式碼的安全性,但由於asm.js以及WebAssembly標準的開放,其安全強度也並非想象中的那麼美好。

本文首先適當回顧目前流行的前端核心程式碼保護的相關技術思路及簡要的實現,後具體講述一種更為安全可靠的前端核心程式碼保護的思路(SecurityWorker)供大家借鑑以及改進。當然,作者並非專業的前端安全從業者,對部分技術安全性的理解可能稍顯片面及不足,歡迎留言一起探討。

2、 使用Javascript的混淆器

在我們的日常開發過程中,對於Javascript的混淆器我們是不陌生的,我們常常使用其進行程式碼的壓縮以及混淆以此來減少程式碼體積並增加人為閱讀程式碼的複雜度。常使用的專案包括:

Javascript混淆器的原理並不複雜,其核心是對目的碼進行AST Transformation(抽象語法樹改寫),我們依靠現有的Javascript的AST Parser庫,能比較容易的實現自己的Javascript混淆器。以下我們藉助 acorn 來實現一個if語句片段的改寫。

假設我們存在這麼一個程式碼片段:

for(var i = 0; i < 100; i++){
    if(i % 2 == 0){
        console.log("foo");
    }else{
        console.log("bar");
    }
}
複製程式碼

我們通過使用UglifyJS進行程式碼的混淆,我們能夠得到如下的結果:

for(var i=0;i<100;i++)i%2==0?console.log("foo"):console.log("bar");
複製程式碼

現在讓我們嘗試編寫一個自己的混淆器對程式碼片段進行混淆達到UglifyJS的效果:


const {Parser} = require("acorn")
const MyUglify = Parser.extend();

const codeStr = `
for(var i = 0; i < 100; i++){
    if(i % 2 == 0){
        console.log("foo");
    }else{
        console.log("bar");
    }
}
`;

function transform(node){
    const { type } = node;
    switch(type){
        case 'Program': 
        case 'BlockStatement':{
            const { body } = node;
            return body.map(transform).join('');
        }
        case 'ForStatement':{
            const results = ['for', '('];
            const { init, test, update, body } = node;
            results.push(transform(init), ';');
            results.push(transform(test), ';');
            results.push(transform(update), ')');
            results.push(transform(body));
            return results.join('');
        }
        case 'VariableDeclaration': {
            const results = [];
            const { kind, declarations } = node;
            results.push(kind, ' ', declarations.map(transform));
            return results.join('');
        }
        case 'VariableDeclarator':{
            const {id, init} = node;
            return id.name + '=' + init.raw;
        }
        case 'UpdateExpression': {
            const {argument, operator} = node;
            return argument.name + operator;
        }
        case 'BinaryExpression': {
            const {left, operator, right} = node;
            return transform(left) + operator + transform(right);
        }
        case 'IfStatement': {
            const results = [];
            const { test, consequent, alternate } = node;
            results.push(transform(test), '?');
            results.push(transform(consequent), ":");
            results.push(transform(alternate));
            return results.join('');
        }
        case 'MemberExpression':{
            const {object, property} = node;
            return object.name + '.' + property.name;
        }
        case 'CallExpression': {
            const results = [];
            const { callee, arguments } = node;
            results.push(transform(callee), '(');
            results.push(arguments.map(transform).join(','), ')');
            return results.join('');
        }
        case 'ExpressionStatement':{
            return transform(node.expression);
        }
        case 'Literal':
            return node.raw;
        case 'Identifier':
            return node.name;
        default:
            throw new Error('unimplemented operations');
    }
}

const ast = MyUglify.parse(codeStr);
console.log(transform(ast)); // 與UglifyJS輸出一致
複製程式碼

當然,我們上面的實現只是一個簡單的舉例,實際上的混淆器實現會比當前的實現複雜得多,需要考慮非常多的語法上的細節,此處僅拋磚引玉供大家參考學習。

從上面的實現我們可以看出,Javascript混淆器只是將Javascript程式碼變化為另一種更不可讀的形式,以此來增加人為分析的難度從而達到增強安全的目的。這種方式在很久以前具有很不錯的效果,但是隨著開發者工具越來越強大,實際上通過單步除錯可以很容易逆向出原始的Javascript的核心演算法。當然,後續也有相當多的庫做了較多的改進,JavaScript Obfuscator Tool 是其中的代表專案,其增加了諸如反除錯、變數字首、變數混淆等功能增強安全性。但萬變不離其宗,由於混淆後的程式碼仍然是明文的,如果有足夠的耐心並藉助開發者工具我們仍然可以嘗試還原,因此安全性仍然大打折扣。

3、 使用Flash的C/C++擴充套件方式

在Flash還大行其道的時期,為了更好的方便引擎開發者使用C/C++來提升Flash遊戲相關引擎的效能,Adobe開源了 CrossBridge 這個技術。在這種過程中,原有的C/C++程式碼經過LLVM IR變為Flash執行時所需要的目的碼,不管是從效率提升上還是從安全性上都有了非常大的提升。對於目前的開源的反編譯器來說,很難反編譯由CorssBridge編譯的C/C++程式碼,並且由於Flash執行時生產環境中禁用除錯,因此也很難進行對應的單步除錯。

使用Flash的C/C++擴充套件方式來保護我們的前端核心程式碼看起來是比較理想的方法,但Flash的移動端上已經沒有任何可被使用的空間,同時Adobe已經宣佈2020年不再對Flash進行維護,因此我們完全沒有理由再使用這種方法來保護我們前端的核心程式碼。

當然,由於Flash目前在PC上仍然有很大的佔有率,並且IE10以下的瀏覽器仍然有不少份額,我們仍舊可以把此作為一種PC端的相容方案考慮進來。

4、使用asm.js或WebAssembly

為了解決Javascript的效能問題,Mozilla提出了一套新的面相底層的Javascript語法子集 -- asm.js,其從JIT友好的角度出發,使得Javascript的整體執行效能有了很大的提升。後續Mozilla與其他廠商進行相關的標準化,產出了WebAssembly標準。

不管是asm.js或是WebAssembly,我們都可以將其看作為一個全新的VM,其他語言通過相關的工具鏈產出此VM可執行的程式碼。從安全性的角度來說,相比單純的Javascript混淆器而言,其強度大大的增加了,而相比於Flash的C/C++擴充套件方式來說,其是未來的發展方向,並現已被主流的瀏覽器實現。

可以編寫生成WebAssembly的語言及工具鏈非常多,我們使用C/C++及其Emscripten作為示範編寫一個簡單的簽名模組進行體驗。

#include <string>
#include <emscripten.h>
#include <emscripten/bind.h>
#include "md5.h"

#define SALTKEY "md5 salt key"

std::string sign(std::string str){
    return md5(str + string(SALTKEY));
}

// 此處匯出sign方法供Javascript外部環境使用
EMSCRIPTEN_BIND(my_module){
    emscripten::function("sign", &sign);
}
複製程式碼

接著,我們使用emscripten編譯我們的C++程式碼,得到對應的生成檔案。

em++ -std=c++11 -Oz --bind \
    -I ./md5 ./md5/md5.cpp ./sign.cpp \
    -o ./sign.js
複製程式碼

最後,我們引入生成sign.js檔案,然後進行呼叫。

<body>
    <script src="./sign.js"></script>
    <script>
        // output: 0b57e921e8f28593d1c8290abed09ab2
        Module.sign("This is a test string");
    </script>
</body>
複製程式碼

目前看起來WebAssembly是目前最理想的前端核心程式碼保護的方案了,我們可以使用C/C++編寫相關的程式碼,使用Emscripten相關工具鏈編譯為asm.js和wasm,根據不同的瀏覽器的支援情況選擇使用asm.js還是wasm。並且對於PC端IE10以下的瀏覽器,我們還可以通過CrossBridge複用其C/C++程式碼,產出對應的Flash目的碼,從而達到非常好的瀏覽器相容性。

然而使用asm.js/wasm後對於前端核心程式碼的保護就可以高枕無憂了麼?由於asm.js以及wasm的標準規範都是完全公開的,因此對於asm.js/wasm標準實現良好反編譯器來說,完全可以儘可能的產出閱讀性較強的程式碼從而分析出其中的核心演算法程式碼。但幸運的是,目前作者還暫時沒有找到實現良好的asm.js/wasm反編譯器,因此我暫時認為使用此種方法在保護前端核心程式碼的安全性上已經可堪重用了。

5、SecurityWorker - 更好的思路及其實現

作者在工作當中經常性會編寫前端核心相關的程式碼,並且這些程式碼大部分與通訊相關,例如AJAX的請求資料的加解密,WebSocket協議資料的加解密等。對於這部分工作,作者通常都會使用上面介紹的asm.js/wasm加CrossBridge技術方案進行解決。這套方案目前看來相當不錯,但是仍然存在幾個比較大的問題:

  1. 前端不友好,大部分前端工程師不熟悉C/C++、Rust等相關技術體系
  2. 無法使用龐大的npm庫,增加了很多工作成本
  3. 長遠來看並非會有很大的破解成本,還需要進一步對安全這塊進行提升

因此我們花費兩週時間編寫一套基於asm.js/wasm更好的前端核心程式碼保護方案:SecurityWorker

5.1 目標

SecurityWorker的目標相當簡單:能夠儘可能舒適的編寫具有極強安全強度的核心演算法模組。其拆分下來實際上需要滿足以下8點:

  1. 程式碼使用Javascript編寫,避免C/C++、Rust等技術體系
  2. 能夠很順利的使用npm相關庫,與前端生態接軌
  3. 最終程式碼儘可能小
  4. 保護性足夠強,目的碼執行邏輯及核心演算法完全隱匿
  5. Browser/小程式/NodeJS多環境支援
  6. 良好的相容性,主流瀏覽器全相容
  7. 易於使用,能夠複用標準中的技術概念
  8. 易於除錯,原始碼不混淆,報錯資訊準確具體

接下來我們會逐步講解SecurityWorker如何達成這些目標並詳細介紹其原理,供大家參考改進。

5.2 實現原理

如何在WebAssembly基礎上提升安全性?回想之前我們的介紹,WebAssembly在安全性上一個比較脆弱的點在於WebAssembly標準規範的公開,如果我們在WebAssembly之上再建立一個私有獨立的VM是不是可以解決這個問題呢?答案是肯定的,因此我們首要解決的問題是如何在WebAssembly之上建立一個Javascript的獨立VM。這對於WebAssembly是輕而易舉的,有非常多的專案提供了參考,例如基於SpiderMonkey編譯的 js.js 專案。但我們並沒有考慮使用SpiderMonkey,因為其產出的wasm程式碼達到了50M,在Web這樣程式碼體積大小敏感的環境基本不具有實際使用價值。但好在ECMAScirpt相關的嵌入式引擎非常之多:

  1. JerryScript
  2. V7
  3. duktape
  4. Espruino
  5. ...

經過比較選擇,我們選擇了duktape作為我們基礎的VM,我們的執行流程變成了如下圖所示:

01.jpg

當然,從圖中我們可以看到整個過程實際上會有一個比較大的風險點,由於我們的程式碼是通過字串加密的方式嵌入到C/C++中進行編譯的,因此在執行過程中,我們是能在記憶體的某一個執行時期等待程式碼解密完成後拿到核心程式碼的,如下圖所示:

02.jpg

如何解決這個問題?我們的解決思路是將Javascript變成另一種表現形式,也就是我們常見的opcode,例如假設我們有這樣的程式碼:

1 + 2;
複製程式碼

我們會將其轉變類似彙編指令的形式:

SWVM_PUSH_L 1  # 將1值壓入棧中
SWVM_PUSH_L 2  # 將2值壓入棧中
SWVM_ADD       # 對值進行相加,並將結果壓入棧中
複製程式碼

最後我們將編譯得到的opcode bytes按照uint8陣列的方式嵌入到C/C++中,然後進行整體編譯,如圖所示:

03.jpg

整個過程中,由於我們的opcode設計是私有不公開的,並且已經不存在明文的Javascript程式碼了,因此安全性得到了極大的提升。如此這樣我們解決了目標中的#1、#2、#4。但Javascript已經被重新組織為opcode了,那麼如何保證目標中的#8呢?解決方式很簡單,我們在Javascript編譯為opcode的關鍵步驟上附帶了相關的資訊,使得程式碼執行出錯後,能夠根據相關資訊進行準確的報錯。與此同時,我們精簡了opcode的設計,使得生成的opcode體積小於原有的Javascript程式碼。

duktape除了語言實現和部分標準庫外並不還有一些外圍的API,例如AJAX/WebSocket等,考慮到使用的便捷性以及更容易被前端開發者接收並使用,我們為duktape實現了部分的WebWorker環境的API,包括了Websocket/Console/Ajax等,並與Emscripten提供的Fetch/WebSocket等實現結合得到了SecurityWorker VM。

那麼最後的問題是我們如何減小最終生成的asm.js/wasm程式碼的體積大小?在不進行任何處理的時候,我們的生成程式碼由於包含了duktape以及諸多外圍API的實現,即使一個Hello World的程式碼gzip後也會有340kb左右的大小。為了解決這個問題,我們編寫了SecurityWorker Loader,將生成程式碼進行處理後與SecurityWorker Loader的實現一起編譯得到最終的檔案。在程式碼執行時,SecurityWorker Loader會對需要執行的程式碼進行釋放然後再進行動態執行。如此一來,我們將原有的程式碼體積從原有gzip也會有340kb左右的大小降低到了180kb左右。

5.3 侷限性

SecurityWorker解決了之前方案的許多問題,但其同樣不是最完美的方案,由於我們在WebAssembly上又建立了一個VM,因此當你的應用對於體積敏感或是要求極高的執行效率時,SecurityWorker就不滿足你的要求了。當然SecurityWorker可以應用多種優化手段在當前基礎上再大幅度的所見體積大小以及提高效率,但由於其已經達到我們自己現有的需求和目標,因此目前暫時沒有提升的相關計劃。

6、結語

我們通過回顧目前主流的前端核心保護方案,並詳細介紹了基於之前方案做的提升方案SecurityWorker,相信大家對整個前端核心演算法保護的技術方案已經有一個比較清晰的認識了。當然,對於安全的追求沒有終途,SecurityWorker也不是最終完美的方案,希望本文的相關介紹能讓更多人蔘與到WebAssembly及前端安全領域中來,讓Web變得更好。

轉載請私信徵求作者意願,謝謝

相關文章