前言

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. 雪花算法 SnowFlake 内部结构【分布式ID生成策略】

    更多内容,前往IT-BLOG 一.前言   如何在分布式集群中生产全局唯一的 ID?[方案一]UUID:UUID是通用唯一识别码 (Universally Unique Identifier),在其他 ...

  2. VUEX面试题

    1.你有写过vuex中store的插件吗? 答:没有 2.你有使用过vuex的module吗?主要是在什么场景下使用? 答:把状态全部集中在状态树上,非常难以维护.按模块分成多个module,状态树延 ...

  3. 字符串常见API(charCodeAt\fromCharCode)

    1.myStr.charCodeAt(num) 返回指定位置的字符的Unicode(是字符编码的一种模式)编码. 2.String.fromCharCode() String的意思就是不能用自己定义的 ...

  4. C++/Qt网络通讯模块设计与实现(总结)

    至此,C++/Qt网络通讯模块设计与实现已分析完毕,代码已应用于实际产品中. C++/Qt网络通讯模块设计与实现(一) 该章节从模块的功能需求以及非功能需求进行分析,即网络通讯模块负责网络数据包的发送 ...

  5. 全网最详细中英文ChatGPT-GPT-4示例文档-智能多功能学习机从0到1快速入门——官网推荐的48种最佳应用场景(附python/node.js/curl命令源代码,小白也能学)

    目录 Introduce 简介 setting 设置 Prompt 提示 Sample response 回复样本 API request 接口请求 python接口请求示例 node.js接口请求示 ...

  6. PHP的WAMP的安装

    WAMP独立安装 软件官网下载: Apache:http://httpd.apache.org/download.cgi MySQL:http://dev.mysql.com/downloads/ P ...

  7. PHP读取XML文件的三种方式

    一  用  simplexml_load_file 读取xml文件 <?php $xml_array=simplexml_load_file('person.xml'); //将XML中的数据, ...

  8. 如何快速在手机中查看UDID,无需itunes、itools

      第一步:使用iPhone/iPad打开这个网页 http://www.appuploader.net/ 如果嫌麻烦也可以电脑打 http://www.appuploader.net/,之后会出现二 ...

  9. 揭开神秘面纱,会stream流就会大数据

    目录 准备工作 1.map类 1.1 java stream map 1.2 spark map 1.2.1 MapFunction 1.2.2 MapPartitionsFunction 2.fla ...

  10. vivo 推送系统的容灾建设与实践

    作者:vivo 互联网服务器团队 - Yu Quan 本文介绍了推送系统容灾建设和关键技术方案,以及实践过程中的思考与挑战. 一.推送系统介绍 vivo推送平台是vivo公司向开发者提供的消息推送服务 ...