实战 | 进程启动技术的思路和研究

VSole2021-11-06 08:12:29

常规api创建进程

通过常用的api来创建进程是常规启动进程的方式,最常用的几个api有WinExecShellExecuteCreateProcess,我们一个一个来看一下

WinExec

首先是WinExec,这个api结构如下,这个api只能够运行exe文件,算是比较局限

UINT WinExec(
  [in] LPCSTR lpCmdLine,    // 命令行
  [in] UINT   uCmdShow        // 显示选项
);

实现代码如下

BOOL winexec(char* szPath, UINT Cmd)
{
    UINT ret = 0;
    
    ret = ::WinExec(szPath,Cmd);
    if (ret > 31)
    {
        return TRUE;
    }
    
    return FALSE;
}

ShellExecute

ShellExecute的功能是运行一个外部程序(或者是打开一个已注册的文件、打开一个目录、打印一个文件等等),并对外部程序有一定的控制

HINSTANCE ShellExecuteA(
  [in, optional] HWND   hwnd,
  [in, optional] LPCSTR lpOperation,
  [in]           LPCSTR lpFile,
  [in, optional] LPCSTR lpParameters,
  [in, optional] LPCSTR lpDirectory,
  [in]           INT    nShowCmd
);

实现代码如下

BOOL shellexecute(char* szPath, UINT Cmd)
{
    HINSTANCE hIns = 0;
    hIns = ::ShellExecute(NULL,NULL,szPath,NULL,NULL,Cmd);
    
    if ((DWORD)hIns > 32)
    {
        return TRUE;
    }
    
    return FALSE;
}

CreateProcess

最常用的创建进程api,本质是调用了内核函数NtCreateProcess创建进程并用NtCreateThread创建一个线程

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

实现代码如下

    char szCommandLine[]="notepad";
    STARTUPINFO si={sizeof(si)};
    PROCESS_INFORMATION pi;
    si.dwFlags=STARTF_USESHOWWINDOW;//指定wShowWindow成员效
    si.wShowWindow=TRUE;//此成员设为TRUE的话则显示新建进程的主窗口
    BOOL bRet=CreateProcess(
        NULL,//不在此指定可执行文件的文件名
        szCommandLine,//命令行参数
        NULL,//默认进程安全性
        NULL,//默认进程安全性
        FALSE,//指定当前进程内句柄不可以被子进程继承
        CREATE_NEW_CONSOLE,//为新进程创建一个新的控制台窗口
        NULL,//使用本进程的环境变量
        NULL,//使用本进程的驱动器和目录
        &si,
        &pi);
    if(bRet)
    {
        //不使用的句柄最好关掉
        CloseHandle(pi.hThread);
        CloseHandle(pi.hProcess);
        printf("新进程的ID号:%d",pi.dwProcessId);
        printf("新进程的主线程ID号:%d",pi.dwThreadId);
    }
    getchar();
    return 0;

这里着重说一下CreateProcess的实现过程

在Windows中,进程是不活动的,只是作为线程的容器,现代操作系统将线程作为最小调度单位,进程作为资源分配的最小单位。

所以,CreateProcess作为一个相对高层的函数,要先通过系统调用``NtCreateProcess()创建进程(容器),成功以后就立即通过系统调用NtCreateThread()创建其第一个线程。

第一阶段:打开目标映像文件

对于32位exe映像,CreateProcess先打开其映像文件,在为其创建一个Section即文件映射区,将文件内容映射进来,前提是目标文件是一个合格的EXE文件(PE文件头部检测);

第二阶段:创建内核中的进程对象

实际上就是创建以EPROCESS为核心的相关数据结构,这就是系统调用NtCreateProcess()要做的事情,主要包括:

①分配并设置EPROCESS数据结构;

②其他相关的数据结构的设置,如句柄表等等;

③为目标进程创建初始的地址空间;

④对EPROCESS进行初始化;

⑤将系统Dll映射到目标用户空间,如ntdll.dll等

⑥设置目标进程的PEB;

⑦将其他需要映射到用户空间,如与”当地语言支持“即NLS有关的数据结构;

⑧完成EPROCESS创建,将其挂入进程队列并插入创建者的句柄表

第三阶段:创建初始线程

前面说过,进程只是一个容器,干活儿是里面的线程,所以下一步就是创建目标进程的初始线程

EPROCESS对应,线程的数据结构是ETHREAD,与进程环境块PEB对应,线程也有线程环境块TEB;

PEB在用户空间的位置大致是固定的,在7ffd0000左右,PEB的下方就是TEB,进程有几个线程就有几个TEB,每个TEB占一个4KB的页面;

这个阶段是通过调用NtCreateThread()完成的,主要包括:

①创建和设置目标线程的ETHREAD数据结构,并处理好与EPROCESS的关系(例如进程块中的线程计数等等)。

②在目标进程的用户空间创建并设置目标线程的TEB。

③将目标线程在用户空间的起始地址设置成指向Kernel32.dll中的BaseProcessStart()或BaseThreadStart(),前者用于进程中的第一个线程,后者用于随后的线程。用户程序在调用NtCreateThread()时也要提供一个用户级的起始函数(地址), BaseProcessStart()和BaseThreadStart()在完成初始化时会调用这个起始函数。ETHREAD数据结构中有两个成份,分别用来存放这两个地址。

④调用KeInitThread设置目标线程的KTHREAD数据结构并为其分配堆栈和建立执行环境。特别地,将其上下文中的断点(返回点)设置成指向内核中的一段程序KiThreadStartup,使得该线程一旦被调度运行时就从这里开始执行。

⑤系统中可能登记了一些每当创建线程时就应加以调用的“通知”函数,调用这些函数。

第四阶段:通知windows子系统

每个进程在创建/退出的时候都要向windows子系统进程csrss.exe进程发出通知,因为它担负着对windows所有进程的管理的责任,

注意,这里发出通知的是CreateProcess的调用者,不是新建出来的进程,因为它还没有开始运行。

至此,CreateProcess的操作已经完成,但子进程中的线程却尚未开始运行,它的运行还要经历下面的第五和第六阶段。

第五阶段:启动初始线程

新创建的线程未必是可以被立即调度运行的,因为用户可能在创建时把标志位CREATE_ SUSPENDED设成了1;

如果那样的话,就需要等待别的进程通过系统调用恢复其运行资格以后才可以被调度运行。否则现在已经可以被调度运行了。至于什么时候才会被调度运行,则就要看优先级等等条件了。

第六阶段:用户空间的初始化和Dll连接

DLL连接由ntdll.dll中的LdrInitializeThunk()在用户空间完成。在此之前ntdll.dll与应用软件尚未连接,但是已经被映射到了用户空间(第二阶段第⑤步)

函数LdrInitializeThunk()在映像中的位置是系统初始化时就预先确定并记录在案的,所以在进入这个函数之前也不需要连接。

session0创建进程

Intel的CPU将特权级别分为4个级别:RING0,RING1,RING2,RING3。Windows只使用其中的两个级别RING0和RING3,RING0只给操作系统用,RING3谁都能用。如果普通应用程序企图执行RING0指令,则Windows会显示“非法指令”错误信息。

ring0是指CPU的运行级别,ring0是最高级别,ring1次之,ring2更次之…… 拿Linux+x86来说, 操作系统(内核)的代码运行在最高运行级别ring0上,可以使用特权指令,控制中断、修改页表、访问设备等等。应用程序的代码运行在最低运行级别上ring3上,不能做受控操作。如果要做,比如要访问磁盘,写文件,那就要通过执行系统调用(函数),执行系统调用的时候,CPU的运行级别会发生从ring3到ring0的切换,并跳转到系统调用对应的内核代码位置执行,这样内核就为你完成了设备访问,完成之后再从ring0返回ring3。这个过程也称作用户态和内核态的切换。

RING设计的初衷是将系统权限与程序分离出来,使之能够让OS更好的管理当前系统资源,也使得系统更加稳定。举个RING权限的最简单的例子:一个停止响应的应用程式,它运行在比RING0更低的指令环上,你不必大费周章的想着如何使系统回复运作,这期间,只需要启动任务管理器便能轻松终止它,因为它运行在比程式更低的RING0指令环中,拥有更高的权限,可以直接影响到RING0以上运行的程序,当然有利就有弊,RING保证了系统稳定运行的同时,也产生了一些十分麻烦的问题。比如一些OS虚拟化技术,在处理RING指令环时便遇到了麻烦,系统是运行在RING0指令环上的,但是虚拟的OS毕竟也是一个系统,也需要与系统相匹配的权限。而RING0不允许出现多个OS同时运行在上面,最早的解决办法便是使用虚拟机,把OS当成一个程序来运行。

我们知道一般用户进程都在3环,而系统进程一般都在0环创建,那么我们可以尝试突破session0的隔离来创建进程

思路

由于SESSION 0会话隔离,使得在系统服务进程内不能通过直接调用CreateProcess等函数创建进程,而是通过CreateProcessAsUser函数来创建。这样,创建的进程才会显示UI界面,与用户进行交互。

首先,调用WTSGetActiveConsoleSessionId函数来获取当前程序的活动会话ID,即Session Id。该函数的调用不需要任何参数,直接返回Session Id。根据Session Id继续调用WTSQueryUserToken函数来检索用户令牌,并获取对应的用户令牌句柄。

然后,使用DuplicateTokenEx函数创建一个一个新令牌,并复制上述获取的用户令牌。设置新令牌的访问权限问MAXIMUM_ALLOWED,表示获取所有令牌权限。新访问令牌的模拟级别为SecurityIdentification,而且令牌类型为TokenPrimary,表示新令牌是可以在CreateProcessAsUser函数中使用的主令牌。

最后,根据新令牌调用CreateEnvironmentBlock函数创建一个环境块,用来传递给CreateProcessAsUser使用。在不需要使用进程环境块后,可以通过调用DestroyEnvironmentBlock函数进行释放。获取环境块之后,就可以调用CreateProcessAsUser来创建用户桌面进程了。新令牌句柄作为用户主令牌的句柄,指定创建进程的路径,设置优先级和创建标志,设置STARTUPINFO结构信息,获取PROCESS_INFORMATION结构信息。

函数

WTSGetActiveConsoleSessionId

检索Session Id

DWORD WTSGetActiveConsoleSessionId(void);

WTSQueryUserToken

获取由Session Id指定的登录用户的主访问令牌

BOOL WTSQueryUserToken(
    _In_  ULONG   SessionId,
    _Out_ PHANDLE phToken
);

DuplicateTokenEx

创建一个新的访问令牌,它与现有令牌重复

BOOL WINAPI DuplicateTokenEx(
   _In_     HANDLE                       hExistingToken,
   _In_     DWORD                        dwDesiredAccess,
   _In_opt_ LPSECURITY_ATTRIBUTES        lpTokenAttributes,
   _In_     SECURITY_IMPERSONATION_LEVEL ImpersonationLevel,
   _In_     TOKEN_TYPE                   TokenType,
   _Out_    PHANDLE                      phNewToken
);

CreateEnvironmentBlock

检索指定用户的环境变量

BOOL WINAPI CreateEnvironmentBlock(
    _Out_    LPVOID *lpEnvironment,
    _In_opt_ HANDLE hToken,
    _In_     BOOL   bInherit
);

CreateProcessAsUser

创建一个新进程及其主要线程,新进程在由指定令牌表示的用户的安全上下文中运行

BOOL WINAPI CreateProcessAsUser(
    _In_opt_    HANDLE                hToken,
    _In_opt_    LPCTSTR               lpApplicationName,
    _Inout_opt_ LPTSTR                lpCommandLine,
    _In_opt_    LPSECURITY_ATTRIBUTES lpProcessAttributes,
    _In_opt_    LPSECURITY_ATTRIBUTES lpThreadAttributes,
    _In_        BOOL                  bInheritHandles,
    _In_        DWORD                 dwCreationFlags,
    _In_opt_    LPVOID                lpEnvironment,
    _In_opt_    LPCTSTR               lpCurrentDirectory,
    _In_        LPSTARTUPINFO         lpStartupInfo,
    _Out_       LPPROCESS_INFORMATION lpProcessInformation
);

实现

首先使用WTSGetActiveConsoleSessionId来获取session ID

::WTSGetActiveConsoleSessionId();

使用WTSQueryUserToken获取当前会话的用户令牌

::WTSQueryUserToken(dwSessionID, &hToken)

复制令牌

::DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL, SecurityIdentification, TokenPrimary, &hDuplicatedToken)

然后使用CreateEnvironmentBlock创建用户的session环境

::CreateEnvironmentBlock(&lpEnvironment,hDuplicatedToken, FALSE)

再在复制的会话下面执行创建进程的操作

::CreateProcessAsUser(hDuplicatedToken, lpszFileName, NULL, NULL, NULL, FALSE, NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, lpEnvironment, NULL, &si, &pi)

完整代码如下

BOOL CreateUserProcess(char* lpszFileName)
{
    BOOL bRet = TRUE;
    DWORD dwSessionID = 0;
    HANDLE hToken = NULL;
    HANDLE hDuplicatedToken = NULL;
    LPVOID lpEnvironment = NULL;
    STARTUPINFO si = { 0 };
    PROCESS_INFORMATION pi = { 0 };
    si.cb = sizeof(si);
    do
    {
        // 获得当前Session ID
        dwSessionID = ::WTSGetActiveConsoleSessionId();
        // 获得当前Session的用户令牌
        if (FALSE == ::WTSQueryUserToken(dwSessionID, &hToken))
        {
            printf("[!] WTSQueryUserToken failed");
            bRet = FALSE;
            break;
        }
        // 复制令牌
        if (FALSE == ::DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL,
            SecurityIdentification, TokenPrimary, &hDuplicatedToken))
        {
            printf("[!] WTSQueryUserToken failed");
            bRet = FALSE;
            break;
        }
        // 创建用户Session环境
        if (FALSE == ::CreateEnvironmentBlock(&lpEnvironment,
            hDuplicatedToken, FALSE))
        {
            printf("[!] WTSQueryUserToken failed");
            bRet = FALSE;
            break;
        }
        // 在复制的用户Session下执行应用程序,创建进程
        if (FALSE == ::CreateProcessAsUser(hDuplicatedToken,
            (LPCWSTR)lpszFileName, NULL, NULL, NULL, FALSE,
            NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
            lpEnvironment, NULL, &si, &pi))
        {
            printf("[!] WTSQueryUserToken failed");
            bRet = FALSE;
            break;
        }
    } while (FALSE);
    // 关闭句柄, 释放资源
    if (lpEnvironment)
    {
        ::DestroyEnvironmentBlock(lpEnvironment);
    }
    if (hDuplicatedToken)
    {
        ::CloseHandle(hDuplicatedToken);
    }
    if (hToken)
    {
        ::CloseHandle(hToken);
    }
    return bRet;
}

因为要实现session0隔离,这里需要添加一个系统服务进程,需要调用StartServiceCtrlDispatcher来设置入口点函数,这里就不展开说了,看一下实现的效果,这里将session0exe.exe注册到系统服务打开nc.exe到session1

内存加载运行

将资源加载到内存,然后把DLL文件按照映像对齐大小映射到内存中,切不可直接将DLL文件数据存储到内存中。

因为根据PE结构的基础知识可知,PE文件有两个对齐字段,一个是映像对齐,另一个是文件对齐大。其中,映像对齐大小是PE文件加载到内存中所用的对齐大小,而文件对齐大小是PE文件存储在本地磁盘所用的对齐大小。一般文件对齐大小会比映像对齐大小要小,这样文件会变小,以此节省磁盘空间。

然而,成功映射内存数据之后,在DLL程序中会存在硬编码数据,硬编码都是以默认的加载基址作为基址来计算的。由于DLL可以任意加载到其他进程空间中,所以DLL的加载基址并非固定不变。当改变加载基址的时候,硬编码也要随之改变,这样DLL程序才会计算正确。

如何知道硬编码的位置?答案就藏在PE结构的重定位表中,重定位表记录的就是程序中所有需要修改的硬编码的相对偏移位置。

根据重定位表修改硬编码数据后,这只是完成了一半的工作。DLL作为一个程序,自然也会调用其他库函数,例如MessageBox。

那么DLL如何知道MessageBox函数的地址呢?它只有获取正确的调用函数地址后,方可正确调用函数。PE结构使用导入表来记录PE程序中所有引用的函数及其函数地址。在DLL映射到内存之后,需要根据导入表中的导入模块和函数名称来获取调用函数的地址。若想从导入模块中获取导出函数的地址,最简单的方式是通过GetProcAddress函数来获取。

但是为了避免调用敏感的WIN32 API函数而被杀软拦截检测,采用直接遍历PE结构导出表的方式来获取导出函数地址。

实现

这里有一些其他的函数,例如修复IAT表、修复重定位表的代码就不细说了,这里需要有一定的基础知识才能够实现,主要是说一下在进程中的操作

首先获取大小

DWORD dwSizeOfImage = GetSizeOfImage(lpData);

使用VirutalAlloc分配内存

::VirtualAlloc(NULL, dwSizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

然后使用RtlZeroMemory清空空间数据

RtlZeroMemory(lpBaseAddress, dwSizeOfImage);

对dll数据进行FileBuffer -> ImageBuffer的转换

MmMapFile(lpData, lpBaseAddress);

修复文件的重定位表和IAT表

DoRelocationTable(lpBaseAddress);
DoImportTable(lpBaseAddress);

修改函数的ImageBase

SetImageBase(lpBaseAddress);

调用dll的入口函数

CallDllMain(lpBaseAddress,IsExe)

完整代码如下

LPVOID LoadLibrary(LPVOID lpData,BOOL IsExe)
{
    LPVOID lpBaseAddress = NULL;
    // 获取映像大小
    DWORD dwSizeOfImage = GetSizeOfImage(lpData);
    // 在进程中申请一个可读、可写、可执行的内存块
    lpBaseAddress = ::VirtualAlloc(NULL, dwSizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
    if (NULL == lpBaseAddress)
    {
        printf("[!] VirtualAlloc failed");
        return NULL;
    }
    
    ::RtlZeroMemory(lpBaseAddress, dwSizeOfImage);
    
    if (FALSE == MmMapFile(lpData, lpBaseAddress))
    {
        printf("[!] MmMapFile failed");
        return NULL;
    }
    // 修改重定位表
    if (FALSE == DoRelocationTable(lpBaseAddress))
    {
        printf("[!] DoRelocationTable failed");
        return NULL;
    }
    // 修改导入表
    if (FALSE == DoImportTable(lpBaseAddress))
    {
        printf("[!] DoImportTable failed");
        return NULL;
    }
    // 修改PE文件的加载基址
    if (FALSE == SetImageBase(lpBaseAddress))
    {
        printf("[!] SetImageBase failed");
        return NULL;
    }
    // 调用DLL的入口函数DllMain
    if (FALSE == CallDllMain(lpBaseAddress,IsExe))
    {
        printf("[!] CallDllMain failed");
        return NULL;
    }
    return lpBaseAddress;
}

然后新建一个dll,写入成功即弹窗

内存加载运行成功

硬编码内存对齐
本作品采用《CC 协议》,转载必须注明作者和本文链接
文中使用的示例代码可以从 这里 获取。的功能是在终端打印出hello这6个字符(包括结尾的?编译它们分别生成libtest.so和?存在严重的内存泄露问题,每调用一次say_hello函数,就会泄露1024字节的内存
这次分析了CVE-2014-0502 Adobe Flash Player中的双重释放漏洞。文章的前半部分是Action Script代码的静态分析以及对于漏洞利用原理的一个初步分析,AS代码分析和书中内容重合,漏洞利用原理的初步分析涉及到了Adobe Flash Player的一些操作机制,通过搜索查看网上的资料完成了前半部分的内容。
知道了杀软在ring0的监测原理,我们该如何进行绕过呢?
常规api创建进程通过常用的api来创建进程是常规启动进程的方式,最常用的几个api有WinExec、ShellExecute、CreateProcess,我们一个一个来看一下WinExec首先是WinExec,这个api结构如下,这个api只能够运行exe文件
在2022年2月,卡巴斯基实验室的研究人员首次观察到将shellcode放入Windows事件日志的技术。该技术允许在文件系统中隐藏“无文件”最后stager的木马。这种对活动中事件日志的关注不仅限于存储 shellcode。Dropper 模块还修复了与事件跟踪 (ETW) 和反恶意软件扫描接口 (AMSI) 相关的 Windows 原生 API 函数,以使感染过程更加隐蔽。
EXE文件内存加载
2021-12-02 16:22:13
作为一名安全菜鸟,单纯的了解某一个方面是并不合格的,安全并不仅限于某一门语言、某一个OS,现如今安全研究的技术栈要求的更深、更广。
Swisslog Healthcare公司生产的TransLogic医疗气动导管系统(PTS)能够帮助医院实现对静脉输液等药品、实验室标本、文件和其他材料的高效稳定传递,在全球超过3000家医院中使用。近日,Armis研究人员在该系统中发现了8类漏洞(PwnedPiper),这些漏洞可成为医院勒索攻击者的有力工具。
Pandora勒索软件针对Windows用户发起攻击,且会对受感染设备上的大多数文件加密。
随着越来越多的企业开始启用DevOps开发模式,CI/CD管道被广泛使用——这也给攻击者们带来了新的攻击路径,从而窃取敏感信息、进行挖矿、以及传输恶意代码。
VSole
网络安全专家