首页
社区
课程
招聘
[原创]病毒分析之shellcode的分析方法
2020-10-27 12:06 3455

[原创]病毒分析之shellcode的分析方法

2020-10-27 12:06
3455

        最近破事儿真多,一周七天有六天要实训,想出去实习都出不去,每天被迫营业。

        前几天逆恶意代码遇到了shellcode,我一想蠕虫头不都是shellcode利用漏洞加载自身吗,今天稍微总结一下shellcode的分析方法

       一般而言,shellcode(或者说蠕虫病毒)用三种方法获取被感染文件的执行控制权,

1  替换进入点(先将原来的地址保存)

2 进入点附近插入病毒码(就本人经验来讲,插入的地方大多都在第一个ret之前,很少有长跳转),用jmp或call跳到病毒的开头,如何识别那?与合法的函数调用相比,病毒不会用栈传递参数,用寄存器传递参数时,病毒仔细保存被修改的寄存器,当把控制权交给最初的函数时,病毒必须从栈顶移走返回地址,否则的话会有两个返回地址

3 修改导入表(节的stub插入,这里需要补充PE文件的相关知识,这里主要讲shellcode,读者可以参考《加密与解密》第四版关于PE文件的相关知识,另外可以看一下DLL注入部分的第一个方法,同样利用了stub插入,PE文件的静态修改实现,你猜对了,shellcode除了经常与漏洞一起使用以外,还可以用于进程注入)


从漏洞利用的角度:(就是从shellcode编写的角度,你要想更好的分析它可以先了解如何编写它,知己知彼,百战不殆嘛)


从漏洞利用的角度来讲编写shellcode要注意一些问题,在编写shellcode的时候有一些限制,在众多的限制中,最令人痛苦的是NULL字符,它在一个字符串中只能出现一次,也就是说,只能出现在字符串的末尾(对Unicode字符串无效),有效消除NULL的方法不止一种,这里说一个最实用的,即对shellcode进行编码,(就个人经验来看,一般都是用XOR,因为这样有一个好处,a xor b xor b ==a,只要选择一个合适的key,在选择这个key的时候要注意,一般用8bit长的key,这个key必须与被编码的shellcode的所有字节都不一样才行,如果8bit不行,就用16bit,32bit),把控制权传给编码的代码之前,必须先把他解码,这个任务由解码器处理,解码器要满足以下要求:

       1 尽量简洁(一般堆栈溢出漏洞shellcode可利用的空间只有几个字节,必须节省空间)

       2 解码器必须完全支持重定位

       3 解码器本身不能包含NULL字符


从反汇编的角度:

  

 将shellcode载入反汇编器,检查多种启动地址,在这些启动地址中,选择提供最有意义代码的那个,实现这个操作最好使用十六进制编辑器,IDA显得有点笨重,比如WinHEX,HIEW等等,前面讲过,shellcode大部分是加密后杂乱的数据,看起来有意义的其实是解密器。(有些蠕虫的作者可能会把解密器与蠕虫分开,然后故意在其中填充一些垃圾指令,让你误以为是解密器。


一:位置无关代码:

   PIC指不使用硬编码地址来寻址指令或数据的代码,shellcode必须是位置无关代码,因为在运行的时候,不同版本的程序会把shellcode加载到不同的位置,所以它不能使用硬编码,看几个例子

      mov edx,dword_407030           8B 15 30 70 40 00               

      jnz short loc_401044                 75 0e                                   

     mov eax,[ebp-4]                         8b 45 fc                              

     call  sub_401000                       e8 c1 ff ff ff                         


在这个例子中,只有第一个不是PIC,其他的都是位置无关代码,jmp和call指令都是位置无关的,这些指令的目标地址是把EIP+保存在指令中的偏移地址,(这里可以参考王爽老师的《汇编语言》第三版,后面部分讲的很详细

      

二:识别执行位置



   shell code在以位置无关的方式访问数据时,需要解引用一个基址指针,用这个基址指针加上或减去偏移值来访问shellcode中包含的数据,有两种方法解决这个问题


  1 使用call/pop指令

当一个call指令被执行的时候,处理器会把call后面的指令的地址压到栈上,然后转到被请求的位置进行执行,这个函数执行完的时候会执行一个ret命令用来把返回地址弹到栈顶病将他载入指令指针寄存器中。这样做的结果就是执行刚好返回到call后面的指令。shellcode可以通过在一个call后面的立刻执行pop指令来把这个指令黑化,让他pop一个我们想要让他去的地方


   

sub    esp, 20h
xor    edx, edx
call   sub_17
db     ‘Hello MyFriend!’

sub_17:
pop     edi                      ;edi gets string pointer
push    edx                      ;uType: MB_OK
push    edi                      ;lpCaption
push    edi                      ;lpText
push    edx                      ;hWnd: NULL
mov     eax, 7E4507EAh           ;MessageBoxA
call    eax                            
push    edx                      ;uExitCode
mov     eax, 7C81CAFAh           ;uExitProcess
call    eax

call指令将控制转移到sub_17函数,call指令将下一个返回地址压栈,pop指令将保存在栈顶的地址载入EDI,这种混合代码和数据的方法对shellcode来说很普遍,但是它能很容易使那些试图将call指令后面的数据作为代码进行解析的反汇编器困惑,结果妖魔是解析出来的反汇编代码很乱,要么遇到无效的opcode组合停止反汇编


   2 使用fnstenv指令

X87 FPU在普通的x86里面提供了一个隔离的环境,这个隔离环境里面包含了一个专用的单独的寄存器集合,当一个进程正在使用FPU执行浮点运算的时候,这些寄存器需要由操作系统上下文切换的时候保存,也就是fstenv指令和fnstenv指令。下面的结构体在32位保护模式中执行时用来保存FPU的状态到内存中。

  

struct FpuSaveState{
   uint32_t control_word;
   uint32_t status_word;
   uint32_t tag_word;
   uint32_t fpu_instruction_pointer;
   uint32_t fpu_instruction_selector;
   uint32_t fpu_opcode;
   uint32_t fpu_operand_pointer;
   uint16_t fpu_operand_selector;
   uint16_t reserved;};

这里唯一影响使用的域是在字节偏移量12处的fpu_instruction_point,这个变量会保留被FPU使用的最后一条CPU指令的地址,并为异常处理器标示哪条FPU指令可能导致错误的上下文信息。需要这个与是因为FPU和CPU是并行的,如果FPU抛出异常,异常处理器不能简单的通过参照中断返回地址来找到这个错误的指令,下面这个代码是使用fnstenv获取EIP中Hello FNSTENV程序的反汇编代码。


sub     esp, 20h
xor     edx, edx
jmp     short loc_1C
dd      7E4507EAh           ;MessageBoxA
dd      7C81CAFAh           ;ExitProcess
db      ‘Hello FNSTENV!’, 0


loc_1C:
fldz
fnstenv     byte ptr[ esp-0Ch ]
pop         ebx                             ;ebx point fldz
lea         edi, [ ebx-0Dh ]                ;load Hello FNSTENV pointer
push        edx                             ;uType MB_OK
push        edi                             ;lpCaption
push        edi                             ;lpText
push        edx                             ;hWnd: NULL
mov         eax, [ ebx-15h]                 ;load MessageBoxA
call        eax                             ;call MessageBoxA
push        edx                             ;uExitCode
mov         eax, [ ebx-11h]                 ;load ExitProcess
call        eax                             ;call ExitProcess

loc_1C处第一句fldz指令将浮点数0.0压到FPU栈上,fpu_instruction_pointer的值在FPU中被更新成指向fldz指令,接着执行fnstenv byte ptr[ esp-0Ch ],把FpuSaveState结构体保存到栈上的esp-0Ch处,这地方会允许shellcode在这一句执行一个pop指令把fpu_instruction_pointer的值载入ebx寄存器中,一旦这个pop执行,ebx会包含一个指向内存中fldz指令位置的值,之后shellcode会调用ebx作为一个基址寄存器访问嵌入到代码中的数据。

和之前call/pop一样,这个代码里面的7E4507EAh、7C81CAFAh也是需要找到相应的位置替换掉才可以取运行。

手动符号解析:

  shellcode作为一个获得执行的二进制代码块存在,一旦它获得执行,必须做些有用的事,这通常意味者通过API与系统交互,shellcode不能使用WIndos的加载器来确保所有需要的库被加载使用,也不能确认所有的外部符号依赖都被解决,它必须自己找到这个符号。shellcode必须动态定位这些函数,以确保它们在不同的环境中都能工作,这里要用到两个函数,LoadLibraryA和GetProcAddress

LoadLibraryA是加载指定的库,并返回一个句柄。而GetProcAddress函数则在库导出表中查找指定的符号名和序号。这两个函数都是从kernel32.dll中导出的,所以shellcode必须做以下事情:

 1 在内存中找到kernel32.dll

 2 解析kernel32.dll的PE文件,并且搜索这两个函数

找到kernell32.dll有三种方法,最常用的是使用PEB结构体

   原理:FS段选择器作为选择子指向当前的TEB结构,在TEB偏移0x30处是PEB指针,而在PEB偏移的0x0c处是指向PEB_LDR_DATA结构的指针,位于PEB_LDR_DATA结构偏移0x1c处,是一个叫 InInitialzationOrderModuleList的成员。它是指向LDR_MODULE链表结构中,相应的双向链表头部的指针,该链表加载的DLL的顺序是ntdll.dll,kernel32.dll,该成员所指的链表偏移0x08处是kernel32.dll的地址

 


PEB结构:


typedef struct _PEB {

// Size: 0x1D8

/*000*/ UCHAR InheritedAddressSpace;

/*001*/ UCHAR ReadImageFileExecOptions;

/*002*/ UCHAR BeingDebugged;

/*003*/ UCHAR SpareBool; // Allocation size/*004*/ HANDLE Mutant;

/*008*/ HINSTANCE ImageBaseAddress; // Instance

/*00C*/ VOID *DllList;/*010*/ PPROCESS_PARAMETERS *ProcessParameters;

/*014*/ ULONG SubSystemData;

/*018*/ HANDLE DefaultHeap;

/*01C*/ KSPIN_LOCK FastPebLock;

/*020*/ ULONG FastPebLockRoutine;

/*024*/ ULONG FastPebUnlockRoutine;

/*028*/ ULONG EnvironmentUpdateCount;

/*02C*/ ULONG KernelCallbackTable;

/*030*/ LARGE_INTEGER SystemReserved;

/*038*/ ULONG FreeList;/

*03C*/ ULONG TlsExpansionCounter;

/*040*/ ULONG TlsBitmap;

/*044*/ LARGE_INTEGER TlsBitmapBits;

/*04C*/ ULONG ReadOnlySharedMemoryBase;

/*050*/ ULONG ReadOnlySharedMemoryHeap;

/*054*/ ULONG ReadOnlyStaticServerData;

/*058*/ ULONG AnsiCodePageData;

/*05C*/ ULONG OemCodePageData;

/*060*/ ULONG UnicodeCaseTableData;

/*064*/ ULONG NumberOfProcessors;

/*068*/ LARGE_INTEGER NtGlobalFlag; // Address of a local copy

/*070*/ LARGE_INTEGER CriticalSectionTimeout;

/*078*/ ULONG HeapSegmentReserve;/

*07C*/ ULONG HeapSegmentCommit;

/*080*/ ULONG HeapDeCommitTotalFreeThreshold;

/*084*/ ULONG HeapDeCommitFreeBlockThreshold;

/*088*/ ULONG NumberOfHeaps;

/*08C*/ ULONG MaximumNumberOfHeaps;

/*090*/ ULONG ProcessHeaps;

/*094*/ ULONG GdiSharedHandleTable;

/*098*/ ULONG ProcessStarterHelper;

/*09C*/ ULONG GdiDCAttributeList;

/*0A0*/ KSPIN_LOCK LoaderLock;

/*0A4*/ ULONG OSMajorVersion;

/*0A8*/ ULONG OSMinorVersion;

/*0AC*/ USHORT OSBuildNumber;

/*0AE*/ USHORT OSCSDVersion;

/*0B0*/ ULONG OSPlatformId;

/*0B4*/ ULONG ImageSubsystem;

/*0B8*/ ULONG ImageSubsystemMajorVersion;

/*0BC*/ ULONG ImageSubsystemMinorVersion;

/*0C0*/ ULONG ImageProcessAffinityMask;

/*0C4*/ ULONG GdiHandleBuffer[0x22];

/*14C*/ ULONG PostProcessInitRoutine;

/*150*/ ULONG TlsExpansionBitmap;

/*154*/ UCHAR TlsExpansionBitmapBits[0x80];

/*1D4*/ ULONG SessionId;

} PEB, *PPEB;



typedef struct _PEB_LDR_DATA{

 ULONG Length; // +0x00

 BOOLEAN Initialized; // +0x04 

PVOID SsHandle; // +0x08

 LIST_ENTRY InLoadOrderModuleList; // +0x0c 

LIST_ENTRY InMemoryOrderModuleList; // +0x14

 LIST_ENTRY InInitializationOrderModuleList;// +0x1c

} PEB_LDR_DATA,*PPEB_LDR_DATA; // +0x24

实现代码

mov  eax,fs:[0x30]

mov  eax,[eax+0x0c]  

mov  esi ,[eax+0x1c]    

lodsd mov eax,[eax+0x08]        

ret

方法二:使用堆栈暴力搜索

原理: 在程序开始时,栈顶所指向的值是kernel32.dll所在页的某个高地址,我们采用向下搜索的方法,来查找。同时,在PE中的

image_optional_header中在偏移0x34处有kernel32.dll的地址。以下是image_optional_header的结构

 


实现代码:

start:   mov  edx,[esp]    

          dec   edx          mov  ebx,[edx+0x3c]  

          mov  ebx,[ebx+edx+0x34]    

         cmp  edx,ebx          jnz    start        

         ret                           ;此时在edx中的值为kernel32.dll地址


方法三:使用SEH的链表来查找

原理:在SEH中默认的unhandled exception hander 是利用kernel32.DLL中的一个函数设置的。因此可以历遍所有的exception hander, 找到最后一个成员,该成员的前4个字节是0ffffffffh,后4个字节是kernel32.dll中的函数

实现代码:

      mov esi,fs[0]

@@1:  lodsd

   cmp [eax],0xffffffff

   jne   @@1

   mov  eax,[eax+0x04]

@@2:  dec   eax

    xor    ax,ax

      cmp  eax word ptr[eax]  'MZ'

     jne    @@2

      ret

解析PE文件导出数据

  导出的数据存放在IMAGE_EXPORT_DIRECTORY中,一个相对IMAGE_EXPORT_DIRECTORY的RVA被保存在IMAGR_OPTIONAL_HEADER末尾部分

的IMAGE_DATA_DIRECTORY结构体数组中,IMAGE_DATA_DIRECTORY数组具体位置取决于这PE文件是32位的还是64位的,一般情况下,我们认为

shellcode在32位环境下运行,所以编译时候从PE特征值域到这个目录数组的正确偏移,计算方法如下:

sizeof(PE_Signature)+sizeof(IMAGE_FILE_HEADER)+sizeof(IMAGE_OPTIONAL_HEADER)=120字节

我们用一个图来表示IMAGE_EXPORT_DIRECTORY结构体中的相对域:


上面这个图中,AddressOfFunctions是一个RVA的数组,我们为了使用这个数组,这段shellcode必须得映射导出函数名到这个序号,并且shellcode的确是使用AddressOfNames和AddressOfNameOrdinals来这么干的,这两个数组同时存在,很轻松的就能看到这两个数组是直接相关的,AddressOfNames是一个32位的RVA数组,指向富豪命的字符串,AddressOfNameOrdinals是一个16位序号的数组,当一个给定的数组索引idx,AddressOfNames[idx]处的符号对应的导出序号就在AddressOfNames[idx]处,AddressOfNames数组是按照字母顺序排列的,这样的话写一个二分搜索就能够很快的找到一个给定的字符串,虽然这么说,大多数shellcode还是简单的线性查找。其实我们按照以下的步骤就可以找到一个符号的导出地址:

1.迭代AddressOfNames数组查看每个char *项目,然后把需要的符号的字符串做一个比较,直到找到一个匹配的项目,我们把这个AddressOfNames的索引称之为AddressOfNames iName。

2.在AddressOfNameOrdinals数组中使用IName索引,得到的值就是IOrdinal值。

3.使用IOrdinal索引到AddressOfFunctions数组,获得到的值就是被导出符号的RVA然后把这个值返回给请求者。





[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

最后于 2020-10-27 12:14 被Golden_Boy编辑 ,原因:
收藏
点赞2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回