软路由折腾记录一

透明代理

在购买了小主机J1900之后, 我在上面安装了OpenWrt, 一切都很正常, 我搭建了Clash, 配置了iptables使TCP/UDP走透明代理.

TCP透明代理

TCP透明代理比较简单, 因为TCP有状态, 所以透明代理成本较低, iptables提供重定向操作.

1
iptables -t nat -A CLASH -p tcp -j REDIRECT --to-ports 7892

规则将tcp所有流量重定向到本地7892, iptables只需要在SYN包时记录下原始目标地址, 7892端口accept之后通过”SO_ORIGINAL_DST”即可拿到原始目标地址.

UDP透明代理

UDP没有状态, 所以想和TCP进行透明代理是不可能的, 每个连接都像TCP一样保存原始目标地址, 成本较大.

1
2
3
iptables -t mangle -A CLASH -p udp -j TPROXY --on-port 7892 --tproxy-mark 0x111
ip rule add fwmark 0x111 table 0x162
ip route add local 0.0.0.0/0 dev lo table 0x162

UDP透明代理需要三条规则, 第一条使用iptables提供的tproxy模块, 将udp流量重定向到本地7892端口. 比较有意思的是tproxy不修改报文, 但是依旧可以完成重定向任务. 在报文匹配这条规则时, tproxy会查找本地是否开启7892端口, 并且该socket是否设置了”IP_TRANSPARENT”属性, 如果符合则将socket与报文做关联, 修改报文的结构体(skb->sock=sk).
但是在报文不做修改的情况下, 因为目的地址不是本机, 所以路由选择的时候, 该报文会被forward而不会进入本地协议栈进行处理, 那么关联socket的修改就没有意义了. 所以后面两条路由规则, 就是将报文送入本地协议栈进行处理.
tproxy会给每个匹配的报文打上标记”0x111”, 第二条路由规则表示带有”0x111”标记的报文使用”0x162”路由表. 第三条创建”0x162”路由表, 表里的规则将流量本地回环网卡lo, 这样流量顺利进入协议栈, 并且关联修改让它顺利被7892端口接收.

example

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <stdio.h>
#include <netinet/in.h>
#include <memory.h>
#include <arpa/inet.h>

int main() {
int server_fd, ret;
struct sockaddr_in ser_addr;

server_fd = socket(AF_INET, SOCK_DGRAM, 0);

if(server_fd < 0) {
printf("create socket fail!\n");
return -1;
}

int enable = 1;

if (setsockopt(server_fd, SOL_IP, IP_RECVORIGDSTADDR, (const char*)&enable, sizeof(enable)) != 0) {
printf("set socket op fail!\n");
return -1;
}

if (setsockopt(server_fd, SOL_IP, IP_TRANSPARENT, (const char*)&enable, sizeof(enable)) != 0) {
printf("set socket op fail!\n");
return -1;
}

memset(&ser_addr, 0, sizeof(ser_addr));

ser_addr.sin_family = AF_INET;
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
ser_addr.sin_port = htons(7892);

ret = bind(server_fd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));
if(ret < 0)
{
printf("socket bind fail!\n");
return -1;
}

char buffer[256];
unsigned char bytes[16*1024];

struct sockaddr_in srcIpAddr, dstIpAddr;
int dstPort;

struct iovec iov;

iov.iov_base = bytes;
iov.iov_len = sizeof(bytes)-1;

struct msghdr mh;

mh.msg_name = &srcIpAddr;
mh.msg_namelen = sizeof(struct sockaddr_in);
mh.msg_control = buffer;
mh.msg_controllen = 100;
mh.msg_iovlen = 1;
mh.msg_iov = &iov;

int res = recvmsg(server_fd, &mh, 0);

if (res <= 0) {
printf("recv msg failed");
return -1;
}

for(struct cmsghdr *msg = CMSG_FIRSTHDR(&mh); msg != NULL; msg = CMSG_NXTHDR(&mh, msg))
{
if(msg->cmsg_level != SOL_IP || msg->cmsg_type != IP_ORIGDSTADDR)
continue;

memcpy(&dstIpAddr, CMSG_DATA(msg), sizeof(struct sockaddr_in));
dstPort = ntohs(dstIpAddr.sin_port);

char str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &(dstIpAddr.sin_addr), str, INET_ADDRSTRLEN);

printf("%s:%d", str, dstPort);
}

return 0;
}

Docker

简单的翻墙使用, 让我觉得浪费了J1900的性能, 我突发奇想, 我想在上面装Docker! 当我在google反复搜索, 貌似出来的答案都是Docker里面装OpenWrt, 没有人在OpenWrt上装Docker, 唯一有价值的帖子是这篇Docker engine on OpenWrt.
Docker现在只存在于master分支, 如果想用需要自己编译, 于是我开始搭环境进行编译.

1
2
3
4
5
6
7
git clone https://git.openwrt.org/openwrt/openwrt.git
cd openwrt
./scripts/feeds update -a
./scripts/feeds install -a
make menuconfig
make -j8 download
make -j8

编译会遇到很多坑, 也和网络环境有关, 因为编译会下载大量的源码, 所以我全程使用了proxychains.

组件

make menuconfig时会让你选择组件, 此时我很自然的只勾选了docker-ce, 然而编译的结果是整个系统只有docker-ce! 自己编译是不会有软件源的, 因为内核版本不兼容, 你从OpenWrt的Snapshot源(官方dev源, 每天自动编译)下载的包是装不上, 所以连iptables-mod-tproxy都没有.
我勾选了tproxy模块重新编译后, 发现自带的web UI没有, 我开始暴躁了起来. 最后我发现官方发出的稳定版本会附上编译时的配置, 所以在make menuconfig之前先下载下来:

1
curl https://downloads.openwrt.org/releases/19.07.3/targets/x86/64/config.buildinfo > .config

再进行make menuconfig会在官方的配置上进行定制, 我勾选了所需要的模块, 编译进行的很顺利.

代理

我习惯使用proxychains进行代理, 它使用LD_PRELOAD进行socket相关的hook, 支持TCP/UDP. 不过在编译Python3时, 出现了错误:

1
2
Fatal: You must get working getaddrinfo() function.
or you can specify "--disable-ipv6".

搜索之后发现是proxychains的问题, 但是我没有详细探究原因, 我转而使用:

1
2
export http_proxy="http://127.0.0.1:8080"
export https_proxy="http://127.0.0.1:8080"

因为编译的下载大多都会使用该环境变量, 所以也没有发生下载缓慢的问题.

问题

之前的文章已经写了详细安装过程, 这里不赘述了, 使用时发现了两个问题.

  • 无法使用官方源, 要安装screen等软件需要自行编译
  • Docker可正常使用, tproxy无法正常工作

第一个问题完全可以忍受, 因为编译后的目录结构与官方源完全相同, 所以可以上传到自己的VPS, 然后修改opkg配置文件, 使用自建源. 第二个问题相当诡异, 调试了几天依旧找不到原因, 我一度以为是OpenWrt的bug, 甚至发朋友圈怒斥之.

转变

最后我起了个念头, 我想把系统换成Debian, 通过DIV将它改造成软路由. 大概想了一下, 无非是搭建DNS/DHCP, 桥接网卡、开启ip中转, 设置iptables的DNAT规则. 我重新燃起了热情, 为即将可以使用舒适稳定的系统而躁动不安, 此时的我显然没有意识到有一个bug在前方等待着我.

JVM Probe技术

原理

JVM提供动态attach机制, 支持动态注入jar包.

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
27
28
29
30
31
package com.security.smith;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class AgentLoader {
public static void main(String[] args) {
if (args.length != 2) {
System.out.println("usage: program <pid> <jar>");
return;
}

String pid = args[0];
String jarPath = args[1];

System.out.println("pid: " + pid + " jar: " + jarPath);

try {
VirtualMachine vm = VirtualMachine.attach(pid);

vm.loadAgent(jarPath);
vm.detach();
} catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
e.printStackTrace();
}
}
}

使用方法如上, attach的过程分为3步:

  • 检查临时目录下是否有.java_pid{pid} unix socket文件
  • 没有则创建.attach_pid{pid}文件, 给目标进程发送信号3
  • 目标进程JVM虚拟机收到信号, 创建.java_pid{pid} unix socket文件

基于unix socket, 可以给JVM发送指令, 例如”load”指令可以加载.so文件, VirtualMachine.loadAgent也是基于load指令实现的. loadAgent内部发送”load”指令, 使JVM加载自带的”.so”, 动态库内部加载参数指定的jar包.
jar包的MANIFEST.MF需要提供必要参数:

1
2
3
4
Manifest-Version: 1.0
Agent-Class: com.security.smith.SmithAgent
Can-Retransform-Classes: true
Boot-Class-Path: SmithAgent.jar

Agent-Class指定入口Class, 该Class需要实现入口方法:

1
2
3
4
5
public class SmithAgent {
public static void agentmain(String agentArgs, Instrumentation inst) {

}
}

Hook

Instrumentation类提供一系列功能:

  1. ClassFileTransformer用于拦截类加载事件, 需要注册到Instrumentation
  2. Instrumentation.redefineClasses
    • 针对已加载的类, 舍弃原本的字节码, 替换为由用户提供的byte数组
    • 功能比较危险, 一般用于修复出错的字节码
  3. Instrumentation.retransformClasses
    • 针对已加载的类, 重新调用所有已注册的ClassFileTransformer的transform方法, 两个场景
    • 在执行premain和agentmain方法前, JVM已经加载了不少类
      • 而这些类的加载事件并没有被拦截并执行相关的注入逻辑
    • 定义了多个Java Agent, 多个注入的情况, 可能需要移除其中的部分注入
      • 调用Instrumentation.removeTransformer去除某个注入类后, 可以调用retransformClasses
      • 重新从原始byte数组开始进行注入
  4. Java Agent的功能是通过JVMTI Agent(C Agent), JVMTI是一个事件驱动的工具实现接口
    • 通常会在C Agent加载后的方法入口Agent_OnLoad处注册各种事件的钩子方法
    • 当JVM触发这些事件时, 便会调用对应的钩子方法
    • 例如可以为JVMTI中的ClassFileLoadHook事件设置钩子, 从而在C层面拦截所有的类加载事件

使用Instrumentation可以添加Class拦截器, 在Class加载时能够插入或修改字节码, 达到控制整个Class的目的. 对于已经加载的Class, 可以使用retransformClasses强行重载Class, 拦截器便能对重载的Class进行拦截.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SmithProbe implements ClassFileTransformer {
public void start() {
inst.addTransformer(this, true);

try {
inst.retransformClasses(Runtime.class);

} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
}

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
return null;
}
}

classfileBuffer就是目标Class的字节码流, 使用asm等库可以解析字节码并篡改, 返回篡改后的字节码流即可篡改Class, 返回null则示意JVM加载原本的字节码.

JVM函数调用

JVM函数调用基于栈, 例如调用Runtime.exec, 需要依次将类实例引用, exec的函数参数推出栈, 伪代码如下:

1
2
3
4
5
6
# 假定类实例存在寄存器1中, 将寄存器1的值推入栈
ALOAD 1
# 将字符串"ls"推入栈
LDC "ls"
# 调用函数, 因为Java支持重载, 需要指定参数签名
INVOKEVIRTUAL Runtime.exec (Ljava/lang/String;)Ljava/lang/Process;

而进入函数之后, JVM自动根据参数个数, 帮方法将参数放入寄存器(0+), 所以方法内获取参数:

1
2
3
4
# 获取类实例, 静态方法无实例, 所以0寄存器保存的是第一个参数
ALOAD 0
# 获取字符串参数"ls"
ALOAD 1

设计

根据上面分析, 要将函数调用的参数信息传出, 只需要在每个函数的开头插入一段字节码, 但是字节码需要动态生成. 没有一段字节码能够搞定所有函数, 因为每个函数的参数个数以及类型都不同. 所以Hook还是需要我们设定好函数的参数类型以及签名.
例如针对Runtime.exe(String), 我们设定函数信息:

1
java/lang/Runtime VIRTUAL_METHOD_TYPE exec {"string"}

那么在对Class进行拦截时, 我们可以根据上面的设定Hook函数, 根据VIRTUAL_METHOD_TYPE我们知道exec的参数从寄存器1开始, 根据{“string”}我们知道参数有1个, 并且是string类型, 这时候我们只需要插入一段字节码在exec的开头:

1
2
3
# 获取参数
ALOAD 1
INVOKESTATIC com/security/smith/SmithProbe.trace (Ljava/lang/String;)V

两个字节码就可以获取函数调用信息, 第一行将参数推入栈, 第二行调用我们编写好的方法:

1
2
3
4
5
public class SmithProbe implements ClassFileTransformer {
public static void trace(String arg) {

}
}

以上只是一个简单的例子, trace方法我们不能简单的写定参数, 因为每个Hook的函数传进来的参数都不一样. 所以我会在Hook时, 将目标函数的参数推入Object[], 然后传到trace里面来. 另外需要标识是什么类的什么方法调用的trace, 可以在生成字节码时, 给每个调用分配一个id.

1
2
3
4
5
public class SmithProbe implements ClassFileTransformer {
public void trace(int classID, int methodID, Object... args) {

}
}

上面是我定义的函数原型, 仅供参考.

搭建OpenWrt软路由

起因

一直以来, 都想在路由器上搭建机场, 不用在每台设备上都安装小飞机. 看了一下路由器, 基本上都是华硕路由器刷梅林固件, 因为价格过高以及我大部分时间使用有线网络的原因, 购买一个新的路由器并没有必要.
无意中看到了软路由这个词, 搜索之后得知, 软路由其实就是基于CPU散片组装的小型PC, 在上面安装OpenWrt等系统, 就可以进行路由转发. 在上面有很多插件, 可以方便的搭建透明代理. 于是当天在淘宝上购买了基于J1900的软路由, 到家之后立刻开始折腾.

安装

在OpenWrt官网下载了img镜像, 准备好Arch Linux的PE盘, 选择Arch是因为镜像小, 下载和烧录迅速. 把软路由接好显示器, 插入U盘, 按下电源键, 默认就优先进入了U盘引导. Arch Linux启动后, 再插入包含OpenWrt镜像的U盘, 或者直接使用curl下载.

1
dd if=openwrt.img of=/dev/sda

使用dd命令将镜像直接写入软路由硬盘, 此时如果重启就可以直接进入OpenWrt系统了. 但这里有个坑需要注意, 不管你的软路由的硬盘有多大, 重启后你就会发现rootfs只有200M+的空间. 因为dd写入镜像, 分区表等信息都和镜像一样, 镜像的ext4分区默认只有200M+. 所以要使用PE系统重建分区, 因为OpenWrt运行时不可以对自身的分区进行修改.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 对sda进行分区扩展
root@OpenWrt:~# sudo fdisk /dev/sda
Welcome to fdisk (util-linux 2.32).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.

# 输入 p 显示分区信息
Command (m for help): p
Disk /dev/sda: 7.2 GiB, 7751073792 bytes, 15138816 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xcbad8a62

Device Boot Start End Sectors Size Id Type
/dev/sda1 * 512 33279 32768 16M 83 Linux
/dev/sda2 33792 558079 524288 256M 83 Linux
# 输入 d 删除 第二个分区,这里记录下第二个分区的起点是33792
Command (m for help): d
Partition number (1,2, default 2):

Partition 2 has been deleted.
# 输入 n 创建新的分区
Command (m for help): n
Partition type
p primary (1 primary, 0 extended, 3 free)
e extended (container for logical partitions)
# 输入 p 确认是主分区
Select (default p): p
# 输入分区id 用默认的2
Partition number (2-4, default 2): 2
# 输入 分区起始位置(就是原来分区2起始的位置)
First sector (33280-15138815, default 34816): 33792
# 这里直接回车,默认选择终止位置就是硬盘的末尾
Last sector, +sectors or +size{K,M,G,T,P} (33792-15138815, default 15138815):

Created a new partition 2 of type 'Linux' and of size 7.2 GiB.
Partition #2 contains a ext4 signature.

Do you want to remove the signature? [Y]es/[N]o: n
# 输入 w ,写入硬盘, 这里可能会有一个warning,忽略即可
Command (m for help): w

The partition table has been altered.
Syncing disks.

此时分区大小扩展了, 但是上面的ext4还没有扩容:

1
2
resize2fs /dev/sda2
e2fsck -f /dev/sda2

执行上面的命令完成rootfs扩容, 重启即可进入OpenWrt.

配置

进入系统后执行:

1
cat /etc/config/network

查看网卡配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
config interface 'loopback'
option ifname 'lo'
option proto 'static'
option ipaddr '127.0.0.1'
option netmask '255.0.0.0'

config globals 'globals'
option ula_prefix 'fddb:f68c:5cd8::/48'

config interface 'lan'
option type 'bridge'
option ifname 'eth0'
option proto 'static'
option ipaddr '192.168.1.1'
option netmask '255.255.255.0'
option ip6assign '60'

config interface 'wan'
option ifname 'eth1'
option proto 'dhcp'

config interface 'wan6'
option ifname 'eth1'
option proto 'dhcpv6'

可以看到wan口配置在eth1, 也就是第二个网线插口, 从光猫牵线插入wan口即可获取动态IP, 此时OpenWrt已经成功联网. 另外eth0也就是第一个网线插口配置成了lan, 将电脑或路由器插上去即可获取IP. 此时用电脑浏览器访问192.168.1.1(可手动修改配置), 登录OpenWrt配置页面.

Clash

安装了clash之后, 可以顺利的科学上网, 可是当我执行:

1
2
3
PING baidu.com (198.18.1.115) 56(84) bytes of data.
--- baidu.com ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 4ms

域名baidu.com被解析到了一个保留地址, 可是此时我的系统可以正常联网, 于是我开始探索这种被称为Fake IP的机制. Clash自带DNS服务器, 监听127.0.0.1:5300, 通过设置dnsmasq的server字段, 将所有DNS请求转发到自身服务.
然后通过建立一个虚拟IP与实际域名的映射表, 随机返回一个IP(198.18.0.1/16). 客户端收到响应后, 与该IP建立TCP/UDP连接, 而Clash通过添加IPTables规则, 将目标IP属于198.18.0.1/16的请求转发到Clash的Redir Port(7892). 此时Clash可以查询IP对应的域名, 进行透明代理.

Nginx反向代理SNI问题溯源

起因

我开发了一个Nginx通用代理服务, 将目标url进行加密生成jwt, 存放于url参数中.

1
2
3
4
target url: https://baidu.com
jwt payload: {"target": "https://baidu.com"}
jwt: jwt_example
url: proxy.example.com?token=jwt_example

当访代理服务时, nginx将获取jwt并解密, 验证token是否过期, 并取出payload中的目标url, 使用proxy_pass进行转发即可. 这种模式非常通用, 适用于资源加速, 隐藏真正的资源地址等用途. 开发完成之后, 运行一直正常, 我一直用来代理某个国外网站的视频资源.

直到最近才发生了错误, 查看nginx日志:

1
SSL_do_handshake() failed (SSL: error:1408F10B:SSL routines:ssl3_get_record:wrong version number) while SSL handshaking to upstream.

错误

从错误日志可以看出来是SSL握手失败, target url的确是HTTPS的协议, 但是为什么到最近才会出现错误, 之前一直运行正常. 我尝试将target url的协议改成HTTP, 则可以正常代理, 而在访问了HTTP之后再访问HTTPS则正常, 这的确十分诡异. 后来我意识到这其实是因为我开了缓存, 当第一次访问HTTP会将资源缓存下载, 之后代理HTTPS则不会再进行请求, 只是简单的读取缓存.

抓包

当一切都没有头绪的时候, 我开始使用tcpdump进行抓包, 在期间我正常访问并复现错误, 并且使用curl正常访问target url一次. 然后使用wireshark分析pcap文件, wireshark能够清晰有层次的展示出网络报文结构, 包括SSL各个字段意义. 通过观察网络数据, 我发现在发出第一个Client Hello之后, 握手便中断了, 我猜测应该是某个字段遗漏或者填写错误.

所以我提取了nginx/curl两者的Client Hello, 进行详细对比.

nginx client hello:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
Transport Layer Security
TLSv1 Record Layer: Handshake Protocol: Client Hello
Content Type: Handshake (22)
Version: TLS 1.0 (0x0301)
Length: 278
Handshake Protocol: Client Hello
Handshake Type: Client Hello (1)
Length: 274
Version: TLS 1.2 (0x0303)
Random: 1f9c1eb1a9e8a36ff7d953967bc0c887bd56b2052552befb…
GMT Unix Time: Oct 22, 1986 08:32:49.000000000 CST
Random Bytes: a9e8a36ff7d953967bc0c887bd56b2052552befb0400350f…
Session ID Length: 32
Session ID: ee143acbaedee8fb98b1dc3d6b90bdedc03cef516d463a55…
Cipher Suites Length: 62
Cipher Suites (31 suites)
Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 (0x009f)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca9)
Cipher Suite: TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca8)
Cipher Suite: TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xccaa)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 (0x009e)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 (0xc024)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 (0xc028)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 (0x006b)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 (0xc023)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 (0xc027)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 (0x0067)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA (0xc00a)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA (0xc014)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA (0x0039)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (0xc009)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (0xc013)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA (0x0033)
Cipher Suite: TLS_RSA_WITH_AES_256_GCM_SHA384 (0x009d)
Cipher Suite: TLS_RSA_WITH_AES_128_GCM_SHA256 (0x009c)
Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA256 (0x003d)
Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA256 (0x003c)
Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA (0x0035)
Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)
Cipher Suite: TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff)
Compression Methods Length: 1
Compression Methods (1 method)
Compression Method: null (0)
Extensions Length: 139
Extension: ec_point_formats (len=4)
Type: ec_point_formats (11)
Length: 4
EC point formats Length: 3
Elliptic curves point formats (3)
Extension: supported_groups (len=12)
Type: supported_groups (10)
Length: 12
Supported Groups List Length: 10
Supported Groups (5 groups)
Extension: session_ticket (len=0)
Type: session_ticket (35)
Length: 0
Data (0 bytes)
Extension: encrypt_then_mac (len=0)
Type: encrypt_then_mac (22)
Length: 0
Extension: extended_master_secret (len=0)
Type: extended_master_secret (23)
Length: 0
Extension: signature_algorithms (len=42)
Type: signature_algorithms (13)
Length: 42
Signature Hash Algorithms Length: 40
Signature Hash Algorithms (20 algorithms)
Signature Algorithm: ecdsa_secp256r1_sha256 (0x0403)
Signature Algorithm: ecdsa_secp384r1_sha384 (0x0503)
Signature Algorithm: ecdsa_secp521r1_sha512 (0x0603)
Signature Algorithm: ed25519 (0x0807)
Signature Algorithm: ed448 (0x0808)
Signature Algorithm: rsa_pss_pss_sha256 (0x0809)
Signature Algorithm: rsa_pss_pss_sha384 (0x080a)
Signature Algorithm: rsa_pss_pss_sha512 (0x080b)
Signature Algorithm: rsa_pss_rsae_sha256 (0x0804)
Signature Algorithm: rsa_pss_rsae_sha384 (0x0805)
Signature Algorithm: rsa_pss_rsae_sha512 (0x0806)
Signature Algorithm: rsa_pkcs1_sha256 (0x0401)
Signature Algorithm: rsa_pkcs1_sha384 (0x0501)
Signature Algorithm: rsa_pkcs1_sha512 (0x0601)
Signature Algorithm: SHA224 ECDSA (0x0303)
Signature Algorithm: SHA224 RSA (0x0301)
Signature Algorithm: SHA224 DSA (0x0302)
Signature Algorithm: SHA256 DSA (0x0402)
Signature Algorithm: SHA384 DSA (0x0502)
Signature Algorithm: SHA512 DSA (0x0602)
Extension: supported_versions (len=5)
Type: supported_versions (43)
Length: 5
Supported Versions length: 4
Supported Version: TLS 1.3 (0x0304)
Supported Version: TLS 1.2 (0x0303)
Extension: psk_key_exchange_modes (len=2)
Type: psk_key_exchange_modes (45)
Length: 2
PSK Key Exchange Modes Length: 1
PSK Key Exchange Mode: PSK with (EC)DHE key establishment (psk_dhe_ke) (1)
Extension: key_share (len=38)
Type: key_share (51)
Length: 38
Key Share extension
Client Key Share Length: 36
Key Share Entry: Group: x25519, Key Exchange length: 32
Group: x25519 (29)
Key Exchange Length: 32
Key Exchange: 4f2de7db6676fb0bd1414fe7a39c7b0b9de762faf020f1db…

curl client hello:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
Transport Layer Security
TLSv1.2 Record Layer: Handshake Protocol: Client Hello
Content Type: Handshake (22)
Version: TLS 1.0 (0x0301)
Length: 512
Handshake Protocol: Client Hello
Handshake Type: Client Hello (1)
Length: 508
Version: TLS 1.2 (0x0303)
Random: a7b82c9ca56d16f7a2185220dda1738524d36f863807571a…
GMT Unix Time: Mar 3, 2059 05:11:56.000000000 CST
Random Bytes: a56d16f7a2185220dda1738524d36f863807571a04287add…
Session ID Length: 32
Session ID: 49e609bb364d985ddead6c63ef722cc2289f68673155a8a5…
Cipher Suites Length: 62
Cipher Suites (31 suites)
Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 (0x009f)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca9)
Cipher Suite: TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca8)
Cipher Suite: TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xccaa)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 (0x009e)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 (0xc024)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 (0xc028)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 (0x006b)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 (0xc023)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 (0xc027)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 (0x0067)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA (0xc00a)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA (0xc014)
Cipher Suite: TLS_DHE_RSA_WITH_AES_256_CBC_SHA (0x0039)
Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (0xc009)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (0xc013)
Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA (0x0033)
Cipher Suite: TLS_RSA_WITH_AES_256_GCM_SHA384 (0x009d)
Cipher Suite: TLS_RSA_WITH_AES_128_GCM_SHA256 (0x009c)
Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA256 (0x003d)
Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA256 (0x003c)
Cipher Suite: TLS_RSA_WITH_AES_256_CBC_SHA (0x0035)
Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)
Cipher Suite: TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff)
Compression Methods Length: 1
Compression Methods (1 method)
Compression Method: null (0)
Extensions Length: 373
Extension: server_name (len=21)
Type: server_name (0)
Length: 21
Server Name Indication extension
Extension: ec_point_formats (len=4)
Type: ec_point_formats (11)
Length: 4
EC point formats Length: 3
Elliptic curves point formats (3)
Extension: supported_groups (len=12)
Type: supported_groups (10)
Length: 12
Supported Groups List Length: 10
Supported Groups (5 groups)
Extension: next_protocol_negotiation (len=0)
Type: next_protocol_negotiation (13172)
Length: 0
Extension: application_layer_protocol_negotiation (len=14)
Type: application_layer_protocol_negotiation (16)
Length: 14
ALPN Extension Length: 12
ALPN Protocol
Extension: encrypt_then_mac (len=0)
Type: encrypt_then_mac (22)
Length: 0
Extension: extended_master_secret (len=0)
Type: extended_master_secret (23)
Length: 0
Extension: post_handshake_auth (len=0)
Type: post_handshake_auth (49)
Length: 0
Extension: signature_algorithms (len=42)
Type: signature_algorithms (13)
Length: 42
Signature Hash Algorithms Length: 40
Signature Hash Algorithms (20 algorithms)
Signature Algorithm: ecdsa_secp256r1_sha256 (0x0403)
Signature Algorithm: ecdsa_secp384r1_sha384 (0x0503)
Signature Algorithm: ecdsa_secp521r1_sha512 (0x0603)
Signature Algorithm: ed25519 (0x0807)
Signature Algorithm: ed448 (0x0808)
Signature Algorithm: rsa_pss_pss_sha256 (0x0809)
Signature Algorithm: rsa_pss_pss_sha384 (0x080a)
Signature Algorithm: rsa_pss_pss_sha512 (0x080b)
Signature Algorithm: rsa_pss_rsae_sha256 (0x0804)
Signature Algorithm: rsa_pss_rsae_sha384 (0x0805)
Signature Algorithm: rsa_pss_rsae_sha512 (0x0806)
Signature Algorithm: rsa_pkcs1_sha256 (0x0401)
Signature Algorithm: rsa_pkcs1_sha384 (0x0501)
Signature Algorithm: rsa_pkcs1_sha512 (0x0601)
Signature Algorithm: SHA224 ECDSA (0x0303)
Signature Algorithm: SHA224 RSA (0x0301)
Signature Algorithm: SHA224 DSA (0x0302)
Signature Algorithm: SHA256 DSA (0x0402)
Signature Algorithm: SHA384 DSA (0x0502)
Signature Algorithm: SHA512 DSA (0x0602)
Extension: supported_versions (len=5)
Type: supported_versions (43)
Length: 5
Supported Versions length: 4
Supported Version: TLS 1.3 (0x0304)
Supported Version: TLS 1.2 (0x0303)
Extension: psk_key_exchange_modes (len=2)
Type: psk_key_exchange_modes (45)
Length: 2
PSK Key Exchange Modes Length: 1
PSK Key Exchange Mode: PSK with (EC)DHE key establishment (psk_dhe_ke) (1)
Extension: key_share (len=38)
Type: key_share (51)
Length: 38
Key Share extension
Client Key Share Length: 36
Key Share Entry: Group: x25519, Key Exchange length: 32
Group: x25519 (29)
Key Exchange Length: 32
Key Exchange: b15a8e88f3f46e4a94dcd6273a89f60aaee9690ed7974975…
Extension: padding (len=183)
Type: padding (21)
Length: 183
Padding Data: 000000000000000000000000000000000000000000000000…

疑惑

令我不解的是, 两个报文的version字段都是一样的(TLS1.2), 但是显示的协议却不一样.

TLSv1 Record Layer: Handshake Protocol: Client Hello
TLSv1.2 Record Layer: Handshake Protocol: Client Hello

因为我之前遇到过curl进行SSL握手失败的问题, 错误原因是现在大多数OpenSSL将最低版本设置成TLS1.2. 所以我怀疑是不是TLS版本设置的问题, 但是我尝试了很多种方法, 依旧失败.
随后我更加仔细的对比两个报文, 我发现了一个差异:

Extension: server_name (len=21)

curl的握手报文里面多出一个server_name的字段, 这时我便开始搜索关于SNI的信息. 简单来说如果一个SSL服务上有多个域名服务, 那么客户端在握手报文里面需要附带SNI, 也就是想访问的域名, 这样SSL服务才能为其选择正确的证书.

解决

我搜索了nginx文档后看到:

1
2
3
4
5
6
Syntax:	proxy_ssl_server_name on | off;
Default: proxy_ssl_server_name off;
Context: http, server, location
This directive appeared in version 1.7.0.

Enables or disables passing of the server name through TLS Server Name Indication extension (SNI, RFC 6066) when establishing a connection with the proxied HTTPS server.

Nginx默认关闭了SNI, 当我打开这个选项之后一切都正常了, 我猜测大概是目标网站最近开启了SNI的选项, 当nginx没有提供SNI的时候就无法正常访问.

内存屏障

起因

在学习linux kernel开发时, 学习了环形缓冲区的使用, 参考Circular Buffers.
生产者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
spin_lock(&producer_lock);

unsigned long head = buffer->head;
/* The spin_unlock() and next spin_lock() provide needed ordering. */
unsigned long tail = READ_ONCE(buffer->tail);

if (CIRC_SPACE(head, tail, buffer->size) >= 1) {
/* insert one item into the buffer */
struct item *item = buffer[head];

produce_item(item);

smp_store_release(buffer->head,
(head + 1) & (buffer->size - 1));

/* wake_up() will make sure that the head is committed before
* waking anyone up */
wake_up(consumer);
}

spin_unlock(&producer_lock);

消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spin_lock(&consumer_lock);

/* Read index before reading contents at that index. */
unsigned long head = smp_load_acquire(buffer->head);
unsigned long tail = buffer->tail;

if (CIRC_CNT(head, tail, buffer->size) >= 1) {

/* extract one item from the buffer */
struct item *item = buffer[tail];

consume_item(item);

/* Finish reading descriptor before incrementing tail. */
smp_store_release(buffer->tail,
(tail + 1) & (buffer->size - 1));
}

spin_unlock(&consumer_lock);

疑惑

可以看到源码中使用了自旋锁, 但是锁的作用仅仅只是用来保证同一时间只有一个消费者或者生产者. 消费者和生产者同时运作是可以的, 也就是对head、tail变量的读取与存储可以同时发生.
但是可以看到代码中包含了”READ_ONCE”、”smp_load_acquire”、”smp_store_release”的使用, 它们又是什么作用.
如果去搜索这几个关键字, 大概率会看到一个关键词”内存屏障”.

理解

关于内存屏障的理解, Memory Barriers这篇文章已经讲得很详细了.
简单概括一下, 就是对内存的一系列读取存储操作的顺序是不可预测的, 这和编译器的优化有关, 也和多个CPU同时运行但拥有独立的内存高速缓冲区相关.

编译器优化

1
2
3
4
void main() {
int x = 1;
int y = 2;
}

编译器对于两个无关的复制, 在开启性能优化时, 不保证生成的机器码顺序和源码中的顺序相同. 所以在生成的程序中, 机器码的顺序可能是”y=1”在”x=1”的前面. 但是在这个例子中, 这无关紧要.
看看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
//thread 1
void run1()
{
x = 1;
r1 = y;
}

//thread 2
void run2
{
y = 1;
r2 = x;
}

两个线程同时运行, 那么有多种情况可能发生. 但是我们编写代码时, 人为的肯定这些代码是按照顺序执行的. 所以不论两个线程如何交替, r1和r2不可能同时为0. 但实际上编译器并不知道这些变量会在别处修改, 所以它按照自己的想法进行优化, 打乱代码的顺序.
最后的结果可能是”r2=x”和”r1=y”最先交替执行, 这样的结果是不可预期的.

CPU

每个CPU可能拥有独立的高速缓冲区, 所以对内存的操作通常会先转化为对缓存的操作. 于是可能发生CPU0往地址x写入值1, 但是只写入了缓冲区. 此时CPU1读取地址x, 但是可惜缓冲区中的值并没有及时刷新到内存中, 所以读出来的值是不正确的.

限制

有没有用什么手段可以限制上面所说的乱序问题, 针对编译器的限制方法十分简单, 就是使用”volatile”. volatile关键字对于编译器而言, 是开发者告诉编译器, 这个变量内存的修改, 可能不再你可视范围内, 不要对这个变量相关的代码进行优化.
针对CPU的限制, CPU本身提供内存屏障指令:

  • 写屏障sfence
  • 读屏障lfence
  • 读写屏障mfence

源码

现在来看看”READ_ONCE”、”smp_load_acquire”、”smp_store_release”的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// READ_ONCE核心代码
#define barrier() __asm__ __volatile__("": : :"memory")
#define __READ_ONCE_SIZE \
({ \
switch (size) { \
case 1: *(__u8 *)res = *(volatile __u8 *)p; break; \
case 2: *(__u16 *)res = *(volatile __u16 *)p; break; \
case 4: *(__u32 *)res = *(volatile __u32 *)p; break; \
case 8: *(__u64 *)res = *(volatile __u64 *)p; break; \
default: \
barrier(); \
__builtin_memcpy((void *)res, (const void *)p, size); \
barrier(); \
} \
})

可以看到READ_ONCE针对标准大小的类型, 直接使用volatile实现编译器内存屏障, 保证编译出的机器码中内存访问顺序与源码一致. 而针对其余大小的类型, 使用barrier让编译器保证其之前的内存访问先于其之后的内存访问完成.
所以”READ_ONCE”只是针对于编译器的内存屏障, 并不包含内存屏障指令.
内核使用宏CONFIG_SMP来判断CPU是否使用了SMP, 在SMP架构下,每个CPU与内存之间,都配有自己的高速缓存. 根据是否使用SMP, “smp_load_acquire”的定义也不同.
无SMP:

1
2
3
4
5
6
7
8
#ifndef smp_store_release
#define smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
barrier(); \
WRITE_ONCE(*p, v); \
} while (0)
#endif

可以看到无SMP的时候, smp_store_release只是简单的使用了barrier+”WRITE_ONCE”进行编译器层面的内存屏障.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define mb() alternative("lock; addl $0,0(%%esp)", "mfence", X86_FEATURE_XMM2)
#define __smp_mb() mb()

#ifndef __smp_store_release
#define __smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
WRITE_ONCE(*p, v); \
} while (0)
#endif

#ifndef __smp_load_acquire
#define __smp_load_acquire(p) \
({ \
typeof(*p) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
___p1; \
})

而有SMP的时候, 会使用mb插入mfence指令.