大前端時代安全性如何做
之前在上家公司的時候做過一些爬蟲的工作,也幫助爬蟲工程師解決過一些問題。然后我寫過一些文章發布到網上,之后有一些人就找我做一些爬蟲的外包,內容大概是爬取小紅書的用戶數據和商品數據,但是我沒做。我覺得對于國內的大數據公司沒幾家是有真正的大數據量,而是通過爬蟲工程師團隊不斷的去各地爬取數據,因此不要以為我們的數據沒價值,對于內容型的公司來說,數據是可信競爭力。那么我接下來想說的就是網絡和數據的安全性問題。
對于內容型的公司,數據的安全性很重要。對于內容公司來說,數據的重要性不言而喻。比如你一個做在線教育的平臺,題目的數據很重要吧,但是被別人通過爬蟲技術全部爬走了?如果核心競爭力都被拿走了,那就是涼涼。再比說有個獨立開發者想抄襲你的產品,通過抓包和爬蟲手段將你核心的數據拿走,然后短期內做個網站和 App,短期內成為你的勁敵。
背景
目前通過 App 中的 網頁分析后,我們的數據安全性做的較差,有以下幾個點存在問題:
網站的數據通過最早期的前后端分離來實現。稍微學過 Web 前端的工程師都可以通過神器 Chrome 分析網站,進而爬取需要的數據。打開 「Network」就可以看到網站的所有網絡請求了,哎呀,不小心我看到了什么?沒錯就是網站的接口信息都可以看到了。比如 “detail.json?itemId=141529859”。或者你的網站接口有些特殊的判斷處理,將一些信息存儲到 sessionStorage、cookie、localStorage 里面,有點前端經驗的爬蟲工程師心想”嘿嘿嘿,這不是在裸奔數據么“。或者有些參數是通過 JavaScript 臨時通過函數生成的。問題不大,工程師也可以對網頁元素進行查找,找到關鍵的 id、或者 css 類名,然后在 "Search“ 可以進行查找,找到對應的代碼 JS 代碼,點擊查看代碼,如果是早期前端開發模式那么代碼就是裸奔的,跟開發者在自己的 IDE 里面看到的內容一樣,有經驗的爬蟲就可以拿這個做事情,因此安全性問題亟待解決。
App 的數據即使采用了 HTTPS,但是對于專業的抓包工具也是可以直接拿到數據的,因此 App 的安全問題也可以做一些提高,具體的策略下文會講到。
爬蟲手段
- 目前爬蟲技術都是從渲染好的 html 頁面直接找到感興趣的節點,然后獲取對應的文本
- 有些網站安全性做的好,比如列表頁可能好獲取,但是詳情頁就需要從列表頁點擊對應的 item,將 itemId 通過 form 表單提交,服務端生成對應的參數,然后重定向到詳情頁(重定向過來的地址后才帶有詳情頁的參數 detailID),這個步驟就可以攔截掉一部分的爬蟲開發者
解決方案
制定出Web 端反爬技術方案
本人從這2個角度(網頁所見非所得、查接口請求沒用)出發,制定了下面的反爬方案。
- 使用HTTPS 協議
- 單位時間內限制掉請求次數過多,則封鎖該賬號
- 前端技術限制 (接下來是核心技術)
- # 比如需要正確顯示的數據為“19950220”
- 1. 先按照自己需求利用相應的規則(數字亂序映射,比如正常的0對應還是0,但是亂序就是 0 <-> 1,1 <-> 9,3 <-> 8,...)制作自定義字體(ttf)
- 2. 根據上面的亂序映射規律,求得到需要返回的數據 19950220 -> 17730220
- 3. 對于第一步得到的字符串,依次遍歷每個字符,將每個字符根據按照線性變換(y=kx+b)。線性方程的系數和常數項是根據當前的日期計算得到的。比如當前的日期為“2018-07-24”,那么線性變換的 k 為 7,b 為 24。
- 4. 然后將變換后的每個字符串用“3.1415926”拼接返回給接口調用者。(為什么是3.1415926,因為對數字偽造反爬,所以拼接的文本肯定是數字的話不太會引起研究者的注意,但是數字長度太短會誤傷正常的數據,所以用所熟悉的 Π)
- ```
- 1773 -> “1*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “3*7+24” -> 313.1415926733.1415926733.141592645
- 02 -> "0*7+24" + "3.1415926" + "2*7+24" -> 243.141592638
- 20 -> "2*7+24" + "3.1415926" + "0*7+24" -> 383.141592624
- ```
- # 前端拿到數據后再解密,解密后根據自定義的字體 Render 頁面
- 1. 先將拿到的字符串按照“3.1415926”拆分為數組
- 2. 對數組的每1個數據,按照“線性變換”(y=kx+b,k和b同樣按照當前的日期求解得到),逆向求解到原本的值。
- 3. 將步驟2的的到的數據依次拼接,再根據 ttf 文件 Render 頁面上。
- 后端需要根據上一步設計的協議將數據進行加密處理
下面以 Node.js 為例講解后端需要做的事情
- 首先后端設置接口路由
- 獲取路由后面的參數
- 根據業務需要根據 SQL 語句生成對應的數據。如果是數字部分,則需要按照上面約定的方法加以轉換。
- 將生成數據轉換成 JSON 返回給調用者
- // json
- var JoinOparatorSymbol = "3.1415926";
- function encode(rawData, ruleType) {
- if (!isNotEmptyStr(rawData)) {
- return "";
- }
- var date = new Date();
- var year = date.getFullYear();
- var month = date.getMonth() + 1;
- var day = date.getDate();
- var encodeData = "";
- for (var index = 0; index < rawData.length; index++) {
- var datacomponent = rawData[index];
- if (!isNaN(datacomponent)) {
- if (ruleType < 3) {
- var currentNumber = rawDataMap(String(datacomponent), ruleType);
- encodeData += (currentNumber * month + day) + JoinOparatorSymbol;
- }
- else if (ruleType == 4) {
- encodeData += rawDataMap(String(datacomponent), ruleType);
- }
- else {
- encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol;
- }
- }
- else if (ruleType == 4) {
- encodeData += rawDataMap(String(datacomponent), ruleType);
- }
- }
- if (encodeData.length >= JoinOparatorSymbol.length) {
- var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length);
- if (lastTwoString == JoinOparatorSymbol) {
- encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length);
- }
- }
- //字體映射處理
- function rawDataMap(rawData, ruleType) {
- if (!isNotEmptyStr(rawData) || !isNotEmptyStr(ruleType)) {
- return;
- }
- var mapData;
- var rawNumber = parseInt(rawData);
- var ruleTypeNumber = parseInt(ruleType);
- if (!isNaN(rawData)) {
- lastNumberCategory = ruleTypeNumber;
- //字體文件1下的數據加密規則
- if (ruleTypeNumber == 1) {
- if (rawNumber == 1) {
- mapData = 1;
- }
- else if (rawNumber == 2) {
- mapData = 2;
- }
- else if (rawNumber == 3) {
- mapData = 4;
- }
- else if (rawNumber == 4) {
- mapData = 5;
- }
- else if (rawNumber == 5) {
- mapData = 3;
- }
- else if (rawNumber == 6) {
- mapData = 8;
- }
- else if (rawNumber == 7) {
- mapData = 6;
- }
- else if (rawNumber == 8) {
- mapData = 9;
- }
- else if (rawNumber == 9) {
- mapData = 7;
- }
- else if (rawNumber == 0) {
- mapData = 0;
- }
- }
- //字體文件2下的數據加密規則
- else if (ruleTypeNumber == 0) {
- if (rawNumber == 1) {
- mapData = 4;
- }
- else if (rawNumber == 2) {
- mapData = 2;
- }
- else if (rawNumber == 3) {
- mapData = 3;
- }
- else if (rawNumber == 4) {
- mapData = 1;
- }
- else if (rawNumber == 5) {
- mapData = 8;
- }
- else if (rawNumber == 6) {
- mapData = 5;
- }
- else if (rawNumber == 7) {
- mapData = 6;
- }
- else if (rawNumber == 8) {
- mapData = 7;
- }
- else if (rawNumber == 9) {
- mapData = 9;
- }
- else if (rawNumber == 0) {
- mapData = 0;
- }
- }
- //字體文件3下的數據加密規則
- else if (ruleTypeNumber == 2) {
- if (rawNumber == 1) {
- mapData = 6;
- }
- else if (rawNumber == 2) {
- mapData = 2;
- }
- else if (rawNumber == 3) {
- mapData = 1;
- }
- else if (rawNumber == 4) {
- mapData = 3;
- }
- else if (rawNumber == 5) {
- mapData = 4;
- }
- else if (rawNumber == 6) {
- mapData = 8;
- }
- else if (rawNumber == 7) {
- mapData = 3;
- }
- else if (rawNumber == 8) {
- mapData = 7;
- }
- else if (rawNumber == 9) {
- mapData = 9;
- }
- else if (rawNumber == 0) {
- mapData = 0;
- }
- }
- else if (ruleTypeNumber == 3) {
- if (rawNumber == 1) {
- mapData = "";
- }
- else if (rawNumber == 2) {
- mapData = "";
- }
- else if (rawNumber == 3) {
- mapData = "";
- }
- else if (rawNumber == 4) {
- mapData = "";
- }
- else if (rawNumber == 5) {
- mapData = "";
- }
- else if (rawNumber == 6) {
- mapData = "";
- }
- else if (rawNumber == 7) {
- mapData = "";
- }
- else if (rawNumber == 8) {
- mapData = "";
- }
- else if (rawNumber == 9) {
- mapData = "";
- }
- else if (rawNumber == 0) {
- mapData = "";
- }
- }
- else{
- mapData = rawNumber;
- }
- } else if (ruleTypeNumber == 4) {
- var sources = ["年", "萬", "業", "人", "信", "元", "千", "司", "州", "資", "造", "錢"];
- //判斷字符串為漢字
- if (/^[\u4e00-\u9fa5]*$/.test(rawData)) {
- if (sources.indexOf(rawData) > -1) {
- var currentChineseHexcod = rawData.charCodeAt(0).toString(16);
- var lastCompoent;
- var mapComponetnt;
- var numbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
- var characters = ["a", "b", "c", "d", "e", "f", "g", "h", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"];
- if (currentChineseHexcod.length == 4) {
- lastCompoent = currentChineseHexcod.substr(3, 1);
- var locationInComponents = 0;
- if (/[0-9]/.test(lastCompoent)) {
- locationInComponents = numbers.indexOf(lastCompoent);
- mapComponetnt = numbers[(locationInComponents + 1) % 10];
- }
- else if (/[a-z]/.test(lastCompoent)) {
- locationInComponents = characters.indexOf(lastCompoent);
- mapComponetnt = characters[(locationInComponents + 1) % 26];
- }
- mapData = "&#x" + currentChineseHexcod.substr(0, 3) + mapComponetnt + ";";
- }
- } else {
- mapData = rawData;
- }
- }
- else if (/[0-9]/.test(rawData)) {
- mapData = rawDataMap(rawData, 2);
- }
- else {
- mapData = rawData;
- }
- }
- return mapData;
- }
- //api
- module.exports = {
- "GET /api/products": async (ctx, next) => {
- ctx.response.type = "application/json";
- ctx.response.body = {
- products: products
- };
- },
- "GET /api/solution1": async (ctx, next) => {
- try {
- var data = fs.readFileSync(pathname, "utf-8");
- ruleJson = JSON.parse(data);
- rule = ruleJson.data.rule;
- } catch (error) {
- console.log("fail: " + error);
- }
- var data = {
- code: 200,
- message: "success",
- data: {
- name: "@杭城小劉",
- year: LBPEncode("1995", rule),
- month: LBPEncode("02", rule),
- day: LBPEncode("20", rule),
- analysis : rule
- }
- }
- ctx.set("Access-Control-Allow-Origin", "*");
- ctx.response.type = "application/json";
- ctx.response.body = data;
- },
- "GET /api/solution2": async (ctx, next) => {
- try {
- var data = fs.readFileSync(pathname, "utf-8");
- ruleJson = JSON.parse(data);
- rule = ruleJson.data.rule;
- } catch (error) {
- console.log("fail: " + error);
- }
- var data = {
- code: 200,
- message: "success",
- data: {
- name: LBPEncode("建造師",rule),
- birthday: LBPEncode("1995年02月20日",rule),
- company: LBPEncode("中天公司",rule),
- address: LBPEncode("浙江省杭州市拱墅區石祥路",rule),
- bidprice: LBPEncode("2萬元",rule),
- negative: LBPEncode("2018年辦事效率太高、負面基本沒有",rule),
- title: LBPEncode("建造師",rule),
- honor: LBPEncode("最佳獎",rule),
- analysis : rule
- }
- }
- ctx.set("Access-Control-Allow-Origin", "*");
- ctx.response.type = "application/json";
- ctx.response.body = data;
- },
- "POST /api/products": async (ctx, next) => {
- var p = {
- name: ctx.request.body.name,
- price: ctx.request.body.price
- };
- products.push(p);
- ctx.response.type = "application/json";
- ctx.response.body = p;
- }
- };
- //路由
- const fs = require("fs");
- function addMapping(router, mapping){
- for(var url in mapping){
- if (url.startsWith("GET")) {
- var path = url.substring(4);
- router.get(path,mapping[url]);
- console.log(`Register URL mapping: GET: ${path}`);
- }else if (url.startsWith('POST ')) {
- var path = url.substring(5);
- router.post(path, mapping[url]);
- console.log(`Register URL mapping: POST ${path}`);
- } else if (url.startsWith('PUT ')) {
- var path = url.substring(4);
- router.put(path, mapping[url]);
- console.log(`Register URL mapping: PUT ${path}`);
- } else if (url.startsWith('DELETE ')) {
- var path = url.substring(7);
- router.del(path, mapping[url]);
- console.log(`Register URL mapping: DELETE ${path}`);
- } else {
- console.log(`Invalid URL: ${url}`);
- }
- }
- }
- function addControllers(router, dir){
- fs.readdirSync(__dirname + "/" + dir).filter( (f) => {
- return f.endsWith(".js");
- }).forEach( (f) => {
- console.log(`Process controllers:${f}...`);
- let mapping = require(__dirname + "/" + dir + "/" + f);
- addMapping(router,mapping);
- });
- }
- module.exports = function(dir){
- let controllers = dir || "controller";
- let router = require("koa-router")();
- addControllers(router,controllers);
- return router.routes();
- };
- 前端根據服務端返回的數據逆向解密
- $("#year").html(getRawData(data.year,log));
- // util.js
- var JoinOparatorSymbol = "3.1415926";
- function isNotEmptyStr($str) {
- if (String($str) == "" || $str == undefined || $str == null || $str == "null") {
- return false;
- }
- return true;
- }
- function getRawData($json,analisys) {
- $json = $json.toString();
- if (!isNotEmptyStr($json)) {
- return;
- }
- var date= new Date();
- var year = date.getFullYear();
- var month = date.getMonth() + 1;
- var day = date.getDate();
- var datacomponents = $json.split(JoinOparatorSymbol);
- var orginalMessage = "";
- for(var index = 0;index < datacomponents.length;index++){
- var datacomponent = datacomponents[index];
- if (!isNaN(datacomponent) && analisys < 3){
- var currentNumber = parseInt(datacomponent);
- orginalMessage += (currentNumber - day)/month;
- }
- else if(analisys == 3){
- orginalMessage += datacomponent;
- }
- else{
- //其他情況待續,本 Demo 根據本人在研究反爬方面的技術并實踐后持續更新
- }
- }
- return orginalMessage;
- }
比如后端返回的是323.14743.14743.1446,根據我們約定的算法,可以的到結果為1773
- 根據 ttf 文件 Render 頁面
上面計算的到的1773,然后根據ttf文件,頁面看到的就是1995
- 然后為了防止爬蟲人員查看 JS 研究問題,所以對 JS 的文件進行了加密處理。如果你的技術棧是 Vue 、React 等,webpack 為你提供了 JS 加密的插件,也很方便處理
個人覺得這種方式還不是很安全。于是想到了各種方案的組合拳。比如
反爬升級版
個人覺得如果一個前端經驗豐富的爬蟲開發者來說,上面的方案可能還是會存在被破解的可能,所以在之前的基礎上做了升級版本
- 組合拳1: 字體文件不要固定,雖然請求的鏈接是同一個,但是根據當前的時間戳的最后一個數字取模,比如 Demo 中對4取模,有4種值 0、1、2、3。這4種值對應不同的字體文件,所以當爬蟲絞盡腦汁爬到1種情況下的字體時,沒想到再次請求,字體文件的規則變掉了 😂
- 組合拳2: 前面的規則是字體問題亂序,但是只是數字匹配打亂掉。比如 1 -> 4, 5 -> 8。接下來的套路就是每個數字對應一個 unicode 碼 ,然后制作自己需要的字體,可以是 .ttf、.woff 等等。
這幾種組合拳打下來。對于一般的爬蟲就放棄了。
反爬手段再升級
上面說的方法主要是針對數字做的反爬手段,如果要對漢字進行反爬怎么辦?接下來提供幾種方案
- 方案1: 對于你站點頻率最高的詞云,做一個漢字映射,也就是自定義字體文件,步驟跟數字一樣。先將常用的漢字生成對應的 ttf 文件;根據下面提供的鏈接,將 ttf 文件轉換為 svg 文件,然后在下面的“字體映射”鏈接點進去的網站上面選擇前面生成的 svg 文件,將svg文件里面的每個漢字做個映射,也就是將漢字專為 unicode 碼(注意這里的 unicode 碼不要去在線直接生成,因為直接生成的東西也就是有規律的。我給的做法是先用網站生成,然后將得到的結果做個簡單的變化,比如將“e342”轉換為 “e231”);然后接口返回的數據按照我們的這個字體文件的規則反過去映射出來。
- 方案2: 將網站的重要字體,將 html 部分生成圖片,這樣子爬蟲要識別到需要的內容成本就很高了,需要用到 OCR。效率也很低。所以可以攔截掉一部分的爬蟲
- 方案3: 看到攜程的技術分享“反爬的最高境界就是 Canvas 的指紋,原理是不同的機器不同的硬件對于 Canvas 畫出的圖總是存在像素級別的誤差,因此我們判斷當對于訪問來說大量的 canvas 的指紋一致的話,則認為是爬蟲,則可以封掉它”。
本人將方案1實現到 Demo 中了。
關鍵步驟
- 先根據你們的產品找到常用的關鍵詞,生成詞云
- 根據詞云,將每個字生成對應的 unicode 碼
- 將詞云包括的漢字做成一個字體庫
- 將字體庫 .ttf 做成 svg 格式,然后上傳到 icomoon 制作自定義的字體,但是有規則,比如 “年” 對應的 unicode 碼是 “u5e74” ,但是我們需要做一個 愷撒加密 ,比如我們設置 偏移量 為1,那么經過愷撒加密 “年”對應的 unicode 碼是“u5e75” 。利用這種規則制作我們需要的字體庫
- 在每次調用接口的時候服務端做的事情是:服務端封裝某個方法,將數據經過方法判斷是不是在詞云中,如果是詞云中的字符,利用規則(找到漢字對應的 unicode 碼,再根據凱撒加密,設置對應的偏移量,Demo 中為1,將每個漢字加密處理)加密處理后返回數據
- 客戶端做的事情:
- 先引入我們前面制作好的漢字字體庫
- 調用接口拿到數據,顯示到對應的 Dom 節點上
- 如果是漢字文本,我們將對應節點的 css 類設置成漢字類,該類對應的 font-family 是我們上面引入的漢字字體庫
- //style.css
- @font-face {
- font-family: "NumberFont";
- src: url('http://127.0.0.1:8080/Util/analysis');
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
- @font-face {
- font-family: "CharacterFont";
- src: url('http://127.0.0.1:8080/Util/map');
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
- h2 {
- font-family: "NumberFont";
- }
- h3,a{
- font-family: "CharacterFont";
- }
傳送門
實現的效果
頁面上看到的數據跟審查元素看到的結果不一致
去查看接口數據跟審核元素和界面看到的三者不一致
頁面每次刷新之前得出的結果更不一致
對于數字和漢字的處理手段都不一致
這幾種組合拳打下來。對于一般的爬蟲就放棄了。
前面的 ttf 轉 svg 網站當 ttf 文件太大會限制轉換,讓你購買,下面貼出個新的鏈接。
運行步驟
- //客戶端。先查看本機 ip 在 Demo/Spider-develop/Solution/Solution1.js 和 Demo/Spider-develop/Solution/Solution2.js 里面將接口地址修改為本機 ip
- $ cd Demo
- $ ls
- REST Spider-release file-Server.js
- Spider-develop Util rule.json
- $ node file-Server.js
- Server is runnig at http://127.0.0.1:8080/
- //服務端 先安裝依賴
- $ cd REST/
- $ npm install
- $ node app.js
App 端安全的解決方案
- 目前 App 的網絡通信基本都是用 HTTPS 的服務,但是隨便一個抓包工具都是可以看到 HTTPS 接口的詳細數據,為了做到防止抓包和無法模擬接口的情況,我們采取以下措施:
- 中間人盜用數據,我們可以采取 HTTPS 證書的雙向認證,這樣子實現的效果就是中間人在開啟抓包軟件分析 App 的網絡請求的時候,網絡會自動斷掉,無法查看分析請求的情況
- 對于防止用戶模仿我們的請求再次發起請求,我們可以采用 「防重放策略」,用戶再也無法模仿我們的請求,再次去獲取數據了。
- 對于 App 內的 H5 資源,反爬蟲方案可以采用上面的解決方案,H5 內部的網絡請求可以通過 Hybrid 層讓 Native 的能力去完成網絡請求,完成之后將數據回調給 JS。這么做的目的是往往我們的 Native 層有完善的賬號體系和網絡層以及良好的安全策略、鑒權體系等等。
- 后期會討論 App 安全性的更深層次玩法,比如從逆向的角度出發如何保護 App 的安全性。
關于 Hybrid 的更多內容,可以看看這篇文章 Awesome Hybrid
- 比如 JS 需要發起一個網絡請求,那么按照上面將網絡請求讓 Native 去完成,然后回調給 JS
- var requestObject = {
- url: arg.Api + "SearchInfo/getLawsInfo",
- params: requestparams,
- Hybrid_Request_Method: 0
- };
- requestHybrid({
- tagname: 'NativeRequest',
- param: requestObject,
- encryption: 1,
- callback: function (data) {
- renderUI(data);
- }
- })
Native 代碼(iOS為例)
- [self.bridge registerHandler:@"NativeRequest" handler:^(id data, WVJBResponseCallback responseCallback) {
- NSAssert([data isKindOfClass:[NSDictionary class]], @"H5 端不按套路");
- if ([data isKindOfClass:[NSDictionary class]]) {
- NSDictionary *dict = (NSDictionary *)data;
- RequestModel *requestModel = [RequestModel yy_modelWithJSON:dict];
- NSAssert( (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) || (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get ), @"H5 端不按套路");
- [HybridRequest requestWithNative:requestModel hybridRequestSuccess:^(id responseObject) {
- NSDictionary *json = [NSJSONSerialization JSONObjectWithData:responseObject options:NSJSONReadingMutableLeaves error:nil];
- responseCallback([self convertToJsonData:@{@"success":@"1",@"data":json}]);
- } hybridRequestfail:^{
- LBPLog(@"H5 call Native`s request failed");
- responseCallback([self convertToJsonData:@{@"success":@"0",@"data":@""}]);
- }];
- }
- }];