实战dll注入(原理, 踩坑及排雷)

摘要

  • 使用vs2019编写注入器程序, 在生成的注入器可用前, 踩了不少坑, 因此记录一下.
  • 本文涉及三种恶意代码注入方法: 直接dll注入, 反射式dll注入, 镂空注入. 之所以选这三种注入方法, 是因最近在做一个检测进程内存空间以期发现代码注入的程序, 而实验发现这三种方法对目标进程的改变各有特点:
    • 直接dll注入: 还有APC注入, 本质都是在目标进程中执行LoadLibrary函数, 因而在枚举进程的模块列表时可看到注入的dll.
    • 反射式dll注入: 这种方法也会在目标进程中开辟新的内存空间并写入代码. 但因为没有调用LoadLibrary, 所以枚举目标进程的模块列表并不能看到注入的dll. (意味着目标进程的PEB没有变化)
    • 镂空注入: 直接改进程中某一模块的内存空间, 或者先注入一个合法模块, 再镂空该模块.
  • 标了注: 的地方基本都是踩的坑.

直接注入

  • 示例: LibSpy项目的OnMouseMoveInjectDll
  • 流程:
    • OpenProcess打开目标进程(一参为PROCESS_CREATE_THREAD|PROCESS_QUERY_INFORMATION|PROCESS_VM_OPERATION|PROCESS_VM_WRITE| PROCESS_VM_READ, 后面CreateRemoteThread才能执行)
      • 需要给进程提权, 得到SeDebug权限.
        0. 注: 准确地说不是提权, 而是将访问令牌中禁用的权限启用. 成功使用下面这些函数的前提是进程具备该权限, 只是访问令牌中没有启用该权限. 而如果进程没有该权限, 则使用下面的函数后再调用GetLastError会返回ERROR_NOT_ALL_ASSIGN.
        1. 先用LookupPrivilegeValue(NULL,SE_DEBUG_NAME,&luid)得到用户的debug权限.
        2. 然后用OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES,&hToken)获取进程的令牌句柄.
        3. 最后用AdjustTokenPrivileges启用特权.
      • 若要打开关键进程(csrss.exe等), 需在驱动中打开, 去掉关键进程的EPROCESS中的PsIsProtectProcess标志位, 并关闭dll签名策略. (参考开源项目blackbone)
    • 获取待注入的dll路径, 在目标进程内分配一块内存(VirtualAllocateEx), 将路径拷贝到该内存中(WriteProcessMemory)
    • 获取kernel32中的LoadLibraryA地址(如果dll路径是宽字符串, 则用LoadLibraryW)
    • 调用CreateRemoteThread, 在目标进程中执行LoadLibrary, 进而执行DllMain函数中的目标代码
      • 注意, 因为VirtualAllocEx返回的是虚拟地址, 默认情况下CreateRemoteThread函数的lpStartAddress参数使用该地址是没问题的. 但是若注入器是32位而被注入程序是64位, 则可能导致CreateRemoteThread失败而返回NULL. 参考: https://stackoverflow.com/questions/60687458/createremotethread-returns-null-while-trying-to-use-it-to-inject-dll

反射式注入(reflective injection)

  • 概要
    • 没有用到LoadLibrary函数注入dll
    • 需由注入器自行解析PE文件:
      • 将dll头部(包括DOS头, PE头, 区块表)逐字节写入新开辟的内存.
      • 按重定位表的信息手动重定位
      • 修复导入函数表: 使用LdrLoadDll得到shellcode需要的库的内存地址, LdrGetProcedureAddress得到要导入的函数的内存地址, 然后将这些地址填入导入表.
  • 流程
    • 将自己实现的LoadLibrary功能函数保存为shellcode.
      • 注: 在shellcode中使用的系统api都要事先通过GetProcAddress获取(使用GetModuleHandleA获取模块句柄, 传入的模块名不用后缀), 并作为参数传给shellcode.
      • 注: 需要审查注入器保存的shellcode是否是真实的函数体. 调试发现在vs2019中, 按默认选项编译得到的函数地址处是一条跳转到实际函数体的jmp指令. 因此需要使用jmp指令的操作数计算实际函数地址. 如下获取一个shellcode函数的代码:
        // shellcode函数
        void shellcodeFunc(PMY_PARAMS pParams) {
            // pParams保存LdrLoadDll等系统api的内存地址
            // 用NtAllocateVirtualMemory在目标进程中开辟一块内存(需指定PAGE_EXECUTE_READWRITE权限)
            // 将dll的文件内容写入开辟的内存
            // 修复导入表; 重定位
            // 执行dll的入口函数DLLMain
        }
        
        DWORD size = 0, ssss=0;
        
        // 获取jmp指令后的双字操作数(即jmp的目的地址偏移)
        DWORD* jmpAddr = (DWORD*) ((BYTE*) shellcodeFunc + 1); 
        
        // 加5得到jmp指令的下一条指令地址, 然后加上jmp的目的地址偏移, 得到函数体的实际起始地址
        WORD* Memx0 = (WORD*) ((BYTE*) shellcodeFunc + 5 + *jmpAddr); 
        LONG_PTR* Memx = (LONG_PTR*) Memx0;
        
        // 用0xCCCCCCCCCCCCCCCC作为函数体结束识别标识.
        while (*Memx != 0xCCCCCCCCCCCCCCCC) { 
            Memx++;
            size += 8;
        }
        
        // 将shellcode写入文件
        HANDLE hFile = CreateFile(LOADECODE, GENERIC_ALL, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, NULL, NULL);
        if (hFile) {
            WriteFile(hFile, Memx0, size, &ssss, NULL);
            CloseHandle(hFile);
        }
        
      • 20230810:发现可以通过修改编译和链接选项, 让编译的程序不会多一个跳转表.
        • 编译时带上/GL选项(C/C++ -> 优化 -> 全程序优化, 选择是 (/GL)).

        • 链接时带上/INCREMENTAL:NO选项(链接器 -> 常规 -> 启用增量链接, 选否 (/INCREMENTAL:NO)). 在微软对这个链接选项的说明文档中, 有如下一句:

          May contain jump thunks to handle relocation of functions to new addresses.

          也就是说/INCREMENTAL选项会让程序多一张跳转表, 以处理函数的重定位. 在Debug模式下就会默认开启此选项.

      • 注: 用windbg调试远程线程, 发现远程线程中出现地址访问冲突:
        • 原因1: 出问题的地方试图读取__security_cookie
          • 解决: 编译时关闭/GS选项(属性 -> C/C++ -> 代码生成 -> 安全检查, 选择禁用安全检查 (/GS-)), 禁用栈保护.
        • 原因2: 远程线程中试图调用一些不可用的函数, 包括__CheckForDebuggerJustMyCode, _RTC_CheckStackVars
          • 解决: 禁用/JMC选项(属性 -> C/C++ -> 常规 -> 支持仅我的代码调试, 选择), /RTC选项(属性 -> C/C++ -> 代码生成 -> 基本运行时检查, 选择默认值), 其他的如果是必要使用的动态库函数, 则需要用LoadLibraryGetProcAddress获取, 且要确保目标进程已载入相应dll.
      • 注: shellcode函数中不要用字符串常量, 因为这些字符串总是存在于注入器进程的堆栈上, 不会随着shellcode一起注入到目标进程中, 这样一来shellcode在运行时无从获取这些字符串常量. 在shellcode中最好是通过传参的方式获取需要用到的字符串常量.
    • 在目标进程中开辟新内存, 依次写入:
      • 要注入的dll的文件内容.
      • shellcode.
      • 传给shellcode的参数, 主要是shellcode需要的如下系统api的函数地址.
        • LdrLoadDll: 获取注入的dll依赖的dll的内存地址.
        • LdrGetProcedureAddress: 获取注入的dll需导入的函数的内存地址.
        • RtlInitAnsiString RtlAnsiStringToUnicodeString RtlFreeUnicodeString: 用以配合上述两个函数, 得到导入函数的内存地址.
        • NtAllocateVirtualMemory: 分配内存空间, 以写入要注入的dll的文件内容.
    • 创建远程线程, 执行shellcode, 由shellcode载入dll.
  • 优点
    • 仅枚举进程模块列表并不能发现注入的dll.
      反射式dll注入后, 进程的内存空间

镂空(hollowing):

  • 概要:
    • 两种方式:
      • 镂空已有进程模块: 直接修改进程中已有模块的代码节, 注入恶意代码.
      • 先注入后镂空: 注入一个合法dll(拥有合法签名), 然后修改dll入口点处代码为自己想执行的代码.
    • 下面只讲先注入dll后镂空的方法.
  • 流程
    • 首先, 如普通的dll注入, CreateRemoteThread创建远程线程, 执行LoadLibrary注入一个dll, 不同的是注入到进程的是一个合法dll(比如system32目录下的dll).
    • EnumProcessModules枚举进程模块, GetModuleBaseNameA得到每个模块的名称, 从而找到注入的dll.
    • 注入器进程中分配0x1000的内存空间(可用mallocHeapAlloc), 然后将找到的dll的PE头部内容读进来.
    • 由PE头中的optional头得到目标dll的入口地址, 加上枚举模块时得到的dll的基址, 得到实际dll入口地址, 并用WriteProcessMemory向该地址写入shellcode.
      • 注: 通过windbg调试发现, 注入入口地址不一定能成功执行shellcode, 因为DllMain函数可能多次执行. 如果只想执行一次shellcode, 可把shellcode及其写在dll的末尾对齐空间.
      • 如同反射式dll注入, 生成shellcode的方法也是在注入器中定义shellcode函数并获取其机器码.
    • 创建远程线程, 并以写入shellcode的地址为线程执行地址.
  • 优点
    • 无需将恶意dll保存在磁盘中, 可躲避静态查杀.
  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
远程注入DLL方法有很多种,也是很多木马病毒所使用的隐藏进程的方法,因为通过程序加载的DLL在进程管理是没有显示的.这里介绍一种用 CreateRemoteThread 远程建立线程的方式注入DLL. 首先,我们要提升自己的权限,因为远程注入必不可免的要访问到目标进程的内存空间,如果没有足够的系统权限,将无法作任何事.下面是这个函数是用来提升我们想要的权限用的. function EnableDebugPriv: Boolean; var hToken: THandle; tp: TTokenPrivileges; rl: Cardinal; begin Result := false; //打开进程令牌环 OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES or TOKEN_QUERY, hToken); //获得进程本地唯一ID if LookupPrivilegeValue(nil, 'SeDebugPrivilege', tp.Privileges[0].Luid) then begin tp.PrivilegeCount := 1; tp.Privileges[0].Attributes := SE_PRIVILEGE_ENABLED; //调整权限 Result := AdjustTokenPrivileges(hToken, false, tp, SizeOf(tp), nil, rl); end; end; 关于 OpenProcessToken() 和 AdjustTokenPrivileges() 两个 API 的简单介绍: OpenProcessToken():获得进程访问令牌的句柄. function OpenProcessToken( ProcessHandle: THandle; //要修改访问权限的进程句柄 DesiredAccess: DWORD; //指定你要进行的操作类型 var TokenHandle: THandle//返回的访问令牌指针 ): BOOL; AdjustTokenPrivileges() :调整进程的权限. function AdjustTokenPrivileges( TokenHandle: THandle; // 访问令牌的句柄 DisableAllPrivileges: BOOL; // 决定是进行权限修改还是除能(Disable)所有权限 const NewState: TTokenPrivileges; { 指明要修改的权限,是一个指向TOKEN_PRIVILEGES结构的指针,该结构包含一个数组, 数据组的每个项指明了权限的类型和要进行的操作; } BufferLength: DWORD; //结构PreviousState的长度,如果PreviousState为空,该参数应为 0 var PreviousState: TTokenPrivileges; // 指向TOKEN_PRIVILEGES结构的指针,存放修改前的访问权限的信息 var ReturnLength: DWORD //实际PreviousState结构返回的大小 ) : BOOL; 远程注入DLL其实是通过 CreateRemoteThread 建立一个远程线程调用 LoadLibrary 函数来加载我们指定的DLL,可是如何能让远程线程知道我要加载DLL呢,要知道在Win32系统下,每个进程都拥有自己的4G虚拟地址空间,各个进程之间都是相互独立的。所我们需要在远程进程的内存空间里申请一块内存空间,写入我们的需要注入DLL 的路径. 需要用到的 API 函数有: OpenProcess():打开目标进程,得到目标进程的操作权限,详细参看MSDN function OpenProcess( dwDesiredAccess: DWORD; // 希望获得的访问权限 bInheritHandle: BOOL; // 指明是否希望所获得的句柄可以继承 dwProcessId: DWORD // 要访问的进程ID ): THandle; VirtualAllocEx():用于在目标进程内存空间中申请内存空间以写入DLL的文件名 function VirtualAllocEx( hProcess: THandle; // 申请内存所在的进程句柄 lpAddress: Pointer; // 保留页面的内存地址;一般用nil自动分配 dwSize, // 欲分配的内存大小,字节单位;注意实际分 配的内存大小是页内存大小的整数倍 flAllocationType: DWORD; flProtect: DWORD ): Pointer; WriteProcessMemory():往申请到的空间中写入DLL的文件名 function WriteProcessMemory( hProcess: THandle; //要写入内存数据的目标进程句柄 const lpBaseAddress: Pointer; //要写入的目标进程的内存指针, 需以 VirtualAllocEx() 来申请 lpBuffer: Pointer; //要写入的数据 nSize: DWORD; //写入数据的大小 var lpNumberOfBytesWritten: DWORD //实际写入的大小 ): BOOL; 然后就可以调用 CreateRemoteThread 建立远程线程调用 LoadLibrary 函数来加载我们指定的DLL. CreateRemoteThread() //在一个远程进程中建立线程 function CreateRemoteThread( hProcess: THandle; //远程进程的句柄 lpThreadAttributes: Pointer; //线程安全描述字,指向SECURITY_ATTRIBUTES结构的指针 dwStackSize: DWORD; //线程栈大小,以字节表示 lpStartAddress: TFNThreadStartRoutine; // 一个TFNThreadStartRoutine类型的指针,指向在远程进程中执行的函数地址 lpParameter: Pointer; //传入参数的指针 dwCreationFlags: DWORD; //创建线程的其它标志 var lpThreadId: DWORD //线程身份标志,如果为0, 则不返回 ): THandle; 整个远程注入DLL的具体实现代码如下: function InjectDll(const DllFullPath: string; const dwRemoteProcessId: Cardinal): Boolean; var hRemoteProcess, hRemoteThread: THandle; pszLibFileRemote: Pointer; pszLibAFilename: PwideChar; pfnStartAddr: TFNThreadStartRoutine; memSize, WriteSize, lpThreadId: Cardinal; begin Result := false; // 调整权限,使程序可以访问其他进程的内存空间 if EnableDebugPriv then begin //打开远程线程 PROCESS_ALL_ACCESS 参数表示打开所有的权限 hRemoteProcess := OpenProcess(PROCESS_ALL_ACCESS, false, dwRemoteProcessId); try // 为注入dll文件路径分配内存大小,由于为WideChar,故要乘2 GetMem(pszLibAFilename, Length(DllFullPath) * 2 + 1); // 之所以要转换成 WideChar, 是因为当DLL位于有中文字符的路径下时不会出错 StringToWideChar(DllFullPath, pszLibAFilename, Length(DllFullPath) * 2 + 1); // 计算 pszLibAFilename 的长度,注意,是以字节为单元的长度 memSize := (1 + lstrlenW(pszLibAFilename)) * SizeOf(WCHAR); //使用VirtualAllocEx函数在远程进程的内存地址空间分配DLL文件名空间 pszLibFileRemote := VirtualAllocEx(hRemoteProcess, nil, memSize, MEM_COMMIT, PAGE_READWRITE); if Assigned(pszLibFileRemote) then begin //使用WriteProcessMemory函数将DLL的路径名写入到远程进程的内存空间 if WriteProcessMemory(hRemoteProcess, pszLibFileRemote, pszLibAFilename, memSize, WriteSize) and (WriteSize = memSize) then begin lpThreadId := 0; // 计算LoadLibraryW的入口地址 pfnStartAddr := GetProcAddress(LoadLibrary('Kernel32.dll'), 'LoadLibraryW'); // 启动远程线程LoadLbraryW,通过远程线程调用创建新的线程 hRemoteThread := CreateRemoteThread(hRemoteProcess, nil, 0, pfnStartAddr, pszLibFileRemote, 0, lpThreadId); // 如果执行成功返回 True; if (hRemoteThread 0) then Result := true; // 释放句柄 CloseHandle(hRemoteThread); end; end; finally // 释放句柄 CloseHandle(hRemoteProcess); end; end; end; 接下来要说的是如何卸载注入目标进程中的DLL,其实原理注入DLL是完全相同的,只是远程调用调用的函数不同而已,这里要调用的是FreeLibrary,代码如下: function UnInjectDll(const DllFullPath: string; const dwRemoteProcessId: Cardinal): Boolean; // 进程注入和取消注入其实都差不多,只是运行的函数不同而已 var hRemoteProcess, hRemoteThread: THandle; pszLibFileRemote: PChar; pszLibAFilename: PwideChar; pfnStartAddr: TFNThreadStartRoutine; memSize, WriteSize, lpThreadId, dwHandle: Cardinal; begin Result := false; // 调整权限,使程序可以访问其他进程的内存空间 if EnableDebugPriv then begin //打开远程线程 PROCESS_ALL_ACCESS 参数表示打开所有的权限 hRemoteProcess := OpenProcess(PROCESS_ALL_ACCESS, false, dwRemoteProcessId); try // 为注入dll文件路径分配内存大小,由于为WideChar,故要乘2 GetMem(pszLibAFilename, Length(DllFullPath) * 2 + 1); // 之所以要转换成 WideChar, 是因为当DLL位于有中文字符的路径下时不会出错 StringToWideChar(DllFullPath, pszLibAFilename, Length(DllFullPath) * 2 + 1); // 计算 pszLibAFilename 的长度,注意,是以字节为单元的长度 memSize := (1 + lstrlenW(pszLibAFilename)) * SizeOf(WCHAR); //使用VirtualAllocEx函数在远程进程的内存地址空间分配DLL文件名空间 pszLibFileRemote := VirtualAllocEx(hRemoteProcess, nil, memSize, MEM_COMMIT, PAGE_READWRITE); if Assigned(pszLibFileRemote) then begin //使用WriteProcessMemory函数将DLL的路径名写入到远程进程的内存空间 if WriteProcessMemory(hRemoteProcess, pszLibFileRemote, pszLibAFilename, memSize, WriteSize) and (WriteSize = memSize) then begin // 计算GetModuleHandleW的入口地址 pfnStartAddr := GetProcAddress(LoadLibrary('Kernel32.dll'), 'GetModuleHandleW'); //使目标进程调用GetModuleHandleW,获得DLL在目标进程中的句柄 hRemoteThread := CreateRemoteThread(hRemoteProcess, nil, 0, pfnStartAddr, pszLibFileRemote, 0, lpThreadId); // 等待GetModuleHandle运行完毕 WaitForSingleObject(hRemoteThread, INFINITE); // 获得GetModuleHandle的返回值,存在dwHandle变量中 GetExitCodeThread(hRemoteThread, dwHandle); // 计算FreeLibrary的入口地址 pfnStartAddr := GetProcAddress(LoadLibrary('Kernel32.dll'), 'FreeLibrary'); // 使目标进程调用FreeLibrary,卸载DLL hRemoteThread := CreateRemoteThread(hRemoteProcess, nil, 0, pfnStartAddr, Pointer(dwHandle), 0, lpThreadId); // 等待FreeLibrary卸载完毕 WaitForSingleObject(hRemoteThread, INFINITE); // 如果执行成功返回 True; if hRemoteProcess 0 then Result := true; // 释放目标进程中申请的空间 VirtualFreeEx(hRemoteProcess, pszLibFileRemote, Length(DllFullPath) + 1, MEM_DECOMMIT); // 释放句柄 CloseHandle(hRemoteThread); end; end; finally // 释放句柄 CloseHandle(hRemoteProcess); end; end; end;

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值