byte

byte 查看完整档案

深圳编辑华中科技大学  |  计算机应用技术 编辑腾讯科技有限公司  |  Web前端开发 编辑 wu.com 编辑
编辑

坚持不一定可贵,找到适合自己的才是王道!

个人动态

byte 发布了文章 · 9月14日

小程序websocket开发指南

背景:一般与服务端交互频繁的需求,可以使用轮询机制来实现。然而一些业务场景,比如游戏大厅、直播、即时聊天等,这些需求都可以或者说更适合使用长连接来实现,一方面可以减少轮询带来的流量浪费,另一方面可以减少对服务的请求压力,同时也可以更实时的与服务端进行消息交互。

背景知识

HTTP vs WebSocket

名词解释

  1. HTTP:是一个用于传输超媒体文档(如HTML)的应用层的无连接、无状态协议。
  2. WebSocket:HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,基于TCP传输协议,并复用HTTP的握手通道。

image.png

特点

  1. HTTP
  2. WebSocket

    1. 建立在TCP协议之上,服务器端的实现比较容易;
    2. 与HTTP协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用HTTP协议,因此握手时不容易屏蔽,能通过各种HTTP代理服务器;
    3. 数据格式比较轻量,性能开销小,通信高效;
    4. 可以发送文本(text),也可以发送二进制数据(ArrayBuffer);
    5. 没有同源限制,客户端可以与任意服务器通信;
    6. 协议标识符是ws(如果加密,则为wss),服务器网址就是URL;
二进制数组

名词解释

  1. ​ArrayBuffer​对象:代表原始的二进制数据。代表内存中的一段二进制数据,不能直接读写,只能通过“视图”(​TypedArray​和​DataView​)进行操作(以指定格式解读二进制数据)。“视图”部署了数组接口,这意味着,可以用数组的方法操作内存。
  2. ​TypedArray​对象:代表确定类型的二进制数据。用来生成内存的视图,通过9个构造函数,可以生成9种数据格式的视图,数组成员都是同一个数据类型,比如:

    1. ​Unit8Array​:(无符号8位整数)数组视图
    2. ​Int16Array​:(16位整数)数组视图
    3. ​Float32Array​:(32位浮点数)数组视图
  1. ​DataView​对象:代表不确定类型的二进制数据。用来生成内存的视图,可以自定义格式和字节序,比如第一个字节是​Uint8​(无符号8位整数)、第二个字节是​Int16​(16位整数)、第三个字节是​Float32​(32位浮点数)等等,数据成员可以是不同的数据类型。

举个栗子

​ArrayBuffer​也是一个构造函数,可以分配一段可以存放数据的连续内存区域

var buf = new  ArrayBuffer(32); // 生成一段32字节的内存区域,每个字节的值默认都是0

为了读写buf,需要为它指定视图。

  1. ​DataView​视图,是一个构造函数,需要提供​ArrayBuffer​对象实例作为参数:
var dataView = new DataView(buf); // 不带符号的8位整数格式
dataView.getUnit8(0) // 0
  1. ​TypedArray​视图,是一组构造函数,代表不同的数据格式。
var x1 = new Init32Array(buf); // 32位带符号整数
x1[0] = 1;
var x2 = new Unit8Array(buf); // 8位不带符号整数
x2[0] = 2;

x1[0] // 2 两个视图对应同一段内存,一个视图修改底层内存,会影响另一个视图

TypedArray(buffer, byteOffset=0, length?)

  • buffer:必需,视图对应的底层​ArrayBuffer​对象
  • byteOffset:可选,视图开始的字节序号,默认从0开始,必须与所要建立的数据类型一致,否则会报错
var buffer = new ArrayBuffer(8);
var i16 = new Int16Array(buffer, 1);
// Uncaught RangeError: start offset of Int16Array should be a multiple of 2

因为,带符号的16位整数需要2个字节,所以byteOffset参数必须能够被2整除。

  • length:可选,视图包含的数据个数,默认直到本段内存区域结束

note:如果想从任意字节开始解读​ArrayBuffer​对象,必须使用​DataView​视图,因为​TypedArray​视图只提供9种固定的解读格式。

​TypedArray​视图的构造函数,除了接受​ArrayBuffer​实例作为参数,还可以接受正常数组作为参数,直接分配内存生成底层的​ArrayBuffer​实例,并同时完成对这段内存的赋值。

var typedArray = new Unit8Array([0, 1, 2]);
typedArray.length // 3

typedArray[0] = 5;
typedArray // [5, 1, 2]

总结

​ArrayBuffer​是一(大)块内存,但不能直接访问​ArrayBuffer​里面的字节。​TypedArray​只是一层视图,本身不储存数据,它的数据都储存在底层的​ArrayBuffer​对象之中,要获取底层对象必须使用buffer属性。其实​ArrayBuffer​ 跟 ​TypedArray​ 是一个东西,前者是一(大)块内存,后者用来访问这块内存。

Protocol Buffers

我们编码的目的是将结构化数据写入磁盘或用于网络传输,以便他人来读取,写入方式有多种选择,比如将数据转换为字符串,然后将字符串写入磁盘。也可以将需要处理的结构化数据由 .proto 文件描述,用 Protobuf 编译器将该文件编译成目标语言。

名词解释

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

基本原理

一般情况下,采用静态编译模式,先写好 .proto 文件,再用 Protobuf 编译器生成目标语言所需要的源代码文件,将这些生成的代码和应用程序一起编译。

读写数据过程是将对象序列化后生成二进制数据流,写入一个 fstream 流,从一个 fstream 流中读取信息并反序列化。

优缺点

  • 优点

Protocol Buffers 在序列化数据方面,它是灵活的,高效的。相比于 XML 来说,Protocol Buffers 更加小巧,更加快速,更加简单。一旦定义了要处理的数据的数据结构之后,就可以利用 Protocol Buffers 的代码生成工具生成相关的代码。甚至可以在无需重新部署程序的情况下更新数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。

Protocol Buffers 很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

  • 缺点

消息结构可读性不高,序列化后的字节序列为二进制序列不能简单的分析有效性;

整体设计

为了维护用户在线状态,需要和服务端保持长连接,决定采用websocket来跟服务端进行通信,同时使用消息通道系统来转发消息。

时序图

image.png

技术要点

交互协议
  • connectSocket:创建一个WebSocket连接实例,并通过返回的​socketTask​操作该连接。
const wsUrl = `${domain}/ws/v2?aid=2493&device_id=${did}&fpid=100&access_key=${access_key}&code=${code}`

let socketTask = tt.connectSocket({

 url: wsUrl,

 protocols: ['p1']

});
  • ​wsUrl​遵循​Frontier​的交互协议:
  • aid:应用id,不是宿主app的appid,由服务端指定
  • fpid:由服务端指定
  • device_id:设备id,服务端通过aid+userid+did来维护长连接
  • access_key:用于防止攻击,一般用md5加密算法生成(​md5.hexMD5(fpid + appkey + did + salt);​)
  • code:调用​tt.login​获取的code,服务端通过code2Session可以将其转化为open_id,然后进一步转化为user_id用于标识用户的唯一性。
  • note:由于code具有时效性,每次重新建立​websocket​连接时,需要调用​tt.login​重新获取code。

数据协议

前面介绍了那么多关于​Protobuf​的内容,小程序的​webSocket​接口发送数据的类型支持​ArrayBuffer​,再加上​Frontier​对​Protobuf​支持得比较好,因此和服务端商定采用​Protobuf​作为整个长连接的数据通信协议。

想要在小程序中使用​Protobuf​,首先将.proto文件转换成js能解析的json,这样也比直接使用.proto文件更轻量,可以使用pbjs工具进行解析:

  1. 安装pbjs工具
  • 基于node.js,首先安装protobufjs
$ npm install -g protobufjs
  • 安装 pbjs需要的库 命令行执行下“pbjs”就ok
$ pbjs
  1. 使用pbjs转换.proto文件
  • 和服务端约定好的.proto文件
// awesome.proto
package wenlipackage;
syntax = "proto2";

message Header {
  required string key = 1;
  required string value = 2;
}

message Frame {
  required uint64 SeqID = 1;
  required uint64 LogID = 2; 
  required int32 service = 3;
  required int32 method = 4;

  repeated Header headers = 5;
  optional string payload_encoding = 6;
  optional string payload_type = 7;
  optional bytes payload = 8;
}
  • 转换awesome.proto文件
$ pbjs -t json awesome.proto > awesome.json

生成如下的awesom.json文件:

{
  "nested": {
    "wenlipackage": {
      "nested": {
        "Header": {
          "fields": {
            ...
          }
        },
        "Frame": {
          "fields": {
            ...
          }
        }
      }
    }
  }
}
  • 此时的json文件还不能直接使用,必须采用​module.exports​的方式将其导出去,可生成如下的awesome.js文件供小程序引用。
module.exports = {
  "nested": {
    "wenlipackage": {
      "nested": {
        "Header": {
          "fields": {
            ...
          }
        },
        "Frame": {
          "fields": {
            ...
          }
        }
      }
    }
  }
}
  1. 采用Protobuf库编/解码数据
// 引入protobuf模块
import * as protobuf from './weichatPb/protobuf'; 
// 加载awesome.proto对应的json
import awesomeConfig from './awesome.js'; 

// 加载JSON descriptor
const AwesomeRoot = protobuf.Root.fromJSON(awesomeConfig);
// Message类,.proto文件中定义了Frame是消息主体
const AwesomeMessage = AwesomeRoot.lookupType("Frame");

const payload = {test: "123"};
const message = AwesomeMessage.create(payload);
const array = AwesomeMessage.encode(message).finish();
// unit8Array => ArrayBuffer
const enMessage = array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset)
console.log("encodeMessage", enMessage);

// buffer 表示通过小程序this.socketTask.onMessage((msg) => {});接收到的数据
const deMessage = AwesomeMessage.decode(new Uint8Array(buffer));
console.log("decodeMessage", deMessage);

消息通信

一个​websocket​实例的生成需要经过以下步骤:

  1. 建立连接
  • 建立连接后会返回一个websoket实例
  1. 连接打开
  • 连接建立->连接打开是一个异步的过程,在这段时间内是监听不到消息,更是无法发送消息的
  1. 监听消息
  • 监听的时机比较关键,只有当连接建立并生成websocket实例后才能监听
  1. 发送消息
  • 发送当时机也很关键,只有当连接真正打开后才能发送消息
将小程序WebSocket的一些功能封装成一个类,里面包括建立连接、监听消息、发送消息、心跳检测、断线重连等等常用的功能。
  1. 封装websocket类
export default class websocket {
  constructor({ heartCheck, isReconnection }) {
    this.socketTask = null;// websocket实例
    this._isLogin = false;// 是否连接
    this._netWork = true;// 当前网络状态
    this._isClosed = false;// 是否人为退出
    this._timeout = 10000;// 心跳检测频率
    this._timeoutObj = null;
    this._connectNum = 0;// 当前重连次数
    this._reConnectTimer = null;
    this._heartCheck = heartCheck;// 心跳检测和断线重连开关,true为启用,false为关闭
    this._isReconnection = isReconnection;
  }
  
  _reset() {}// 心跳重置
  _start() {} // 心跳开始
  onSocketClosed(options) {}  // 监听websocket连接关闭
  onSocketError(options) {}  // 监听websocket连接关闭
  onNetworkChange(options) {}  // 检测网络变化
  _onSocketOpened() {}  // 监听websocket连接打开
  onReceivedMsg(callBack) {}  // 接收服务器返回的消息
  initWebSocket(options) {}  // 建立websocket连接
  sendWebSocketMsg(options) {}  // 发送websocket消息
  _reConnect(options) {}  // 重连方法,会根据时间频率越来越慢
  closeWebSocket(){}  // 关闭websocket连接
}
  1. 多个page使用同一个​websocket​对象

引入​vuex​维护一个全局​websocket​对象​globalWebsocket​,通过​mapMutations​的​changeGlobalWebsocket​方法改变全局​websocket​对象:

methods: {
    ...mapMutations(['changeGlobalWebsocket']),
    linkWebsocket(websocketUrl) {
      // 建立连接
      this.websocket.initWebSocket({
        url: websocketUrl,
        success(res) { console.log('连接建立成功', res) },
        fail(err) { console.log('连接建立失败', err) },
        complate: (res) => {
          this.changeGlobalWebsocket(res);
        }
      })
    }
}
  • 通过WebSocket类建立连接,将tt.connectSocket返回的websocket实例透传出来,全局共享。
computed: {
    ...mapState(['globalWebsocket']),
    newGlobalWebsocket() {
      // 只有当连接建立并生成websocket实例后才能监听
      if (this.globalWebsocket && this.globalWebsocket.socketTask) {
        if (!this.hasListen) {
          this.globalWebsocket.onReceivedMsg((res, data) => {
            // 处理服务端发来的各类消息
            this.handleServiceMsg(res, data);
          });
          this.hasListen = true;
        }
        if (this.globalWebsocket.socketTask.readyState === 1) {
          // 当连接真正打开后才能发送消息
        }
      }
      return this.globalWebsocket;
    },
},
watch: {
    newGlobalWebsocket(newVal, oldVal) {
      if(oldVal && newVal.socketTask && newVal.socketTask !== oldVal.socketTask) {
        // 重新监听
        this.globalWebsocket.onReceivedMsg((res, data) => {
          this.handleServiceMsg(res, data);
        });
      }
    },
  },

由于需要监听​websocket​的连接与断开,因此需要新生成一个computed属性​newGlobalWebsocket​,直接返回全局的​globalWebsocket​对象,这样才能watch到它的变化,并且在重新监听的时候需要控制好条件,只有​globalWebsocket​对象socketTask真正发生改变的时候才进行重新监听逻辑,否则会收到重复的消息。

问题总结

  1. 直接引入google官方Protobuf库(protobuf.js)将json => pb,在开发者工具能正常使用,真机却报错:

image.png

image.png

原因是protobufjs 代码里面有用到 Function() {} 来执行一段代码,在小程序中Function 和 eval 相关的动态执行代码方式都给屏蔽了,是不允许开发者使用的,导致这个库不能正常使用。

解决办法:搜了一圈github,找到有人专门针对这个问题,修改了dcodeIO 的protobuf.js部分实现方式,写了一个能在小程序中运行的protobuf.js

  1. ​ArrayBuffer​ vs ​Unit8Array​ 到底是个什么关系??!
  • 受小程序框架、protobuf.js工具以及Frontier系统限制,发送消息和接收消息的格式如下

image.png

image.png

可以看到:

  • 发送消息经过protobuf.js编码后的消息是​Unit8Array​格式的
  • 接收到的服务器原始消息是​ArrayBuffer​格式的

上文介绍了​TyedArray​和​ArrayBuffer​的区别,​Unit8Array​是​TypedArray​对象的一种类型,用来表示​ArrayBuffer​的视图,用来读写​ArrayBuffer​,要访问​ArrayBuffer​的底层对象,必须使用​Unit8Array​的buffer属性。

  • 一开始跟服务端调websocket的连通性,发现用​AwesomeMessage.decode​解析服务端消息会解析失败:

image.png

const msg = xxx; // ArrayBuffer类型
const res = AwesomeMessage.decode(msg); // 直接解析ArrayBuffer会报错
const res = AwesomeMessage.decode(new Uint8Array(msg)); // ArrayBuffer => Unit8Array => decode => JSON

原因是原始msg是​ArrayBuffer​类型,protobuf.js在解码的时候限制了类型是​TypedArray​类型,否则解析失败,因此需要将其转换为​TypedArray​对象,选择​Uint8Array​子类型,才能解析成前端能读取的json对象。

  • 在开发者工具调通协议后,转到真机,发现后端解析不了前端发的消息:

image.png

image.png

【开发者工具抓包消息】
image.png

【真机抓包消息】

抓包发现在开发者工具发送的消息是二进制(Binary)类型的,真机却是文本(Text)类型,这就很奇怪了,仔细翻了下小程序文档:

image.png

小程序框架对发送的消息类型进行了限制,只能是string(Text)或arraybuffer(Binary)类型的,真机为啥被转成了text类型呢,首先肯定不是主动发送的string类型,一种可能就是发送的消息不是arraybuffer类型,默认被转成了string。看了下代码:

const encodeMsg = (msg) => {
  const message = AwesomeMessage.create(msg);
  const array = AwesomeMessage.encode(message).finish();// unit8Array
  return array;
};

发现发送的类型直接是​Unit8Array​,开发者工具没有对其进行转换,这个数据是能直接被服务端解析的,然而在真机被转换成了String,导致服务端解析不了,更改代码,将​Unit8Array​转换成​ArrayBuffer​,问题得到解决,在真机和开发者工具都正常:

const encodeMsg = (msg) => {
  const message = AwesomeMessage.create(msg);
  const array = AwesomeMessage.encode(message).finish();
  console.log('加密后即将发送的消息', array);
  // unit8Array => ArrayBuffer,只支持ArrayBuffer
  return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset)
};

其实还发现一个现象:

image.png

即收到的服务端原始消息最外层是​ArrayBuffer​类型的,解密后的业务数据payload却是​Unit8Array​类型的,结合发送消息时encdoe后的类型也是​Unit8Array​类型,得出如下结论:

  • protobuf.js库和Frontier对数据的处理是以​Unit8Array​类型为准,服务端同时支持​ArrayBuffer​和​Unit8Array​两种类型数据的解析;
  • 小程序框架只支持​ArrayBuffer​和​String​类型数据,其余类型会默认当成​String​类型;

上述两个规则限制导致在数据传输过程中,需要将数据格式转成标准的​ArrayBuffer​即小程序框架支持的数据格式。

ps:至于为啥开发者工具和真机表现不一致,这是因为开发者工具其实是一个web,和小程序的运行时并不太一样,同时由于两者不统一,导致在开发调试过程中踩了许多的坑。🤷‍♀️

参考文献

小程序WebSocket接口文档:

https://developer.toutiao.com/docs/api/connectSocket.html#%E8%BE%93%E5%85%A5

protocol buffers介绍:

https://halfrost.com/protobuf_encode/

查看原文

赞 1 收藏 1 评论 0

byte 发布了文章 · 4月27日

了解「前端对抗」

一、业务安全常见场景

  • 登录注册

撞库攻击,注册机批量注册

  • 活动秒杀

刷单、羊毛党泛滥

  • 点赞发帖

广告屠版、恶意灌水、刷票

  • 数据保护

自动机、爬虫盗取网页内容和数据

二、业务安全问题根源

前端代码暴露、逻辑可见、可读(JavaScript解释型语言)等原因导致前端不可信,一个可信前端环境对业务来说是非常重要的。

三、黑产常用破解手段

  • 自动机:debug逻辑,破解协议,直接构造请求
  • 模拟器:直接模拟前端环境,模拟器多开,进行操作

四、防御思路

针对自动机(防止debug逻辑)

  • 代码加固

    • JS混淆
    • 虚拟机
  • 动态脚本

针对模拟器

  • 模拟器探测
  • 设备指纹(频限)
  • 持久化指纹
  • pwa(工作量证明)
  • 其他(图片分析)

通用手段

  • 蜜罐

顾名思义,引诱黑客攻击的陷阱就是蜜罐。从广义上看,蜜罐并不具体指某种技术,而是一种思想。正常用户不会触发,黑产/破解者触发后就将其记录下来,形成一个指纹黑名单。

五、代码加固

JS混淆

  • 为什么要混淆?

前端代码是直接暴漏在浏览器中的,很多web攻击都是通过直接debug业务逻辑找到漏洞进行攻击,另外还有些喜欢"不劳而获"的分子暴力盗取他人网页简单修改后用来获利,总体上来说就是前端的逻辑太容易读懂了。

  • 压缩 vs 混淆

一些能被搜索引擎搜索到的文章会将代码压缩与混淆混为一谈,类似uglify/terser的工具能把代码压缩成可读性很低的代码,但被浏览器强大的格式化功能格式化之后,各种逻辑仍然一览无余,严格意义上说算不上混淆。代码压缩工具并不会对代码起到太多的保护作用,其作用只是缩短变量名、删减空格以及删除未被使用的代码,这些工具的目的是优化而非保护,只能"防君子而不防小人"。为了进一步保护前端代码,需要使用一些代码混淆工具。

1. 原理

对JS进行一下AST(抽象语法树)分析、修改,再重新根据AST生成JS。

2. 混淆过程

混淆过程:code --> parser --> AST --> transform --> AST --> generate --> code

AST解析常用工具:babel、esprima

AST的解析过程

  • 词法分析

把js解析器能识别的最小词法单元

  • 语法分析

    • 语句
    • 表达式
var esprima = require('esprima');
var program = 'const answer = 42';
// 词法分析
esprima.tokenize(program);
>[
  { type: 'Keyword', value: 'const' },
  { type: 'Identifier', value: 'answer' },
  { type: 'Punctuator', value: '=' },
  { type: 'Numeric', value: '42' }
 ]
// 语法分析
esprima.parse(program);
> Script {
      type: 'Program',
      body: [
        VariableDeclaration {
          type: 'VariableDeclaration',
          declarations: [Array],
          kind: 'const'
        }
      ],
      sourceType: 'script'
  }

3. 混淆工具

  • JsFuck:It uses only six different characters ( []!+() ) to write and execute code.

[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]([+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+(![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+[+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]])()

  • aaencode
  • JavaScript Obfuscator

4. 混淆规则

  • 关键字提取,增加读取难度

将js里面的关键字,如字符串常量等提取出来放到数组中,调用的时候用数组下标的方式调用。

var test = "hello";
// 处理后
var _0x7deb = ['hello']; 
var _0xb7de = function (_0x4c7513, _0x1cb87c) { 
    _0x4c7513 = _0x4c7513 - 0x0; 
    var _0x96ade5 = _0x7deb[_0x4c7513]; 
    return _0x96ade5; 
}; 
var test = _0xb7de('0x0');
  • 关键字编码|加密

    • 编码,进一步增加阅读难度

从上面的混淆可以看出,虽然做了关键字提取,但数组中hello还是清晰可见,为了进一步增加代码难度,还可以利用js中16进制编码会直接解码的特性将关键字的Unicode进行16进制编码。

var test = "hello";
//处理后
var _0x5f41=['\x68\x65\x6c\x6c\x6f'];(function(_0x265fed,_0x59b917){var _0x468703=function(_0x2e4674){while(--_0x2e4674){_0x265fed['push'](_0x265fed['shift']());}};_0x468703(++_0x59b917);}(_0x5f41,0xdd));var _0x15f4=function(_0x551d6e,_0x2697e4){_0x551d6e=_0x551d6e-0x0;var _0x40c0ad=_0x5f41[_0x551d6e];return _0x40c0ad;};var test=_0x15f4('0x0');
  • 加密,增加手动调试难度

做了关键字提取后,如果想要破解那么必须要单步调试才可以(先忽略反AST的情况),JavaScript Obfuscator提供了两种关键字加密方式用来对抗单步调试,base64加密和rc4加密,这样处理后单步调试就会加大一些成本。

  • 控制流扁平化,增加手动调试难度

JavaScript Obfuscator提供了一个控制流平展的能力,可以用控制流来控制逻辑(有限状态机),增加调试的复杂度, 这样处理的好处在于在反汇编、反编译静态分析的时候,无法判断哪些代码先执行哪些后执行,必须要通过动态运行才能记录执行顺序,从而加重了分析的负担会发现当代码量很大的时候手动debug困难就非常大了。

function testFn() {
    var test = "hello";
    if (test) {
        test = "hello juno";
    }
    return test;
}

// 处理后 为了大家能看清将上面的方法都去掉了 这里只处理控制流 并且做了格式化

function testFn() {
    var _0x25ac20 = {
        'roscj' : 'hello',
        'BjrCW' : 'hello\x20juno'
    };
    var _0x52a030 = _0x25ac20['roscj'];
    if (_0x52a030) {
        _0x52a030 = _0x25ac20['BjrCW'];
    }
    return _0x52a030;
}
  • 废代码注入

如果增加了以上变换以及控制流难度还不够的话,JavaScript Obfuscator还提供了废代码注入的机制,可以随机注入废代码,增加手动调试难度。还可以在注释中设计蜜罐钓鱼,比如一个简单的http请求,web hook 等。

  • 反debug(debugger 时间校验)

上面的思路都是在增加手动调试的难度,debug防护可以让开启控制台的用户一直卡在debugger控制台上,这里的实现思路比较暴力,一直在调用debugger,实际上可以做些时间上的控制逻辑,可以自由发挥。

  • 反美化(toString CRC循环校验)

恶意在试调试代码的时候都会使用devTools的美化功能,将代码美化后进行调试,JavaScript Obfuscator针对这种情况提供了selfDefending的功能,如果美化代码整个JS会报错无法执行,原理就是一个CRC校验,不详细说了。

  • 域名锁定

上面的debug防护、代码美化都是在JS里面加了控制代码实现的,如果将JS拖到本地去掉后就可以继续破解,JavaScript Obfuscator还做了一个域名锁定的功能,即判断当前域名是否是设置域名,不是就无法执行下去。

  • 其他:逗号操作、eval、数字转换等

5. 对性能影响

由于增加了废代码,改变了原有的AST,所以对性能肯定会造成一定的影响,要尽量控制影响的大小可以通过一些规则来控制:

  • 减少循环混淆,循环太多会直接影响代码执行效率
  • 避免过多的字符串拼接,因为字符串拼接在低版本IE下面会有性能问题
  • 控制代码体积,在插入废代码时应该控制插入比例,文件过大会给网络请求和代码执行都带来压力

动态对抗

  • 为什么要做动态对抗?

单一脚本只能增加破解难度,普通强度的混淆可以在一段时间内保护业务逻辑,一段时间以后,代码便没那么安全了。以JavaScript Obfuscator的混淆强度,「一段时间」通常不会超过一周。如果页面承载的是一个高收益多恶意的业务,即使页面的js代码被JavaScript Obfuscator混淆过,上线一周时间后,大部分关键逻辑也可能已经被逆向出来了。关键逻辑被逆向意味着刷量工具很快会被编写出来,该业务将面临被刷的风险。

  • 动态JS

尽量保证用户每时每刻拉取到的JS是不同的,配合混淆以及webpack加固,这样来打击自动机,增加破解成本,提高坏人debug的门槛。

对抗方案、变换维度

  • 数据上报顺序
  • 随机插入spot
  • 变换加密方式
  • 变换加密密钥(可变换、拆分)
  • 组合加密
  • 自定义上报格式

JS虚拟机

  • 天然JS虚拟机:webAssembly --兼容性不好、代码膨胀率太高、开发调试等问题
  • Google虚拟机:reCaptcha,后台动态输出不同的指令,让逻辑隐藏在自己设计的指令之中

主要流程

byteCode -> 解密 -> VM执行 -> byteCode -> 加密上报

核心部分

  • 指令(directive):计算机执行某种操作的命令,一般由开发者自己设计
  • 寄存器(register):保存当前正在执行的一条指令
  • 程序计数器(PC):记录指令位置,用来确定下一条指令的地址
  • 字节码(byteCode):数据的ascii码

简单示例demo

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>demo</title>
</head>
<body>
    <script>

        var str2arry = function(str){
            return str.split("").map((item, idx)=>{
                return item.charCodeAt();
            })
        }

        var bytecode = [
            110, 1, 8, 
            100, 111, 99, 117, 109, 101, 110, 116,//document
            110, 2, 5,
            119, 114, 105, 116, 101,//write
            184, 1, 3,
            185, 3, 2, 4,
            110, 5, 15,
            104, 101, 108, 108, 111, 32, 101, 118, 101, 114, 121, 111, 110, 101, 33,//hello everyone!
            88, 4, 6, 2, 3, 5
        ]

        var toString = "toString"

        var set_name = "set"

        // 寄存器 register
        var regs = [] //reg[0] PC

        var get_with_value = function (init_value) {
            var func, my_value = init_value
            func = function () {
                return my_value
            }
            func[set_name] = function (value) {
                my_value = value
            }
            return func
        }

        var set_reg = function (reg_num, value) { // 加入寄存器
            if (regs[reg_num])
                regs[reg_num][set_name](value)
            else
                regs[reg_num] = get_with_value(value)
        }

        var get_reg = function (reg_num) { // 读取寄存器
            return regs[reg_num]()
        }

        // 指令 directive

        set_reg(0, 0)

        set_reg(253, function (obj) {
            mov_int(obj, 1);
        });

        // push reg(int8) to reg(array)
        set_reg(131, function (obj) {
            push_int(obj, 1);
        });

        // mov reg, int32
        set_reg(167, function (obj) {
            mov_int(obj, 4);
        });

        // push reg(int32) to reg(array)
        set_reg(84, function(obj) {
            push_int(obj, 4);
        });

        // mov reg, int16
        set_reg(198, function(obj) {
            mov_int(obj, 2);
        });

        // mov reg, str
        set_reg(110, function(obj, T, X, a, J, Z, Y, e, z, q) {
            var reg_num = decrypt_next(obj), size = get_size(obj), str = "", i = 0;

            if (false && void 0 != obj.regs[10]) {
                var arr = obj.get_reg(10), len = arr.length;
                for (; size--;) {
                    i = (i + get_size(obj)) % len;
                    console.log("index:" + i);
                    str += arr[i];
                }
            } else {
                var j = 0, result = [], chr1, chr2, chr3, chr4;
                i = 0, str = [];
                for (; i < size; i++) {
                    str.push(decrypt_next());
                }
                for (i = 0; i < str.length;) {
                    chr1 = str[i++];
                    if (chr1 < 128) {
                        result[j++] = String.fromCharCode(chr1);
                    } else if (191 < chr1 && chr1 < 224) {
                        chr2 = str[i++];
                        result[j++] = String.fromCharCode((chr1 & 31) << 6 | chr2 & 63);
                    } else if (239 < chr1 && chr1 < 365) {
                        chr2 = str[i++];
                        chr3 = str[i++];
                        chr4 = str[i++];
                        chr1 = ((chr1 & 7) << 18 | (chr2 & 63) << 12 | (chr3 & 63) << 6 | chr4 & 63) - 65536;
                        result[j++] = String.fromCharCode(55296 + (chr1 >> 10));
                        result[j++] = String.fromCharCode(56320 + (chr1 & 1023));
                    } else {
                        chr2 = str[i++];
                        chr3 = str[i++];
                        result[j++] = String.fromCharCode((chr1 & 15) << 12 | (chr2 & 63) << 6 | chr3 & 63);
                    }
                }
                str = result.join("");
            }

            console.log(`mov reg[${reg_num}] '${str}'`);
            set_reg(reg_num, str);
        });


        // mov reg, eval(str)
        set_reg(184, function(obj, T, X) {
            var eval_reg = decrypt_next(), save_reg = decrypt_next(), eval_str = get_reg(eval_reg);
            console.log("mov reg[" + save_reg + "], eval('" + eval_str + "')");
            set_reg(save_reg, function(str) {
                return eval(str)
            }(eval_str));
        });


        // mov reg, reg[reg]
        set_reg(185, function(obj, T, X) {
            var reg_0 = decrypt_next(), reg_1 = decrypt_next(), save_reg = decrypt_next();
            console.log(`mov reg[${save_reg}] reg[${reg_0}][reg[${reg_1}]]`);
            set_reg(save_reg, get_reg(reg_0)[get_reg(reg_1)]);
        });


        // mov reg, func.apply(obj, params)
        set_reg(88, function (obj, T) {
            var call = get_call();
            console.log("mov reg[" + call.save_reg + "], " + call.func + ".apply(" + call.obj + ", [ " + call.params + " ])");
            set_reg(call.save_reg, call.func.apply(call.obj, call.params));
        });

        var get_size = function() {
            var num = decrypt_next();
            // 128 == 0b10000000
            if (num & 128) {
                num = num & 127 | decrypt_next() << 7;
            }
            return num;
        }

        var decrypt_next = function (T, X) {
            var pc = get_reg(0);
            set_reg(0, pc + 1);
            return bytecode[pc];
        }

        var mov_int = function (size) {
            var num = 0, reg = decrypt_next();
            for (; 0 < size; size--) {
                num = num << 8 | decrypt_next();
            }
            set_reg(reg, num);
        }

        // 4, 6, 2, 3, 5
        var get_call = function() {
            debugger;
            var call = {}, i = 0, func_reg = decrypt_next(), param_size, obj_reg;
            call.save_reg = decrypt_next();
            call.params = [];
            param_size = decrypt_next() - 1;
            obj_reg = decrypt_next();
            for (; i < param_size; i++) {
                call.params.push(decrypt_next());
            }
            call.func = get_reg(func_reg);
            call.obj = get_reg(obj_reg);
            for (; param_size--;) {
                call.params[param_size] = get_reg(call.params[param_size]);
            }
            return call;
        }

        var run = function () {
            var current_pc = 0, opcode_func = void 0, total = 500, opcode, lth;
            for (lth = bytecode.length; --total  && (current_pc = get_reg(0)) < lth;) {
                try {
                    //set_reg(this_obj, 219, current_pc);
                    opcode = decrypt_next();
                    console.log('opcode', opcode), 'pc', current_pc;
                    opcode_func = get_reg(opcode);
                    if(opcode_func && opcode_func.call){
                        opcode_func();
                    }
                } catch (e) {
                    
                }
            }
        }

        run();

        //reg[0] -> PC
        // mov reg[1] 'document'
        // mov reg[2] 'write'
        // mov reg[3] eval(reg[1]) --> document
        // mov reg[4] reg[3][reg[2]] --> function document.write
        // mov reg[5] 'hello everyone!'
        // apply reg[4](reg[5]) --save[6]
        // document.write("hello everyone!")

    </script>
</body>
</html>
var bytecode = [
    110, 1, 8, // 指令1
    100, 111, 99, 117, 109, 101, 110, 116, // 数据:'document'的ascii码
    110, 2, 5, // 指令2
    119, 114, 105, 116, 101, // 数据:'write'的ascii码
    184, 1, 3, // 指令3
    185, 3, 2, 4, // 指令4
    110, 5, 15, // 指令5
    104, 101, 108, 108, 111, 32, 101, 118, 101, 114, 121, 111, 110, 101, 33, // 数据:'hello everyone!'的的ascii码
    88, 4, 6, 2, 3, 5 // 指令6
]

mov reg[1] 'document'
mov reg[2] 'write'
mov reg[3] eval('document') -> document对象
mov reg[4] reg[3][reg[2]] -> document.write
mov reg[5] 'hello everyone!'
mov reg[6] function write() { [native code] }.apply([object HTMLDocument], [ hello everyone! ])

六、模拟器对抗

常用的模拟器

  • 无头浏览器:可以在图形界面情况下运行的浏览器,一般用作爬虫工具

    • Phantomjs
    • Selenium
    • Puppeteer
  • 群控设备

    • 猫池:是一个设备,模拟成手机终端,能同时放多张卡,使运营商系统上显示这些卡为开机状态。

模拟器特征检测

userAgent、其他特征:如 window.navigator.webdriver ,nodejs环境检测

设备指纹

什么是设备指纹?

关联设备的硬件、系统、网络等信息,通过专有加密算法,赋予其全球唯一的设备标识符,即设备指纹。
设备指纹采集维度
50+

  • cpu、ua、色深、字体、设备像素比、插件
  • canvas指纹
  • 声卡信息、声纹
  • 内网ip(webRtc)

冲突率以及解决方法

冲突率在1/1000左右,前端持久化加固
前端持久化方法——evercookie
前端持久化就是要将数据永久的保存在前端,让数据难以删除或者删除后能够重新恢复。存储的数据可以理解为是一种 僵尸数据。

  • 原理

将数据写入浏览器各个维度,获取的时候再从各个维度中读出来,无论用户怎样清洗,只要其中一个维度有数据就可以得到数据。

  • 特点

    1. 存储的维度非常多,用户很难清理
    2. 取数据的时候会将已经清除的数据重新恢复,名副其实的僵尸cookie
  • 存储维度

    1. 标准HTTP Cookie
    2. Flash Cookie
    3. localStorage
    4. sessionStorage
    5. globalStorage
    6. openDatabase
    7. IndexedDB
    8. 图片缓存数据存储
    9. ETag存储
    10. Silvelright
    11. java应用程序本地存储
    12. IE的userData存储
    13. window.name 存储
    14. a 标签历史访问状态存储
    15. HSTS存储

设备指纹标记

频限(建设指纹池)方式来防御、类似于IP,比IP更有效

pow工作量证明

消耗机器攻击成本从而让攻击得不偿失,基于POW(proof of work)原理,通过服务端下发问题消耗前端的计算量。对于有足够空余CPU资源的普通用户少量的计算并不消耗成本,而攻击者需要达到批量攻击的效果则会占用极大的计算资源,让攻击得不偿失。摘自

七、蜜罐

顾名思义,引诱黑客攻击的陷阱就是蜜罐。从广义上看,蜜罐并不具体指某种技术,而是一种思想。正常用户不会触发,黑产/破解者触发后就将其记录下来,形成一个指纹黑名单。
举几个例子:

  • 在代码中放段注释,加个同域url,为了增加迷惑性,可base64编码一下
  • 滑动轨迹,采集滑动点(x, y, t),最后一个点是特殊点,是之前所有点的平均值
  • 滑动拼图,是否拉取底图

八、总结

  • 单一对抗往往达不到很好的效果,需要结合多种对抗方式,才能有效抵御攻击
  • 安全和体验是相对的,要权衡双方给业务带来的影响和收益,避免过度、极端,反而得不偿失
  • 攻击者也在不断训练跟“成长”,开发者要未雨绸缪,努力打造可信前端环境

九、参考文档

查看原文

赞 0 收藏 0 评论 0

byte 发布了文章 · 2016-09-12

CSS之伪类元素

近期在做各大公司Web前端笔试题的时候,收获颇丰,让我感受强大的CSS的魅力,真的好神奇!


下面是一个demo:

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <style type="text/css">
        #demo {
            width: 100px;
            height: 100px;
            background-color: #fff;
            position: relative;
            border: 2px solid #333;
        }
        #demo:after,#demo:before {
            border: solid transparent;
            content: ' ';
            height: 0;
            left: 100%;
            position: absolute;
            width: 0;
        }
        #demo:after {
            border-width: 10px;
            border-left-color: #fff;
            top: 20px;
        }
        #demo:before {
            border-width: 12px;
            border-left-color: #000;
            top: 18px;
        }
    </style>
</head>
<body>
    <div id="demo"></div>
</body>
</html>

将这段代码保存成.html文件,放入浏览器中运行,你会发现一个非常有个性的图片:

是不是很神奇!

这里是用css中的伪类元素(:before和:after)来实现的,:before和:after的作用就是在指定的元素内容(而不是元素本身)之前或之后插入一个包含content属性指定内容的行内元素。


再来一枚栗子,八卦图,哈哈:

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <style type="text/css">
        #yin-yang {
            width: 96px;
            height: 48px;
            background: #eee;
            border-color: red;
            border-style: solid;
            border-width: 2px 2px 50px 2px;
            border-radius: 100%;
            position: relative;
        }

        #yin-yang:before {
            content: "";
            position: absolute;
            top: 50%;
            left: 0;
            background: #eee;
            border: 18px solid red;
            border-radius: 100%;
            width: 12px;
            height: 12px;
        }

        #yin-yang:after {
            content: "";
            position: absolute;
            top: 50%;
            left: 50%;
            background: red;
            border: 18px solid #eee;
            /*这里换成border-radius:50%发现效果是一样的*/
            border-radius:100%;
            width: 12px;
            height: 12px;
        }
    </style>
</head>
<body>
    <div id="yin-yang"></div>
</body>
</html>

其中学到的小技巧不仅仅包括伪类元素的使用,还有如何“画”一个圆,用border-radius来控制就好了,一般设置成50%或100%都能达到画圆的效果。效果图如下:

图片描述

好啦,今天学到得不少呢,继续怀挺!

查看原文

赞 0 收藏 7 评论 0

byte 发布了文章 · 2016-08-31

浏览器默认解析文档模式IE7

背景:实习期间开发了一套页面,兼容到IE8+,可是在给产品体验时,他的QQ浏览器默认解析到了文档模式IE7,出现了页面的兼容性问题。
问题排查:QQ浏览器目前有两个模式,极速模式和兼容模式,其中极速模式用的Chrome内核,兼容模式适合只支持IE的网站。首先查看了QQ浏览器的版本,发现是9.4.2,而且兼容模式下用的是IE9的内核,觉得很奇怪,页面在IE8及以上是兼容的啊,为什么会出现问题?后来F12开发者模式发现产品那边浏览器的文档模式自动解析到了IE7,因此出现了兼容性的问题,而且这个问题不易发现。
解决方法:一般出现默认浏览器模式不对的根本原因都是某些标签不对劲,可以用删除一些标签来定位。

<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />

后来分析加上上面这一段就能解决,这一段的具体含义是:这是IE8的专用标记,用来指定IE8浏览器去模拟某个特定版本的IE浏览器的渲染方式,以此来解决部分兼容问题。上面是模拟chrome,通过查资料还知道并不是微软增强了IE,而是谷歌做了个外挂,即Google Chrome Frame(谷歌内嵌浏览器框架GCF),这个插件可以让用户的IE浏览器外不变,但用户在浏览网页时,实际上使用的是Google Chrome浏览器内核,而且支持IE6、7、8等多个版本的IE浏览器。

还有模拟IE7的方式如下:

<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />

1.页面头部加入meta标记,有两种写法:
(1)如果加上如下meta标记,如果安装了GCF,则指定页面使用chrome内核来渲染。

< meta http-equiv = "X-UA-Compatible" content = "chrome=1">

(2)如果加上如下meta标记,达到的效果是如果安装了GCF,则使用GCF来渲染页面,如果未安装GCF,则使用最高版本的IE内核进行渲染。

<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />

2.通过修改HTTP头文件的方法来实现指定的页面使用GCF内核进行渲染。具体方法不再详述。

查看原文

赞 0 收藏 3 评论 0

byte 发布了文章 · 2016-08-31

动态加载js

在Web前端开发中,经常需要用ajax从后台获取动态数据,因此<script></script>不能写死,而要动态加载js。
问题描述:实习期间在做一个需求的过程中碰到需要从后台动态加入js的问题。

首先,我给出的解决方案是:

<script class="url1">
        $.ajax({
            type: "GET",
            data: {captchaType:1},
            url: "url_demo",
            dataType: "jsonp",
            jsonp: "callback",
            success: function(json) {
                if(json.code == 0){
                    url_1 = json.url;
                    $(".url1").attr("src",url_1);
                    }    
            },
            error: function() {
                            
            }
        }); 
</script>

其中向url_demo发送http请求,返回一个json串,其中包括我们想要的url,这里的url是一段js,获取url后赋值给<script>的src属性。

这里存在的问题是:获取到的url虽然成功赋给了src属性,但js脚本并未将资源加载,导致出错。分析了下,发现是因为只要在<script>...</script>内部嵌入了一段脚本之后,就不能修改其属性值了。因此不能采取上述方法来动态加载js。
解决方案可修改为:

<script  class="url1"></script>
<script>
        $.ajax({
            type: "GET",
            data: {captchaType:1},
            url: "url_demo",
            dataType: "jsonp",
            jsonp: "callback",
            success: function(json) {
                if(json.code == 0){
                    url_1 = json.url;
                    $(".url1").attr("src",url_1);
                    }    
            },
            error: function() {
                            
            }
        }); 
</script>

也就是单独拎一个<script></script>,里面不加任何js代码,然后用另外的<script></script>编写js脚本前一个<script></script>的src属性进行修改,达到页面动态加入js的要求。
后来导师说上面的做法有点low,而且“极具创造力”(不过发现了一个问题然后解决了,还是不错滴),就要我去找loadjs的常规方法,这里借用一下玉伯seajs里的loadJs方法,学习一下大神的写法:

function loadJs(url , callback){
    var node = document.createElement("script");
    node[window.addEventListener ? "onload":"onreadystatechange"] = function(){
        if(window.addEventListener || /loaded|complete/i.test(node.readyState)){
            callback();
            node.onreadystatechange = null;
        }                                                                             
    }
    node.onerror = function(){};
    node.src = url;
    var head = document.getElementsByTagName("head")[0];
    head.insertBefore(node,head.firstChild);  
}

用法如下:

$(function(){
        $.ajax({
            type: "GET",
            data: {captchaType:1},
            url: "demo_url",
            dataType: "jsonp",
            jsonp: "callback"
        }).done(function(json){
            if (json.code == 0) {
                //参数url和回调函数
                loadJs(json.url, fCb);
            }
        });
        function fCb(){
            //回调函数,第一次需要用到上述url对应js脚本的函数
            init("container1", "point");
        }
})

好啦,还是看的、学的太少了,以后要多看大牛的写法,只有看得多了,才能不会一头钻进死胡同里。

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 1 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-08-29
个人主页被 162 人浏览