sakuraの从零开始のIoT漏洞挖掘系列(一): Western Digital My Cloud Pro系列PR4100 NAS认证前RCE漏洞分析与利用
简述
本文主要是对crowdstrike团队的pwn2own-tale-of-a-bug-found-and-lost-again文章进行学习,并梳理漏洞模式和探究漏洞利用方法,因为笔者手上没有这款固件,如果有人手上有或者用qemu仿真出来了,可以自己调试一下。
FIRMWARE
首先下载有漏洞的固件,该漏洞从2.31.204版本开始,一直在5.04.114版本修复,跨度长达一年,还是十分值得学习的。
https://downloads.wdc.com/gpl/WDMyCloud_PR4100_GPL_v2.40.155_20200713.tar.gz
攻击面枚举
因为是从零开始的IoT漏洞挖掘,从本篇开始我们首先讲述一下,在开始挖掘漏洞之前,我们需要做什么。第一件事就是要枚举攻击面,即这个目标它起了哪些服务,然后哪些服务是从外网可以访问。
一般可以用Netstat来看这些东西。
netstat -tulpn
-t
tcp-u
udp-l
listening, Show only listening sockets.-n
Show numerical addresses instead of trying to determine symbolic host, port or user names.-p
Show the PID and name of the program to which each socket belongs.
1 | root@MyCloudPR4100 root # netstat -tulpn |
一般看到httpd就可以确定这可能是使用了apache来做的服务端,所以再搜一下conf配置文件,一般以我的习惯会把每个conf文件都读一下,不过这里我们主要关注一下alias.conf
和rewrite.conf
1 | sakura@sakuradeMacBook-Pro:~/Desktop/WDMyCloud_PR4100_GPL_v2.40.155_20200713$ find . -name "*.conf" |
alias.conf
https://www.docs4dev.com/docs/zh/apache/2.4/reference/mod-mod_alias.html1
ScriptAlias /cgi-bin/ /var/www/cgi-bin/
这句配置的含义是把web请求的url中,如果它访问的目录是
/cgi-bin/
,就重定向到/var/www/cgi-bin/
目录下。rewrite.conf
https://www.jianshu.com/p/103742cccaff
对于rewrite.conf
,主要读懂RewriteCond和RewriteRule两个关键字的含义就行了。
RewriteCond起到的是过滤作用
以RewriteCond %{REMOTE_ADDR} !^127\.0\.0\.1$
这句为例,如果%{REMOTE_ADDR}
和!^127\.0\.0\.1$
正则匹配,即REMOTE_ADDR
不是来自localhost的话,就使用紧邻着的下一句RewriteRule来重定向web请求。
以RewriteRule ^(\w*).cgi$ /web/cgi_api.php?cgi_name=$1&%{QUERY_STRING} [L]
这句为例,就是把所有访问xx.cgi
文件的请求,都重定向到/web/cgi_api.php?cgi_name=xxx
,即用cgi_api.php
来分发请求,如果鉴权不通过,就不能访问该cgi文件。
这里的鉴权主要指的就是攻击者是否有普通用户登录的权限,也就是一般说的pre-auth和after-auth了。
我们主要关注的都是pre-auth的rce,所以从这个配置文件和从cgi_api.php
里的逻辑可以看出,认证前能够访问的cgi文件只有webpipe.cgi
和login_mgr.cgi
,而前者内部也有鉴权,所以主要关注login_mgr.cgi
至此为止我们就分析出了攻击者易达的攻击面,如果要深挖的话还需要再读一下其他的配置文件,和ps -ef
看看还开了哪些进程,能否通过httpd路由到。
1 | <IfModule rewrite_module> |
漏洞分析
首先抓包看一下正常的请求包是什么样的,可以看出用户输入的密码其实是被base64之后再发往server端处理的
1 | POST /cgi-bin/login_mgr.cgi HTTP/1.1 |
入口函数在cgiMain,该函数根据post请求里的cmd参数来选择使用哪个函数,这里我们主要看的就是wd_login
函数
cgiMain
1 | __int64 cgiMain() |
wd_login
在我简单的处理了一下符号之后的伪代码如下。
1 | int wd_login() |
- 首先读取用户输入的username到username数组里,最大读取32个字节,读取pwd到pwd_b64数组里,最大读取256个字节
- base64decode解密pwd_b64,将结果保存在pwd_decoded数组里,最大写入256个字节,但问题是pwd_decoded数组的size是64字节,所以会越界写入到pwd_b64数组里,但在这里不会影响程序的逻辑,因为pwd_b64在解密后就不会被用到。
is_username_allowed
校验输入的用户名是否合法,该函数先将用户名里的大写字母转成小写,然后和一个全局字符串数组里的每个字符串比较,如果有任何一个匹配就返回0,代表非法,否则返回1,代表合法。- 之所以这样比较是因为它将所有注册的用户的账号密码都写入到了
/etc/shadow
文件里,而这个文件里的root, anonymous...
等用户是linux系统使用的,而不是给注册用户使用的。
- 之所以这样比较是因为它将所有注册的用户的账号密码都写入到了
- 然后将被溢出的数组pwd_decoded传给check_login函数。
check_login
1 | __int64 __fastcall check_login(const char *username, const char *pwd_decoded) |
- 按行读取
/etc/shadow
里的数据,并解析成passwd结构体。 - 拷贝pw_passwd字段到栈上变量password_copy_shadow数组里
- 拷贝pwd_decoded到栈上变量password_copy_input数组里,因为pwd_decoded是一个写入溢出的字符串,其长度最大是192字节(base64算法,最大解密出来就是输入字符串的3/4长度),而password_copy_input数组的size是88,所以在这个栈布局里就可以溢出到返回地址了。
如下是ida的stack layout视图,r代表返回地址,如图可以看到从password_copy_input数组到返回地址,一共是120个字节,而我们可以写入192个字节,所以可以劫持返回地址。
1 | -00000000000000C8 ; D/A/* : change type (data/ascii/array) |
漏洞模式
这个漏洞的模式就是写入的数据超出了数组本身的大小导致的写入越界,但实际造成栈溢出的地方是在更后面的strcpy的地方,相对来说其实比较隐蔽,strcpy这个函数会从源地址向目的地址拷贝数据,一直到遇到\0
停止。
正常来说在往字符数组写入一个字符串的时候,都会把最后一个字节设置\0
,但因为写入的越界,导致\0
出现在了数组越界后的位置。
最终导致前面base64decode函数造成的写入越界向后传播,最终在某次strcpy的时候造成了栈溢出。
漏洞利用
正常来说栈溢出的漏洞利用只需要rop构造gadaget即可,但是对于64位架构的栈溢出来说,因为程序的装载基地址是0x400000,所以不考虑return to libc等情况,直接在程序体内来找合适的gadaget地址的话,不可避免的在写入地址的时候会遇到\x00
,比如0000000000401D00
这个地址,它的高位都是0。
所以在strcpy的时候,遇到高位的\x00
就会被截断,所以在溢出的时候,最多就只能覆盖到返回地址,写入一个想到劫持到的地址,不能向后继续写入了。
如图可以看出,尽管我们溢出password_copy_input
由于截断只能写到返回地址那个位置,进行一次gadaget。
但是我们可以寻找lea rsp, [rsp+??] ; retn
这样的gadaget来抬升栈,通过stack pivot来将rsp指到wd_login
栈上的pwd_decoded字符串里,而这个字符串的值显然是我们可以任意控制,并且不受\x00
截断影响,它是base64解出来的。
所以到这里我们就可以进行多次gadaget了。
即我们要让pwd_decoded字符串里的内容形如,即可
1 | AAAAA * ? + p64(gadaget_addr1) + 需要的pop的寄存器值 + p64(gadaget_addr2) + 需要的pop的寄存器值 + p64(gadaget_addr3)... |
然后由于一般的cgi程序里其实都会调很多system函数,所以我们只要再通过多次gadaget传递我们需要的命令到调用system函数的地方,最终执行该代码就可以反弹shell了。
但这个cgi程序里有个非常有趣的地方,就是00000000004039B7
这个地址,它既有栈抬升,又有call system。
所以我们需要的payload就是A * 120 + p64(0x4039B7) + system_cmd_str
即可。
解释一下,在溢出覆盖返回地址后,会跳到00000000004039B7
去call一次无效的system命令,然后lea rsp, [rsp+108h]
栈抬升,此时rsp指向我们在pwd_decoded里的p64(0x4039B7) + system_cmd_str
字符串。
然后再retn,弹出p64返回地址,再次跳回到00000000004039B7
执行,此时rsp指向的就是要执行的反弹shell字符串,并传给rdi,作为system的参数执行,此时就成功的反弹shell了。
1 | .text:00000000004039B7 lea rdi, [rsp] |
具体的调试就留给读者权做练习了。
总结一下,iot的栈溢出,找gadaget的要点就是
- 栈抬升
lea rsp, [rsp+?]
- 找system,传参劫持过去。