前言

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. Maven 自动化构建

    一.Maven:是一款服务于 Java平台的自动化构建工具 [1]Maven可以将一个项目按模块划分成不同的工程,利于分工协作;[2]Maven可以将 jar包保存在自己的中央"仓库&quo ...

  2. C++库封装JNI接口——实现java调用c++

    1. JNI原理概述 通常为了更加灵活高效地实现计算逻辑,我们一般使用C/C++实现,编译为动态库,并为其设置C接口和C++接口.用C++实现的一个库其实是一个或多个类的简单编译链接产物.然后暴露其实 ...

  3. [大数据]ETL之增量数据抽取(CDC)

    关于:转载/知识产权 本文遵循 GPL开源协议,如若转载: 1 请发邮件至博主,以作申请声明. 2 请于引用文章的显著处注明来源([大数据]ETL之增量数据抽取(CDC) - https://www. ...

  4. 五月十二号java基础知识点

    1.注解是代码中特殊标记,作用是告知编译器做什么事2.反射允许程序在运行状态时,对任意一个字节码获取它所有信息3.内部类是定义在类中的嵌套类4.匿名内部类是定义在类的同时创建该类的一个对象5.lamb ...

  5. docker安装python+nginx

    一个容器安装python和nginx dockerfile FROM centos:7.9.2009 USER root RUN yum install gcc openssl-devel bzip2 ...

  6. 探究公众号接口漏洞:从后台登录口到旁站getshell

    探究公众号接口漏洞:从后台登录口到旁站getshell 1.入口 发现与利用公众号接口安全漏洞 某120公众号提供了一处考核平台,通过浏览器处打开该网站. 打开可以看到一处密码登录口,试了一下常用的手 ...

  7. 在Jupyter Notebook,沉浸式体验ChatGPT

    大家好,我是章北海mlpy 写代码,修Bug是 ChatGPT 目前最擅长的领域之一 今天向大家推荐一个刚刚开源的Python包 安装后可以直接在IPython和Jupyter Notebook中直接 ...

  8. ThreadLocal实现原理和使用场景

    ThreadLocal是线程本地变量,每个线程中都存在副本. 实现原理: 每个线程中都有一个ThreadLocalMap,而ThreadLocalMap中的key即是ThreadLocal.  内存泄 ...

  9. SpringBoot SpringSecurity 介绍(基于内存的验证)

    SpringBoot 集成 SpringSecurity + MySQL + JWT 附源码,废话不多直接盘 SpringBoot已经为用户采用默认配置,只需要引入pom依赖就能快速启动Spring ...

  10. Centos7.x jmeter + ant + jenkins接口自动化框架部署

    一.基础环境准备 1.jmeter安装(之前文章有介绍过) 2.ant安装 · 官网下载:https://ant.apache.org/bindownload.cgi · 上传服务器,执行 tar - ...