关于如何获取 FortiGate shell 权限以及如何完成 License 授权验证。(一)
处理判断逻辑
本博客某历史文章中提到过一篇参考资料,不过这篇资料是针对旧版本 FortiGate 的,Fortinet 曾在 2019 年 11 月 14 日发布了一个影响 FortiGate 的 CVE,在这个漏洞的描述中,官方认为 VM 应用缺少对文件系统的检查,可能导致攻击者向系统中注入恶意程序。为此在启动流程中添加了文件系统校验,按照参考资料的步骤修改系统文件之后可能会导致无法启动或者无限重启。
对于这个问题,我们首先想到的是定位检查逻辑,看看能否根据算法打包出正确的文件或者直接将检查逻辑 patch 掉。先看一下系统启动时的输出信息:
考虑文件系统的校验可能是在内核或者用户态实现,我们首先在文件系统中尝试搜索 System is starting 字符串
在二进制文件 bin/init 中找到了匹配,逆向该文件看看这个字符串是何时打印的:
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
| t_printf("\nSystem is starting...\n", v11, v8, v12, v5, v6, requested_time.tv_sec); fflush(stdout); sub_206FB40(); reboot(0); close(0); close(1); close(2); sub_44DE80(0); chdir("/"); setsid(); v14 = sub_44DDF0("/dev/null"); v15 = v14; if ( v14 >= 0 ) { dup2(v14, 0); dup2(v15, 1); dup2(v15, 2); } if ( sub_290E8D0(1024LL, 1LL) < 0 ) { t_printf("could not setup epoll in init.\n", 1, v16, v17, v18, v19, requested_time.tv_sec); } else if ( sub_452C00() >= 0 ) { sub_450A80(); sub_1F6CD90(16, "%s()-%d: %s: run_initlevel(SYSINIT)\n\n", "main", 2649, "main", v20, requested_time.tv_sec); sub_44E2D0(1LL); sub_451670(); sub_450BC0(); if ( sub_44F240() ) do_halt(); if ( !sub_44F1A0() ) do_halt(); if ( sub_2745D50() ) { sub_2825E00(); if ( sub_44DFC0("/bin/fips_self_test") ) do_halt(); } else { if ( sub_44F1F0() ) do_halt(); sub_2781560(); } }
|
我们看到在 init 程序的 main 函数中第 82 行位置打印了此字符串,而向下分析发现有几处判断,其中比较明显的位置引用了 /bin/fips_self_test 字符串。do_halt 函数的内容:
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
| unsigned __int64 do_halt() { FILE *v0; FILE *v1; int v2; int v3; struct timespec requested_time; unsigned __int64 v6;
v6 = __readfsqword(0x28u); sub_451FA0("do_halt", 1388); v0 = fopen("/etc/shutdown.dat", "w"); if ( v0 ) { v1 = v0; fprintf(v0, "%d", 1LL); fclose(v1); sync(); sleep(1u); } sub_44EF50(); v2 = open("/dev/console", 2305); v3 = v2; if ( v2 >= 0 ) { dprintf(v2, "\r\nThe system is halted.\r\n"); fsync(v3); close(v3); } requested_time.tv_sec = 2LL; requested_time.tv_nsec = 0LL; while ( nanosleep(&requested_time, &requested_time) == -1 && *__errno_location() == 4 ) ; if ( !fork() ) reboot(1126301404); while ( pause() ) ; return v6 - __readfsqword(0x28u); }
|
这里会输出信息 The system is halted,表示系统已停止运行。
第一个判断函数内部执行了 ioctl 和 socket 等函数,向内核发送或接收某些信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int __fastcall sub_2907930(int a1, __int64 a2) { int v2; int v3; int v4;
if ( dword_468D350 >= 0 ) return ioctl(dword_468D350, a1, a2); v2 = socket(2, 2, 0); v3 = v2; if ( v2 < 0 ) return -1; v4 = ioctl(v2, a1, a2); close(v3); return v4; }
|
第二个函数 fork 出一个子进程,主要处理逻辑:
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
| _BOOL8 sub_447D40() { char v0; char v1; __int64 i; char v3; char v4; __int64 v5; FILE *v6; FILE *v7; int v8; FILE *v9; _BOOL8 v10; __int64 v11; __int64 v12; __int64 v13; __int64 v14; void *v15; void *v16; size_t v17; __int64 v19; size_t n; void *v21; __int64 *v22; void *v23; __int64 v24; char v25[32]; char filename[8]; __int16 v27; __int64 ptr[16];
v0 = 97; v1 = 78; ptr[9] = __readfsqword(0x28u); *filename = 0x42F1C441217474ELL; strcpy(ptr, "aiqu0oZi"); for ( i = 0LL; ; v0 = *(ptr + i) ) { v25[i++] = v0 ^ v1; if ( i == 8 ) break; v1 = filename[i]; } v3 = 97; v4 = 78; v24 = 0x42F1C441217474ELL; strcpy(ptr, "aiqu0oZi"); v5 = 0LL; v25[8] = 0; while ( 1 ) { filename[v5++] = v3 ^ v4; if ( v5 == 8 ) break; v4 = *(&v24 + v5); v3 = *(ptr + v5); } v27 = 50; v6 = fopen(filename, "r"); v7 = v6; if ( v6 && (v8 = fread(ptr, 1uLL, 0x40uLL, v6), fclose(v7), v8 > 0) && (v9 = fopen(v25, "r")) != 0LL ) { LODWORD(v10) = 0; v23 = &unk_3FAC280; v11 = d2i_PUBKEY(0LL, &v23, 294LL); v12 = v11; if ( v11 ) { v13 = EVP_PKEY_get1_RSA(v11); v14 = v13; if ( v13 ) { v22 = &v19; n = RSA_size(v13); v15 = alloca(n); v21 = &v19; v16 = alloca(RSA_size(v14)); v17 = fread(v21, 1uLL, n, v9); if ( RSA_public_decrypt(v17, v21, &v19, v14, 1LL) == v8 ) v10 = memcmp(ptr, &v19, v8) == 0; RSA_free(v14); } EVP_PKEY_free(v12); } fclose(v9); } else { LODWORD(v10) = 0; } return v10; }
|
函数开头通过异或运算处理了一个字符串,解密之后得到结果 /.fgtsum
在根目录能找到这个文件,那么显然此函数就是利用该文件实现了某些系统校验。
第三个函数(sub_2745D50)似乎和 FIPS 模式相关,FIPS 简单来说是美国政府针对计算机系统定义的一种标准化信息处理方式,旨在提升信息的安全性。第四个函数(sub_44F1F0)同样 fork 出了子进程,相关逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| _BOOL8 __fastcall sub_2780BD0(unsigned int a1) { __int64 v1; __int64 v2; _BOOL8 result; char *v4; char v5[268]; __int16 v6; char v7; unsigned __int64 v8;
v8 = __readfsqword(0x28u); qmemcpy(v5, &off_35CFDC0, sizeof(v5)); v6 = 256; v7 = 0; v4 = v5; v1 = d2i_RSAPublicKey(0LL, &v4, 270LL); if ( v1 && (v2 = v1, !sub_2746F20("/data/rootfs.gz", "/data/rootfs.gz.chk", a1, v1)) ) result = sub_2746F20("/data/flatkc", "/data/flatkc.chk", a1, v2) == 0; else result = 0LL; return result; }
|
此函数实现对 rootfs.gz 的判断。
到这里可以大体推断文件系统的校验是在用户态程序 /bin/init 中实现的。相关逻辑并不复杂,我们可以深入研究以便于打包一份合适的 rootfs.gz 通过校验,或者采用更简单的方法即 patch 掉校验逻辑。
经过验证,只需要 patch fgtsum 和 rootfs 两个检验即可,将对应跳转指令取反,保存并替换原始文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| if ( sub_44F240() ) do_halt(); if ( sub_44F1A0(1LL, "%s()-%d: %s: run_initlevel(SYSINIT)\n\n") ) do_halt(); if ( sub_2745D50() ) { sub_2825E00(); if ( sub_44DFC0("/bin/fips_self_test") ) do_halt(); } else { if ( !sub_44F1F0(1LL, "%s()-%d: %s: run_initlevel(SYSINIT)\n\n") ) do_halt(); sub_2781560(); }
|
或者,我们使用更简单的补丁,直接将 do_halt 函数的第一条指令改为 ret,这样就算无法通过验证,也不会执行重启操作。
植入后门并启动
后续的操作和参考文章提到的类似,先用 msf 生成一个后门程序,替换 /bin/smartctl 文件,然后将 patch 的 init 程序替换 /bin/init,同时在系统中放置一个 busybox 并将 /bin/sh 指向 busybox 方便后续使用。
根据前面的文章我们知道系统启动时会首先执行 /sbin/init,这个程序的逻辑很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| __int64 __fastcall main(__int64 a1, char **a2, char **a3) { char *argv[4];
argv[3] = (char *)__readfsqword(0x28u); sub_4017D0(a1, a2, a3); unlink("/sbin/init.chk"); if ( (int)sub_401AD0("bin") >= 0 && (int)sub_401AD0("migadmin") >= 0 && (int)sub_401AD0("node-scripts") >= 0 ) sub_401AD0("usr"); argv[0] = "/bin/init"; argv[1] = 0LL; execve("/bin/init", argv, 0LL); return 0LL; }
|
它负责解压各个压缩包并唤起 /bin/init 继续执行,没有其他操作。方便起见我们跳过重新打包 xxx.tar.xz 的步骤,在系统启动过程中调试内核并修改内核参数,让它直接去执行 /bin/init 程序。
打包命令
1 2
| find . | cpio -H newc -o > ../rootfs.raw cat ./rootfs.raw | gzip > rootfs.gz
|
将新的 rootfs.gz 替换到磁盘中,挂载到原先系统上,并添加调试桥。
启动时用 gdb 附加,并定位到执行用户空间程序位置:
此时执行的目标程序为 /sbin/init
我们将其修改成 /bin/init,然后继续执行,系统将正常启动,执行命令 diagnose hardware smartctl
即可触发后门程序。由于设备存在防火墙,端口不能随便访问,我们将其自带的 sshd 服务 kill 掉并替换成 telnetd 后门
1
| killall sshd && busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22
|
这样就可以获取一个完整的 root 权限:
另外,也可以使用我编写的一个简单工具,使用方法请参考说明文档。
License 授权分析
第一次启动系统时,需要导入一个合适的 License 才能使用各项功能,在前面的文章中我们提到可以注册 FortiCloud 账户并使用评估版本 License,不过这样操作比较复杂,而且每有一个新版本就要重新注册一个账户,有没有一种方法可以绕过或者破解 License 验证呢?本部分将会介绍一种构造 License 的方法。
首先定位校验 License 的逻辑,通过查询 FortiGate 手册,得知存在一个叫做 vm-print-license
的调试命令,使用方法为 diagnose debug vm-print-license
,在未激活状态下执行会看到以下输出,提示我们当前处于试用状态。
系统大部分业务逻辑都在 /bin/init 中,我们直接在 /bin/init 程序中搜索 License
、vm-print-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
| unsigned __int64 maybe_check_license() { __int64 v0; int v1; const char *v2; time_t v3; void *lic_file_content; __int64 lic_length; struct tm tp; unsigned __int64 v8;
v8 = __readfsqword(0x28u); lic_file_content = 0LL; lic_length = 0LL; read_file("/data/etc/vm.lic", &lic_file_content, &lic_length); if ( lic_length && lic_file_content ) { v0 = 0LL; init_lic_struct(&lic_struct); v1 = sub_29200D0(lic_file_content, lic_length, &lic_struct); v2 = lic_struct; if ( !v1 ) { while ( strncmp(v2, lincese_types[v0].name, 6uLL) ) { if ( ++v0 == 27 ) goto LABEL_8; } license_tag_val = lincese_types[v0].tag_val; LABEL_8: if ( qword_F5E2DC8 ) { strptime(qword_F5E2DC8, "%a %b %e %H:%M:%S %Y", &tp); v3 = timegm(&tp); qword_F5E2DF8 = v3; if ( nCfg_debug_zone ) *(nCfg_debug_zone + 3559) = v3; } } free(lic_file_content); } return v8 - __readfsqword(0x28u); }
|
该函数尝试读取 /data/etc/vm.lic 文件,并使用 sub_29200D0 对其解析。由于已经获取到了 shell 权限,我们可以看到未激活时这个文件的内容:
1 2 3 4 5
| -----BEGIN FGT VM LICENSE----- IAAAAJ89UQoa5fpHDP95to/FvROLPIKeChd2EMS1QHS43LMGUAAAAGULEt3EN96B mKhmST8dR9f5RV2Yfp6EojX+Rx92RqAf+vBkeklRAh+GglCJzHRgGCqhgedueWbt YQ/dmgF7MWbFqOkYRXjiaLPznTCafNb8 -----END FGT VM LICENSE-----
|
这进一步印证了我们的猜想,即这部分代码逻辑和 License 有较大关系。
外层函数 maybe_check_license 的逻辑比较简单,首先从 /data/etc/vm.lic 中读取授权信息,接着调用 init_lic_struct 函数初始化一个全局变量结构体 lic_struct,然后使用 sub_29200D0 函数解析授权文件
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
| __int64 __fastcall sub_29200D0(char *lic_file_content, __int64 lic_length, lic_struct *lic_struct) { char *lic_start; char *lic_end; char *lic_end_1; char *lic_start_t; const unsigned __int16 *v8; char *decode_content; unsigned int *decode_content_2; int v11; __int64 v12; unsigned int *v13; __int64 v14; unsigned int *v15; size_t enc_data_length; __int64 v17; __int64 v18; int v19; __m128i *v20; int v21; unsigned int v22; char *aes_decrypted_1; __int64 v24; char *data_start; char *v26; unsigned __int64 key_name_length; _DWORD *v28; char *v29; size_t key_value_length; unsigned __int32 *key_value; char key_flag; bool v34; void *aes_enc_data; __m128i *ptr; __int64 v37; char *decode_content_1; unsigned __int32 v39; __m128i aes_key; __m128i v41; char key_name[264]; unsigned __int64 v43;
v43 = __readfsqword(0x28u); lic_struct->SERIALNO = 0LL; lic_struct->unknow9 = 0LL; memset( (&lic_struct->unknow2 & 0xFFFFFFFFFFFFFFF8LL), 0, 8LL * ((lic_struct - ((lic_struct + 8) & 0xFFFFFFF8) + 168) >> 3)); if ( lic_file_content ) { lic_start = strstr(lic_file_content, "-----BEGIN FGT VM LICENSE-----"); lic_end = strstr(lic_file_content, "-----END FGT VM LICENSE-----"); lic_end_1 = lic_end; if ( lic_start ) { if ( lic_end ) { lic_start_t = lic_start + 30; if ( lic_end > lic_start_t ) { v8 = *__ctype_b_loc(); while ( (v8[*lic_start_t] & 0x2000) != 0 ) { if ( lic_end_1 == ++lic_start_t ) return -1; } if ( lic_end_1 > lic_start_t ) { while ( (v8[*lic_end_1] & 0x2000) != 0 ) { if ( --lic_end_1 == lic_start_t ) return -1; } if ( lic_end_1 > lic_start_t ) { *lic_end_1 = 0; decode_content = malloc(3 * ((lic_end_1 - lic_start_t + 3) >> 2)); decode_content_1 = decode_content; decode_content_2 = decode_content; if ( decode_content ) { v11 = maybe_base64_decode(lic_start_t, decode_content, 3 * ((lic_end_1 - lic_start_t + 3) >> 2)); *lic_end_1 = '-'; if ( v11 <= 15 || (v12 = *decode_content_2, v13 = decode_content_2, v14 = (decode_content_2 + 1), v15 = (v14 + v12), v14 + v12 > v13 + v11) || (enc_data_length = *v15, aes_enc_data = v15 + 1, (v37 = decode_pub_key()) == 0) ) { v22 = -1; free(decode_content_1); return v22; } v17 = EVP_PKEY_get1_RSA(v37); v18 = v17; if ( v17 ) { v19 = RSA_size(v17); v20 = calloc(v19, 1uLL); if ( !v20 ) { v22 = -1; RSA_free(v18); goto LABEL_47; } ptr = v20; v21 = RSA_public_decrypt(v12, v14, v20, v18, 1LL); RSA_free(v18); if ( v21 > 31 ) { v22 = 0; aes_key = _mm_loadu_si128(ptr); v41 = _mm_loadu_si128(ptr + 1); free(ptr); LABEL_22: aes_decrypted_1 = AES_DEC(aes_enc_data, enc_data_length, &aes_key, &aes_key); if ( *(aes_decrypted_1 + 1) == 0x13A38693 ) { v24 = aes_decrypted_1[enc_data_length - 1]; data_start = aes_decrypted_1 + 12; if ( v24 < 0x10u ) enc_data_length -= v24; v26 = &aes_decrypted_1[enc_data_length]; if ( data_start >= v26 ) { final_check: if ( v22 ) { LOBYTE(lic_struct->unknow9) = 1; v34 = memcmp(lic_struct->SERIALNO, "FGVMEV", 6uLL) == 0; v22 = !v34; if ( !v34 ) { v34 = memcmp(lic_struct->SERIALNO, "FGVM--UNLICENSED", 0x10uLL) == 0; v22 = !v34; if ( !v34 ) v22 = -(memcmp(lic_struct->SERIALNO, "FGVMPG", 6uLL) != 0); } } else { LOBYTE(lic_struct->unknow9) = 0; } goto LABEL_47; } while ( 1 ) { key_name_length = *data_start; v28 = data_start + 1; v29 = &data_start[key_name_length + 1]; key_value_length = *(v29 + 1); key_value = (v29 + 3); key_flag = *v29; v39 = 0; data_start = &v29[key_value_length + 3]; if ( key_name_length < 8 ) { if ( (key_name_length & 4) != 0 ) { *key_name = *v28; *&key_name[key_name_length - 4] = *(v28 + key_name_length - 4); } else if ( key_name_length ) { key_name[0] = *v28; if ( (key_name_length & 2) != 0 ) *&key_name[key_name_length - 2] = *(v28 + key_name_length - 2); } key_name[key_name_length] = 0; if ( key_flag == 'n' ) { LABEL_37: if ( key_value_length != 4 ) goto LABEL_34; key_value = &v39; v39 = _byteswap_ulong(*(v29 + 3)); fillin_struct: fillin_struct_func(lic_struct, key_name, key_flag, key_value, key_value_length); goto LABEL_34; } } else { *&key_name[key_name_length - 8] = *(v28 + key_name_length - 8); qmemcpy(key_name, v28, 8 * ((key_name_length - 1) >> 3)); key_name[key_name_length] = 0; if ( key_flag == 'n' ) goto LABEL_37; } if ( key_flag == 's' ) goto fillin_struct; LABEL_34: if ( v26 <= data_start ) goto final_check; } } goto failed; } free(ptr); if ( v12 == 32 ) { v22 = 1; aes_key = _mm_loadu_si128((decode_content_1 + 4)); v41 = _mm_loadu_si128((decode_content_1 + 20)); goto LABEL_22; } } failed: v22 = -1; LABEL_47: free(decode_content_1); EVP_PKEY_free(v37); return v22; } *lic_end_1 = 45; } } } } } } return -1; }
|
我重命名了一些变量并添加了一部分注释,这个函数逻辑稍稍复杂,简要描述如下:
首先检查授权文件是否包含定位符,根据定位符确定主要内容的起止位置,对内容进行 Base64 解码,然后从解码的数据中读取头部结构。从内存中解压一个 RSA 公钥,先尝试使用这个公钥解密余下请求,如果解密失败,再尝试使用 AES-128-CBC 解密内容,使用的 key 和 iv 是从 Base64 解码的数据头部获得的。
之后对解密的数据进行判断,首先是 magic 部分需要等于 0x13A38693,之后跟随数个 Block 结构,Block 结构类似 TLV,由数据长度、数据名、数据值组成,每个 Block 对应 License 中的一个信息字段。
解析好各个结构之后将这些信息填充到 lic_struct 结构体中,并在后续功能继续使用。
根据以上代码逻辑,我们可以整理出 Licnese 的大致组成,以 Python 来表示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class License: aes_key_iv_length = 32 aes_key = b"\x61" * 32 enc_data_length = None enc_data = None license_data = None
def __init__(self, licensedata): pass
class LicenseDataBlock: key_name_length = None key_name = None key_flag = None key_value_length = None key_value = None def __init__(self, keyname, keyvalue): pass
|
程序中还存在一个关键结构体,定义了 Block 基本信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| struct lic_block { char *key_name; __int64 key_flag; __int64 offset_in_lic_struct; };
lic_block lic_block_array[12] = { { "SERIALNO", 115LL, 0LL }, { "CERT", 115LL, 8LL }, { "KEY", 115LL, 16LL }, { "CERT2", 115LL, 24LL }, { "KEY2", 115LL, 32LL }, { "CREATEDATE", 115LL, 40LL }, { "UUID", 115LL, 48LL }, { "CONTRACT", 115LL, 56LL }, { "USGFACTORY", 110LL, 64LL }, { "LENCFACTORY", 110LL, 68LL }, { "CARRIERFACTORY", 110LL, 72LL }, { "EXPIRY", 110LL, 76LL } };
|
至此我们已经得知了 License 的结构和组成成分,接下来需要分析 block 中各项都起什么作用,以及 lic_struct 结构体是如何参与 License 验证的。不过这里有比较简单的方法,Block 具有比较清晰的名称,根据名称可大致猜测其作用,例如 SERIALNO 应该表示设备序列号,CREATEDATE 表示 license 导入日期,EXPIRY 表示过期时间等。
通过调试并结合代码分析,Block 中其关键作用的应该是 SERIALNO、CREATEDATE、USGFACTORY、LENCFACTORY、CARRIERFACTORY、EXPIRY 几个字段,其余字段可以省略。
根据分析结果,编写出对应的注册程序,你可以在 Github 上获取。
导入生成的 License 文件后再次执行 vm-print-license
得到 License 合法的结果,而且后台功能也可以正常使用了。
本部分介绍了 FortiGate License 授权验证逻辑,分析了 License 组成结构和各字段大致含义,并编写了一个注册程序可以生成合适的 License。