之前在上家公司的时候做过一些爬虫的工作,也帮助爬虫工程师解决过一些问题。然后我写过一些文章发布到网上,之后有一些人就找我做一些爬虫的外包,内容大概是爬取小红书的用户数据和商品数据,但是我没做。我觉得对于国内的大数据公司没几家是有真正的大数据量,而是通过爬虫工程师团队不断的去各地爬取数据,因此不要以为我们的数据没价值,对于内容型的公司来说,数据是可信竞争力。那么我接下来想说的就是网络和数据的安全性问题。
对于内容型的公司,数据的安全性很重要。对于内容公司来说,数据的重要性不言而喻。比如你一个做在线教育的平台,题目的数据很重要吧,但是被别人通过爬虫技术全部爬走了?如果核心竞争力都被拿走了,那就是凉凉。再比说有个独立开发者想抄袭你的产品,通过抓包和爬虫手段将你核心的数据拿走,然后短期内做个网站和 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":@""}]);
- }];
- }
- }];