搭建 FortiGate 调试环境 (二)

Catalpa 网络安全爱好者

关于如何获取 FortiGate shell 权限,可信执行和新版本 License 授权分析。(二)

新的验证逻辑

我们在之前的文章中介绍了如何向 FortiGate 中植入后门并获取 SHELL 方便调试,在新版本中,开发者添加了多种系统完整性验证逻辑,并且加密了 rootfs.gz 文件,旧的方法失效,在本篇文章中,提供一种相对简单的方法,实现向新版本 FortiGate 中添加后门并获取 SHELL 权限。

在 2024 年 3 月 4 日,optistream 的研究人员发布了一篇文章,详细描述了在新版 FortiGate 中添加的加密和校验逻辑,并且能够绕过这些校验,最终获取 root shell,感兴趣的朋友可以参考他们的分析文章,这里不再赘述,只做简要总结。

相对于旧版本来说,主要的变动有

  1. 在内核中添加了对 rootfs.gz 文件的完整性校验和解密算法
  2. 在用户态添加了 .db 文件的完整性校验
  3. 在系统中实现了 “forticron“ 自动任务,可能会在系统运行期间自动对文件系统执行完整性校验

后两点本质上对破解流程没有很大影响,只需要找到这些新添加的校验逻辑并将它们 Patch 掉即可。而影响较大的是 rootfs.gz 被加密,并且在内核中进行校验和解密。解密算法在 optistream 分析文章中已经给出,他们的思路为 Patch 掉用户空间完整性校验,植入后门并启动系统,在系统启动时调试内核,跳过内核中的校验算法,最终使得系统能够正常启动,这一点和本博客前篇文章类似。

本文将介绍一种更加简洁的方法,无需调试内核即可正常启动系统。

修改内核

FortiGate 的内核文件是 flatkc,通过 file 命令可以看到它的格式为:

1
flatkc: Linux kernel x86 boot executable bzImage, version 4.19.13 (root@build) #1 SMP Thu Feb 1 17:10:41 UTC 2024, RO-rootFS, swap_dev 0X7, Normal VGA

bzImage 是 Linux 内核的一种引导映像格式,主要用于基于 x86/x64 架构的计算机,bzImage 中包含被压缩的内核文件(vmlinux)以及一段用于解压内核的代码,另外它还负责处理内核命令行参数等辅助数据。

bzImage 等格式的镜像可以使用 vmlinux-to-elf 项目直接转换为 ELF 文件,通过分析 ELF 文件定位到校验和解密内核的函数是 fgt_verify_initrd。理想情况下 Patch 内核的思路应该是

  1. 将 bzImage 解压
  2. 修改 vmlinux 中的代码
  3. 将 vmlinux 压缩回 bzImage,并确保系统仍能够正常启动

前两步可以容易的完成,但如何将 vmlinux 压缩回 bzImage 却没有想象中那样简单。

在分析过程中我查阅了网络上的多篇资料,最终找到一位作者 jamchamb 发布的博客文章 ,文中介绍了如何从逆向角度修改 ARM zImage 内核文件,最终成功完成重打包操作,修改了系统启动时输出的字符串信息。为了方便理解,我们先复现一下文中提到的方法,详细过程可以阅读原作者文章。

首先下载 zImage 文件并使用 QEMU 尝试启动

1
2
wget https://archive.openwrt.org/releases/17.01.0/targets/armvirt/generic/lede-17.01.0-r3205-59508e3-armvirt-zImage-initramfs -O zImage-initramfs
qemu-system-arm -serial stdio -M virt -m 1024 -kernel zImage-initramfs

等待启动后按下回车可正常进入 shell,输出信息如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BusyBox v1.25.1 () built-in shell (ash)

_________
/ /\ _ ___ ___ ___
/ LE / \ | | | __| \| __|
/ DE / \ | |__| _|| |) | _|
/________/ LE \ |____|___|___/|___| lede-project.org
\ \ DE /
\ LE \ / -----------------------------------------------------------
\ DE \ / Reboot (17.01.0, r3205-59508e3)
\________\/ -----------------------------------------------------------

=== WARNING! =====================================
There is no root password defined on this device!
Use the "passwd" command to set up a new password
in order to prevent unauthorized SSH logins.
--------------------------------------------------
root@(none):/#

我们希望将 WARNING! 字符串修改为 NORMAL!!

通过查看 Linux 源码目录 ,在 zImage 中被压缩的 vmlinux 叫做 Piggy,Piggy 可以由不同的算法压缩,我们下载的镜像使用的压缩算法为 xz

1
2
3
4
5
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 Linux kernel ARM boot executable zImage (little-endian)
15400 0x3C28 xz compressed data
15632 0x3D10 xz compressed data

在 piggy.xzkern.S 汇编中看到 Piggy 在 zImage 文件中的位置由 input_data、input_data_end 界定,这些变量在 arch/arm/boot/compressed/misc.c 中的 decompress_kernel 函数被引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void
decompress_kernel(unsigned long output_start, unsigned long free_mem_ptr_p,
unsigned long free_mem_ptr_end_p,
int arch_id)
{
int ret;

__stack_chk_guard_setup();

output_data = (unsigned char *)output_start;
free_mem_ptr = free_mem_ptr_p;
free_mem_end_ptr = free_mem_ptr_end_p;
__machine_arch_type = arch_id;

arch_decomp_setup();

putstr("Uncompressing Linux...");
ret = do_decompress(input_data, input_data_end - input_data,
output_data, error);
if (ret)
error("decompressor returned an error");
else
putstr(" done, booting the kernel.\n");
}

do_decompress 函数负责对内核文件进行解压,对照源码查看 zImage 的反编译代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __fastcall decompress_kernel(unsigned int output_start, unsigned int free_mem_ptr_p, unsigned int free_mem_ptr_end_p, int arch_id)
{
const char *v5; // r3
int result; // r0
const char *v8; // r3

sub_9AC();
v5 = "Uncompressing Linux...";
while ( *v5++ )
;
result = do_decompress(input_data, 0x2BB404, output_start, sub_940);
if ( result )
sub_940("decompressor returned an error");
v8 = " done, booting the kernel.\n";
while ( *v8++ )
;
return result;
}

这样找到了 input_data 和 input_data_end 两个变量的值,也就可以定位到 Piggy 的位置。

将 Piggy 拆出

1
dd if=zImage-initramfs of=vmlinux.xz ibs=1 skip=$[0x3d10] count=$[0x2BB404]

注意拆分出来的文件是一个正常的 xz 压缩包,但是在末尾多出了 4 个字节,用来存放原始 vmlinux 的大小,这一点可以在源码 中看到。因此解压时使用参数 --single-stream 避免出现解压失败的提示。

1
unxz --verbose --single-stream vmlinux.xz

打开解压得到的 vmlinux,在其中找到想要修改的字符串进行修改,完成之后把 vmlinux 重新压缩回 Piggy (这里使用了 xz 的 nice 参数,以便于让重打包的文档尽可能小于原始文档)

1
xz --check=crc32 --arm --lzma2=,dict=32MiB,nice=128 < vmlinux > vmlinux-mod-warntest.xz

得到的 xz 文档相比源文档更小

1
2
-rw-rw-r--  1 admin admin 2863840  3月 14 18:04 vmlinux-mod-warntest.xz
-rw-rw-r-- 1 admin admin 2864132 3月 14 18:05 vmlinux.xz

接下来要把修改之后的文档重新塞入 zImage 中,由于新的文档更小,所以不用考虑扩容的问题,缺失的部分使用 00 填充,不会影响 xz 正常解压。不过注意保留原来 xz 文档结尾的 4 个字节。

1
2
3
cp zImage-initramfs zImage-initramfs-warnmod
dd if=/dev/zero of=zImage-initramfs-warnmod bs=1 seek=$[0x3d10] count=$[0x2bb400] conv=notrunc
dd if=vmlinux-mod-warntest.xz of=zImage-initramfs-warnmod bs=1 seek=$[0x3d10] conv=notrunc

修改之后启动新的镜像

1
qemu-system-arm -serial stdio -M virt -m 1024 -kernel zImage-initramfs-warnmod

可以在终端看到修改已经成功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BusyBox v1.25.1 () built-in shell (ash)

_________
/ /\ _ ___ ___ ___
/ LE / \ | | | __| \| __|
/ DE / \ | |__| _|| |) | _|
/________/ LE \ |____|___|___/|___| lede-project.org
\ \ DE /
\ LE \ / -----------------------------------------------------------
\ DE \ / Reboot (17.01.0, r3205-59508e3)
\________\/ -----------------------------------------------------------

=== NORMAL!! =====================================
There is no root password defined on this device!
Use the "passwd" command to set up a new password
in order to prevent unauthorized SSH logins.
--------------------------------------------------

基于以上思路,猜测对 FortiGate 的 flatkc 也可以执行类似的操作,先将 Piggy 取出,解压后把校验和解密 rootfs.gz 的函数跳过,再将内核重新压缩并塞回 flatkc 中。

flatkc 是一个 x86 镜像,所以在 Linux 源码中找到 arch/x86/boot/compressed 目录,在 misc.c 中找到了叫做 extract_kernel 的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
asmlinkage __visible void *extract_kernel(void *rmode, memptr heap,
unsigned char *input_data,
unsigned long input_len,
unsigned char *output,
unsigned long output_len)
{
const unsigned long kernel_total_size = VO__end - VO__text;
unsigned long virt_addr = LOAD_PHYSICAL_ADDR;

/* Retain x86 boot parameters pointer passed from startup_32/64. */
boot_params = rmode;

/* Clear flags intended for solely in-kernel use. */
boot_params->hdr.loadflags &= ~KASLR_FLAG;

sanitize_boot_params(boot_params);

if (boot_params->screen_info.orig_video_mode == 7) {
vidmem = (char *) 0xb0000;
vidport = 0x3b4;
} else {
vidmem = (char *) 0xb8000;
vidport = 0x3d4;
}

lines = boot_params->screen_info.orig_video_lines;
cols = boot_params->screen_info.orig_video_cols;

console_init();
debug_putstr("early console in extract_kernel\n");

free_mem_ptr = heap; /* Heap */
free_mem_end_ptr = heap + BOOT_HEAP_SIZE;

/* Report initial kernel position details. */
debug_putaddr(input_data);
debug_putaddr(input_len);
debug_putaddr(output);
debug_putaddr(output_len);
debug_putaddr(kernel_total_size);

#ifdef CONFIG_X86_64
/* Report address of 32-bit trampoline */
debug_putaddr(trampoline_32bit);
#endif

/*
* The memory hole needed for the kernel is the larger of either
* the entire decompressed kernel plus relocation table, or the
* entire decompressed kernel plus .bss and .brk sections.
*/
choose_random_location((unsigned long)input_data, input_len,
(unsigned long *)&output,
max(output_len, kernel_total_size),
&virt_addr);

/* Validate memory location choices. */
if ((unsigned long)output & (MIN_KERNEL_ALIGN - 1))
error("Destination physical address inappropriately aligned");
if (virt_addr & (MIN_KERNEL_ALIGN - 1))
error("Destination virtual address inappropriately aligned");
#ifdef CONFIG_X86_64
if (heap > 0x3fffffffffffUL)
error("Destination address too large");
if (virt_addr + max(output_len, kernel_total_size) > KERNEL_IMAGE_SIZE)
error("Destination virtual address is beyond the kernel mapping area");
#else
if (heap > ((-__PAGE_OFFSET-(128<<20)-1) & 0x7fffffff))
error("Destination address too large");
#endif
#ifndef CONFIG_RELOCATABLE
if ((unsigned long)output != LOAD_PHYSICAL_ADDR)
error("Destination address does not match LOAD_PHYSICAL_ADDR");
if (virt_addr != LOAD_PHYSICAL_ADDR)
error("Destination virtual address changed when not relocatable");
#endif

debug_putstr("\nDecompressing Linux... ");
__decompress(input_data, input_len, NULL, NULL, output, output_len,
NULL, error);
parse_elf(output);
handle_relocations(output, output_len, virt_addr);
debug_putstr("done.\nBooting the kernel.\n");
return output;
}

通过搜索函数出现的一些字符串,在 flatkc 中找到了该函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
__int64 *__fastcall extract_kernel(
void *rmode,
void *heap,
unsigned __int8 *input_data,
unsigned int input_len,
unsigned __int8 *output,
unsigned int output_len)
{
// ...

v8 = heap;
v11 = rmode;
*(rmode + 529) &= ~2u;
v12 = *(rmode + 495) == 0;
qword_700690 = rmode;
if ( !v12 )
{
sub_6E6A00(rmode + 192, 0, 256LL);
sub_6E6A00(rmode + 491, 0, 6LL);
sub_6E6A00(rmode + 616, 0, 40LL);
sub_6E6A00(rmode + 3280, 0, 48LL);
heap = 0LL;
sub_6E6A00(rmode + 3820, 0, 276LL);
v11 = 0LL;
}
if ( v11[6] == 7 )
{
qword_7006B8 = 720896LL;
dword_7006B0 = 948;
}
else
{
qword_7006B8 = 753664LL;
dword_7006B0 = 980;
}
dword_7006AC = v11[14];
dword_7006A8 = v11[7];
sub_6E6F90();
qword_700688 = v8;
qword_700680 = v8 + 0x10000;
v13 = &unk_1826000;
if ( *&output_len >= 0x1826000uLL )
v13 = *&output_len;
if ( (output & 0x1FFFFF) != 0 )
error("Destination physical address inappropriately aligned", heap);
if ( v8 > 0x3FFFFFFFFFFFLL )
error("Destination address too large", heap);
if ( v13 + 0x200000 > 0x20000000 ) // KERNEL_IMAGE_SIZE
error("Destination virtual address is beyond the kernel mapping area", heap);
if ( output != LOAD_PHYSICAL_ADDR )
error("Destination address does not match LOAD_PHYSICAL_ADDR", heap);
v14 = input_data;
if ( !input_data )
{
v14 = malloc(0x4000uLL);
if ( !v14 )
error("Out of memory while allocating input buffer", heap);
*&input_len = 0LL;
}
v15 = malloc(0x60uLL);
if ( !v15 )
error("Out of memory while allocating z_stream", heap);
v16 = malloc(0x2548uLL);
v15[8] = v16;
v18 = v16;
if ( !v16 )
error("Out of memory while allocating workspace", heap);
if ( !*&input_len )
{
heap = sub_4000;
*&input_len = fill(v14, 0x4000LL);
}
if ( *&input_len <= 9 || *v14 != 31 || v14[1] != 0x8B || v14[2] != 8 )
error("Not a gzip file", heap);
v19 = *&input_len - 10LL;
*v15 = v14 + 10;
v15[1] = v19;
if ( (v14[3] & 8) != 0 )
{
do
{
if ( !v19 )
error("header error", heap);
v20 = *v15;
v15[1] = --v19;
*v15 = v20 + 1;
}
while ( *v20 );
}
v15[7] = v18;
v15[3] = 0x200000LL;
v15[4] = v17;
v15[6] = 0LL;
v18[2] = 0;
v18[10] = 15;
*(v18 + 7) = v15[8] + 9544LL;
v21 = sub_6E4440(v15);
*(v15[8] + 44LL) = 0;
*(v15[8] + 56LL) = 0LL;
if ( !v21 )
{
while ( 1 )
{
if ( !v15[1] )
{
v22 = fill(v14, 0x4000LL);
if ( v22 < 0 )
error("read error", 0x4000LL);
*v15 = v14;
v15[1] = v22;
}
v23 = sub_6E4540(v15, 0LL);
if ( v23 == 1 )
break;
if ( v23 )
error("uncompression error", 0LL);
}
}
dword_700698 = -2;
if ( !input_data )
{
dword_700698 = -3;
qword_7006A0 = 0LL;
}
sub_6E6A90(v29, LOAD_PHYSICAL_ADDR);
if ( v29[0] != 1179403647 )
error("Kernel is not a valid ELF file", LOAD_PHYSICAL_ADDR);
v24 = malloc(56 * v31);
v25 = v24;
if ( !v24 )
error("Failed to allocate space for phdrs", LOAD_PHYSICAL_ADDR);
v26 = 0;
v27 = v30 + 0x200000;
sub_6E6A90(v24, v30 + 0x200000);
if ( v31 )
{
do
{
if ( *v25 == 1 )
{
if ( (v25[12] & 0x1FFFFF) != 0 )
error("Alignment of LOAD segment isn't multiple of 2MB", v27);
v27 = *(v25 + 1) + 0x200000LL;
sub_6E6A30(*(v25 + 3), v27);
}
++v26;
v25 += 14;
}
while ( v26 < v31 );
}
dword_700698 = -1;
return LOAD_PHYSICAL_ADDR;
}

观察发现函数开头和源码大致相同,但后面出现了一些和 gzip 解压相关的代码,搜索字符串在 decompress_inflate.c 找到的相关定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
STATIC int INIT __gunzip(unsigned char *buf, long len,
long (*fill)(void*, unsigned long),
long (*flush)(void*, unsigned long),
unsigned char *out_buf, long out_len,
long *pos,
void(*error)(char *x)) {
u8 *zbuf;
struct z_stream_s *strm;
int rc;

rc = -1;
if (flush) {
out_len = 0x8000; /* 32 K */
out_buf = malloc(out_len);
} else {
if (!out_len)
out_len = ((size_t)~0) - (size_t)out_buf; /* no limit */
}
if (!out_buf) {
error("Out of memory while allocating output buffer");
goto gunzip_nomem1;
}

if (buf)
zbuf = buf;
else {
zbuf = malloc(GZIP_IOBUF_SIZE);
len = 0;
}
if (!zbuf) {
error("Out of memory while allocating input buffer");
goto gunzip_nomem2;
}

strm = malloc(sizeof(*strm));
if (strm == NULL) {
error("Out of memory while allocating z_stream");
goto gunzip_nomem3;
}

strm->workspace = malloc(flush ? zlib_inflate_workspacesize() :
sizeof(struct inflate_state));
if (strm->workspace == NULL) {
error("Out of memory while allocating workspace");
goto gunzip_nomem4;
}

if (!fill)
fill = nofill;

if (len == 0)
len = fill(zbuf, GZIP_IOBUF_SIZE);

/* verify the gzip header */
if (len < 10 ||
zbuf[0] != 0x1f || zbuf[1] != 0x8b || zbuf[2] != 0x08) {
if (pos)
*pos = 0;
error("Not a gzip file");
goto gunzip_5;
}

/* skip over gzip header (1f,8b,08... 10 bytes total +
* possible asciz filename)
*/
strm->next_in = zbuf + 10;
strm->avail_in = len - 10;
/* skip over asciz filename */
if (zbuf[3] & 0x8) {
do {
/*
* If the filename doesn't fit into the buffer,
* the file is very probably corrupt. Don't try
* to read more data.
*/
if (strm->avail_in == 0) {
error("header error");
goto gunzip_5;
}
--strm->avail_in;
} while (*strm->next_in++);
}

strm->next_out = out_buf;
strm->avail_out = out_len;

rc = zlib_inflateInit2(strm, -MAX_WBITS);

if (!flush) {
WS(strm)->inflate_state.wsize = 0;
WS(strm)->inflate_state.window = NULL;
}

while (rc == Z_OK) {
if (strm->avail_in == 0) {
/* TODO: handle case where both pos and fill are set */
len = fill(zbuf, GZIP_IOBUF_SIZE);
if (len < 0) {
rc = -1;
error("read error");
break;
}
strm->next_in = zbuf;
strm->avail_in = len;
}
rc = zlib_inflate(strm, 0);

/* Write any data generated */
if (flush && strm->next_out > out_buf) {
long l = strm->next_out - out_buf;
if (l != flush(out_buf, l)) {
rc = -1;
error("write error");
break;
}
strm->next_out = out_buf;
strm->avail_out = out_len;
}

/* after Z_FINISH, only Z_STREAM_END is "we unpacked it all" */
if (rc == Z_STREAM_END) {
rc = 0;
break;
} else if (rc != Z_OK) {
error("uncompression error");
rc = -1;
}
}

zlib_inflateEnd(strm);
if (pos)
/* add + 8 to skip over trailer */
*pos = strm->next_in - zbuf+8;

gunzip_5:
free(strm->workspace);
gunzip_nomem4:
free(strm);
gunzip_nomem3:
if (!buf)
free(zbuf);
gunzip_nomem2:
if (flush)
free(out_buf);
gunzip_nomem1:
return rc; /* returns Z_OK (0) if successful */
}

这说明 vmlinux 使用 gzip 算法压缩,通过 binwalk 也可以验证这一点

1
2
3
4
5
6
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 Microsoft executable, portable (PE)
16820 0x41B4 gzip compressed data, maximum compression, from Unix, last modified: 1970-01-01 00:00:00 (null date)
7381096 0x70A068 Object signature in DER format (PKCS header length: 4, sequence length: 3274
7381235 0x70A0F3 Certificate in DER format (x509 v3), header length: 4, sequence length: 2279

从源码看到 extract_kernel 函数在 head_64.S 中被调用

1
2
3
4
5
6
7
8
9
10
11
12
/*
* Do the extraction, and jump to the new kernel..
*/
pushq %rsi /* Save the real mode argument */
movq %rsi, %rdi /* real mode address */
leaq boot_heap(%rip), %rsi /* malloc area for uncompression */
leaq input_data(%rip), %rdx /* input_data */
movl $z_input_len, %ecx /* input_len */
movq %rbp, %r8 /* output target address */
movq $z_output_len, %r9 /* decompressed length, end of relocs */
call extract_kernel /* returns kernel location in %rax */
popq %rsi

在 flatkc 也可以找到对应代码

1
2
3
4
5
6
7
8
9
10
push    rsi
mov rdi, rsi
lea rsi, qword_6EC680
lea rdx, gzip_start
mov ecx, 6DF3A4h
mov r8, rbp /* 0x20000 */
mov r9, 1A34918h
call extract_kernel
pop rsi
jmp rax

根据参数位置,发现 Piggy 的起始地址为 0x41B4,长度为 0x6DF3A4,最终解压得到的 vmlinux 大小应该是 0x1A34918。

所以先将 Piggy 拆出

1
dd if=flatkc of=vmlinux.gz ibs=1 skip=$[0x41B4] count=$[0x6DF3A4]

查看 Piggy 信息

1
vmlinux.gz: gzip compressed data, max compression, from Unix, original size modulo 2^32 27478296

使用 gzip 解压得到 vmlinux

1
2
3
gzip -d vmlinux.gz

vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=c1b391e6e7366ccf79173bd1fd93aac1935cf9f6, stripped

接下来要对 vmlinux 进行修改,为了方便定位需要修改的地址,可以先使用 vmlinux-to-elf 将 vmlinux 转换为带有符号信息的 ELF 文件,通过逆向定位到 fgt_verify_initrd 函数地址是 0xFFFFFFFF81709689,考虑这个函数只操作了 initramfs_start 和 initramfs_stop 即 rootfs.gz 的数据,我们选择直接将函数第一条指令改为 ret

1
2
3
4
5
.init.text:FFFFFFFF81709689  retn
.init.text:FFFFFFFF8170968A mov rbp, rsp
.init.text:FFFFFFFF8170968D push r15
.init.text:FFFFFFFF8170968F push r14
.init.text:FFFFFFFF81709691 push r13

然后把 Patch 好的 vmlinux 重新压缩回 gzip 格式

1
cat vmlinux | gzip -9 > vmlinux.gz

查看新文档和原文档的大小差异

1
2
-rw-rw-r--  1 admin admin   7205795  3月 15 09:07 vmlinux.gz
-rw-rw-r-- 1 admin admin 7205796 3月 15 08:57 vmlinux.gz.ori

新的文档比原文档小一个字节,先将新文档覆盖回 flatkc

1
2
dd if=/dev/zero of=flatkc bs=1 seek=$[0x41B4] count=$[0x6DF3A4] conv=notrunc
dd if=vmlinux.gz of=flatkc bs=1 seek=$[0x41B4] conv=notrunc

由于在 gzip 文件结尾添加多余字符时可能会导致解压失败,所以修改调用 extract_kernel 的汇编代码,将 input_len 修改为实际长度 7205795。

使用 qemu 在本地启动测试

1
qemu-system-x86_64 -serial stdio -M q35 -m 1024 -kernel flatkc

启动之后内核 panic 在 mount_block_root 位置,因为本地模拟不存在 rootfs.gz 文件,所以这应该是正常现象。

植入后门

内核修改完毕,下面要向系统植入后门。

修改之后的内核理论上已经不会再对 rootfs.gz 执行校验和加密,所以要将虚拟磁盘中被加密的 rootfs.gz 替换为明文版本。解密算法可参考之前的文章, 也可以使用我编写的小工具 。需要注意的是生成的 dec.gz 末尾 256 字节是校验数据,要手动将它们去除。

解压 rootfs.gz 得到文件系统,首先要把 /bin/init 中完整性校验逻辑 Patch 掉,通过逆向分析发现当完整性校验失败时,都会执行 do_halt 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...
if ( sub_4557C0() )
do_halt();
if ( system_integrity_check(1LL, "%s()-%d: %s: run_initlevel(SYSINIT)\n\n") < 0 )
do_halt();
if ( sub_2BC8AF0(1LL, "%s()-%d: %s: run_initlevel(SYSINIT)\n\n") )
{
sub_2CC6290();
if ( sub_4543F0("/bin/fips_self_test") )
do_halt();
}
else
{
if ( sub_455770() )
do_halt();
}
// ...

所以我们直接将 do_halt 的第一条指令改为 ret,跳过这个函数,这样就算校验失败也不会导致系统重启。

然后向系统添加 busybox 和 sh,再将 smartctl 替换成启动 shell 的脚本

1
2
3
#!/bin/sh

/bin/busybox sh -i

重新打包 rootfs.gz,替换掉原文件。

可信执行

至此我们已经将内核和文件系统中部分完整性校验绕过,并且无需关心 rootfs.gz 加解密的问题,将内核和 rootfs.gz 替换之后尝试启动

从 log 中看到由于完整性校验失败输出错误信息,但是系统仍然成功启动并进入登录界面。

登录后执行 diagnose hardware smartctl 即可得到 SHELL 权限

当尝试使用 wget 下载新的文件到系统并执行时,系统会重新启动。在官方文档找到了导致这一问题的原因:https://docs.fortinet.com/document/fortigate/7.4.0/new-features/226732/real-time-file-system-integrity-checking

新版本添加了实时完整性检查,即可信执行,在系统启动时内核会统计关键文件的 hash 值并存放到内存,当执行程序时内核验证这个程序的 hash 是否和原始值相同,如果不同或不存在,则说明该程序非法,会导致系统直接重启。

不过我们离线添加的 busybox 等程序能够正常执行,因此只需要将想要运行的程序提前添加到磁盘中(bin.tar.xz),进入系统就能正常使用。例如再向系统添加一个 gdbserver 程序,重新启动后可正常运行。

通过阅读官方文档得知,新版本添加的可信执行校验位于内核中,具体来说,开发者利用 Linux 的 IMA(完整性子系统) 实现了一套运行时文件完整性校验。此外还通过 LSM(Linux Security Module) 框架实现了访问控制系统。

逆向分析内核,定位到 forti_security_module_init 函数:

1
2
3
4
__int64 forti_security_module_init()
{
return security_add_hooks(&fortism_func_list, 5LL, aFortiSecurityM);
}

这个函数通过 security_add_hooks 注册 LSM 的 handler,在 fortism_func_list 函数列表中包含 fortism_file_openfortism_path_linkfortism_path_renamefortism_kernel_load_datafortism_sb_mount 五个函数,实现了对文件、符号链接、内核模块、磁盘挂载等操作的访问控制。我们以 fortism_file_open 函数为例简要分析。fortism_file_open 最终会调用 fortism_file_open_part_0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
unsigned __int64 __fastcall fortism_file_open_part_0(__int64 a1)
{
v8 = __readgsqword(0x28u);
result = d_path(a1 + 16, v7, 511);
v2 = result;
v3 = paths;
if ( result <= 0xFFFFFFFFFFFFF000LL )
{
while ( 1 )
{
v4 = strlen(*v3);
if ( !strncmp(v2, *v3, v4) )
break;
if ( ++v3 == &qword_FFFFFFFF8165F678 )
{
result = 0LL;
goto LABEL_5;
}
}
v5 = paths2;
while ( 1 )
{
v6 = strlen(*v5);
result = strncmp(v2, *v5, v6);
if ( !result )
break;
if ( ++v5 == paths )
return fortism_file_open_part_0_cold();
}
}
LABEL_5:
if ( v8 != __readgsqword(0x28u) )
JUMPOUT(0xFFFFFFFF8055054FLL);
return result;
}

此函数会遍历两个全局数组,数组中包含一些路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/migadmin/fortiguard_resources
/node-scripts/report-runner
/node-scripts/logs
/node-scripts/http-config.json
/bin
/migadmin
/node-scripts
/sbin
/tools
/usr
/lib
/lib64
/data/rootfs.gz
/data/datafs.tar.gz
/data/flatkc

当传入的参数匹配以上路径时,函数打印失败信息 try to write readonly file(xxx) 并返回负数值。这一点可以在 SHELL 中验证,例如我们尝试在 /bin 目录下创建一个 testfile 文件,会出现错误信息

另外内核中又存在 ima_file_mmap 函数:

1
2
3
4
5
6
7
8
9
10
11
__int64 __fastcall ima_file_mmap(__int64 file, char prot)
{
char v3[4]; // [rsp+4h] [rbp-14h] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-10h]

v4 = __readgsqword(0x28u);
if ( !file || (prot & 4) == 0 ) // PROT_EXEC
return 0LL;
security_task_getsecid(__readgsqword(&off_14D80), v3);
return fos_process_appraise_constprop_0(file);
}

最终调用 fos_process_appraise_constprop_0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
__int64 __fastcall fos_process_appraise_constprop_0(unsigned __int64 file)
{
v32 = __readgsqword(0x28u);
inode = *(file + 32);
v2 = integrity_iint_find(inode);
if ( need_ima_check != 2 )
{
v3 = v2;
v4 = d_path(file + 16, v30, 511);
v5 = v4;
if ( v4 > 0xFFFFFFFFFFFFF000LL )
return fos_process_appraise_constprop_0_cold();
if ( v3 )
{
v24 = 3;
v26 = dword_FFFFFFFF8165F6B4;
for ( i = 0; ; i = 0 )
{
do
{
v29[i] = 0LL;
v29[i + 1] = 0LL;
v29[i + 2] = 0LL;
v29[i + 3] = 0LL;
i += 4;
}
while ( i < 8 );
result = ima_calc_file_hash(file, &v26);// 计算文件 hash 值
if ( !result )
break;
if ( !--v24 )
{
if ( result < 0 )
return result;
break;
}
v27 = 0;
v28 = 0;
v26 = dword_FFFFFFFF8165F6B4;
}
if ( !memcmp(*(v3 + 112) + 4LL, v29, *(*(v3 + 112) + 1LL)) )// 比较 hash 值
return 0LL;
ima_pr_emerg(3LL, v5);
fos_print_hash_isra_0(aNew, v29, *(*(v3 + 112) + 1LL), v12, v13, v14);
fos_print_hash_isra_0(aOld, (*(v3 + 112) + 4LL), *(*(v3 + 112) + 1LL), v15, v16, v17);
printk(&unk_FFFFFFFF81401B64, *(inode + 80), v18, v19, v20, v21);
time64_to_tm(*(inode + 104), 0LL, v25);
v23 = v25[0];
printk(&unk_FFFFFFFF81401C48, aModifiedTime, v25[6] + 1900, v25[4] + 1, v25[3], v25[2]);
logmsg = fos_ima_get_logmsg(3LL);
(_fgtlog_vf_text_0[0])(36864LL, 255LL, 255LL, 20234LL, 0LL, logmsg, v5, v23);
failed:
msleep(5000LL);
kernel_restart(0LL);
return 0xFFFFFFF3LL;
}
v8 = off_FFFFFFFF8165F6A0[0];
if ( strcmp(off_FFFFFFFF8165F6A0[0], v4) ) // /data/lib/libav.so
{
v8 = off_FFFFFFFF8165F6A8;
if ( strcmp(off_FFFFFFFF8165F6A8, v5) ) // /data/lib/libips.so
{
ima_pr_emerg(1LL, v5);
v9 = 1LL;
failed2:
v10 = fos_ima_get_logmsg(v9);
(_fgtlog_vf_text_0[0])(36864LL, 255LL, 255LL, 20233LL, 0LL, v10, v5);
goto failed;
}
}
memset(v31, 0, sizeof(v31));
snprintf(v31, 511, aSX_0, v8);
if ( fos_verify_pkcs7(file, v5, v31) < 0 )
{
if ( sys_security_level != 2 )
{
if ( sys_security_level == 1 )
{
ima_pr_warning(0);
v11 = fos_ima_get_logmsg(0LL);
(_fgtlog_vf_text_0[0])(36864LL, 255LL, 255LL, 20233LL, 0LL, v11, v5);
}
else if ( !sys_security_level )
{
ima_pr_warning(0);
return 0LL;
}
return 0LL;
}
ima_pr_emerg(0LL, v5);
v9 = 0LL;
goto failed2;
}
}
return 0LL;
}

简单来说,函数会先判断当前是否开启了 IMA 验证,IMA 可以通过修改 /sys/kernel/security/integrity/fos/fix_to_enforce 值来开启或关闭。在 init 程序中也能找到相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__int64 enforce_fos_integrity()
{
FILE *v0; // rax
FILE *v1; // r12

v0 = fopen("/sys/kernel/security/integrity/fos/fix_to_enforce", "w");
if ( v0 )
{
v1 = v0;
fputc('1', v0);
fclose(v1);
return 0LL;
}
else
{
sub_23338D0("Failed to enforce FortiOS Security Enforce mode\n");
return 0xFFFFFFFFLL;
}
}

不过实际测试发现修改此文件并不能关闭 IMA 验证,原因暂时未知。

继续观察 fos_process_appraise_constprop_0,当在缓存中找不到待验证文件的 Hash 时,代码判断这个文件是否为 /data/lib/libav.so 或者 /data/lib/libips.so,如果是二者之一,调用 fos_verify_pkcs7 检查文件签名。

如果检查失败,应该返回错误并重启系统,但这里在失败的情况下仅打印了 log 信息,函数继续向下执行并返回 0,表示验证通过。

/data/lib 目录不在访问控制的范围内,且代码对这两个文件缺乏校验,所以我们尝试自己编写程序覆盖其中之一,看看能否绕过可信执行。

虽然内核 log 中留下了加载非法文件的提示,但程序依然成功执行,验证了前面的分析。

破解 License

在之前的文章中介绍了如何生成测试版 License,新版代码修改了验证逻辑,导致以前生成的 License 无法正常使用。

新的验证函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
__int64 __fastcall do_process_license(char *license_data, __int64 a2, char *lic_struct)
{
v61 = __readfsqword(0x28u);
*lic_struct = 0LL;
*(lic_struct + 20) = 0LL;
memset(
((lic_struct + 8) & 0xFFFFFFFFFFFFFFF8LL),
0,
8LL * ((lic_struct - ((lic_struct + 8) & 0xFFFFFFF8) + 168) >> 3));
*(lic_struct + 20) = 1;
if ( license_data )
{
license_start = strstr(license_data, "-----BEGIN FGT VM LICENSE-----");
license_end = strstr(license_data, "-----END FGT VM LICENSE-----");
license_end_1 = license_end;
if ( license_start )
{
if ( license_end )
{
license_body = license_start + 30;
if ( license_end > license_start + 30 )
{
v7 = *__ctype_b_loc();
while ( (v7[*license_body] & 0x2000) != 0 )// 检查字符是否合法
{
if ( license_end_1 == ++license_body )
return -1;
}
if ( license_end_1 > license_body )
{
license_end_1_1 = license_end_1;
while ( (v7[*license_end_1_1] & 0x2000) != 0 )
{
if ( --license_end_1_1 == license_body )
return -1;
}
if ( license_end_1_1 > license_body )
{
*license_end_1_1 = 0;
v48 = 3 * ((license_end_1_1 - license_body + 3) >> 2);
decoded_license = malloc(v48);
if ( decoded_license )
{
b64_decoded_len = base64_decode(license_body, decoded_license, v48);
*license_end_1_1 = 45;
b64_decoded_len_1 = b64_decoded_len;
if ( !b64_decoded_len )
goto LABEL_74;
cms_start = strstr(license_data, "-----BEGIN CMS-----");// 新增的字段
cmd_end = strstr(license_data, "-----END CMS-----");
b64_decoded_len_1_1 = b64_decoded_len_1;
if ( cms_start && cmd_end )
{
v38 = 0LL;
cms_len = cmd_end + 17 - cms_start;
v40 = 0LL;
v41 = license_end_1 + 28 - license_start;
if ( v41 )
{
while ( v41 != ++v40 )
{
while ( license_start[v40] == 10 && license_start[v40 - 1] != 13 )
{
++v40;
++v38;
if ( v41 == v40 )
goto LABEL_66;
}
}
LABEL_66:
v42 = v41 + v38;
}
else
{
v42 = 0LL;
}
v43 = malloc(v42);
b64_decoded_len_1_1 = b64_decoded_len_1;
if ( v43 )
{
sub_2DCE5F0(license_start, v41, v43, v42);
ptrc = v44;
v45 = do_check_cms_license(v44, v42, cms_start, cms_len);// 检查 cms 内容
free(ptrc);
b64_decoded_len_1_1 = b64_decoded_len_1;
if ( v45 == 1 )
lic_struct[162] = 1;
}
}
if ( b64_decoded_len_1_1 <= 0xF
|| (v13 = &decoded_license[*decoded_license + 4],
v46 = *decoded_license,
v14 = *decoded_license,
v13 > &decoded_license[b64_decoded_len_1_1])
|| (v15 = *v13, dest = v13 + 4, (v50 = decrypt_pubkey()) == 0) )// 解密公钥
{
LABEL_74:
v31 = -1;
free(decoded_license);
return v31;
}
v16 = EVP_PKEY_get1_RSA(v50); // 加载公钥
rsa_obj = v16;
if ( !v16 )
goto LABEL_73;
v18 = RSA_size(v16);
decrypted_license = calloc(v18, 1uLL);
if ( !decrypted_license )
{
v31 = -1;
RSA_free(rsa_obj);
goto LABEL_60;
}
decrypted_license_1 = decrypted_license;
v20 = RSA_public_decrypt(v14, (decoded_license + 4), decrypted_license, rsa_obj, 1LL);
RSA_free(rsa_obj);
if ( v20 > 31 )
{
v58 = _mm_loadu_si128(decrypted_license_1);
v59 = _mm_loadu_si128(decrypted_license_1 + 1);
free(decrypted_license_1);
pub_dec_len = 0;
}
else
{
free(decrypted_license_1);
if ( v46 != 32 )
goto LABEL_73;
pub_dec_len = 1;
v58 = _mm_loadu_si128((decoded_license + 4));
v59 = _mm_loadu_si128((decoded_license + 20));
}
v21 = sub_2DCE270(dest, v15, &v58, &v58);
if ( *(v21 + 1) == 0x13A38693 ) // 检查 magic
{
v22 = v21[v15 - 1];
v23 = v21 + 12;
if ( v22 < 0x10u )
v15 -= v22;
v24 = &v21[v15];
if ( v23 >= v24 )
{
LABEL_45:
v33 = *lic_struct;
lic_struct[160] = pub_dec_len;
if ( v33 )
{
v34 = *(lic_struct + 1);
if ( v34 )
{
v35 = check_cert(v34, v33); // 检查证书
if ( v35 == 1 )
lic_struct[161] = 1;
v33 = *lic_struct;
}
for ( i = 0LL; i != 22; ++i )
{
if ( !strncmp(v33, (&vm_prefix_list)[2 * i], 6uLL) )
{
v31 = 0;
*(lic_struct + 20) = qword_4ED4B48[4 * i];
goto LABEL_54;
}
}
v31 = -1;
LABEL_54:
if ( !pub_dec_len || (v37 = *(lic_struct + 20), v37 == 0x17) || v37 == 2 )// 一定会进入,因为公钥解密得到的字节数量为 32
{
if ( *(lic_struct + 6) )// uuid
{
gen_uuid(v57);
input_uuid_to_bin(*(lic_struct + 6), v60);
if ( check_uuid(v57, v60) )// 检查 UUID
*(lic_struct + 20) = 1;// 如果赋值为 1 表示非法
}
}
else
{
*(lic_struct + 20) = 1;
v31 = -1;
}
goto LABEL_60;
}
goto LABEL_73;
}
while ( 1 )
{
v25 = *v23;
v26 = v23 + 1;
v27 = &v23[v25 + 1];
v28 = *(v27 + 1);
v29 = (v27 + 3);
v30 = *v27;
v56 = 0;
v23 = &v27[v28 + 3];
if ( v25 < 8 )
{
if ( (v25 & 4) != 0 )
{
*v60 = *v26;
*(&v59.m128i_i32[3] + v25) = *(v26 + v25 - 4);
}
else if ( v25 )
{
v60[0] = *v26;
if ( (v25 & 2) != 0 )
*(&v59.m128i_i16[7] + v25) = *(v26 + v25 - 2);
}
v60[v25] = 0;
if ( v30 == 110 )
{
LABEL_40:
if ( v28 != 4 )
goto LABEL_37;
v29 = &v56;
v56 = _byteswap_ulong(*(v27 + 3));
LABEL_36:
fillin_license_struct(lic_struct, v60, v30, v29, v28);
goto LABEL_37;
}
}
else
{
*(&v59.m128i_i64[1] + v25) = *(v26 + v25 - 8);
qmemcpy(v60, v26, 8 * ((v25 - 1) >> 3));
v60[v25] = 0;
if ( v30 == 110 )
goto LABEL_40;
}
if ( v30 == 115 )
goto LABEL_36;
LABEL_37:
if ( v24 <= v23 )
goto LABEL_45;
}
}
LABEL_73:
v31 = -1;
LABEL_60:
free(decoded_license);
EVP_PKEY_free(v50);
return v31;
}
*license_end_1_1 = 45;
}
}
}
}
}
}
return -1;
}

对比旧版验证逻辑,总结变动如下

1
2
3
4
1. 去除了单独使用 AES 算法解密 License 的代码
2. 新增对 License 中证书的检查 (对比证书的 CN 和序列号是否相同)
3. 新增对 UUID 的检查 (判断是否和程序计算得到的 UUID 相同)
4. License 中新增 CMS 内容,如果检测到上传的文件包含 CMS,则对 CMS 进行检查

对于第一点,代码在处理 License 时会先读取开头 4 个字节,这 4 字节表示 Header 长度,默认为 64。然后获取后续 64 字节,再用内存中解压得到的公钥进行解密,将解密结果作为 AES KEY,使用 AES 算法继续解密剩余内容。所以我们只需要获取一个合法的 License Header,并使用它对应的 AES KEY 加密 License 数据即可通过验证。

对于第二、三点,由于在获取 SHELL 权限时就需要对 init 程序进行修改,所以顺便把两个判断也绕过。是否包含 CMS 对 License 验证基本无影响,所以 CMS 验证暂时忽略。

按照上面的思路我编写了针对新版的 License 生成工具,你可以在 Github 上获取。

参考资料

本文介绍了一种通过修改 FortiGate 内核与文件系统实现获取 SHELL 权限的方法,修改后无需调试内核或者关心 rootfs.gz 加解密即可启动系统。分析了新版添加的可信执行策略,并找到一种方法来绕过。分析了新版的 License 校验逻辑,并更新工具实现对新版本的破解。

除文中提到的以外,系统还存在其它校验逻辑,绕过方法就留给感兴趣的读者自行探索了。

https://jamchamb.net/2022/01/02/modify-vmlinuz-arm.html

https://www.optistream.io/blogs/tech/fortigate-firmware-analysis

https://stackoverflow.com/questions/76571876/how-to-repack-vmlinux-elf-back-to-bzimage-file

https://reverseengineering.stackexchange.com/questions/27803/repacking-vmlinux-into-zimage-bzimage

https://github.com/kiler129/recreate-zImage

https://elixir.bootlin.com

https://zhuanlan.zhihu.com/p/438209486

https://liwugang.github.io/2020/10/25/introduce_lsm.html

  • Title: 搭建 FortiGate 调试环境 (二)
  • Author: Catalpa
  • Created at : 2024-04-02 00:00:00
  • Updated at : 2024-10-17 08:48:40
  • Link: https://wzt.ac.cn/2024/04/02/fortigate_debug_env2/
  • License: This work is licensed under CC BY-NC-SA 4.0.