0x00 前言
UPX作为一个跨平台的著名开源压缩壳,随着Android的兴起,许多开发者和公司将其和其变种应用在.so库的加密防护中。虽然针对UPX及其变种的使用和脱壳都有教程可查,但是至少在中文网络里并没有针对其源码的分析。作为一个好奇宝宝,我花了点时间读了一下UPX的源码并梳理了其对ELF文件的处理流程,希望起到抛砖引玉的作用,为感兴趣的研究者和使用者做出一点微不足道的贡献。
0x01 编译一个debug版本的UPX
UPX for Linux的源码位于其git仓库地址https://github.com/upx/upx.git 中,使用git工具或者直接在浏览器中打开页面就可以获取其源码文件。为了方便学习,我编译了一个debug版本的UPX4debug
将UPX源码clone到本地Linux机器上后,我们需要修改/src/Makefile中的BUILD_TYPE_DEBUG := 0 为BUILD_TYPE_DEBUG = 1 ,编译出一个带有符号表的debug版本UPX方便后续的调试。此外,UPX依赖UCL算法库,ZLIB算法库和LZMA算法库。在修改完Makefile返回其根目录下输入make all进行编译时,编译器会报出如下错误提示:
按照提示输入命令 git submodule update --init --recursive后成功下载安装lzma,再次运行make all报错提示依赖项UCL未找到:
UCL库最后一次版本更新为1.03,运行命令
wget http://www.oberhumer.com/opensource/ucl/download/ucl-1.03.tar.gz 下载UCL源码,编译安装成功后再次运行make all,报错提示找不到zlib
wget http://pkgs.fedoraproject.org/re ... /zlib-1.2.11.tar.xz 获取最新版本的zlib库并编译安装成功后再次运行make all编译,编译器未报错,在/src/下发现编译成功的结果upx.out
这个upx.out保留了符号,可以被IDA识别,方便后续进行调试。
0x02 UPX源码结构
UPX根目录包含以下文件及文件夹
其中,README,LICENSE,THANKS等文件的含义显而易见。在/doc中目前包含了elf-to-mem.txt,filter.txt,loader.txt,Makefile,selinux.txt,upx.pod几项。elf-to-mem.txt说明了解压到内存的原理和条件,filter.txt解释了UPX所采用的压缩算法和filter机制,loader.txt告诉开发者如何自定义loader,selinux.txt介绍了SE Linux中对内存匿名映像的权限控制给UPX造成的影响。这部分文件适用于想更加深入了解UPX的研究者和开发者们,在此我就不多做介绍了。
我们在这个项目中感兴趣的UPX源码都在文件夹/src中,进入该文件夹后我们可以发现其源码由文件夹/src/stub,/src/filter,/lzma-sdk和一系列*.h, *.cpp文件构成。其中/src/stub包含了针对不同平台,架构和格式的文件头定义和loader源码,/src/filter是一系列被filter机制和UPX使用的头文件。其余的代码文件主要可以分为负责UPX程序总体的main.cpp,work.cp和packmast.cpp,负责加脱壳类的定义与实现的p_*.h和p_*.cpp,以及其他起到显示,运算等辅助作用的源码文件。我们的分析将会从main.cpp入手,经过work.cpp,最终跳转到对应架构和平台的packer()类中。
0x03 加壳前的准备工作
在上文中我们提到分析将会从main.cpp入手。main.cpp可以视为整个工程的“入口”,当我们在shell中调用UPX时,main.cpp中的代码将对程序进行初始化工作,包括运行环境检测,参数解析和实现对应的跳转。
我们从位于main.cpp末尾的main函数开始入手。可以看到main函数开头的代码进行了位数检查,参数检查,压缩算法库可用性检查和针对windows平台进行文件名转换。从1516行开始的switch结构针对不同的命令cmd跳转至不同的case其中compress和decompress操作直接break,在1549行注释标注的check options语句块后,1565行出现了一个名为do_files的函数。
int __acc_cdecl_main main(int argc, char *argv[])
{
......
/* check options */
......
/* start work */
set_term(stdout);
do_files(i,argc,argv);
......
return exit_code;
}
do_files()的实现位于文件work.cpp中。work.cpp非常简练,只有do_one_file(), unlink_ofile()和do_files()三个函数,而do_files()几乎由for循环和try…catch块构成
void do_files(int i, int argc, char *argv[])
{
......
for ( ; i < argc; i++)
{
infoHeader();
const char *iname = argv;
char oname[ACC_FN_PATH_MAX+1];
oname[0] = 0;
try {
do_one_file(iname,oname);
}......
}
......
}
从for循环和iname的赋值我们可以看出UPX具有操作多个文件的功能,每个文件都会调用do_one_file()进行操作。
继续深入do_one_file(),前面的代码对文件名进行处理,并打开了两个自定义的文件流fi和fo,fi读取待操作的文件,fo根据参数创建一个临时文件或创建一个文件,这个参数就是-o. 随后函数获取了PackMaster类的实例pm并调用其成员函数进行操作,在这里我们关心的是pm.pack(&fo)。这个函数的实现位于packmast.cpp中。
packMaster::pack()非常简单,调用了getPacker()获取一个Packer实例,随后调用Packer的成员函数doPack()进行加壳。跳转到getPacker()发现其调用visitAllPakcers()获取Packer
Packer *PackMaster::getPacker(InputFile *f)
{
Packer *pp = visitAllPackers(try_pack, f, opt, f);
if (!pp)
throwUnknownExecutableFormat();
pp->assertPacker();
return pp;
}
跳转到函数visitAllPackers()中,我们发现获取对应平台和架构的Packer的方法其实是一个遍历操作,以输入文件流fi和不同的Packer类作为参数传递给函数指针类型参数try_pack,通过函数try_pack()进行判断。
Packer* PackMaster::visitAllPackers(visit_func_t func, InputFile *f, const options_t *o, void *user)
{
Packer *p = NULL;
......
// .exe
......
// atari
......
// linux kernel
......
// linux
if (!o->o_unix.force_execve)
{
......
if ((p = func(new PackLinuxElf64amd(f), user)) != NULL)
return p;
delete p; p = NULL;
if ((p = func(new PackLinuxElf32armLe(f), user)) != NULL)
return p;
delete p; p = NULL;
if ((p = func(new PackLinuxElf32armBe(f), user)) != NULL)
return p;
delete p; p = NULL;
......
}
// psone
......
// .sys and .com
......
// Mach (MacOS X PowerPC)
......
return NULL;
}
当且仅当其返回true,函数不返回空,此时visitAllPackers()的对应if分支被执行,packer被传递回PackMaster::pack()执行Packer::doPack()开始加壳。
跳转到位于同一个源码文件下的函数try_pack()
static Packer* try_pack(Packer *p, void *user)
{
if (p == NULL)
return NULL;
InputFile *f = (InputFile *) user;
p->assertPacker();
try {
p->initPackHeader();
f->seek(0,SEEK_SET);
if (p->canPack())
{
if (opt->cmd == CMD_COMPRESS)
p->updatePackHeader();
f->seek(0,SEEK_SET);
return p;
}
} catch (const IOException&) {
} catch (...) {
delete p;
throw;
}
delete p;
return NULL;
}
try_pack()调用了Packer类的成员函数assertPacker(), initPackHeader(), canPack(), updatePackHeader(),在其中起到关键作用的是canPack().通过查看头文件p_lx_elf.h,p_unix.h和packer.h我们发现PackLinuxElf64amd()位于一条以在packer.h中定义的类Packer为基类的继承链尾端,assertPacker(), initPackHeader()和updatePackHeader()的实现均位于文件packer.cpp中,其功能依次为断言一些UPX信息,初始化和更新一个用于加壳的类PackHeader实例ph.
0x04 Packer的适配和初始化
通过对上一节的分析我们得知Packer能否适配成功最终取决于每一个具体Packer类的成员函数canPack().我们以常用的Linux for AMD 64为例,其实现位于p_lx_elf.cpp的PackLinuxElf64amd::canPack()中,而Linux for x86和Linux for ARM的实现均位于PackLinuxElf32::canPack()中,从visitAllPackers()的代码中我们也可以看到UPX当前并不支持64位ARM平台。
我们接下来将以Linux for AMD 64为例进行代码分析,并在每一个小节的末尾补充Linux for x86和Linux for ARM的不同之处。我们从PackLinuxElf64amd::canPack()开始:
PackLinuxElf64amd::canPack()
{
第一部分代码,该部分代码主要是对ELF文件头Ehdr和程序运行所需的基本单位Segment的信息Phdr进行校验。代码读取了文件中长度为Ehdr+14*Phdr大小的内容,首先通过checkEhdr()将Ehdr中的字段与预设值进行比较,确定Phdr数量大于1且偏移值正确,随后对Ehdr的大小和偏移进行判定,判定Phdr数量是否大于14,最后确定第一个具有PT_LOAD属性的segment是否覆盖了整个文件的头部。
union {
unsigned char buf[sizeof(Elf64_Ehdr) + 14*sizeof(Elf64_Phdr)];
//struct { Elf64_Ehdr ehdr; Elf64_Phdr phdr; } e;
} u;
COMPILE_TIME_ASSERT(sizeof(u) <= 1024)
fi->readx(u.buf, sizeof(u.buf));
fi->seek(0, SEEK_SET);
Elf64_Ehdr const *const ehdr = (Elf64_Ehdr *) u.buf;
// now check the ELF header
if (checkEhdr(ehdr) != 0)
return false;
// additional requirements for linux/elf386
if (get_te16(&ehdr->e_ehsize) != sizeof(*ehdr)) {
throwCantPack("invalid Ehdr e_ehsize; try '--force-execve'");
return false;
}
if (e_phoff != sizeof(*ehdr)) {// Phdrs not contiguous with Ehdr
throwCantPack("non-contiguous Ehdr/Phdr; try '--force-execve'");
return false;
}
// The first PT_LOAD64 must cover the beginning of the file (0==p_offset).
Elf64_Phdr const *phdr = phdri;
for (unsigned j=0; j < e_phnum; ++phdr, ++j) {
if (j >= 14)
return false;
if (phdr->T_LOAD64 == get_te32(&phdr->p_type)) {
load_va = get_te64(&phdr->p_vaddr);
upx_uint64_t file_offset = get_te64(&phdr->p_offset);
if (~page_mask & file_offset) {
if ((~page_mask & load_va) == file_offset) {
throwCantPack("Go-language PT_LOAD: try hemfix.c, or try '--force-execve'");
// Fixing it inside upx fails because packExtent() reads original file.
}
else {
throwCantPack("invalid Phdr p_offset; try '--force-execve'");
}
return false;
}
exetype = 1;
break;
}
}
第二部分代码,从两段长注释中我们可以看出UPX仅支持对位置无关(PIE)的可执行文件和代码位置无关(PIC)的共享库文件进行加壳处理,然而可执行文件和共享库都(可能)具有ET_DYN属性,理论上没有办法将他们区分开。作者采用了一个巧妙的办法:当文件入口点为
__libc_start_main,__uClibc_main或__uClibc_start_main之一时,说明文件依赖于libc.so.6,该文件为满足PIE的可执行文件。因此该部分通过判定文件是否具有ET_DYN属性,若是则在其重定位表中搜寻以上三个符号,满足则跳转至proceed标号处
// We want to compress position-independent executable (gcc -pie)
// main programs, but compressing a shared library must be avoided
// because the result is no longer usable. In theory, there is no way
// to tell them apart: both are just ET_DYN. Also in theory,
// neither the presence nor the absence of any particular symbol name
// can be used to tell them apart; there are counterexamples.
// However, we will use the following heuristic suggested by
// Peter S. Mazinger <[email]ps.m@gmx.net[/email]> September 2005:
// If a ET_DYN has __libc_start_main as a global undefined symbol,
// then the file is a position-independent executable main program
// (that depends on libc.so.6) and is eligible to be compressed.
// Otherwise (no __libc_start_main as global undefined): skip it.
// Also allow __uClibc_main and __uClibc_start_main .
if (Elf32_Ehdr::ET_DYN==get_te16(&ehdr->e_type)) {
// The DT_STRTAB has no designated length. Read the whole file.
alloc_file_image(file_image, file_size);
fi->seek(0, SEEK_SET);
fi->readx(file_image, file_size);
memcpy(&ehdri, ehdr, sizeof(Elf64_Ehdr));
phdri= (Elf64_Phdr *)((size_t)e_phoff + file_image); // do not free() !!