elva

Do all in one exe file Under Win32

Do all in one exe file Under Win32

icelord @ 2006

    希望题目没有语法错误。
    Exe可执行,可以使用系统提供的各种服务,do all in one exe看起来是句废话。
很早就看过高手写过的文章,<<脚本的故事>>、<<do all in smd shell>>,今天我也来班门弄斧一把。做后门、木马,现在的技术不知道是什么样的,但是个人认为将他们做到内核之中,将更有威力。当然,这也是把双刃剑。
见过一些rootkit,通常做法是写驱动,然后将驱动包含在exe中,运行时释放驱动为一个独立文件,然后加载驱动,由驱动完成Rootkit的各种功能,这样就会生成两个文件。为什么不把它们在一个exe文件中实现呢?今天我们就试验一下,能不能在exe中实现驱动的功能。
其实这个想法来自一个程序。当时想试验一下arp攻击,还以为win32下有相关的系统调用,google了一把,不,baidu了一把,说win32下发包IpPacket到可以,Arp只能用IpHlpApi提供的SendARP()函数发送(可能孤陋寡闻),而且不能手动构造arp包,而且SP2下发送TCP SYN是不允许的。晕。借助工具的话就要使用WinPcap,或者自己写驱动。我当时想是否能像Fport那样,直接操作AFD(好像是TDI,还是\device\tcp udp?),会不会能成功。看了看网上流传的源码,好复杂。WinPcap导出的调用很简单,而且linux下可以平滑过渡。不过,我的初始想法是一个比较隐蔽的程序,这就要求它有很高的独立性。所以,很自然的落到了exe驱动上来。
第一个想法:在exe中导出DriverEntry,看了看sys文件,好像没有这个导出函数,那么程序入口就是DriverEntry,但是win32下exe的ImageBase是4MB,驱动好像是0x10000,不知道win32在加载时会不会对程序作重定位。将EXE的入口修改为DriverEntry…,然后在入口函数中作判断,是用户态还是ring0。好像VC的编译器会在入口加入xxx代码,这样很麻烦,而且相当麻烦。以上证明这个想法暂时不对。不过突然有个想法,程序里面包含main 和DriverEntry,入口为main,运行后修改入口为DriverEntry,然后加载驱动…,弊端很多…
第二个想法,在exe中执行ring0代码。这是老想法了,网上有无驱执行ring0的源码,我当初也装模作样的分析了一下。具体的实现方法是使用\Device\PhysicalMemory对象映射物理内存并修改(GDT,IDT,或者hook内核函数),对此有篇文章有精彩的解说(baidu关键字'/dev/kmem ring0')。个人觉得hook内核函数比较简单快速,进入ring0后不需要作任何环境修改。如果使用中断门或者调用门,构造就很麻烦,而且好像在哪见过门的段地址要自己重新构造…。没试验过,仅是猜测。
具体思路:
(1)   使用ZwQuerySystemInformation获取ntoskrnl.exe的基址
(2)   用户态加载ntoskrnl.exe,获取内核api相对于它所在模块基址的偏移,加上内核镜像的基址获得真正的api的地址,计算需要使用的Kernel API的地址。
(3)   将api地址减去0x80000000(NT使用flat内存映射,前512Mb物理内存直接映射到2GB,不知道打开/3GB开关是什么情况,暂不考虑),获得api的物理内存地址
(4)   映射物理内存并写入hook代码
(5)   调用Kernel API对应的NaiveAPI,进入ring0
(6)   系统调用在当前进程的内存空间执行,所以可以使用整个进程空间,直接调用(2)计算的KernelAPI完成任务(注意Irql)
看到这里,问题似乎解决了。但是,以上方法只能执行ring0代码并不能实现驱动所实现的功能,功能还非常弱,对于一个简单的MemoryDump/ProcessList还可以,其它很多都受限制。由于代码运行于进程用户内存空间,使用内核栈,如果在别的进程空间调用将KeBugCheck之类的…。很自然,我们会想到把代码拷贝到内核空间去运行。这的确是解决方法。
第一种方案:由于KernelAPI大部分由ntoskrnl.exe和HAL.DLL导出,我们可以计算这些导出函数的地址,调用ExAllocatePool()分配空间(设pKeApiSet),拷贝。然后使用同样的方法来拷贝函数代码到内核,然后将pKeApiSet作为拷贝函数的参数,调用,这样代码就运行在内核空间了,但是还是只能在当前线程的时间片下运行。如果向其它驱动注册回调函数就麻烦了,因为这些函数都有固定的格式,而且调用时无法取得我们自己的参数。使用过下面的方法解决了这个问题:
//===========================================
//修改堆栈,对于_fastcall没试过 !!!!!
#define X_RELOC_CODE "\xFF\x34\x24"\
    "\xC7\x44\x24\x04\x00\x00\x00\x00"

//若想对齐,此代码后面可以加入 nop nop
//若想跳转,此处加入push addr;   ret即可,好像不能直接jmp abs_addr??(VC)
//vc编译使用相对地址
//  __asm push dword ptr [esp]
//  __asm mov dword [esp+4],lpParam

#define X_RELOC_HEADER_SIZE     11
#define X_RELOC_CODE_SIZE       11

typedef struct
{
    char b1[7];
    void *lpParam;
}X_RELOC_HEADER;

//=================================================

X_RELOC_HEADER *pCode=( X_RELOC_HEADER *)__KeApiSet.ExAllocatePool(NonPagedPool,UPALIGN(size+ X_RELOC_CODE_SIZE,4096));

Memcpy((char *)pCode, X_RELOC_CODE, X_RELOC_CODE_SIZE);
pCode->lpParam=(void *)&__KeApiSet;
//x_size>=MyCallback函数的大小
memcpy((void *)((ULONG)pCode+11),(void *)MyCallback,x_size);

//这里将pCode注册到其它驱动或者对象的回调函数去,对于新的回调函数添加一个参数(指向__KeApiSet)在最左边。
例如,原来的回调函数为
    OriginalCallback(Type1 Param1,Type2 Param2….)
新的回调函数如下
    MyCallback(void *lpParam, Type1 Param1,Type2 Param2…)
其实就是在调用函数时修改函数堆栈
-------------------------------------------------------------
                [堆栈状态]
NewCallback '   |EIP        |    OriginalCallback'  |EIP    |
                |MyParam    |                       |Param1 |
                |Param1     |                       |Param2 |
                |Param2     |
-------------------------------------------------------------
CodeAddr'               NewCodeLayout'
|push ebp       |               |push dword ptr [esp]       |
|mov ebp,esp    |               |movdword [esp+4],xParam    |
|sub esp,xxxx   |               |push ebp                   |
|   ….          |               |mov ebp,esp                |
-------------------------------------------------------------

还有一种方法是使用EIP定位。Linux下的动态库有一种浮动代码的技术,其中就用到了使用EIP定位,原理如下
Call Nextaddr___        //?push eip(=Nextaddr___)  +   jmp addr
Nextaddr___:                //
    Pop ebx             //ebx=EIP
在刚进入函数时获取EIP值,将参数拷贝到代码前面,eip减去一个数值就可以找到参数。不过这样好象有点麻烦。

    后来想起来VC编译器支持一种naked函数,用这个函数__declspec(naked)写就可以了,白白花费了这么长时间。

    应用:使用第一种方法(代码拷贝到内核空间,修改堆栈),在进入ring0后,可以注册一个回调函数到IPFilterDriver,实现一个简单的Ip过滤,美其名曰:"放火墙"。IoGetDevicePointer'IoBuildDeviceIoControlRequest'IofCallDriver。细节可以看网上的高手写的xxxWndows下防火墙。

    第三种方法,好像有篇文章说可以使用ZwSetSystemInformation(SysLoadAndCallImage),可以直接加载并运行,而且有种rootkit就使用了这种方法。具体没有看过,可以到网上看看。

-------------------------------------------------------

    本来到这里基本算结束了,但是上面的方法只能实现简单的功能,而且代码有点复杂,对于第二种方法,特别是函数调用,必须使用额外参数来定位。这样还不如单独写一个sys文件快。这时我考虑到了代码重定位,于是baidu一下,找到了局部变量大哥写的《NT环境下进程的隐藏》,如获珍宝,自己按着方法把代码重写了一遍,学到不少。其实PE教程说的很明白,就是看不下去…。
    对EXE进行重定位方法我就不多说了。具体方法就是在内核空间中分配代内存,拷贝自身镜像到内核,然后对内核空间的镜像作重定位。由于进入ring0后,在win2k下还可以调用用户态API(printf()还可以使用,在当前进程空间),在xp下调用会导致进程退出,估计对调用前后状态或者地址作了限制。
    有了重定位,就可在函数中使用全局变量,这样对函数的限制大大减小。但还有个问题,就是对kernel API的调用还是使用的显式调用,非常麻烦,对每个函数都要GetProcAddress(),于是我将ntoskrnl.lib和hal.lib加入到程序中,原本以为这样就可以了,可是编译通过,程序根本运行不起来,运行便出错。不知道是机器上的VC有问题,还是…,估计是加载EXE时,回加载它所使用的所有库,估计在加载ntoskrnl.exe时出错了。我手动修改PE的导入表,去掉ntoskrnl.exe模块,程序便能正常运行。
卡到了这里,决定使用先编译,再用工具修改导入表,去掉ntoskrnl.exe的引用。方法就是将ntoskrnl.exe的描述符放到导入表的最后一项,然后将ntoskrnl.exe的IMAGE_IMPORT_DESCRIPTOR拷贝到程序的其他地方(就像病毒那样,将代码写入到PE的间隙处,VC默认对齐大小是4096,这样会有很多空隙),然后将这项置零。这样加载时就不会加载ntoskrnl.exe。说起来容易,实际根本不可行,也没试,因为我查到了VC编译器的延时加载功能(/DelayLoad:dll_name)。
    通常在调用dll导出函数时,编译器为所调用的函数生成导入地址表,将函数所在模块生成IMAGE_IMPORT_DESCRIPTOR,win32在加载程序时会自动加载指定的模块,并确定导入函数的实际地址。DelayLoad将所调用的导出函数生成一个stub,类似如下:
Pid=PsGetCurentProcessId();
Call 40E68E //这里使用了Debug模式/增量编译,所以会有个跳转
            //release/static/GZ估计为call dword ptr[xxxxxxxx]
-------------------------------------------------------------------------------
0040E682    Push ecx
Push edx
Push 00429364       //压入IAT地址
Jmp 40E66E
-------------------------------------------------------------------------------
0040E68E    jmp dword ptr [00429364]//'0040E682
-------------------------------------------------------------------------------
0040E66E    Push 00429000   //压入PCImgDelayDescr地址,类似导入表
Call 00401087   //__delayLoadHelper(pImgDelayDesc,ppfnIATEntry)
Pop edx         //__ delayLoadHelper计算API真正地址,填入IAT
Pop ecx
Jmp eax         //eax=Real Address==[IAT]
-------------------------------------------------------------------------------
由于PCImgDelayDescr被放置在单独的延时输入描述符目录中,而不是通常的输入表目录,所以win32在加载时,这些延时加载的模块不会被加载。第一次调用函数时IAT间接指向__delayLoadHelper函数,而__delayLoadHelper根据函数所在模块名和函数名计算函数地址,然后写会IAT,下次执行直接跳转到真正的函数地址上去。
    DelayLoad的这种特性正好解决了当前的问题。方法就是连接ntoskrnl.lib和hal.lib,然后将ntoskrnl.exe和hal.dll延时加载,自己重写__delayLoadHelper函数来取得KernelAPi的地址,这样问题迎刃而解了。VC6自己的__delayLoadHelper函数在文件DELAYHLP.CPP中实现,其中加入了对函数Hook的功能,就是导出两个函数指针,在__delayLoadHelper中先调用指针指向的函数,若为空怎使用默认的LoadLibrary()和GetProcAddress()。这样如果想hook函数只需要将模块延时加载然后自己实现__pfnDliNotifyHook即可。
    问题:
(1)   由于在内核之中,(同一进程空间)不能调用win32API (XP下不行,2K下可以,注意:非GUI API),如果还可能在其它进程空间使用,则根本不能使用UserAPI
(2)   经过重定位的镜像在获取API名称时有问题,镜像基址大于0x80000000,地址的最高位肯定为1,这在PE中与函数的INT冲突,信息会丢失。
解决方法:
(1)   其实使用的API就是LoadLibrary() 和GetProcAddress,重写LoadLibrary和GetProcAddress函数即可。对于LoadLibrary(),只需要调用ZwQuerySystemInformation获得模块的基址即可;对于GetProcAddress,根据模块镜像的基址找到IMAGE_NT_HEADERS->IMAGE_EXPORT_DIRECTORY逐个查找即可,注意函数使用序号(ordinal)的查找。
(2)   WIN32 PE文件的导入表和延时加载描述符表都有各自的INT和IAT(暂时这样理解),对于函数地址的确定都使用类似的机制。从INT获得函数名称或者函数ordinal,然后从模块里查找函数地址,填入IAT,只是确定函数地址时间有不同。对于函数名称,,PE使用IMAGE_THUNK_DATA来描述,它其实是一个DWORD指针,指向一个DWORD数组,对于数组中的每个DWORD,如果最高位为1(&IMAGE_ORDINAL_FLAG32),则此函数按序号引入,否则此DWORD指向一个IMAGE_IMPORT_BY_NAME结构,此结构包含函数名称字符串的RVA。普通Win32进程运行在0-2GB中,所以这没问题。但我们的镜像被拷贝到0xFExx xxxx ,重定位后IMAGE_THUNK_DATA的地址肯定>0x80000000,所以只能根据经验,全部按名称引入,理由是NT、95的函数序号并不一致(从书上看的,很简单,xp导出的函数比2K多,如果按序号肯定出问题)。就在这个地方,没有调试器,只有printf,浪费了两天的时间,晕。还以为是VC自带的代码决不会有问题,重写__delayLoadHelper后解决(__delayLoadHelper对函数Ordinal & IMAGE_ORDINAL_FLAG做了判断…)。

呵呵,终于到主题了,其实前面的要困难些。
当代运行在ring0时,它已经能实现多数功能了,但是如果要作个木马,后门之类的,还要解决与用户态通信的问题(其实并不是这样),所以想到了事件之类的,或者ring0 call ring0,apc…最后还是落到了文件设备上。通过文件设备,任何进程都可以与ring0通信,而且创建驱动的宿主可以安全撤退。如果使用ring0 代码(call gate,hook),则进程不能退出,因为线程的系统调用没有返回,所以即使强行中止也无济于事。如果通过ring0代码创建一个驱动,然后ring0返回到ring3,进程就可以安全退出。而这时的代码还驻留在内核的未分页区,以回调的方式响应请求。
    学过操作系统都知道,VFS的特性,就是所有设备都是用文件来表示,其实质就是分层的函数调用,使用注册机制来处理各种设备之间的差异。实际实现表现为一个文件对象对应一个设备对象,同时设备对象又对应一个驱动对象,不同的i/o请求通过文件对象分发到不同的设备,进而转发到设备对应的驱动对象,由驱动完成最终请求。这种分层的设计有何多优点,特别适合扩展和抽象,下面深略3000字。
    要创建驱动,无非就是创建一个驱动对象,创建一个设备对象,注册函数到设备对象,然后创建设备链接以使win32可以访问到设备。看win2K 源码(\private\ntos\io\internal.c),驱动加载的基本过程为

+-->'一堆注册表操作
+-->'取得驱动文件名
+-->'MmLoadSystemImage()加载驱动到内存
+-->'ObCreateObject()创建驱动对象
+-->'对驱动对象的各成员初始化
+-->'ObInsertObject()
+-->'ObReferenceObjectByHandle()    //取得对象指针
+-->'NtClose()                  //关闭ObInsertObject()创建的句柄
+-->'驱动名称操作..
+-->'status = driverObject->DriverInit( driverObject, &registryPath->Name );
+-->//调用驱动入口DriverEntry进行驱动初始化
+-->'检查驱动对象的合法性(MajorFunction函数,驱动是否创建设备…)
+-->'IopBootLog()
+-->'MmFreeDriverInitialization()
+-->'IopReadyDeviceObjects()    //VIP!!!!!

有了上面的过程,基本可以自己手动创建驱动并加载了,看了看搜索结果,发现ntoskrnl.exe导出了IoCreatDriver这个函数,其中实现了IopLoadDriver的大部分功能,事情变得简单起来。
NTKERNELAPI
NTSTATUS
IoCreateDriver (
    IN PUNICODE_STRING DriverName,   OPTIONAL
    IN PDRIVER_INITIALIZE InitializationFunction
    );
    具体方法就是:用exe中的DriverEntry做为参数,调用IoCreateDriver,即可实现驱动的加载。
    当在win2K下运行程序后,用WinObj居然打开失败。晕,再次看IoLoadDeiver,才发现漏掉了一个重要地方,创建驱动时,驱动对象的标志Flags和所创建设备的标志都有限制,使用IopReadyDeviceObjects()来添加驱动DRVO_INITIALIZED标志和,去掉驱动创建的所有设备的DO_DEVICE_INITIALIZING标志。IopReadyDeviceObjects()函数没有被ntoskrnl.exe导出?简单,自己写就行了。曾找不到问题原因时,还重写过IopfCompleteRquest和IopCompleteRequest,那才叫痛苦呢。
    Finally,搞定。为了方便,写了一个库,下次使用时就简单多了

#include "DrvHlpApi.h"
NTSTATUS Driverentry(void *pDriverObject,void *pRegPath)
{
//  ….IoCreateDevice()..
    Return STATUS_SUCCESS;
}

Int main(int argc.char **argv)
{
    x_InitRing0Utils();
    x_StartDriver((ULONG)DriverEntry,0,0);
    return 0;
}
使用这个方法,完全可以实现驱动的所有功能,同时将它与win32程序结合在一起。在XP SP2和win2k SP4下测试通过(就试过IpFilter那个)。就这么简单。有兴趣的朋友可以mail来取得代码,头文件和库可以在
http://icelord.bokee.com下载到。

参考文章:
1.《NT环境下进程的隐藏》
2.Win2K Source Code
3.PE文件格式详解
4.Ring0Demo.c v1.0 by zzzEVAzzz
5.


posted on 2007-05-23 19:12 叶子 阅读(460) 评论(0)  编辑 收藏 引用 所属分类: 技术研究


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理