一个 Drcom 客户端的实现
本文仅适用韶关学院 Drcom 客户端。
因为自己需要经常用到 Linux 的缘故,所以打算把 Linux 上的网络问题搞定。学校使用的是 Drcom 认证客户端,在学校 MIS 系统上也提供了对应的 Linux 版本。可惜的是把一切依赖都弄好终于能运行后却总是秒断。这个程序并没有提供源码,我也无法获知是什么问题,所以打算干脆自己写一个,也就刚好分析一下 Drcom 的协议。
0x00 工作准备
- 抓包软件 Wireshark
- 反汇编器 IDA Pro
- 能正常使用的 Windows 版 Drcom 客户端
0x01 EAP 协议
简单的实验可以发现,在没有登陆内网前,机器并不能 Ping 通网关,也就是说认证的方式应该是通过广播的方式来进行的。通过 Wireshark 抓包可以得到几个重要的包:
EAPOL 96 Start 04:44:23.334841000
0000 ff ff ff ff ff ff 28 d2 44 2d 90 69 88 8e 01 01 ......(.D-.i....
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
协议的类型为 0x888e,也即EAP,可扩展身份验证协议,是 802.1x 认证机制的核心。而 EAPOL 则是基于局域网的 EAP。当我们的机器开始登录时,首先会向广播地址发出一个 Start 包,同在一个局域网的网关便会收到这个 Start 包并回复:
EAP 60 Request, Identity 04:44:23.335480000
0000 28 d2 44 2d 90 69 58 6a b1 56 78 00 88 8e 01 00 (.D-.iXj.Vx.....
0010 00 05 01 01 00 05 01 00 00 00 00 00 00 00 00 00 ................
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030 00 00 00 00 00 00 00 00 98 a7 44 0c ..........D.
这个包即是向客户端请求一个 Identity,也即身份信息。客户端收到这个 request 后,就应该向网关回应一个 Identity:
EAP 96 Response, Identity 04:44:23.459779000
0000 ff ff ff ff ff ff 28 d2 44 2d 90 69 88 8e 01 00 ......(.D-.i....
0010 00 19 02 01 00 19 01 31 34 31 31 35 30 36 31 30 .......141150610
0020 32 34 00 44 61 00 00 c0 a8 c3 5f 00 00 00 00 00 24.Da....._.....
0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
这个包已经开始包含学号和 IP 地址,当网关收到这个 Identity 后,网关开始请求一个 MD5-Challenge:
EAP 60 Request, MD5-Challenge EAP (EAP-MD5-CHALLENGE) 04:44:23.462349000
0000 28 d2 44 2d 90 69 58 6a b1 56 78 00 88 8e 01 00 (.D-.iXj.Vx.....
0010 00 1a 01 00 00 1a 04 10 19 54 6c 79 c0 a8 7f 81 .........Tly....
0020 c0 a8 7f 81 00 00 00 00 10 82 00 00 00 00 00 00 ................
0030 00 00 00 00 00 00 00 00 94 96 f5 b8 ............
这个包中包含了一串 Challenge 值 (19 54 6c 79 c0 a8 7f 81 c0 a8 7f 81 00 00 00 00
)。客户端收到这个值后,将其与密码做 MD5 运算,则可得到 Challenged-Password。
$$
C = md5 (Id+Challenge+Password)
$$
在 Drcom 中 Id 固定为 0。
得到这串 Challenged-Password 后,客户端将其与学号一同发给网关
EAP 96 Response, MD5-Challenge EAP (EAP-MD5-CHALLENGE) 04:44:23.991527000
0000 ff ff ff ff ff ff 28 d2 44 2d 90 69 88 8e 01 00 ......(.D-.i....
0010 00 2a 02 00 00 2a 04 10 9e 47 e9 3a 45 c7 d8 6d .*...*...G.:E..m
0020 18 38 24 c0 20 91 2b 6c 31 34 31 31 35 30 36 31 .8$. .+l14115061
0030 30 32 34 00 44 61 24 00 c0 a8 c3 5f 00 00 00 00 024.Da$...._....
0040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
网关收到这串 Challenged-Password 后进行验证,如果加密后的密码无误,则原密码也无误,就会发出一个 Success 包:
EAP 60 Success 04:44:24.010811000
0000 28 d2 44 2d 90 69 58 6a b1 56 78 00 88 8e 01 00 (.D-.iXj.Vx.....
0010 00 04 03 00 00 04 00 00 00 00 00 00 00 00 00 00 ................
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030 00 00 00 00 00 00 00 00 55 00 41 e9 ........U.A.
整个认证握手过程到这里就结束了。此时使用 PPPoE 进行连接,发现已经可以连接到外网,也就表示认证已经成功。
0x02 认证过程的 Go 语言实现
Go 语言中可以使用第三方包 gopacket 对数据包进行捕获和发送。但原版的 gopacket 在这个情景下使用会有些许问题,所以做了少许修改,并在文末附件给出。
1 | /* 发送EAPOL包 */ |
首选封装两个函数分别用来发送 EAPOL 包和 EAP 包,gopacket 的用法请参考GoDoc。
readNewPacket 将从认证一开始就以一个协程运行,直至程序终止。在程序 main 函数中,有如下语句:
1 | handle, err = pcap.OpenLive(devName, 1024, false, 5*time.Second) |
readNewPacket () 会根据 Code 和 Type 分别进行处理。
1 | /* 读取新数据包 */ |
按照前文所说的认证方法分别对各种回应包进行构造,然后调用EAPAuth()
函数即可。readNewPacket()
函数将收到来自网关的 request,并交由不同的 response 函数处理。
至此,可以构造出一个可用的认证程序,使用系统自带的 PPPoE 拨号程序即可。
0x03 问题的发现
使用这种认证方式虽然可以成功拨上号,但会发现隔一段时间就会自己掉线。通过 Wireshark 查看,发现网关主动发送了 Failure 包,也就是说网关注销了我们的账户。这种情况可以联想到使用官方客户端经常碰到的一个错误:
获取用户属性超时!请检查防火墙配置允许 UDP 61440 端口。
当客户端出现这个错误时,一般也可以上网,但一段时间后就会发现已经断开。所以我们重新打开校方提供的客户端,通过内网认证,并用 wireshark 筛选出 UDP 协议,端口为 61440 的所有数据包。
通过仔细查看会发现,客户端每隔一段时间会向一个服务器 192.168.127.129:61440 发送 UDP 报文,而服务器也会做出响应。也就是说,客户端应该是通过定时发送数据包来保持自己的在线状态,也就是所谓的心跳包。
0x04 心跳包的分析
(以下出现的数据仅为 UDP 数据部分,而非完整的数据包)
当客户端与网关完成认证流程后,客户端便开始向服务器 192.168.127.129:61440 发送报文:
0000 07 00 08 00 01 00 00 00 ........
一共只有八个字节,这部分是固定的,大部分 Drcom 心跳包都以 07 开头,其他部分含义未知。
然后服务器方面会回应一个报文:
0000 07 00 10 00 02 00 00 00 01 72 96 00 c0 a8 c3 5f .........r....._
0010 a8 ac 00 00 4f e4 16 c1 00 00 00 00 dc 02 00 00 ....O...........
具体含义我们暂且不管。收到这样的数据包后,客户端即发送一个数据包,包含用户的若干信息:
0000 07 01 f4 00 03 0b 28 d2 44 2d 90 69 c0 a8 c3 5f ......(.D-.i..._
0010 02 22 00 24 01 72 96 00 4a 6a 72 32 00 00 00 00 .".$.r..Jjr2....
0020 31 34 31 31 35 30 36 31 30 32 34 6c 7a 79 2d 70 14115061024lzy-p
0030 63 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c...............
0040 00 00 00 00 00 00 00 00 00 00 00 df 05 05 05 00 ................
0050 00 00 00 df 06 06 06 00 00 00 00 00 00 00 00 94 ................
0060 00 00 00 06 00 00 00 02 00 00 00 f0 23 00 00 02 ............#...
0070 00 00 00 44 72 43 4f 4d 05 b8 01 04 00 00 00 00 ...DrCOM........
0080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0090 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00b0 00 00 00 33 39 31 35 31 35 66 64 33 33 39 66 36 ...391515fd339f6
00c0 32 62 35 33 30 63 64 36 33 61 30 32 37 63 64 34 2b530cd63a027cd4
00d0 65 66 39 35 31 33 39 30 36 39 66 00 00 00 00 00 ef95139069f.....
00e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00f0 00 00 00 00 ....
具体分析如下
07 //固定头
01 //计数器,这里固定为01
00 f4 //报文长度,这个长度是不定的,原因是用户名不定长
03 //未知
0b //用户名长度
28 d2 44 2d 90 69 //本机MAC
c0 a8 c3 5f //本机IP
02 22 00 24 //这个是固定值
01 72 96 00 //这个来源于第一个回应包的第8到第11个字节
4a 6a 72 32 //校验值,具体方法下面会讲到
31 34 31 31 35 30 36 31 30 32 34 //用户名
6c 7a 79 2d 70 63 //主机名
df 05 05 05 //DNS-1
df 06 06 06 //DNS-2
...
判断字段固不固定的主要方法还是多次登录,多账号登录,然后判断哪些部分是一致的,哪些部分每次都不同。该数据包有四个字节4a 6a 72 32
每次捕获到的都不一样,也猜不出是什么含义,故使用反汇编工具 IDA Pro 进行逆向工程,文件为DrAuthSvr.dll。
首先找到一个开口:输入表里面的 sendto 函数。每次发送报文肯定都需要经过 sendto 函数,然后再通过交叉参考,最后找到发送数据的函数。我将其命名为send_udp_packet(char *buf,int len)
。
1 | int __cdecl send_udp_packet(char *buf, int len) |
再次通过交叉参考,可以得知所有调用该函数的语句。其中有几个调用处写着:
send_udp_packet(buf, *(unsigned __int16 *)&buf[2])
这与该数据包正好吻合(长度为 buf[2])。该数据包还有一个特征点即是 buf[4]为 3,最终可以确定发送这个包的函数。我们需要找的四个字节是[24:28],函数比较靠前的位置有:
*(_DWORD *)&buf[24] = 20000711;
*(_DWORD *)&buf[28] = 126;
也即是说函数直接对 buf[24:32]的进行赋值。在比较靠后的位置还发现了:
1 | v4 = 4 * ((*(unsigned __int16 *)&buf[2] + 3) / 4); |
也就是说变量 v5 为长度的 1/4,然后做下面的运算,最后把 19680126*v6 回填到 buf[24:28]中,并将 buf[28]置零。需要注意的是这里采用的都是小端法。最后还将计算的值保存起来,后面会用到。Go 语言实现如下:
1 | /* 信息包校验码计算 */ |
将这个二百余个字节的信息包发送出去后,服务器会发出第二个响应包。
0000 07 01 30 00 04 0b 20 00 92 9a 9c 8c 01 00 00 00 ..0... .........
0010 44 39 d8 ed 0c 45 fd 03 af 85 30 15 3c fa 04 68 D9...E....0.<..h
0020 b0 82 00 60 00 00 00 00 00 00 00 00 00 00 00 00 ...`............
按照捕获到的数据包,客户端开始每隔一定时间发送两种不同的心跳包。
第一种心跳包有 40 个字节,正常情况下每次都会接连出现四个包。
//Ping-1
0000 07 02 28 00 0b 01 dc 02 6c 6f 00 00 00 00 00 00 ..(.....lo......
0010 4d 71 96 00 00 00 00 00 00 00 00 00 00 00 00 00 Mq..............
0020 00 00 00 00 00 00 00 00 ........
//Ping-2
0000 07 02 28 00 0b 02 dc 02 6c 6f 00 00 00 00 00 00 ..(.....lo......
0010 10 72 96 00 00 00 00 00 00 00 00 00 00 00 00 00 .r..............
0020 00 00 00 00 00 00 00 00 ........
//Ping-3
0000 07 03 28 00 0b 03 dc 02 6c 6f 00 00 00 00 00 00 ..(.....lo......
0010 10 72 96 00 00 00 00 00 37 87 84 02 c0 a8 c3 5f .r......7......_
0020 00 00 00 00 00 00 00 00 ........
//Ping-4
0000 07 03 28 00 0b 04 dc 02 6c 6f 00 00 00 00 00 00 ..(.....lo......
0010 11 72 96 00 00 00 00 00 00 00 00 00 00 00 00 00 .r..............
0020 00 00 00 00 00 00 00 00 ........
这四个数据包中,第 1 个和第 3 个为客户端发送,第 2 个和第 4 个则为服务器端响应。以最复杂的 Ping-3 作为例子进行分析:
07 //头
03 //计数器,一来一回为一次
00 28 //包长度,即40个字节
0b //固定
03 //步骤编号,该包为第三步
dc 02 6c 6f //不清楚,不影响登陆
10 72 96 //置零不影响
37 87 84 02 //校验值,具体方法下面会讲到
c0 a8 c3 5f //客户端IP
跟上面分析方法相同,可以发现37 87 84 02
基本没什么规律,应该也是一种校验值。继续使用 IDA Pro 进行静态分析,可以找到一个函数带有语句buf[2]=40
,即是我们要找的发包函数。手动将 buf 类型改为 char[40]后,可以找到这样一个代码片段:
1 | if ( buf[5] == 3 ) |
Go 语言实现如下:
1 | /* 40字节心跳包校验码计算 */ |
第二种心跳包是 38 个字节,在第一种心跳包发出后大约 10 秒发出。
0000 ff 4a 6a 72 32 45 c7 d8 6d 18 38 24 c0 20 91 2b .Jjr2E..m.8$. .+
0010 6c 00 00 00 44 72 63 6f c0 a8 7f 81 af 0b c0 a8 l...Drco........
0020 c3 5f 01 34 33 9b ._.43.
对这个数据包做分析:
ff //固定头
4a 6a 72 32 //发送信息包时的校验值
45 c7 d8 6d 18 38 24 c0 20 91 2b 6c //Challenged-Password的后12位
44 72 63 6f //字符串Drco
c0 a8 7f 81 //服务器IP
af 0b //下面会讲到
c0 a8 c3 5f //客户端IP
01 34 //下面会讲到
3e 9b //取当前时间的最后两个字节
第 1 个字节开始往后 16 个字节,通过与早前的数据包做比对即可推出;而最后的 36、37 个字节,则是通过 IDA Pro 得知:
v0 = _time64(0);
...
*(_WORD *)&buf[36] = v0;
而服务器 IP 和客户端 IP 后面分别带的两个字节,却始终未能使用逆向工程分析出来,Github 上的代码也只是与反汇编出来的代码一致,跟我拿到的数据并不相符,最后只能通过慢慢比对,寻找线索。
首先可以发现的是,第 34 个字节固定为 01,然后比对其他历史数据包,最终在服务器第二次响应的数据包中,找到了每次登陆都不同的三个字节:
0000 07 01 30 00 04 0b 20 00 92 9a 9c 8c 01 00 00 00 ..0... .........
0010 44 39 d8 ed 0c 45 fd 03 af 85 30 15 3c fa 04 68 D9...E....0.<..h
0020 b0 82 00 60 00 00 00 00 00 00 00 00 00 00 00 00 ...`............
其中第 24、25 个字节af 85
,第 31 个字节68
每次登陆都会有所变化,而第 24 个字节总是与上述心跳包第 28 个字节相同,第 25 个字节和心跳包第 29 个字节则存在以下关系:
$$
H_{29}=\left{
\begin{array}{rcl}
R_{25}{\scriptstyle<<}1&&{R_{25}<128}\
(R_{25}{\scriptstyle<<}1)|1&&{R_{25}\geq128}
\end{array} \right.
$$
同理可以得知第 30 个字节总是与心跳包第 35 个字节存在以下关系:
$$
H_{35}=\left{
\begin{array}{rcl}
R_{30} {\scriptstyle >>}1&&{R_{30} 为偶数}\
(R_{30} {\scriptstyle >>} 1)|128&&{R_{30} 为奇数}
\end {array} \right.
$$
0x05 心跳包部分的 Go 语言实现
udpServerAddr, err = net.ResolveUDPAddr("udp4", "192.168.127.129:61440")
if err != nil {
fmt.Println(err)
os.Exit(0)
}
udpConn, err = net.DialUDP("udp4", nil, udpServerAddr)
if err != nil {
fmt.Println(err)
os.Exit(0)
}
defer udpConn.Close()
go recvPing()
用内置的 net.DialUDP 建立一个 UDP 对话到 192.168.127.129:61440,这里注意客户端的端口并不要求一定是 61440,前面提到的那个关于 UDP61440 的错误,就是因为偶尔启动时 61440 端口由于各种原因被占用,其实只需要让系统自动分配一个端口即可。
然后启动一个协程,负责 UDP 回应的接受,对各种形式的回应做出处理。
/* 接收服务器的UDP回应 */
func recvPing() {
data := [4096]byte{}
for {
n, _, err := udpConn.ReadFromUDP(data[0:])
if err != nil {
fmt.Println(err)
}
if n > 0 {
if data[0] == 0x07 { //应答包
if data[2] == 0x10 && n == 32 { //第一次应答
sendPingInfo(data[8:12]) //发送用户信息包
} else if data[2] == 0x30 { //第二次应答
UknCode_1 = data[24]
UknCode_2 = data[25]
UknCode_3 = data[30]
UknCode_3 = data[31]
go pingCycle() //发送Ping-1
} else if data[2] == 0x28 { //Ping应答
if data[5] == 0x02 { //收到Ping-2
sendPing40(3) //发送Ping-3
}
}
}
}
}
}
还有一个协程则负责两种心跳包的交替发送。这里注意发送心跳包是定时发送的,因为并不是每个心跳包都会收到回应,不能依赖于收到回应后再继续发送。
/* 两种心跳包循环发送 */
func pingCycle() {
time.Sleep(1 * time.Second)
for {
sendPing40(1)
time.Sleep(10 * time.Second)
sendPing38()
time.Sleep(5 * time.Second)
}
}
其他包的发送函数就只是简单的对数据进行封装,这里就不再一一给出。