针对 Diffie Hellman 协议的中间人攻击与协议改进
需要源码请联系108564078@qq.com,提供付费资源
本文为网络空间安全实践与设计 III 的报告内容。该项目实现了客户端与服务器通过 Diffie Hellman 协议进行密钥交换、AES 加密通信内容、中间人攻击实现窃听等,并针对中间人攻击对 Diffie Hellman 协议进行了改进。
课设要求:使用 C 语言,在 Linux 平台进行开发。
第一阶段:Diffie-Hellman 协议的实现
- 客户端与服务器之间通过 TCP Socket 通信;
- 客户端与服务器之间通过 Diffie-Hellman 协议协商出对称密钥;
- 客户端使用协商出的对称密钥对传输内容做加密,并发送给服务端;
- 服务端接受客户端发送过来的内容,进行解密;
- 对称加密算法采用 AES256-GCM;
第二阶段:Diffie-Hellman 中间人攻击方法研究与实现
- 研究 Diffie-Hellman 协议,研究中间人攻击方法并完成相关代码。当通信双方进行通信时,中间人攻击程序可以解密出传输内容;
第三阶段:Diffie-Hellman 协议改进
- 基于预共享密钥的方式对 Diffie-Hellman 做改进,使协议抵抗中间人攻击。完成协议设计,实现代码并用第 2 阶段的中间人攻击程序做验证。
为完成本次课设,总共用到了 libgmp 和 libpcap 两个库(apt install libgmp-dev libpcap-dev
);使用了 arpspoof(apt install dsniff
)工具进行局域网内的 arp 攻击;用到了三台虚拟机,均为 Ubuntu Server 20.04,分别扮演了服务器、客户端与中间人的角色。
在开始通信时,客户端生成一个大素数 p,发送给服务器保存,然后各自生成自己的私钥和公钥,并交换公钥,最后计算出用于 AES 加密的密钥,并互相发送消息。进行中间人攻击时,需要先使用 arpspoof 进行双向投毒:arpspoof -i 网卡(如ens33) -t 客户端IP -r 服务器IP
,然后再启动中间人程序。中间人程序后台运行,将客户端服务器通信的内容写入同目录下的 middle.txt 文件中,偶尔会乱码,大部分情况下正常。
预共享密钥我是按照自己理解写的,可能并不完全正确,大致过程就是提前在代码中为服务器和客户端写入一个相同的密钥,运行时在 DH 结束后,服务器随机生成一个字符串,以明文形式发送给客户端,客户端使用预共享密钥加密这段明文并返回给服务器,服务器解密,得到的明文如果与发送的相同,那么就允许通信,否则中断连接。而且这个过程中发送的数据包都是以公钥形式发送的,中间人截获后写入自己的公钥,自然就替换掉原来的随机字符串了。
以上就是大致的过程,详细请看下文,并结合代码自行理解。
概要设计
抽象数据类型定义
Socket 结构
数据对象 :客户端与服务器进行通信所需要的所有函数等元素。
数据关系 :根据 IP 地址和端口号,服务器绑定端口号并监听,等待客户端连接;客户端尝试连接指定 IP 的指定端口号,成功连接则可以相互通信。
基本操作 :
- socket (): 创建一个 socket 句柄,用于客户端与服务器通信。
- bind (): 绑定指定的端口号,成功则继续,失败报错并结束。
- listen (): 监听指定端口号,等待客户端连接。
- accept (): 当有客户端尝试连接时,接收连接,并返回句柄。
- connect (): 客户端根据指定的 IP 地址和端口号,连接服务器,成功则返回连接句柄,失败则报错,并终止程序。
Diffie Hellman 结构
数据对象 :进行 Diffie Hellman 密钥交换协议所需要的所有数据对象,包括结构体、函数等。
数据关系 :通过 Socket 进行客户端与服务器的通信,协商出用于 AES256-GCM 加密的密钥。
基本操作 :
- struct DH_key: 用于 Diffie Hellman 密钥交换协议的结构体,包含了素数、原根、公钥、私钥、协商出来的密钥等数据。
- get_random_int (): 输入一个 mpz_t 类型的数据指针,生成一个随机整数,并输出到输入的指针中,这个数会非常大。
- check_prime (): 输入一个 mpz_t 类型的数据指针,检测对应的数据是否为素数,一定是素数则返回 2,可能是素数则返回 1,不是素数则返回 0.
- generate_p (): 输入一个 mpz_t 类型的数据指针,输出一个大素数。
- generate_pri_key (): 输入一个 mp_t 类型的数据指针,输出一个大素数,用作客户端或者服务器进行 Diffie Hellman 协议的私钥。
- psk (): 预共享密钥函数,主要用于防止中间人攻击。
AES 加密结构
数据对象 :用于 AES256-GCM 加密的所有数据类型、函数等。
数据关系 :使用 Diffie Hellman 结构中的 DH_key 数组中保存的密钥作为 AES 加密的密钥,对需要发送到明文进行加密,然后通过 Socket 结构发送;对通过 Socket 接收到的密文进行解密,输出明文。
基本操作 :
- sbox: 256 位数组,AES 加密需要的 S-Box。
- contrary_sbox: 256 位数组,AES 解密需要的逆向 S-Box。
- ScheduleKey (): 编排密钥(密钥扩展),输入用于 AES256-GCM 加密的密钥,输出每一轮加密的轮密钥。
- SubBytes (): 查 S-Box,对输入的明文分组进行字节替换。
- Contrary_SubBytes (): 查逆向 S-Box,对输入的密文分组进行字节替换。
- ShiftRows (): 行移位,用于加密。
- Contrary_ShiftRows (): 逆向行移位,用于解密。
- Mix_Column (): 列混合,用于加密。
- Contrary_Mix_Column (): 逆向列混合,用于解密。
- AesEncrypt (): AES 加密函数,输入明文,输出密文。
- Contrary_AesEncrypt (): AES 解密函数,输入密文,输出明文。
中间人数据包捕获结构
数据对象 :用于中间人程序,捕获经过网卡的数据包,对满足特定要求的数据包进行数据提取和保存。
数据关系 :监听网卡,调用 Diffie Hellman 结构中的函数用于和客户端、服务器分别建立信任关系;调用 AES 结构对提取到的密文进行解密和加密。
基本操作 :
- pcap_lookupdev (): 包含在 libpcap 中,输入要存放错误信息的数组,输出本机第一个网络设备名称。
- pcap_open_live (): 包含在 libpcap 中,输入网络设备名字指针,打开对应的网络设备,输出网络设备的句柄。
- pcap_compile (): 包含在 libpcap 中,编译 BPF 过滤规则。
- pcap_setfilter (): 包含在 libpcap 中,应用 BPF 过滤规则。
- pcap_loop (): 包含在 libpcap 中,输入设备句柄,开始捕获数据包,每捕获到一个数据报,就调用一次回调函数处理数据包。
- pcap_sendpacket (): 包含在 libpcap 中,输入设备句柄和数据包数据与长度,将数据包发往目的地。
- process_pkt (): 是 pcap_loop () 的回调函数,输入网络地址信息和设备句柄,对抓到的符合 BPF 过滤规则的包进行处理。
- calc_checksum (): 输入数据包数据和长度,输出 TCP 校验和。
- set_psd_header (): 设置 TCP 数据包的头部,输入头部指针和需要写入头部的数据指针。
主程序流程
客户端主程序流程
- 进行 Socket 连接,成功连接上服务器后进行下一步,否则退出;
- 生成大素数 p 和原根 g,并发送给服务器
- 生成客户端私钥,并结合 p 和 g 计算出客户端公钥;
- 接收服务器的公钥并保存,发送客户端公钥给服务器;
- 根据服务器公钥、素数 p 和客户端私钥计算出最终用于 AES 加解密的密钥;
- 发送数据给客户端,数据内容为用户输入明文加密后的密文;
- 接收服务器发送的密文,并解密输出;
- 重复 6 和 7,直到连接中断。
服务器主程序流程
- 创建 Socket 连接,当有客户端连接时,接收素数 p 和原根 g;
- 生成服务器私钥,并计算出公钥,发送给客户端;
- 接收客户端的公钥,并计算出用于 AES 加解密的密钥;
- 等待客户端发送的密文,并进行解密和输出;
- 对服务器输入的明文加密,将密文发送给客户端;
- 重复 4 和 5,直到连接中断。
中间人主程序流程
- 使用 arpspoof 进行 ARP 欺骗;
- 打开网络设备,设置 BPF 过滤规则,开始捕获数据包;
- 每捕获到一个符合要求的数据包,查看是从客户端发送的还是从服务器发送的,前者执行 4) 和 5),后者执行 6) 和 7);
- 若是客户端公钥,则自己保存后替换为自己的公钥,重新设置校验和和头部后发送给服务器,返回 3);
- 若是客户端发送的加密信息,则使用对客户端的密钥解密信息并写入文件,然后将明文使用对服务器的密钥加密,重新设置校验和和头部后再发送给服务器,返回 3);
- 若是服务器公钥,则自己保存后生成自己的私钥和公钥,重新设置校验和和头部后,将公钥发送给客户端,返回 3);
- 若是服务器发送的加密信息,则使用对服务器的密钥解密信息并写入文件,然后将明文使用对客户端的密钥加密后,重新设置校验和和头部后,再发送给客户端,返回 3)。
各程序调用关系
不管是客户端还是服务器,都需要先使用 Socket 结构的相关函数来建立连接。建立连接之后,调用 Diffie Hellman 协议相关的函数协商出密钥,并将中间生成的素数、原根、私钥、公钥等数据保存在一个结构体当中。接下来,调用 AES256-GCM 加密模块,对要发送的数据加密、接收到的数据解密,直到程序结束。
对于中间人程序,不需要使用 Socket,取而代之的是数据包捕获模块。抓到服务器和客户端交换公钥的数据包后,会调用 Diffie Hellman 模块中的函数生成自己的私钥和公钥;抓到加密的信息后,会调用 AES256-GCM 加解密模块来对信息进行解密和加密。完成后再次回到数据包捕获状态。
技术开发思路
Socket 通信的实现
该内容较简单,如图所示。
Diffie Hellman 协议的实现
Diffie Hellman 协议的原理如下图所示。协议的实现需要大数处理,为了方便,可以使用 libgmp 来进行操作,它可以生成大数、判断一个大数是否为素数、进行大数之间的基本运算等。可以使用该库生成最初的大素数 p,并计算原根 g,然后随机生成两端的私钥 a 和 b,并进行模运算计算出公钥 A 和 B,交换公钥后,就可以利用模运算计算出最终的密钥 K 了,两端的 K 应该是一样的。
AES256-GCM 加解密的实现
(实际上我的搭档交给我的只是 AES,是不是 256 都尚且存疑,但是我懒得去修改了,有兴趣的可以去参考 openssl 库。)GCM 是认证加密模式中的一种,它结合了 CTR 模式和 GMAC 模式的特点,能同时确保数据的保密性、完整性及真实性,另外,它还可以提供附加消息的完整性校验。具体原理如下图所示。
需要的密钥 K 由 Diffie Hellman 协议得到,然后根据矩阵运算写出行移位、列混合的函数,并引入官方的 S-Box 和逆向 S-Box 进行字节替换。不过在此之前,首先要进行密钥扩展,生成 AES 加密的轮密钥。iv 为初始向量,在这里主要用来进行完整性校验。
中间人捕获数据包的实现
这里主要用到了 libpcap,该库可以捕获数据包,并对捕获到的每一个数据包进行处理,提取出需要的内容,放入想要放进去的数据,最后还要计算一下校验和,否则接收方收到后没有通过校验和检查,会将其视作损坏包而丢弃。
为了让数据包能够发送到中间人机器上来,还需要进行 ARP 欺骗,告诉服务器自己是客户端,告诉客户端自己是服务器。这里不需要写程序,可以直接使用工具 arpspoof 来实现双向欺骗。
防止中间人攻击的实现
根据要求使用预共享密钥的方法。首先要将一个密钥封装到代码内部,客户端和服务器都有且相同。在 Diffie Hellman 协商密钥后、AES 加密前,服务器先随机生成一段字符串,以公钥的形式发送给客户端,客户端收到后使用预共享密钥进行加密,将密文返回给服务器,服务器使用预共享密钥进行解密,如果解密后的字符串与发送的字符串相同,则允许互相通信,否则拒绝。
因为是以公钥形式发送的数据包,中间人在截获后会将其视作服务器发送的公钥而处理,重新生成私钥和公钥,并将公钥写入数据包发送给客户端,客户端收到的自然也就不是服务器发送的字符串了。
git 版本管理
根据课设要求,需要使用 git 进行版本管理,这一步要从项目一开始就进行。由于是在 Linux 机器上进行开发,安装 git 很简单,之后只需要掌握 git 相关的命令,如 git add,git commit,git status,git log 等,就可以轻松使用了。如果使用了 VS Code 或者其他 IDE,可以很方便的利用它们提供的图形界面进行版本控制。
使用 makefile 将多个源文件结合
由于是两个人一起进行设计,使用了多个源文件,因此需要使用 makefile,在写好代码后将源代码汇聚到一起,根据其中的逻辑编写 makefile,在修改源代码后只需要输入 make 就可以编译了。
详细设计
客户端程序流程图
客户端程序的流程图如图所示,具体细节后面解释。
服务器程序流程图
服务器端程序的流程图如下图所示,具体细节后面解释。
中间人程序流程图
中间人程序的流程图如下图所示,具体细节后面解释。
这里用到了 libgmp。GNU MP (gmp) 是一个用 C 语言编写的可移植库,用于对整数、有理数和浮点数进行任意精度的算术运算,它旨在为所有需要比 C 语言直接支持运算的精度更高的应用程序提供尽可能快的算法。根据操作数的大小来选择使用的算法,并将开销控制在最低,这就是 gmp 被设计的目的。
对于 Diffie Hellman 协议,需要用到一个结构体来保存中间过程的数据。p
表示生成的大素数,g
为 p
的原根,这里方便起见,一般取 2 或者 5,pri_key
为随机生成的一个私钥,pub_key
为计算得到的公钥,k
为最终协商得到的密钥。mpz_t
为 libgmp 中定义的数据类型,为大整型数据。在中间人程序中,公钥变量和密钥变量需要有两个,分别为对服务器的和对客户端的。
typedef struct{
mpz_t p;
mpz_t g;
mpz_t pri_key;
mpz_t pub_key;
mpz_t k;}DH_key;
}
要进行 Diffie Hellman 协议,不可避免要生成大随机数,并且还要生成一个大素数,这里可以使用 libgmp 提供的一些函数和数据类型。要生成大随机数,可以结合使用两个随机数生成函数:mpz_rrandomb()
和 mpz_urandomb()
,将这两个数相乘就是最终的随机数,至于为什么这样做在 3.1 节中有详细解释。这两个函数都需要一个 gmp_randstate
类型的数据变量作为传入参数,该变量可以由函数 gmp_randinit_default()
来初始化,中间使用了 libgmp 默认的初始化算法。然后,获取当前时间作为种子传入 gmp_randstate
变量当中,这样就可以将它作为状态传入随机数生成函数当中了。具体代码如下:
void get_random_int(mpz_t z, mp_bitcnt_t n){
mpz_t temp; // 临时mpz_t变量,用于生成随机数,用完即废弃
gmp_randstate_t grt; // gmp状态,用于生成随机数
gmp_randinit_default(grt); // 使用默认算法初始化状态
gmp_randseed_ui(grt, (mp_bitcnt_t)clock()); //将时间作为种子传入状态grt中
mpz_rrandomb(z, grt, n); // 生成2^(n-1)到2^n-1之间一个随机数
mpz_init(temp);
gmp_randinit_default(grt);
gmp_randseed_ui(grt, (mp_bitcnt_t)clock());
do
{
mpz_urandomb(temp, grt, n); // 生成一0~2^(n-1)之间的随机数,可能为0
}while (mpz_cmp_ui(temp, (unsigned long int)0) \<= 0);
mpz_mul(z, z, temp); // 两个随机数相乘
mpz_clear(temp);
}
上面生成的知识一个较大的随机数,可是 Diffie Hellman 协议需要一个大素数。大素数的生成也可以使用 libgmp 提供的一些函数。首先使用上面的函数生成一个大随机数,然后对其进行素性检测,这里用到了 mpz_probab_prime_p()
函数,它可以检测一个数是否为素数,函数会执行一个 Baillie-PSW 概率素性检测,然后执行指定次数的 Miller-Robin 素性检测,根据需要可以设置 15 到 50 次。如果一定是素数,函数返回 2;可能是素数,返回 1;肯定不是素数,返回 0。这里只需要返回非零值即可。mpz_nextprime()
函数可以得到比传入参数大、且离得最近的一个素数,如果随机生成的数不是素数,就使用该函数得到一个素数。
int check_prime(mpz_t prime)
{
return mpz_probab_prime_p(prime, 30);
}
void generate_p(mpz_t prime){
get_random_int(prime, (mp_bitcnt_t)128);
while (!check_prime(prime)){
mpz_nextprime(prime, prime);
}
}
要生成私钥,和随机生成一个大数没什么不同,而且没有了素数的限制,只需要调用 get_random_int()
函数即可,不再赘述。
不管是生成公钥还是计算得到最终的密钥,都需要进行指数运算和模运算,这里 libgmp 也提供了相关的运算函数:mpz_powm(rlt, a, b, c)
。它可以计算 abmod c,并将结果传递给 rlt。这样一来公钥和密钥的计算也迎刃而解。
以客户端为例(服务器类似),最终进行 Diffie Hellman 协议的相关代码如下:
void exchange_dh_key(int sockfd, mpz_t s){
DH_key client_dh_key; // 客户端生成的密钥
mpz_t server_pub_key; // 服务器公钥
// 初始化mpz_t类型的变量
mpz_inits(client_dh_key.p, client_dh_key.g, client_dh_key.pri_key, client_dh_key.pub_key, client_dh_key.s, server_pub_key, NULL);
generate_p(client_dh_key.p);
mpz_set_ui(client_dh_key.g, (unsigned long int)5); // base g = 5
/* 将p发送给服务器,代码略 */
generate_pri_key(client_dh_key.pri_key); // 计算客户端的公钥A
mpz_powm(client_dh_key.pub_key, client_dh_key.g,client_dh_key.pri_key, client_dh_key.p);
/* 接收服务器公钥,发送客户端公钥,代码略 */
// 客户端计算DH协议得到的密钥s
mpz_powm(client_dh_key.s, server_pub_key, client_dh_key.pri_key, client_dh_key.p); // 清除mpz_t变量
mpz_clears(client_dh_key.p,client_dh_key.g,client_dh_key.pri_key, client_dh_key.pub_key,client_dh_key.s,server_pub_key,NULL);
}
AES 加解密详细设计
AES 加解密模块基本全部由我的搭档完成,在此只做简单叙述。首先,不管是客户端、服务器还是中间人,都需要有 S-Box 和逆向的 S-Box,这个直接写入代码即可。因为内容过多且并不重要,不再展示。
AES 加解密是定义在有限域上的,因此需要实现有限域乘法。下面给出了 2 乘法和 3 乘法,其他的可类似推出。
static unsigned char x2time(unsigned char x){
if(x & 0x80)
return(((x << 1)^0x1B) & 0xFF);
return x << 1;
}
static unsigned char x3time(unsigned char x){
return (x2time(x) ^ x);
}
AES 加解密都需要扩展密钥,生成轮密钥,具体实现如下:
void ScheduleKey(unsigned char *key, unsigned char *expansion_key, int key_col, int en_round){
unsigned char temp[4], t; int x, i;
for (i = 0; i < (4 * key_col); i++)
expansion_key[i] = key[i];
i = key_col;
while (i < (4 * (en_round + 1))){
for (x = 0; x< 4; x++)
temp[x] = expansion_key[(4 * (i - 1)) + x];
if (i % key_col == 0){
t = temp[0];
temp[0] = temp[1];
temp[1] = temp[2];
temp[2] = temp[3];
temp[3] = t;
for (x = 0; x < 4; x++)
temp[x] = sbox[temp[x]];
temp[0] ^= Rcon[(i / key_col) - 1];
}
else if (key_col>6 && (i%key_col) == 4){
for (x = 0; x < 4; x++)
temp[x] = sbox[temp[x]];
for (x=0; x<4; x++)
expansion_key[(4*i)+x]=expansion_key[(4*(i - key_col)) + x]^temp[x];
++i;
}
}
}
生成轮密钥后,开始进行字节替换、行移位、列混合等操作。字节替换很简单,就是通过查找 S-Box,将每个字节替换成相应的字节,逆向字节替换类似,只是将 S-Box 换成了 Contrary S-Box。这里只展示正向的。
static void SubBytes(unsigned char *col){
int x;
for (x = 0; x < 16; x++)
col[x] = sbox[col[x]];
}
行移位就是将矩阵中的每个横列进行循环式移位,对于长度 256 比特的区块,第一行维持不变,第二行、第三行、第四行的偏移量分别是 1 字节、3 字节、4 位。这个过程只需声明中间变量,然后进行交换即可。列混合是为了混合矩阵中各个直行而进行的操作。这个步骤使用线性转换来混合每内联的四个字节,只有最后一轮才省略掉列混合,而以轮密钥加替代。
static void MixColumns(unsigned char \*col){
unsigned char tmp\[4\];
int i;
for (i = 0; i \< 4; i++, col += 4){
tmp[0] = x2time(col[0]) ^ x3time(col[1]) ^ col[2] ^ col[3];
tmp[1] = col[0] ^ x2time(col[1]) ^ x3time(col[2]) ^ col[3];
tmp[2] = col[0] ^ col[1] ^ x2time(col[2]) ^ x3time(col[3]);
tmp[3] = x3time(col[0]) ^ col[1] ^ col[2] ^ x2time(col[3]);
col[0] = tmp[0];
col[1] = tmp[1];
col[2] = tmp[2];
col[3] = tmp[3];
}
}
static void AddRoundKey(unsigned char *col, unsigned char *expansionkey, int round){
int x;
for (x = 0; x < 16; x++)
col[x] ^= expansionkey[(round << 4) + x];
}
最终的加密总函数就是将上面叙述的各个步骤叠加到一起,主要代码如下:
void AesEncrypt(unsigned char *text, unsigned char *expansionkey, int en_round){
int round;
AddRoundKey(text, expansionkey, 0);
for (round = 1; round <= (en_round - 1); round++){
SubBytes(text);
ShiftRows(text);
MixColumns(text);
AddRoundKey(text, expansionkey, round);
}
SubBytes(text);
ShiftRows(text);
AddRoundKey(text, expansionkey, en_round);
}
解密函数也类似,如下所示:
void Contrary_AesEncrypt(unsigned char *text, unsigned char *expansionkey, int en_round){
int x;
AddRoundKey(text, expansionkey, en_round);
Contrary_ShiftRows(text);
Contrary_SubBytes(text);
for (x = (en_round - 1); x >= 1; x--){
AddRoundKey(text, expansionkey, x);
Contrary_MixColumns(text);
Contrary_ShiftRows(text);
Contrary_SubBytes(text);
}
AddRoundKey(text, expansionkey, 0);
}
中间人攻击详细设计
中间人攻击就是截获局域网内两台主机通信的数据包,并进行提取和修改,让通信双方都以为在和对方通信,实际上却在和中间人通信。具体原理如图所示。
要进行中间人攻击,最首先、最关键的一步就是捕获数据包,这里可以使用 libpcap 进行捕获。使用 libpcap 的主要流程是:
pcap_lookupdev()
: 获取可用的网络设备名指针;pcap_open_live()
: 打开指定的网络设备,并返回用于捕获网络数据包的描述字;pcap_compile()
: 将用户制定的 BPF 过滤规则编译到过滤程序当中;pcap_setfilter()
: 应用 BPF 过滤规则,让过滤规则生效;pcap_loop()
: 循环抓包,遇到错误或者执行结束时退出。- 自己编写
callback
函数传入pcap_loop()
函数,对捕获到的每一个数据包进行处理。
这里前面五步都是模板,重点是回调函数,这点在 2.3 节中间人程序流程图中已经大致展示了,下面具体叙述一下。
在启动中间人程序之前,需要先进行 ARP 欺骗,不断地告诉服务器:客户端的 MAC 地址为中间人的 MAC;告诉客户端:服务器的 MAC 地址为中间人的 MAC。这样它们发送的数据包都会送到中间人这里。当中间人程序抓到一个数据包后,首先需要判断它是来自客户端还是来自服务器的,然后判断是公钥还是加密信息或大素数。如果来自服务器,那么需要修改数据包以太帧头中的目的 MAC 地址为客户端(因为有 ARP 欺骗,原来的 MAC 地址为中间人的);如果来自客户端,那么需要修改以太帧头中的目的 MAC 地址为服务器(原来的 MAC 地址为中间人)。如果是服务器发送的公钥,那么需要使用 Diffie Hellman 协议模块定义的一些函数生成自己的私钥,并计算得到公钥,然后用自己的公钥把服务器的替换掉,发送给客户端;如果是客户端发送的公钥,直接将自己的公钥写入替换掉客户端的公钥,然后发送给服务器。收到上面两个数据包后,中间人对服务器的密钥、中间人对客户端的密钥都已经可以计算出来了。如果是服务器发送的加密信息,就使用对服务器的密钥解密,保存在文件当中,然后使用对客户端的密钥加密,发送给客户端;如果是客户端发送的密文,和上面类似。
具体实现其实并不复杂,因为逻辑也很简单,但是一些细节很容易出错,需要细心。这里只展示抓到客户端发出的数据包的代码,服务器类似,可在附录中查看。
if (strncmp(src_ip, ip_t->client_ip, strlen(src_ip)) == 0){
if (strncmp(packet + header_len, "pri", 3) == 0)
mpz_set_str(middle_dh.p, packet + header_len + 3, 16);
else if (strncmp(packet + header_len, "pub", 3) == 0){
mpz_t client_pub_key;
mpz_init_set_str(client_pub_key, packet + header_len + 3, 16); // 计算对客户端的密钥
mpz_powm(middle_dh.key2client, client_pub_key, middle_dh.pri_key, middle_dh.p);
mpz_get_str(key2client, 16, middle_dh.key2client);
ScheduleKey(key2client, expansion_key2client, AES256_KEY_LENGTH, AES256_ROUND);
mpz_get_str(packet + header_len + 3, 16, middle_dh.pub_key);
/* 计算校验和,代码略 */
}
else if (strncmp(packet + header_len, "msg", 3) == 0){
char *buf = packet + header_len + 3;
bzero(plain_text, 33);
strncpy(plain_text, buf, 32);
Contrary_AesEncrypt(plain_text, expansion_key2client, AES256_ROUND);
/* 将明文写入文件,代码略 */
AesEncrypt(plain_text, expansion_key2server, AES256_ROUND);
memcpy(packet + header_len + 3, plain_text, sizeof(plain_text));
/* 计算校验和,代码略 */
}
memcpy(ethernet->ether_dhost, server_mac, 6);
}
如果就这样直接发送数据包,那么会因为 TCP 包头部的校验和和接收方计算得到的校验和不相同而被当做损坏包丢掉。因此,当我们构造好要发送给目的方的消息后,还需要重新计算校验和,并写入 TCP 头部。具体代码如下(注释可参考附录):
uint16_t calc_checksum(void *pkt, int len){
uint16_t *buf = (uint16_t *)pkt;
uint32_t checksum = 0;
while (len > 1){
checksum += *buf++;
checksum = (checksum >> 16) + (checksum & 0xffff);
len -= 2;
}
if (len){
checksum += *((uint8_t *)buf);
checksum = (checksum >> 16) + (checksum & 0xffff);
}
return (uint16_t)((~checksum) & 0xffff); }
void set_psd_header(struct psd_header *ph, struct iphdr *ip, uint16_t tcp_len){
ph->saddr = ip->saddr;
ph->daddr = ip->daddr;
ph->must_be_zero = 0;
ph->protocol = 6;
ph->tcp_len = htons(tcp_len);
}
到此为止,中间人的详细设计已叙述完毕,完整代码(包含详细注释)可以查看附录。
预共享密钥详细设计
预共享密钥的原理为,客户端和服务器在开始通信前先定义一个密钥,这个密钥写死在代码中,不允许查看,自然也无法被中间人获取。当客户端和服务器通信时,服务器随机生成一个长度为 20 的字符串,采用发送公钥的方式发送给客户端,客户端收到后使用预共享密钥加密,将密文返回给服务器,服务器解密,如果解密后的字符串与原来的字符串一样,那么允许通信,否则不允许。
我在这里采用的加密方式仍然为 AES,预共享密钥写在了客户端和服务器的代码中。服务器的代码如下:
int psk(int sockfd){
int flag = 1;
unsigned char ch[PSK_LEN + 3 + 1];
unsigned char text[33];
unsigned char key[32] = "0a12541bc5a2d6890f2536ffccab2e";
unsigned char expansion_key[15 * 16];
ScheduleKey(key, expansion_key, AES256_KEY_LENGTH, AES256_ROUND);
memcpy(ch, "pub", 3);
get_random_str(ch + 3);
write(sockfd, ch, sizeof(ch));
bzero(text, 33);
read(sockfd, text, sizeof(text));
Contrary_AesEncrypt(text + 3, expansion_key, AES256_ROUND);
flag = strncmp(ch + 3, text + 3, PSK_LEN);
return flag;
}
其中,key
中保存的为预共享密钥,PSK_LEN
为宏定义,值为 20,“pub” 表示以公钥方式发送数据包,flag
标识两个字符串是否相同。get_random_str()
函数为得到随机的字符串,主要原理是生成一个随机数,除以 26 取余数,根据余数来设定字符为多少,大小写由随机数奇偶性决定。具体代码如下:
void get_random_str(unsigned char *ch){
int flag, charLengt;
int j = 0;
srand((unsigned)time(NULL));
for (int i = 0; i < PSK_LEN; ++i){
flag = rand() % 2;
if (flag)
ch[j++] = 'A' + rand() % 26;
else
ch[j++] = 'a' + rand() % 26;
}
ch[j] = '0';
}
当中间人截获数据包后,会将其识别为服务器发送的公钥,重新生成公钥并写入数据包发送给客户端,客户端加密返回再解密后,自然与原来就不一样了。
调试分析
如何生成一个较大的随机数?
要生成一个较大的随机数,可以使用 libgmp 提供的三个随机数生成函数。但是它们都有一些局限性。
mpz_urandomb()
:可以生成一个 0 到 2n1 之间的随机数,但是有可能生成的随机数很小,此时无法保证生成密钥的安全性;mpz_urandomm()
: 可以生成一个 0 到 n-1 之间的随机数,这个范围明显太小了,而且也和上一个函数具有同样的问题;mpz_rrandomb()
: 可以生成一个 2n1 到 2n1 之间的随机数,可以保证生成随机数的位数,但是范围较小,该范围内的素数也很少,有一定的安全隐患。
解决方法:将第一个和第三个函数结合起来使用。首先使用 mpz_rrandomb()
生成一个到 2n1→2n1 之间的随机数(以保证生成的随机数不会太小),然后再使用 mpz_urandomb()
函数生成一个 0→2n1 之间的随机数(以保证生成的随机数范围较广),将这两个随机数相乘,最终的结果作为生成的大随机数,这样既保证了随机数位数足够多,也保证了随机数的范围足够广。
如何让中间人知道发送的数据包是什么?
虽然中间人可以抓包,但是却无法得知抓到的包具体是公钥还是密文,或者是素数。如果无法区分数据包类型,那么自然也就无法解密了。
解决方法:在客户端服务器发送的每一个数据包前面加上一个头部,标识素数、公钥还是密文。如果是公钥,则加上 “pub”;如果是素数,加上 “pri”;如果是密文,加上 “msg”。这样一来,不仅客户端和服务器可以识别发送的数据包是否为想要的,而且中间人也可以根据这个字符串来确定是不是自己想要的。
inet_ntop () 处报错
在 inet_ntop(AF_INET, &(ip->saddr), src_ip, 16)
处错误,我先将重点放在 src_ip
上,将其修改为 32 位,但不可行;然后将重点放在 ip->saddr
上,查看了相关定义,依然没有发现有任何问题,但我发现无法将其输出,而它又与 packet
关系密切,我就在看是否是传参过程出了问题,可是并没有发现任何明显的问题。但是在我看了官方文档中对 pcap_loop()
函数的定义后,发现最后一个参数的类型为 u_char
,而我传入的是一个结构体参数,所以我进行了格式转换,成功解决问题。
如何让中间人代码后台运行?
课设要求能够后台运行,客户端和服务器因为需要互相通信,自然不能设置后台运行,那么只能让中间人程序后台运行,将截获的明文写在文件里方便后续查看。那么怎么实现代码后台运行呢?
解决方法:在中间人程序 main()
函数开头加上 daemon(1, 1)
函数。它将创建一个可以在后台运行的守护进程,需要关闭时使用 ps
命令查看进程号,然后 kill
关闭。
如何开启编译器优化?
这个很简单,在使用 gcc 编译时加入 -O
参数即可,可以选择 - O1、-O2 或者 - O3,数字越大,优化级别越高,代码效率越高,但是稳定性、兼容性可能变差。因为代码并没有特别多,且为了保证验收时不会出错,我只使用了 -O1
。
测试结果
第一阶段测试结果
第二阶段测试结果
先开始 ARP 欺骗攻击,然后开启服务器,开启中间人(后台运行),最后开启客户端。前面的 Diffie Hellman 协议和第一阶段一样,不再赘述,直接看加解密。最后的乱码为 “Ctrl+C”。可以对照右侧客户端服务器发送的明文来检查写入文件的明文是否正确。
第三阶段测试结果
加上 psk 后,在没有中间人的情况下,结果如图 11 所示,Diffie Hellman 协议和 AES 加解密和第一阶段一样,不再赘述,只展示 psk 相关部分;有中间人的情况下结果如图 12 所示,同样只展示 psk 相关部分。