0x00 概述
这篇文章主要分析Bitdefender反病毒引擎中的一系列内存损坏漏洞。
二进制加壳的目的是压缩或混淆二进制文件,通常是为了节省空间和带宽,或者是逃避恶意软件分析。加壳后的二进制文件中通常包含压缩或混淆的数据Payload。当执行二进制文件时,加载程序对Payload进行脱壳,然后跳转到(内部)二进制文件的实际入口点。大多数反病毒引擎都支持对流行的加壳程序(例如UPX)进行二进制脱壳。
本文是关于Bitdefender核心引擎中PE二进制文件的UPX脱壳过程。UPX脱壳PE二进制文件的主要步骤包括:
(1)从入口点检测加载工具;
(2)找到压缩的数据Payload并提取;
(3)避免过滤提取的代码;
(4)重建各种结构,例如导入表、重定位表、导出表和资源。
下面将按照UPX脱壳工具的控制流顺序依次说明我们发现的漏洞。
需要说明的是,我们将介绍Bitdefender核心引擎的反编译代码。对于其中的变量、字段和宏的命名,在很大程度上受到原始UPX的启发。对于某些片段,添加了对原始函数的引用以进行比较。其中的某些类型可能不正确。
0x01 提取前反混淆处理过程中存在栈缓冲区溢出
在检测到UPX加载程序之后,Bitdefender引擎将尝试检测加载程序是否在提取压缩数据Payload之前进行了特定类型的反混淆处理。混淆/反混淆的过程非常简单,仅使用三个操作——ADD、XOR和ROTATE_LEFT。如果检测到这种反混淆处理,则引擎会循环访问加载程序的相应指令,并使用它们的操作数对其进行解析,以便能够对数据进行反混淆处理。如下所示:
int32_t operation[16]; // on the stack int32_t operand[16]; // on the stack int i = 0; int pos = 0; do { bool op_XOR_or_ADD = false; if (loaderdata[pos] == 0x81u && (loaderdata[pos + 1] == 0x34 || loaderdata[pos + 1] == 0x4)) { operation[i] = (loaderdata[pos + 1] == 0x34) ? OP_XOR : OP_ADD; operand[i] = *(int32_t *)&loaderdata[pos + 3]; ++i; pos += 7; op_XOR_or_ADD = true; } } if (loaderdata[pos] == 0xC1u && loaderdata[pos + 1] == 4) { operation[i] = OP_ROTATE_LEFT; operand[i] = loaderdata[pos + 3]; ++i; pos += 4; if (i == 16) break; continue; } if (op_XOR_or_ADD) { if (i == 16) break; continue; } if (loaderdata[pos] == 0xE2u) { /* omitted: apply collected operations */ } pos += 2; } while (pos + SOME_SLACK < loaderdata_end);
我们可以观察到如何对索引变量进行边界检查。由于缓冲区加载程序数据是完全由攻击者控制的,因此很容易验证,我们可以在执行检查i == 16之前,将索引变量i增加2。特别是,可以将i从15增加到17,之后就可以使用完全任意的数据覆盖堆栈。
(10ec.12dc): Break instruction exception - code 80000003 (first chance) 00000000`0601fe42 cc int 3
调试中断是因为我们已经覆盖了堆栈Canary。如果继续,则会看到返回失败,因为此时堆栈已损坏。
0:000 > g (10ec.12dc): Access violation - code c0000005 (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. 00000000`06006603 c3 ret 0:000 > dd rsp 00000000`0014ed98 deadbeef deadbeef deadbeef deadbeef 00000000`0014eda8 deadbeef deadbeef deadbeef deadbeef
0x02 提取前反混淆处理过程中存在堆缓冲区溢出
根据收集的操作(0x01中所展示的反混淆处理),我们在攻击者控制的偏移量write_offset处,将其应用到Payload缓冲区。显然,这个偏移量需要在写入前进行检查。有两个针对write_offset的检查。第一个是:
if (write_offset < = extractobj- > dword10 + 3)
第二个是:
if (loaderdata[pos] == 0xE2u) { if (write_offset > = extractobj- > dword10 - 3)
这两项检查都针对字段dword10进行检查。位于调用函数的堆栈框架上的字段dword10未经过初始化。这会导致边界检查无效,此时引入了完全由攻击者控制的堆缓冲区溢出。
0x03 提取后反混淆处理过程中的堆缓冲区溢出
在提取后,引擎尝试使用静态XOR键对提取的数据进行混淆处理。
for(int i=0; i < 0x300; i++) { if (*(int32_t *)&entrypoint_data[i] == 0x4243484B) { int32_t j = i + 0x4A; uint8_t xor_key = entrypoint_data[j]; // attacker-controlled int32_t xor_len = *(int32_t *)&entrypoint_data[j - 7]; // attacker-controlled if (xor_len > packer- > set_to_size_of_rawdata) return j; // < -- wrong bound check for(int32_t k=0; k < xor_len; k++) { packer- > extracted_data[k] ^= xor_key; // < -- oob write } *info_string = "encrypted"; } }
这里的边界检查是完全错误的。它本应检查提取的数据缓冲区的大小,但实际上检查的却是一个先前设置为我们从中提取数据的部分的原始数据大小的值。这个值与数据缓冲区大小是无关的,并且它们之间可能存在较大的差异。
由于该函数在第一次反混淆操作后没有返回,因此内存损坏可以连续触发多达0x300次。这样一来,我们就可以绕过限制:在单个反混淆过程中,我们可以只是对同一个字节进行XOR。我们可以对XOR进行如下操作:
第一次循环(i=0):使用B0 B0 B0 B0 B0 B0 B0进行XOR;
第二次循环(i=1):使用B1 B1 B1 B1 B1进行XOR;
第三次循环(i=2):使用B2 B2进行XOR。
总体而言,对于完全任意的C0、C1和C2,我们随后对C0 C0 C1 C1 C1 C2 C2进行了XOR。我们基本上可以使用几乎任意长度的内容来进行XOR,可以最多对字节进行0x300次操作。
不得不说,这个漏洞是一个有效的利用原语,因为它会导致非常强大的内存损坏。XOR允许我们仅选择性地修改数据的某些部分,同时保持其他部分(例如堆的元数据、关键对象)不变。
0x04 过滤器中存在堆缓冲区溢出
过滤器是对二进制代码(例如x86-64代码)的简单转换,这个转换在压缩之前应用,其目的是使代码更具可压缩性。在对数据进行解压缩之后,我们需要还原这个过滤。Bitdefender支持大约15种不同的过滤器。下面是其中的一个示例(过滤器0x11):
int32_t bytes_to_filter = /* omitted. is guaranteed not to be oob. */; int i = 0; while (1) { do { if (--bytes_to_filter < 0) break; } while (extracted_data[i++] != 0xE8u); if (bytes_to_filter < 0) break; *(int32_t *)&extracted_data[i] -= i; // < -- oob write i += 4; }
这里的问题在于,bytes_to_filter仅在i递增1的时候更新,但在递增4的时候不会更新。
在这15个过滤器中,大概有8个似乎受到这个堆缓冲区溢出漏洞的影响,我将这些统一合并为一个漏洞。
0x05 重建导入时存在堆缓冲区溢出
在函数PeFile::rebuildImports(参见PeFile::rebuildImports)的循环中,发生了以下的内存损坏。具体如下:
this- > im- > iat = this- > iatoffs; this- > newiat = &extract_obj- > extracted_data[this- > iatoffs - (uint64_t)(uint32_t)pefile- > rvamin]; while (*p) { if (*p == 1) { ilen = strlen(++p) + 1; if (this- > inamespos) { if (ptr_diff(this- > importednames,this- > importednames_start) & 1) --this- > importednames; memcpy(this- > importednames + 2, p, ilen); // < -- memory corruption *this- > newiat = ptr_diff(this- > importednames,extract_obj- > extracted_data - pefile- > rvamin); this- > importednames += ilen + 2; p += ilen; } else { //omitted, see below //5// } } else if (*p == 0xFFu) { p += 3; *this- > newiat = ord_mask + *(uint16_t *)(p + 1); } else { // omitted } ++this- > newiat; }
传递给memcpy的长度ilen完全由攻击者控制,因此需要进行检查。请注意,原始的UPX在这里已经进行了检查。
0x06 重建导入时另一处堆缓冲区溢出
在函数PeFile::rebuildImports(参见PeFile::rebuildImports)中,同一个循环里,还存在另一处内存损坏漏洞:
this- > im- > iat = this- > iatoffs; this- > newiat = &extract_obj- > extracted_data[this- > iatoffs - (uint64_t)(uint32_t)pefile- > rvamin]; while (*p) { if (*p == 1) { ilen = strlen(++p) + 1; if (this- > inamespos) { //omitted, see above //5// } else { extracted_data = extract_obj- > extracted_data; dst_ptr = (extracted_data - pefile- > rvamin) + (*this- > newiat + 2); if (dst_ptr < extracted_data) return 0; extracted_data_end = &extracted_data[extract_obj- > extractbuffer_bytes_written]; if (dst_ptr > extracted_data_end || &dst_ptr[ilen + 1] > extracted_data_end) return 0; strcpy(dst_ptr,p); // < -- memory corruption p += ilen; } } else if (*p == 0xFFu) { p += 3; *this- > newiat = ord_mask + *(uint16_t *)(p + 1); } else { // omitted } ++this- > newiat; }
这里的问题在于字符串dst_ptr和p可以重叠,因此覆盖了前面调用strlen()的字符串。这个问题可以将终止的空字节替换为非空字节,那么在调用strcpy()时,字符串就超过了预期长度,从而导致缓冲区溢出。
可行的修复方案是将strcpy(dst_ptr,p)替换为memmove(dst_ptr,p,ilen)。
看起来原始UPX也受到了这一漏洞的影响,我两次提交了14992260和1faaba8f,以尝试解决UPX开发分支中存在的问题。
0x07 未优化重定位表时存在堆缓冲区溢出
另一处内存损坏位于函数Packer::unoptimizeReloc中(参见Packer::unoptimizeReloc):
for (uint8_t * p = *in; *p; p++, relocn++) { if (*p > = 0xF0u) { if (*p == 0xF0u && !*(uint16_t *)(p + 1)) { p += 4; } p += 2; } } uint32_t * outp = (uint32_t *)malloc(4*relocn + 4); if (!outp) return -1; uint32_t * relocs = outp; int32_t jc = -4; for (uint8_t * p = *in; *p; p++) { if (*p > = 0xF0u) { uint32_t dif = *(uint16_t *)(p + 1) + ((*p & 0xF) * 0x10000); p += 2; if (dif == 0) { dif = *(int32_t *)(p + 1); p += 4; } jc += dif; } else { jc += *p; } *relocs = jc; // < -- oob write ++relocs; if (!packer- > extracted_data) return -1; if (bits == 32) { if (jc > packer- > extractbuffer_bytes_written - 4) return -1; uint32_t tmp = *(uint32_t*)&extracted_data[jc]; packer- > extracted_data[jc + 0] = (uint8_t)(tmp > > 24); packer- > extracted_data[jc + 1] = (uint8_t)(tmp > > 16); packer- > extracted_data[jc + 2] = (uint8_t)(tmp > > 8); packer- > extracted_data[jc + 3] = (uint8_t)tmp; } else { // omitted } }
我们忽略if分支的if (bits == 32),这部分看起来没问题。第一个循环进行遍历表格,直至遇到空字节,然后relocn计数有多少个条目。在此之后,分配大小为4 * relocn + 4的缓冲区,然后第二次遍历该表。
问题在于,if (bits == 32)的分支中,一个4字节值的字节顺序被交换,并且偏移量jc可以指向空字节所在的位置,因此可以将空字节转换为一个非空字节。如果发生这样的情况,很容易看出,在第二次循环中,变量p会比在第一个循环中增加更多,而由于导致分配的缓冲区过小,这将导致*reloc = jc最终超出范围。
似乎原始UPX也受到了这一漏洞的影响,我提交了e03310fc,以尝试解决UPX开发分支中存在的问题。
0x08 完成构建重定位表时存在堆缓冲区溢出
下一个内存损坏发生在函数PeFile::reloc::finish中(参见PeFile::reloc::finish):
*(uint32_t *)&start[4 * counts[0] + 1024] = this- > endmarker; qsort(start + 1024, ++counts[0], 4i64, le32_compare); rel = (reloc *)start; rel1 = (uint16_t *)start; for (ic = 0 ; ic < counts[0]; ++ic) { unsigned pos = *(int32_t *)&start[4 * ic + 1024]; if ((pos ^ this- > prev) > = 0x10000) { rel1 = rel1; this- > prev = pos; *rel1 = 0; rel- > size = ALIGN_UP(ptr_diff(rel1,rel), 4); //rel1 increased by up to 3 bytes next_rel = (reloc *)((char *)rel + rel- > size); rel = next_rel; rel1 = (uint16_t *)&next_rel[1];// rel1 increased by sizeof(reloc)==8 bytes next_rel- > pagestart = (pos > > 4) & ~0xFFF; } *rel1 = ((int16_t)pos < < 12) + ((pos > > 4) & 0xFFF); // < -- oob write ++rel1; }
在没有进入到内部if分支的情况下,ic < counts[0]的边界校验一切正常,因为此时可以保证rel1在索引4*ic+1024所表示的位置之前。但是,如果进入到if分支,则rel1的增加速度会比预期的要快。如果每次循环结束后原本应该增加2字节,那么进入到if分支后,会增加3个字节(由于ALIGN_UP(_,4)),并且另一个sizeof(reloc) == 8字节。
原始UPX同样受到这一漏洞的影响。通过查看原始代码,我们看到rel和rel1在调用函数PeFile::reloc::newRelocPos时产生了增加,该函数内嵌在上面的反编译代码片段中。我们在9月20日通知了UPX的开发人员,但目前尚无补丁程序。
0x09 构建导出表时存在堆缓冲区溢出
目前看来,引擎并没有真正完全重建导出表,而是仅做了一些基本的虚拟地址调整(与原始的PeFile::Export::build相比较):
uint32_t num_entries = export_dir_buffer[6]; // attacker-controlled uint32_t va_dif = export_dir_virtualaddress - outer_export_dir_virtualaddress; uint32_t table_base_offset = export_dir_buffer[8] - outer_export_dir_virtualaddress; export_dir_buffer[3] += va_dif; export_dir_buffer[7] += va_dif; export_dir_buffer[8] += va_dif; export_dir_buffer[9] += va_dif; for(uint32_t i=0; i < num_entries; i++) { if ((table_base_offset + 4*i > export_dir_buffer_size) || (table_base_offset + 4*i < export_dir_buffer_size)) // < -- what? goto LABEL_ERROR; *(uint32_t*)((uint8_t*)export_dir_buffer + table_base_offset + 4*i) += va_dif; // < -- oob write }
对table_base_offset + 4*i的边界检查看起来也有问题。这可能是一处笔误。即使忽略内存安全性,它也无法实现所需的功能。最多只能进行一次循环,并且在这一次循环中,有table_base_offset + 4*i == export_dir_buffer_size,从而会实现内存损坏(由攻击者控制的4字节整数越界写入)。
修复后的检查代码如下:
if ((table_base_offset + 4*i > = export_dir_buffer_size) || (table_base_offset + 4*i + 3 > = export_dir_buffer_size)) goto LABEL_ERROR;
由于最初的检查是将UPX的边界检查宏以错误的方式添加的,因此可能在那时引入了这一漏洞。
0x0A 重建资源时存在堆缓冲区溢出
最后,在PeFile::rebuildResource的末尾部分(参见PeFile::rebuildResource)存在内存损坏,其中已构造的资源被重新写回:
if (!*(int32_t *)(&extracted_data[*((int32_t *)pefile- > extracted_ntheader + 34) - pefile- > rvamin + 12])) { result = memcpy(&extracted_data[*((uint32_t*)pefile- > extracted_ntheader + 34) - (uint64_t)(uint32_t)pefile- > rvamin], p, res.dirsize()); }
在这里,没有对res.dirsize()进行边界检查。
而在原始的UPX代码中,包含omemcpy的检查。
0x0B 总结
我们发现,Bitdefender UPX脱壳程序的所有主要步骤都存在严重的内存损坏漏洞。有趣的是,实际的解压缩过程似乎是不受漏洞影响的唯一步骤。原因在于,引擎会使用无法产生内存损坏漏洞的API,将内容提取到动态大小的缓冲区(类似于std::vector),在此过程中使用的操作仅仅是append_byte(b)和append_bytes(buf,len)。
在几乎一般的案例中,根本没有进行临界的边界检查。而在其他大多数情况下,边界检查出现了一些问题。其中唯一图爱明显的内存损坏是0x06和0x07,因为它们是由于意外的重叠而引起的。
似乎0x06、0x07和0x08中的漏洞是从原始的UPX代码中继承而来的。我们已将问题反馈给UPX开发人员,目前正在进行修复。由于原始的UPX并不是用来解压缩不受信任的二进制文件(例如恶意软件)的,并且不能以较高的特权级别运行,因此这些漏洞在UPX中似乎不是那么重要。但是,将其放在反病毒引擎的场景中,就变成了非常危险的漏洞。
Bitdefender的引擎在非沙箱运行的情况下以NT\AUTHORITY SYSTEM权限运行,并且会自动扫描不受信任的文件,使其成为了远程代码执行的一个潜在目标。实际的漏洞利用还必须绕过ASLR和DRP(可能还有堆栈Canary)。此前对F-Secure反病毒产品的漏洞利用表明,即使在无法编写脚本的环境中,漏洞利用也并不是那么困难。
0x0C 披露时间
下面的时间线看起来可能有些混乱,因为我们发现漏洞的时间并未按照这篇文章介绍漏洞的先后顺序排列的。
2020-07-03 发现漏洞5
2020-07-08 漏洞5已修复,Bitdefender团队在我报告此漏洞之前就发现了这一问题
2020-07-15 发现并报告漏洞7
2020-07-16 发现并报告漏洞9
2020-07-20 Bitdefender发布漏洞7和9的补丁
2020-07-28 Bitdefender为漏洞7分配编号CVE-2020-15729
2020-08-09 发现并报告漏洞4
2020-08-09 发现并报告漏洞6
2020-08-09 发现并报告漏洞8
2020-08-12 Bitdefender发布漏洞4、6、8的补丁
2020-08-14 Bitdefender表示不再为每个相近类型漏洞分配CVE编号
2020-08-15 发现并报告咯多功能3
2020-08-16 发现并报告漏洞1
2020-08-17 Bitdefender发布漏洞1和3的补丁
2020-08-30 发现并报告漏洞10
2020-09-02 Bitdefender发布漏洞10的补丁
2020-09-04 由于漏洞8的补丁修复不完善,可能导致使用同一文件触发崩溃,已重新提交,要求彻底进行修复
2020-09-07 Bitdefender为漏洞8发布第二个补丁
2020-09-07 发现漏洞3的补丁修复不完善,要求彻底进行修复
2020-09-08 Bitdefender要求提供详细信息,已于当日提供触发漏洞的新文件
2020-09-08 发现漏洞8的第二个补丁仍然不完善,重新提交新文件
2020-09-10 Bitdefender发布漏洞3的第二个补丁和漏洞8的第三个补丁
2020-09-17 发现并报告漏洞2(未提交PoC文件)
2020-09-20 与UPX开发人员联系,提交漏洞6、7、8
2020-09-21 Bitdefender发布漏洞2的补丁
2020-09-30 漏洞3的第三个补丁不完善,提交触发漏洞的新文件
2020-09-30 漏洞2的补丁不完善,提交更详细的问题描述
2020-09-30 Bitdefender发布漏洞2的第二个补丁
2020-10-05 Bitdefender发布漏洞3的第四个补丁
2020-10-31 漏洞2的第二个补丁会检查初始值,但是引入了整数下溢出(导致完全由攻击者控制的堆缓冲区溢出),提交文件触发新漏洞
2020-11-02 Bitdefender发布漏洞2的第三个补丁
厂商对于上述所有漏洞提交(包括指出补丁不完善的后续提交)都支付了奖金。
0x0D 致谢
感谢Bitdefender团队修复了所有已报告的漏洞。此外,我还要感谢Alex Balan、Octavian Guzu和Marius Gherghinoiu为我提供了定期的状态更新。
本文翻译自:https://landave.io/2020/11/bitdefender-upx-unpacking-featuring-ten-memory-corruptions/如若转载,请注明原文地址