
Thread Stack Spoofing - 고급 인메모리 우회 기법의 PoC로, 주입된 셸코드의 메모리 할당을 스캐너와 분석가로부터 더 잘 숨길 수 있도록 합니다.
스레드 호출 스택을 스푸핑하는 고급 인메모리 회피 기법의 PoC 구현입니다. 이 기술은 스레드 기반 메모리 검사 규칙을 우회하고 인-프로세스 메모리에서 셸코드를 더 잘 숨길 수 있게 해줍니다.
이는 검사 중인 스레드의 호출 스택에서 셸코드 프레임에 대한 참조를 찾는 악성코드 분석가, AV 및 EDR을 회피하기 위한 Thread Stack Spoofing 기법의 예제 구현입니다. 아이디어는 스레드의 호출 스택에서 셸코드에 대한 참조를 숨겨 악성코드 코드가 포함된 할당을 위장하는 것입니다.
이 구현은 제 ShellcodeFluctuation과 함께 상용 C2 제품이 제공하는 기능을 따라잡기 위한 모범 구현을 공격적 보안 커뮤니티에 제공하여, Red Team 도구에서 뒤처지지 않도록 합니다. 💪
현재 구현은 원래 공개된 것과 크게 다릅니다. 그 이유는 우리가 제어하는 첫 번째 프레임의 반환 주소에 0을 쓰기만 하면 스레드 호출 스택 처리를 종료하고 셸코드 관련 프레임을 숨기는 훨씬 간단한 방법이 있다는 것을 깨달았기 때문입니다:
void WINAPI MySleep(DWORD _dwMilliseconds)
{
[...]
auto overwrite = (PULONG_PTR)_AddressOfReturnAddress();
const auto origReturnAddress = *overwrite;
*overwrite = 0;
[...]
*overwrite = origReturnAddress;
}
StackWalk64를 활용한 이전 구현은 이 커밋 c250724에서 확인할 수 있습니다.
이 구현은 훨씬 안정적이며 두 아키텍처(x64 및 x86)에서 Debug 및 Release 모두에서 잘 작동합니다.
호출 스택이 스푸핑되지 않은 경우의 모습입니다:

반면, 스레드 스택 스푸핑이 활성화된 경우입니다:

위에서 볼 수 있듯이 호출 스택의 마지막 프레임은 MySleep 콜백입니다. 이것이 즉시 새로운 IOC 기회를 제공하는지 궁금할 수 있습니다. 헌팅 규칙은 호출 스택이 시스템 라이브러리 내에 있는 다음과 같은 예상 스레드 진입점으로 풀리지 않는 스레드를 찾을 수 있습니다:
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21
그러나 스푸핑된 스레드의 호출 스택은 처음에는 다소 이상해 보일 수 있습니다. 제 시스템을 간략히 살펴보면 위의 진입점으로 풀리지 않는 다른 스레드도 있음을 보여줍니다:

위 스크린샷은 수정되지 않은 Total Commander x64의 스레드를 보여줍니다. 보시다시피, 초기 호출 스택 프레임 측면에서 우리의 것과 매우 유사합니다.
단순히 모방할 수 있는 특성을 보이는 프로세스가 있는데 왜 호출 스택을 신중하게 위조해야 할까요?
대략적인 알고리즘은 다음과 같습니다:
dbghelp.dll에서 필요한 모든 함수 포인터를 획득하고 SymInitialize를 호출합니다.kernel32!Sleep을 후킹하여 우리의 콜백을 가리키게 합니다.VirtualAlloc + memcpy + CreateThread를 통해 셸코드를 인젝션하고 실행합니다. 스레드는 우리의 runShellcode 함수에서 시작해야 하며, 스레드의 _StartAddress_가 예상치 못한 비정상적인 곳(예: ntdll!RtlUserThreadStart+0x21)을 가리키는 것을 방지합니다.MySleep 콜백이 호출됩니다.0으로 덮어쓰며, 이는 효과적으로 호출 스택을 종료합니다.::SleepEx를 호출하여 추가 통신을 기다리는 동안 Beacon의 Sleep을 허용합니다.함수 반환 주소는 RBP/EBP 레지스터가 가리키는 스레드 스택 메모리 영역 전체에 흩어져 있습니다. 스택에서 이를 찾으려면 먼저 프레임 포인터를 수집한 다음 역참조하여 덮어써야 합니다:

(위 이미지는 Eli Bendersky의 게시물 Stack frame layout on x86-64에서 가져왔습니다.)
*(PULONG_PTR)(frameAddr + sizeof(void*)) = Fake_Return_Address;
ThreadStackSpoofer의 초기 구현은 walkCallStack 및 spoofCallStack 함수에서 그렇게 했지만, 현재 구현은 이러한 노력이 _은밀한 호출 스택 유지에 필요하지 않음_을 보여줍니다.
사용법:
C:\> ThreadStackSpoofer.exe <shellcode> <spoof>
여기서:
<shellcode>는 셸코드 파일의 경로입니다.<spoof>가 1 또는 true이면 스레드 스택 스푸핑을 활성화하고, 그 외의 값이면 비활성화합니다.비콘의 스레드 호출 스택을 스푸핑하는 실행 예시:
PS D:\dev2\ThreadStackSpoofer> .\x64\Release\ThreadStackSpoofer .\tests\beacon64.bin 1
[.] Reading shellcode bytes...
[.] Hooking kernel32!Sleep...
[.] Injecting shellcode...
[+] Shellcode is now running.
[>] Original return address: 0x1926747bd51. Finishing call stack...
===> MySleep(5000)
[<] Restoring original return address...
[>] Original return address: 0x1926747bd51. Finishing call stack...
===> MySleep(5000)
[<] Restoring original return address...
[>] Original return address: 0x1926747bd51. Finishing call stack...
코드와 구현을 살펴보고 개념을 이해한 후 Red Team 작업을 전달하는 데 사용하는 자체 셸코드 로더에 개념을 재구현하세요. 이는 고급 인메모리 회피를 위한 또 다른 기술로, 팀이 안티바이러스, EDR 및 임플란트를 검사하는 악성코드 분석가에게 적발되지 않을 가능성을 높여줍니다.
고급 셸코드 로더를 개발하는 동안 다음을 구현하는 것도 고려할 수 있습니다:
BeaconEye와 같은 Beacon 구성 추출기를 회피할 수 있습니다.RW로 변경하고 내용을 암호화 - Shellcode Fluctuation 기법 사용 - Sleep 직전에 수행 ( Moneta 또는 pe-sieve와 같은 스캐너를 회피할 수 있습니다.)지적된 바와 같이, 여기서의 기술은 _스택 스푸퍼_라는 이름에 아직 진정으로 부합하지 않습니다. 단순히 스레드 스택의 반환 주소를 덮어쓰기 때문에 스택 자체의 나머지 영역을 스푸핑하는 것은 아닙니다. 게다가 호출 스택을 _언와인더블(unwindable)_하게 남겨두어 시스템이 전체 호출 스택 프레임 체인을 제대로 탐색할 수 없기 때문에 이상하게 보입니다.
하지만 이러한 단점을 알고 있지만, 현재로서는 그대로 두었습니다. 주로 프로세스를 반복하고, 스레드를 열거하고, 스레드 스택을 탐색하며 비이미지 메모리(예: SEC_PRIVATE - VirtualAlloc 등에 의해 동적으로 할당된 메모리)를 가리키는 반환 주소를 찾는 자동화된 스캐너를 회피하는 데 주로 관심이 있었기 때문입니다. 집중된 악성코드 분석가는 즉시 이상함을 발견하고 스레드를 상당히 비정상적으로 간주하여 임플란트를 추적할 것입니다. 확신합니다. 그러나 현재 AV/EDR과 같은 자동화된 스캐너가 각 스레드의 스택을 _실제로 탐색_하여 언와인더블(un-windable) 여부를 확인하는 휴리스틱을 구현했다고 생각하지 않습니다 ¯\_(ツ)_/¯.
확실히 이 프로젝트(및 C2 프레임워크에서 발견되는 상용 구현)는 AV 및 EDR 공급업체가 이러한 새로운 회피 기술을 다루는 적절한 휴리스틱 구현을 고려해야 하는 근거를 제공합니다.
이 기술을 개선하기 위해 역언와인딩 프로세스에서 신중하게 제작된 가짜 스택 프레임을 삽입하여 진정한 _Thread Stack Spoofer_를 목표로 할 수 있습니다. 아래에서 이 아이디어에 대해 자세히 읽어보세요.
namazso와의 수 시간에 걸친 대화를 통해 적절한 스레드 스택 스푸퍼를 목표로 하려면 x64 호출 스택 언와인딩 프로세스를 역공학해야 한다는 것을 배웠습니다. 먼저 아래 (a) 링크에서 설명된 스택 언와인딩 프로세스를 주의 깊게 이해해야 합니다. 시스템이 x64 아키텍처에서 스레드 호출 스택을 탐색할 때 단순히 스레드 스택에 흩어져 있는 반환 주소에 의존하지 않고 다음과 같이 수행합니다:
RUNTIME_FUNCTION, UNWIND_INFO 및 UNWIND_CODE 구조체를 반환합니다. 이러한 구조체는 함수의 시작 주소, 종료 주소 및 RBP 또는 RSP를 수정하는 모든 코드 시퀀스의 위치를 설명합니다.UNWIND_CODE를 처리하여 해당 프레임의 반환 주소 및 스택 포인터 값의 위치를 정확히 계산합니다.이 프로세스를 방해하려면 RtlVirtualUnwind의 역(reverted) 형태를 만들어 되돌려야 합니다. 모듈(예: kernel32)에 정의된 함수를 반복하고, 각 함수의 UNWIND_CODE 코드를 스캔하고, 이를 역방향으로 면밀히 에뮬레이션하여(RtlVirtualUnwind 및 정확히 RtlpUnwindPrologue와 비교) 가짜 반환 주소를 배치할 스택의 위치를 찾아야 합니다.
namazso는 호출 스택을 깔끔하게 연결하기 위해 3개의 가짜 스택 프레임을 도입해야 한다고 언급합니다:
MySleep의 호출자와 다르게 언와인드되는 "desync" 프레임(가젯 프레임(gadget-frame)으로 간주)입니다(다른 UWOP - Unwind Operation 코드 사용). 모듈의 모든 함수를 살펴보고 UWOP를 확인하여 가짜 프레임의 크기를 계산합니다. 이 프레임은 MySleep의 호출자와 다른 UWOP를 가져야 합니다.RBP로 팝(pop)하여 언와인드되는 함수입니다. 기본적으로 UWOP_PUSH_NONVOL 코드를 통해 수행됩니다.UWOP_SET_FPREG 코드를 통해 RBP에서 RSP를 복원하는 함수가 필요합니다.복원된 RSP는 제어 흐름이 MySleep으로 진입한 지점에서 가져온 RSP로 설정되어야 하며, 세 번째 가젯이 거기서 언와인드됨에 따라 우리의 모든 프레임이 숨겨집니다.
프로세스를 시작하려면 IMAGE_DIRECTORY_ENTRY_EXCEPTION 데이터 디렉터리 항목을 역참조하여 실행 파일의 .pdata를 반복할 수 있습니다. 다음 예를 고려하세요:
ULONG_PTR imageBase = (ULONG_PTR)GetModuleHandleA("kernel32");
PIMAGE_NT_HEADERS64 pNthdrs = PIMAGE_NT_HEADERS64(imageBase + PIMAGE_DOS_HEADER(imageBase)->e_lfanew);
auto excdir = pNthdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
if (excdir.Size == 0 || excdir.VirtualAddress == 0)
return;