之前在上家公司的時候做過一些爬蟲的工作,也幫助爬蟲工程師解決過一些問題。然後我寫過一些文章釋出到網上,之後有一些人就找我做一些爬蟲的外包,內容大概是爬取小紅書的使用者資料和商品資料,但是我沒做。我覺得對於國內的大資料公司沒幾家是有真正的大資料量,而是通過爬蟲工程師團隊不斷的去各地爬取資料,因此不要以為我們的資料沒價值,對於內容型的公司來說,資料是可信競爭力。那麼我接下來想說的就是網路和資料的安全性問題。 對於內容型的公司,資料的安全性很重要。對於內容公司來說,資料的重要性不言而喻。比如你一個做線上教育的平臺,題目的資料很重要吧,但是被別人通過爬蟲技術全部爬走了?如果核心競爭力都被拿走了,那就是涼涼。再比說有個獨立開發者想抄襲你的產品,通過抓包和爬蟲手段將你核心的資料拿走,然後短期內做個網站和 App,短期內成為你的勁敵。
背景
目前通過 App 中的 網頁分析後,我們的資料安全性做的較差,有以下幾個點存在問題:
- 網站的資料通過最早期的前後端分離來實現。稍微學過 Web 前端的工程師都可以通過神器 Chrome 分析網站,進而爬取需要的資料。開啟 「Network」就可以看到網站的所有網路請求了,哎呀,不小心我看到了什麼?沒錯就是網站的介面資訊都可以看到了。比如 “detail.json?itemId=141529859”。或者你的網站介面有些特殊的判斷處理,將一些資訊儲存到 sessionStorage、cookie、localStorage 裡面,有點前端經驗的爬蟲工程師心想”嘿嘿嘿,這不是在裸奔資料麼“。或者有些引數是通過 JavaScript 臨時通過函式生成的。問題不大,工程師也可以對網頁元素進行查詢,找到關鍵的 id、或者 css 類名,然後在 "Search“ 可以進行查詢,找到對應的程式碼 JS 程式碼,點選檢視程式碼,如果是早期前端開發模式那麼程式碼就是裸奔的,跟開發者在自己的 IDE 裡面看到的內容一樣,有經驗的爬蟲就可以拿這個做事情,因此安全性問題亟待解決。
想知道 Chrome 更多的除錯使用技巧,看看這篇文章
- App 的資料即使採用了 HTTPS,但是對於專業的抓包工具也是可以直接拿到資料的,因此 App 的安全問題也可以做一些提高,具體的策略下文會講到。 想知道 Charles 的更多使用技巧,可以看看這篇文章
爬蟲手段
- 目前爬蟲技術都是從渲染好的 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 檔案太大會限制轉換,讓你購買,下面貼出個新的連結。
Demo 地址
執行步驟
//客戶端。先檢視本機 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
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":@""}]); }]; } }]; 複製程式碼
以上是第一階段的安全性總結,後期應該會更新(App逆向、防重放、服務端等)。