前言
良好的习惯是人生产生复利的有力助手
本篇文章晚了两三天发,说明一下原因哈,最近在搞TSRC和青藤云的webshell挑战赛,时间被挤压了,文章会晚到,但不会缺席,继续今年的Flag。突然发现写完凌晨三点了,赶紧睡觉。。。
在上一篇文章中,我讲了认知方面的事情,主要包括 边界思想,复利模型和时间定价,算是这段时间读书的一个小分享,希望能对大家有一些启发,并应用到实践中。经常读一些思想方面的书,思考会逐渐变得深入全面,还是有很多不足,继续努力。
有一句话,我挺喜欢:
教育目的本质是改变自己,改变自己对经验的解读。
如果你买了很多书,买了很多课程, 想想这些到底有没有改变自己,自己是否在成长?
如果自己前后没有改变,那不就意味着为此付出的时间和金钱都浪费了吗?
因为焦虑去学习,只会让自己更加焦虑。
曾经读过曾国藩的传记,他对付太平天国的策略给我留下了深刻的印象,简单总结为六个字:
结硬寨,打呆仗
其实这是很多人不喜欢的“笨方法”,虽然注定能成功,可是觉得慢,觉得累,觉得克制,不过这确实是一条捷径:
认准目标,稳扎稳打,步步为营,不投机,始终掌握战略主动
不多说了,上周文章的最后预告了今天的内容是关于linux下的loader:用户态execve。
接下来会用 黄金思维圈 why-how-what 的方式来拆解这个问题。
why
做一件事情背后总有它根本的原因,不要被事情的表象迷惑。举个例子,老板让你给招聘会贴海报,那你直接去贴海报,其实有点单纯了。
同样,我不能直接给你摆出个linux下的loader,给答案是最没用的,对你们没有帮助,你们需要知道我研究它的原因和应用场景,以及我的思考过程。
研究用户态execve的实现,起初是从攻击的方向去思考的,在linux主机安全中,使用shell命令进行攻击是非常常见的场景,无论是横向移动,还是种马,很难不应用shell命令。
起初,我的想法是绕策略,可是想想这不通用,这家可以,到别家又不行。如果能避免被抓到shell命令,这应该是个通用的解决方案。
在之前的文章中,无"命令"反弹shell-逃逸基于execve的命令监控(上) 分享过关于shell命令的各种监控方式,其中最难绕过的是内核态的execve监控。
我的选择是抛弃execve系统调用来执行命令,而是思考自己实现用户态execve,这样就可以彻底摆脱命令监控,如果再延展一下,还会有更深层次的操作。
how
用户态execve 是仿照linux内核中execve syscall的原理 ,在应用层实现程序的加载和运行,如果做过windows pe loader的话,可以知道这其实是 linux下的elf loader,由于篇幅有限,只能捡最相关的进行讲解了。
在设计之前,需要回顾一下之前的旧知识,可以帮助你建立知识体系,同时指明接下来的方向。与之相关的技术栈:
1.shellcode执行
shellcode是一段可以直接在内存运行的二进制代码,执行shellcode的流程是首先申请一段可读可写可执行的内存,然后将这段代码复制到内存中,最后将eip指向内存的的首地址,即可完成运行。
elf loader的实现方式应该与此类似,通过复制文件到申请的内存,并将eip指针指向elf入口地址e_entry。
2.PE 加载器
之前发过一篇文章 PE to shellcode,其中讲到了如何将exe文件转化为可以在内存中执行的shellcode,里面用到的技术手段就是给普通的exe文件加一个shellcode壳,壳的作用是将exe文件加载到内存中,并完成一系列库的加载,最后将eip指针指向exe文件的执行入口。
初步的猜测,elf loader基本上也是完成这样的功能。
在对原来的相似知识进行回顾和思考后,心里大致有了实现方法,但这是不够的,还需要搜集一些这方面的资料,对心中的实现方法进行校准和补充。根据上述的思考,我们需要从以下四个方向去搜集资料和分析:
ELF,即 Executable and Linking Format,译为“可执行可连接格式”,具有这种格式的文件称为 ELF 文件。ELF 规范中把 ELF 文件宽泛地称为“目标文件 (object file)”,主要有三种类型:
由此可见,ELF文件结构考虑了两方面的内容:一个是链接,一个是运行。为了反映了两种活动的不同需求,目标文件格式提供了两种并行视图。
这两种视图都在一个文件里,可以简单理解为你写了程序,里面既有A功能也有B功能,执行链接的时候,使用A;实际运行的时候,使用B。
对我们elf loader 有用的是执行视图,毕竟我们是要将可执行文件加载到内存运行起来。在执行视图中,程序头部表对可执行文件是必须的,同时在执行的时候,是按照段进行加载的,节的意义不大。
程序头部表主要是用来告诉操作系统如何将可执行文件映射到内存。可执行文件程序头部是一个结构数组,每个结构描述了一个段或者系统准备程序执行所必需的其它信息。目标文件的“段”包含一个或者多个“节区”, 也就是“段内容(Segment Contents)”。程序头部仅对于可执行文件和共享目标文件有意义。
可执行目标文件在 ELF 头部的 ephentsize 和 ephnum 成员中给出其自身程序头部 的大小。程序头部的数据结构:
/* Program Header */typedef struct { Elf32_Word p_type; /* segment type */ Elf32_Off p_offset; /* segment offset */ Elf32_Addr p_vaddr; /* virtual address of segment */ Elf32_Addr p_paddr; /* physical address - ignored? */ Elf32_Word p_filesz; /* number of bytes in file for seg. */ Elf32_Word p_memsz; /* number of bytes in mem. for seg. */ Elf32_Word p_flags; /* flags */ Elf32_Word p_align; /* memory alignment */} Elf32_Phdr;
typedef struct { Elf64_Half p_type; /* entry type */ Elf64_Half p_flags; /* flags */ Elf64_Off p_offset; /* offset */ Elf64_Addr p_vaddr; /* virtual address */ Elf64_Addr p_paddr; /* physical address */ Elf64_Xword p_filesz; /* file size */ Elf64_Xword p_memsz; /* memory size */ Elf64_Xword p_align; /* memory & file alignment */} Elf64_Phdr;
其中各个字段说明:
可执行 ELF 目标文件中的段类型:
这样看有点不直观,以ls为例子,我们使用readelf -l /bin/ls的方式查看程序头表,图中标红的部分是对我们有意义的。
LOAD段是需要操作系统加载到内存的部分,而INTERP段则是存储链接器的位置,我们的ls所需要的链接器为/lib64/ld-linux-x86-64.so.2。大家会不会很奇怪为什么需要链接器?下面会揭晓。
程序装载是操作系统创建或扩充进程镜像的过程。进程空间如何构造,内存页面如何管理,以及进程如何被处理,不同的操作系统有不同的作法。
当系统创建或者扩充一个进程镜像时,逻辑上,它要把文件中的段复制成为虚拟内存中的一个段。但是系统不一定立刻真正地去读文件,什么时候读,还要依赖于程序的行为、系统负载等等。
为了提高了系统的性能和效率,可执行文件和共享目标文件中镜像在文件中的偏移量或者内存虚拟地址尽量是面向页面大小对齐。
对于 Intel 架构来说,页面的最大尺寸为 4KB,所以段的虚拟地址和文件内偏移量要向 4KB(0x1000)或者 4KB 的整数倍对齐,这样便于整页的换入换出,可以提高效率。以一个elf文件的程序头部表为例,包括代码段和数据段的内容,对齐属性p_align为4KB。
通过上表中的数据,计算出的文件偏移与内存偏移之间的关系如下图所示,其中不足一页的地方用0填充。
链接有两种方式,一种是静态链接,另一种是动态链接,这两种链接方式各有好处。
所谓静态链接就是在编译链接时直接将需要的执行代码拷贝到调用处,优点是在程序发布的时候就不需要依赖库,也就是不再需要带着库一块发布,程序可以独立执行,但是体积可能会相对大一些。
所谓动态链接就是在编译的时候不直接拷贝可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库可执行代码,最终达到运行时连接的目的。
假如是静态链接产生的elf可执行文件,则不需要这一步,完成装载阶段即可,只需最后将eip指向e_entry。
但是静态链接的程序相对较少,更多地是需要动态链接的程序,在linux 中很多程序都会依赖glibc,那谁来负责完成glibc的加载呢?这就涉及INTERP段了,里面包含了动态链接器的路径。
当创建一个可执行文件时,如果依赖其它的动态链接库,那么链接编辑器会在可执行文件的程序头中加入一个 PT_INTERP 项,告诉系统这里需要使用动态链接器,一般链接器为ld。
可执行文件与动态链接器一起创建了进程的镜像,这个过程包含以下活动(很重要!!!)。
因此如果程序是动态链接的,那在可执行程序运行之前,首先需要完成对链接器的装载并执行,之后的工作都交给链接器就好了。
上文说到链接器会对程序进行处理,并且最后将控制权交还给程序,那站在链接器的位置思考就会出现问题:
当操作系统把控制权交给链接器时,它将开始进行链接工作,那么它至少需要知道关于可执行文件与进程的一些信息,不然无法对已经映射好的可执行文件镜像进行重定位。
这就涉及一个对动态链接器很重要的信息:辅助信息数组。辅助信息的格式也是一个结构数组,它的结构被定义在“elf.h":
typedf struct{ uint32_t a_type; union { uint32_t a_val; } a_un;} Elf32_auxv_t;
摘录几个比较重要的类型值,这几个类型值都是比较常见的,而且是动态链接器在启动时所需要的:
在程序运行的时候,这些辅助信息很容易看到,通过设置环境变量 LD_SHOWAUXV=1:
通过上文的思考,可以知道elf loader需要实现三个方面的内容: