한 때 모든 위도우즈 버전에서 돌아가는 막을 수 없는 공격이라며 보안업계를 흔들었던 인젝션 기법 아톰보밍(AtomBombing)을 소개하고자 합니다. 이 기법의 요지는 인젝션에 많이 사용되는 CreateRemoteThread() 나 WriteProcessMemory() 없이도 원하는 프로세스에 실행코드를 주입할 수 있다는 점입니다. 따라서 유저모드 API기반으로 인젝션을 탐지했다면, 많은 부분 우회를 할 수 있습니다. 또한 Win10 에서도 잘 동작한다는 점도 이 기법의 유명세에 한 몫 거들었습니다.
많은 언론사가 절대 막을 수 없는 기법이기에 매우 위험한 것처럼 말했지만, 완전히 동의할 순 없는 말입니다. 어쨌거나 이 기법을 쓴단 얘기는 해당 PC는 이미 감염됐단 말이니까요. 감염에 이르는 치명적 취약점이라기 보단 이미 감염된 시스템에서 더 나쁜 짓 하는걸 막기 힘들다? 정도로 보면 되지 싶습니다. 몇 가지 포인트에서 탐지 및 차단할 수 있는 지점이 있지만 이를 위한 부하가 많이 걸리는건 사실이니까요.
이 글은 Tal Liberman 이 2016년 10월에 작성한 포스트를 거의 그대로 번역했습니다. 혹시나 틀린 부분이 있다면 피드백은 언제라도 환영합니다.
개요
AtomBombing은 윈도우 OS를 대상으로한 새로운 코드 인젝션 기법입니다. 이 기법은 크게 세가지 단계에 걸쳐 진행됩니다.
1. Write-What-Where
대상 프로세스의 주소공간 임의의 장소에 임의의 데이터를 쓰는 것.
2. Execution
1단계를 실행하기 위해 공격대상 프로세스의 쓰레드를 hijacking 하는 것.
3. Restoration
흔적을 지우고, 2에서 하이젝한 쓰레드의 실행을 복구하는 것.
이 글에서는 1단계, Write-What-Where 에 대해 다루겠습니다.
1단계. Write-What-Where
먼저 공격 대상 프로세스에 실행코드를 인젝션 시키는 단계입니다. 일반적으로 이 단계는 WriteProcessMemory()
함수를 통해 쉽게 목적을 달성할 수 있습니다. 하지만 AtomBombing 은 그 특징적인 이름에서 알 수 있듯이, 아래 두 함수를 이용합니다.
GlobalAddAtom()
global atom table에 문자열을 더하고, 해당 문자열을 식별키 위한 고유한 값(atom)을 리턴한다.
GlobalGetAtomName()
해당 global atom에 해당하는 문자열의 복사본을 가져온다.
이 두 함수를 사용해 어떻게 원격 프로세스에 실행코드를 기록하는지, 더 자세히 알아보겠습니다.
핵심원리
GlobalAddAtom()
호출하면 null terminated 버퍼를 global atom table에 저장할 수 있습니다. 이 테이블은 해당 시스템의 다른 모든 프로세스에서 접근할 수 있습니다. 이후 null terminated 버퍼를 필요로 하는 프로세스는 GlobalGetAtomName()
을 호출해 가져올 수 있습니다.
GlobalGetAtomName()
은 버퍼를 가져오기 위한 포인터를 인자로 받습니다. 따라서 이 함수의 콜러는 해당 버퍼가 어디 저장될지 지정할 수 있습니다.
이론적으로 본다면, GlobalAddAtom()
호출을 통해 쉘코드가 포함된 버퍼를 global atom table에 저장할 수 있다면. 어떻게든 대상 프로세스가 GlobalGetAtomName()
을 호출하게 만들어 WriteProcessMemory()
호출 없이 대상 프로세스에 버퍼를 쓰게 할 수 있습니다.
인자가 세 개인 함수의 원격함수 호출
악성 프로세스에서 GlobalAddAtom()
를 호출하는 것은 그리 복잡하지 않습니다. 하지만, 대상 프로세스에서 어떻게 GlobalGetAtomName()
을 호출하게 만들까요?
AtomBombing 에선 APC 쓰레드를 이용합니다. 사용자정의 쓰레드인 APCProc()
을 APC 큐에 등록하는 함수, QueueUserAPC()
의 원형은 아래와 같습니다. 쓰레드를 만들어 보셨다면 낯이 익으실 겁니다.
DWORD WINAPI QueueUserAPC(
_In_ PAPCFUNC pfnAPC,
_In_ HANDLE hThread,
_In_ ULONG_PTR dwData
);
VOID CALLBACK APCProc(
_In_ ULONG_PTR dwParam
);
문제는 우리가 원격 프로세스에서 호출하길 원하는 함수가 GlobalGetAtomName()
이란 것입니다. dll 인젝션에 많이 쓰이는 CreateRemoteThread(… pLoadLibraryA, "evil.dll", …)
형태의 함수 호출은 LoadLibraryA()
의 인자의 수와, ThreadProc()
의 인자수가 같기에 가능했습니다. GlobalGetAtomName()
의 인자는 몇 개 일까요?
UINT WINAPI GlobalGetAtomName(
_In_ ATOM nAtom,
_Out_ LPTSTR lpBuffer,
_In_ int nSize
);
함수 원형에서 보이듯이 3개의 인자를 필요로 하기 때문에, 하나의 인자를 필요로 하는APCProc()
으론 원격 프로세스에서 GlobalGetAtomName()
을 호출하게 만들수 없습니다.
하지만 함수 내부에선 어떨까요. QueueUserApc()
의 내부는 아래와 같습니다.
이처럼 QueueUserApc()
는 내부적으로 undocumented 함수인 NtQueueApcThread()
를 호출하는 것을 볼 수 있습니다.
흥미롭게도, QueueUserApc()
를 호출할 때 인자로 주었던APCPoc()
은 그대로 NtQueueApcThread() 에 전달될 것 같지만 그렇지 않습니다. NtQueueApcThread()
는 APCProc()
를 인자로 받기는 하지만 ApcRoutine 으로써 받지 않고, 대신, ntdll!RtlDispatchAPC()
를 ApcRoutine으로 받습니다. 그리고 원래 전달된 APCProc()
의 주소는 ntdll!RtlDispatchAPC()
의 인자로 넘겨집니다.1
ntdll!RtlDispatchAPC()
의 내용을 살펴보겠습니다.
ntdll!RtlDispatchAPC()
는 세 번째 인자 APCProc()
가 유효한지 확인하는 루틴으로 시작합니다. 이는 APC를 보내기 전에 ActivationContext가 활성화 되어야 함을 의미합니다.
ActivationContext 가 비활성 상태라면 ntdll!RtlDispatchAPC()
는 아래의 동작을 수행합니다.
- 전달된 ActivationContext(위에서는 ESI)는
RtlActivateActivationContextUnsafeFast()
를 통해 활성화된다.APCProc()
의 인자QueueUserApc() 를 호출할 때 쓴 세 번째 인자는 스택에 쌓는다.APCPorc()
를 곧 호출할 것이기 때문이다.- APC를 보내기 직전, APC 대상이 CFG2 유효한 함수인지 다시 한번 확인한다.(__guard_check_icall_fptr)
APCProc()
의 호출이 이뤄진다. 끝 (==APC가 보내짐)
일단 APCProc()
이 종료되면, ActivationContext 는 비활성 상태가 됩니다.
다른 한편으로, 만약 어떤 ActivationContext도 활성화 될 필요가 없다면
코드는 모든 ActivationContext 관련 동작을 스킵하고, CFG 호출 이후 단순히 APC를 보냅니다.
우린 처음에 대상 프로세스에서 GlobalGetAtomName()
호출하는 방법을 알아내려고 했습니다만, 왜 위의 과정을 장황하게 설명했을까요?
우리는 QueueUserApc()
를 호출할 때, 하나의 인자를 받는 APCProc()
를 전달해야 합니다. 그런데, QueueUserApc()
내부에선 NtQueueApcThread()
를 호출하고, NtQueueApcThread()
내부에선 원격 프로세스의 ntdll!RtlDispatchAPC()
를 호출하는데, 바로 이 ntdll!RtlDispatchAPC()
가 3개의 인자를 받습니다.
마무리
최초 우리의 목표는 세 개의 인자를 받는 GlobalGetAtomName()
을 원격 프로세스에서 호출할 방법을 찾는 것이었습니다. 결론적으로 이는 NtQueueApcThread()
를 호출함으로써 이룰 수 있습니다.
계속…
-
이는 Alertable 상태가 되었을 때 APC 가 어떻게 동작하는지 알면 이해에 도움이 될 수 있습니다.
1. 쓰레드가 Alertable 상태가 되면 커널은 APC queue를 탐색하고, 이 queue에 callback함수 포인터가 있다면, 해당 포인터를 지우고, 이것을 쓰레드로 전송합니다.
2. 쓰레드는 콜백함수를 호출합니다.
3. 1과 2를 APC queue에 함수포인터가 없을 때까지 반복합니다.
4. 함수포인터가 없으면, 원래 쓰레드로 돌아옵니다. ↩ -
CFG – Control Flow Guard, VC++ 컴파일러 기능 중 하나, Callee 함수가 유효한 주소영역에 있는지 검사한다.
CALL CFG == CALL ds:___guard_check_icall_fptr // ECX는 검사 대상 주소 ↩
1 Comment