前言

HTTP 代理给页面注入 JS 是很常见的需求。由于上游服务器返回的页面可能是压缩状态的,因此需解压才能注入,同时为了节省流量,返回下游时还得再压缩。为了注入一小段代码,却将整个页面的流量解压再压缩,白白浪费大量性能。

是否有高效的解决方案?本文从注入位置、压缩格式、校验算法进行探讨。

注入位置

常见的注入方式,是对某个 HTML 标签进行替换,例如将 <head> 替换成 <head><script>...

字符匹配的方式虽然简单,但并不严谨。假如页面中没有出现 <head>,那么就不会注入了。若要考虑大小写、标签存在属性的情况,还得使用正则匹配。更极端的情况,例如第一个匹配点出现在注释中,那么注入的代码根本不会运行:

<html>
<!-- <head></head> -->
<head></head>
<body></body>
</html>

至于在网关上解析 HTML 这样的重量级操作,通常不会考虑。

现实中使用正则匹配足以支持大多数情况。不过正则匹配仍有一定的开销,是否有更轻量甚至零开销的注入方式?

其实可以有,直接将代码注入到页面最顶端!这种做法虽然不规范,但主流浏览器都支持。如果担心 doctype 失效,可以在注入的代码里补上:

<!doctype html><script src="inject.js"></script>
<!doctype html>
<html>
<head></head>
<body></body>
</html>

这样网关无需任何替换操作,只需转发时将注入的代码拼在第一个 chunk 之前即可。

不过这只是明文传输的情况。如果上游返回的是压缩流量,那么在其之前拼上「压缩后的注入代码」,是否仍有效?

我们以 gzip 为例接着探讨。

文件格式

gzip 使用 DEFLATE 算法压缩数据(下图 body 部分),并在前面加上 10 字节的文件头、不定长的可选头(记录文件名等),末尾加上 8 字节的文件尾:

struct field length
header magic number (1f 8b) 2
compression method (08) 1
flags 1
timestamp 4
compression flags 1
operating system ID 1
extra headers (optional) ... ...
... ...
body block1 ...
block2 ...
... ...
trailer CRC32 4
uncompressed data length 4

https://en.wikipedia.org/wiki/Gzip

由于我们的数据在最前面,因此需提供文件头,并删除上游返回的文件头。

此外,还需要确定如下问题:

  1. 文件尾的 CRC32 校验是否需要更新

  2. 压缩数据中每个 block 块是否独立

第一个问题即使不调研,大概也能猜到,在浏览器端肯定是不需要的。因为网页是流模式的,收到一些渲染一些。等渲染完成后才说数据有问题,那网页是留着还是不让显示?至少到目前还没见过网页提示 gzip 校验失败的错误。

第二个问题,在 RFC1951 中有讲解:

Each block is compressed using a combination of the LZ77 algorithm

and Huffman coding. The Huffman trees for each block are independent

of those for previous or subsequent blocks; the LZ77 algorithm may

use a reference to a duplicated string occurring in a previous block,

up to 32K input bytes before.

Each block consists of two parts: a pair of Huffman code trees that

describe the representation of the compressed data part, and a

compressed data part. (The Huffman trees themselves are compressed

using Huffman encoding.) The compressed data consists of a series of

elements of two types: literal bytes (of strings that have not been

detected as duplicated within the previous 32K input bytes), and

pointers to duplicated strings, where a pointer is represented as a

pair <length, backward distance>. The representation used in the

"deflate" format limits distances to 32K bytes and lengths to 258

bytes, but does not limit the size of a block, except for

uncompressible blocks, which are limited as noted above.

https://www.rfc-editor.org/rfc/rfc1951#page-4

每个块可能会引用之前块的数据,好在引用方式是从当前位置计算的(<长度, 反向距离>),因此是个相对值,不会因数据流开头插入我们的块而受到干扰。

此外还需注意的是,每个块的头部有个 BFINAL 字段标记当前是否为最后一块,因此我们的块中该字段不能被标记,否则后续块就不会解析了。

尝试

我们用 Node.js 实现一个初步演示:

import zlib from 'node:zlib'
import http from 'node:http' // 上游返回的 gzip 数据(出于演示,未使用流模式)
const htmlGzipBuf = zlib.gzipSync('<h1>Hello World</h1>') // 注入代码的 gzip 数据(部分压缩,防止被标记成最后一个 block)
let injectGzipBuf = Buffer.alloc(0) const tmp = zlib.createGzip()
tmp.on('data', buf => {
injectGzipBuf = Buffer.concat([injectGzipBuf, buf])
})
tmp.write('<!doctype html><script>console.log("Hi Jack")</script>')
tmp.flush() http.createServer((req, res) => {
res.setHeader('content-type', 'text/html')
res.setHeader('content-encoding', 'gzip')
// 输出压缩态的注入代码
res.write(injectGzipBuf)
// 跳过上游的 gzip 文件头(默认 10 字节)
res.end(htmlGzipBuf.subarray(10))
}).listen(8080)

这个案例中,我们两次输出的都是压缩态数据,最终被浏览器成功解析。

经测试所有主流浏览器都没问题,curl 也没问题。但也有一些库会校验 CRC,例如 Node.js 的 fetch:

const res = await fetch('http://127.0.0.1:8080/')
const reader = res.body.getReader()
for (;;) {
const {done, value} = await reader.read()
if (done) {
break
}
console.log(value)
}

读取最后块时报错:

Uncaught TypeError: terminated
at Fetch.onAborted ...
[cause]: Error: incorrect data check
at Zlib.zlibOnError [as onerror] ...
code: 'Z_DATA_ERROR'

导致读取的数据比预期少。

校验算法

如何更新校验值?最笨的办法,就是把上游流量全都解开,重新计算一次 CRC。毕竟解压的开销比压缩小很多,还是可以接受的。

不过本文追求的是低开销甚至零开销,因此这个方案很不完美。记得曾经开发防火墙时,如果数据包只修改很小一部分,那么 checksum 是不用重新计算的,只需稍加修正即可。这个思路是否可用在 CRC 上?毕竟 CRC 又不是什么密码学 hash 算法,就几个简单的 xor 运算,大概是可以玩出一些花招的。

一查文档,发现不仅可以,甚至这个奇技淫巧还被 zlib 库收录了,提供了一个 crc32_combine 函数,用于合并两个 CRC32 值:

crc32_combine(crc1, crc2, len2)

  Combine two CRC-32 check values into one.  For two sequences of bytes,
seq1 and seq2 with lengths len1 and len2, CRC-32 check values were
calculated for each, crc1 and crc2. crc32_combine() returns the CRC-32
check value of seq1 and seq2 concatenated, requiring only crc1, crc2, and
len2.

至于原理细节,可参考:

https://stackoverflow.com/questions/23122312/crc-calculation-of-a-mostly-static-data-stream/23126768

https://github.com/stbrumme/crc32/blob/master/Crc32.cpp#L560

使用这个方案,即可兼容所有 HTTP 客户端。

完整演示

前面的演示出于简单,未考虑 gzip 扩展文件头,并且直接使用 Buffer 代替数据流。下面分享一个更完整的演示:

https://github.com/EtherDream/gzip-js-injector

后记

几年前研究流量劫持时写的文章,不过一直没发布,前段时间翻新了下并补了个 demo。由于那时还没 brotli 压缩,因此也没调研。之后有时间再补充。

流量劫持 —— GZIP 页面零开销注入 JS的更多相关文章

  1. WiFi流量劫持—— JS脚本缓存投毒

    在上一篇<WiFi流量劫持—— 浏览任意页面即可中毒>构思了一个时光机原型,让我们的脚本通过HTTP缓存机制,在未来的某个时刻被执行,因此我们可以实现超大范围的入侵了. 基于此原理,我们用 ...

  2. 【流量劫持】SSLStrip 终极版 —— location 瞒天过海

    前言 之前介绍了 HTTPS 前端劫持 的方案,虽然很有趣,然而现实却并不理想.其唯一.也是最大的缺陷,就是无法阻止脚本跳转.若是没有这个缺陷,那就非常完美了 -- 当然也就没有必要写这篇文章了. 说 ...

  3. HTTPS-能否避免流量劫持

    流量劫持是什么? EtherDream在一篇科普文章<>中详细介绍了流量劫持途径和方式. 流量劫持是一种古老的攻击方式,比如早已见惯的广告弹窗等,很多人已经对此麻木,并认为流量劫持不会造成 ...

  4. 关于全站https必要性http流量劫持、dns劫持等相关技术

    关于全站https必要性http流量劫持.dns劫持等相关技术 微信已经要求微信支付,申请退款功能必须12月7号之前必须使用https证书了(其他目前为建议使用https),IOS也是2017年1月1 ...

  5. 【流量劫持】躲避 HSTS 的 HTTPS 劫持

    前言 HSTS 的出现,对 HTTPS 劫持带来莫大的挑战. 不过,HSTS 也不是万能的,它只能解决 SSLStrip 这类劫持方式.但仔细想想,SSLStrip 这种算劫持吗? 劫持 vs 钓鱼 ...

  6. 【流量劫持】SSLStrip 的未来 —— HTTPS 前端劫持

    前言 在之前介绍的流量劫持文章里,曾提到一种『HTTPS 向下降级』的方案 -- 将页面中的 HTTPS 超链接全都替换成 HTTP 版本,让用户始终以明文的形式进行通信. 看到这,也许大家都会想到一 ...

  7. htaccess文件还可以被用来把访问网站的流量劫持到黑客的网站

    看是否有文件上传操作(POST方法), IPREMOVED--[01/Mar/2013:06:16:48-0600]"POST/uploads/monthly_10_2012/view.ph ...

  8. Linux-某电商网站流量劫持案例分析与思考

    [前言] 自腾讯与京东建立了战略合作关系之后,笔者网上购物就首选京东了.某天在家里访问京东首页的时候突然吃惊地发现浏览器突然跳到了第三方网站再回到京东,心里第一个反应就是中木马了. 竟然有这样的事,一 ...

  9. Web流量劫持

    BadTunnel实战之远程劫持任意内网主机流量 http://www.freebuf.com/articles/web/109345.html http://blog.csdn.net/ts__cf ...

  10. 如何使用HTTPS防止流量劫持

    何为流量劫持 前不久小米等六家互联网公司发表联合声明,呼吁运营商打击流量劫持.流量劫持最直观的表现,就是网页上被插入了一些乱七八糟的广告/弹窗之类的内容.比如这样: 网页右下角被插入了游戏的广告. 流 ...

随机推荐

  1. C#中的命名空间和程序集

    前言 今天这篇文章和大家一起学习下C#语言下的命名空间和程序集. 在日常的编码工作中,我们对命名空间和程序集都不会很陌生.在创建项目文件时,IDE自动会为我们创建好一个大的命名空间和程序集.大多数业务 ...

  2. 深入消息队列MQ,看这篇就够了!

    大厂面试爱问消息队列 MQ. 因为消息队列MQ,既是大型分布式系统不可缺少的中间件,也是高并发系统的基石中间件. 如果你想要快速掌握消息队列 MQ 最内核的知识,以及消息队列MQ的主流应用场景.主流产 ...

  3. ES6let const

    let const class 类 import 引入模块 export 暴露接口 // var a = 10; // let b = 20; // const c = 30; var和let比 co ...

  4. 统计数据异常值的处理——R语言

    在数据分析工作中,面对收集而来的数据,数据清洗是首要环节.异常值(outlier)是数据清洗的重要环节,异常值可能直接会导致后面的数据分析.建模工作出现偏差,下面就给大家介绍一下如何处理数据中的异常值 ...

  5. [Java EE]解决浏览器跨域问题

    1 解决浏览器跨域问题的方案 方式1: 浏览器(chrome)中取消跨域限制 step1 浏览器 chrome://flags step2 搜索:same step3 将搜索结果中的3个插件[Same ...

  6. 1.使用cookie简单实现单点登录流程

    1.动手 实现了简单使用多系统,单一位置同时登陆,以及注销 主要认证中心流程代码编写在为在sso-login包下的ViewConreoller和LoginController:各系统的用户名显示是写在 ...

  7. MySQL 读书笔记(一)

    1 MySQL 表 1.1 索引组织表 在 InnoDB 存储引擎中,表都是根据主键顺序存放的,这种存储方式称为索引组织表. InnoDB存储引擎中,每张 MySQL表 都有一个唯一主键,如果创建表时 ...

  8. windows10设置共享目录

    win10设置目录局域网内共享 1.右键点击文件属性,点击共享 2.选择与其共享的用户 3.点击共享,选择everyone,可以让在同一局域网下的用户访问 4.显示你的文件夹已共享 5.在同一局域网的 ...

  9. 借助 APISIX Ingress,实现与注册中心的无缝集成

    作者张晋涛,API7.ai 云原生技术专家,Apache APISIX PMC 成员,Apache APISIX Ingress Controller 项目维护者. 原文链接 云原生场景下是否需要服务 ...

  10. 论文解析 -- A Survey of AIOps Methods for Failure Management

    此篇Survey是A Systematic Mapping Study in AIOps的后续研究 对于AIOPS中占比较高的Failure Management进行进一步的研究 Compared t ...