前端核心代碼保護技術面面觀
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(vari=0;i<100;i++){if(i%2==0){console.log("foo");}else{console.log("bar");}}
我們通過使用UglifyJS進行代碼的混淆,我們能夠得到如下的結果:
for(vari=0;i<100;i++)i%2==0?console.log("foo"):console.log("bar");
現在讓我們嘗試編寫一個自己的混淆器對代碼片段進行混淆達到UglifyJS的效果:
const{Parser}=require("acorn")constMyUglify=Parser.extend();constcodeStr=`for(var i = 0; i < 100; i++){if(i % 2 == 0){console.log("foo");}else{console.log("bar");}}`;functiontransform(node){const{type}=node;switch(type){case'Program':case'BlockStatement':{const{body}=node;returnbody.map(transform).join('');}case'ForStatement':{constresults=['for','('];const{init,test,update,body}=node;results.push(transform(init),';');results.push(transform(test),';');results.push(transform(update),')');results.push(transform(body));returnresults.join('');}case'VariableDeclaration':{constresults=[];const{kind,declarations}=node;results.push(kind,' ',declarations.map(transform));returnresults.join('');}case'VariableDeclarator':{const{id,init}=node;returnid.name+'='+init.raw;}case'UpdateExpression':{const{argument,operator}=node;returnargument.name+operator;}case'BinaryExpression':{const{left,operator,right}=node;returntransform(left)+operator+transform(right);}case'IfStatement':{constresults=[];const{test,consequent,alternate}=node;results.push(transform(test),'?');results.push(transform(consequent),":");results.push(transform(alternate));returnresults.join('');}case'MemberExpression':{const{object,property}=node;returnobject.name+'.'+property.name;}case'CallExpression':{constresults=[];const{callee,arguments}=node;results.push(transform(callee),'(');results.push(arguments.map(transform).join(','),')');returnresults.join('');}case'ExpressionStatement':{returntransform(node.expression);}case'Literal':returnnode.raw;case'Identifier':returnnode.name;default:thrownewError('unimplemented operations');}}constast=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技術方案進行解決。這套方案目前看來相當不錯,但是仍然存在幾個比較大的問題:
- 前端不友好,大部分前端工程師不熟悉C/C++、Rust等相關技術體系
- 無法使用龐大的npm庫,增加了很多工作成本
- 長遠來看並非會有很大的破解成本,還需要進一步對安全這塊進行提升。
因此我們花費兩週時間編寫一套基於asm.js/wasm更好的前端核心代碼保護方案: SecurityWorker 。
5.1 目標
SecurityWorker的目標相當簡單:能夠盡可能舒適的編寫具有極強安全強度的核心算法模塊。其拆分下來實際上需要滿足以下8點:
- 代碼使用Javascript編寫,避免C/C++、Rust等技術體系
- 能夠很順利的使用npm相關庫,與前端生態接軌
- 最終代碼盡可能小
- 保護性足夠強,目標代碼執行邏輯及核心算法完全隱匿
- Browser/小程序/NodeJS多環境支持(默認不允許Node端使用,防止大規模黑盒調用)
- 良好的兼容性,主流瀏覽器全兼容
- 易於使用,能夠復用標準中的技術概念
- 易於調試,源碼不混淆,報錯訊息準確具體
接下來我們會逐步講解SecurityWorker如何達成這些目標並詳細介紹其原理,供大家參考改進。
5.2 實現原理
如何在WebAssembly基礎上提升安全性?回想之前我們的介紹,WebAssembly在安全性上一個比較脆弱的點在於WebAssembly標準規範的公開,如果我們在WebAssembly之上再創建一個私有獨立的VM是不是可以解決這個問題呢?答案是肯定的,因此我們首要解決的問題是如何在WebAssembly之上建立一個Javascript的獨立VM。這對於WebAssembly是輕而易舉的,有非常多的項目提供了參考,例如基於SpiderMonkey編譯的js.js項目。但我們並沒有考慮使用SpiderMonkey,因為其產出的wasm代碼達到了50M,在Web這樣代碼體積大小敏感的環境基本不具有實際使用價值。但好在ECMAScirpt相關的嵌入式引擎非常之多:
- JerryScript
- V7
- duktape
- Espruino
- ...
經過比較選擇,我們選擇了duktape作為我們基礎的VM,我們的執行流程變成瞭如下圖所示:

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

如何解決這個問題?我們的解決思路是將Javascript變成另一種表現形式,也就是我們常見的opcode,例如假設我們有這樣的代碼:
1+2;
我們會將其轉變類似彙編指令的形式:
SWVM_PUSH_L 1 # 将1值压入栈中
SWVM_PUSH_L 2 # 将2值压入栈中
SWVM_ADD # 对值进行相加,并将结果压入栈中
最後我們將編譯得到的opcode bytes按照uint8數組的方式嵌入到C/C++中,然後進行整體編譯,如圖所示:

整個過程中,由於我們的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變得更好。