
간접 시스템 콜을 탐지하는 사용자 모드 탐지기입니다. Hell's Hall, Tartarus' Gate, RecycledGate, VEH 시스템 콜 및 더 많은 것을 포착합니다.
Copyright (C) 2026 Adam Zypherion <[email protected]> — GPL-3.0 라이선스.
간접 시스템 콜은 한 번 보면 간단합니다. 로더가 ntdll을 탐색하고 Nt* 프롤로그에서 SSN을 읽어내고, 두 바이트 뒤에서 0F 05를 찾은 다음, r10/rax/rdx/r8/r9를 직접 설정하고, 그 두 바이트를 향해 jmp합니다. 시스템 콜은 ntdll 내부에서 발생합니다. kernel32 익스포트에 걸린 훅은 절대 닿지 않습니다. ntdll 익스포트에 걸린 훅도 절대 닿지 않습니다. SYSCALL 시점의 [RSP]는 로더의 RWX 페이지를 가리키지만, 외부에서 보기에 호출 자체는 특이한 점이 없습니다.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
변종에는 이름이 있습니다. Tartarus' Gate와 RecycledGate는 한 스텁에 속한 syscall 명령어를 사용하지만 다른 스텁의 SSN을 사용하므로, 훅이 보고하는 스텁 이름을 로깅하더라도 거짓입니다. VEH syscall은 의도적으로 액세스 위반을 발생시키고 자체 VEH를 사용하여 컨텍스트를 다시 작성해 RIP가 ntdll의 syscall 명령어에 도달하도록 하되 레지스터는 이미 준비된 상태로 만듭니다. Hell's Gate는 ntdll을 전혀 사용하지 않습니다. 로더가 자체 RWX 페이지에 0F 05를 작성하고 그곳에서 실행합니다.
커널 모드(KM)의 대답은 드라이버입니다. 시간이 지나면 실제로 작업하여 프로젝트를 공개할 수도 있지만, 당장은 아닙니다 :D
PAGE_GUARD는 작동할 것처럼 보입니다. syscall 바이트가 있는 페이지를 PAGE_GUARD로 표시하고, VEH에서 STATUS_GUARD_PAGE_VIOLATION을 잡은 다음, 검사하고, 개인 트램펄린으로 리디렉션하고, 트랩 플래그를 설정하고, 단일 스텝으로 빠져나온 후 페이지를 다시 복원합니다. Nt 호출당 한 번의 트랩이며, 로더가 어떻게 그곳에 도달했든 상관없습니다. Hell's Gate를 제외한 모든 것에서 작동합니다.
문제는 OS가 협조하지 않는다는 점입니다. PAGE_GUARD는 일회용입니다. 즉, 매번 발동될 때마다 비트가 지워지고 다시 설정해야 합니다. 핸들러는 syscall을 수행합니다(NtProtect로 가드를 다시 설정하고, NtContinue로 재개). 그 syscall에는 스텁이 있고, 그 스텁은 방금 가드한 페이지에 있습니다. 대부분의 문제는 우리가 소유한 별도의 페이지에 private syscall 스텁을 구축하여 해결했습니다(RWX 할당, mov r10,rcx; mov eax,SSN; syscall; ret 작성, RX 잠금, ntdll을 절대 건드리지 않음). 하지만 취약했습니다. Windows 마이너 버전이 바뀔 때마다 타이밍이 달라졌습니다. 샘플이 생성하는 모든 스레드는 가드를 재구축하는 무결성 작업자와의 또 다른 경쟁이었습니다. 동일한 시스템에서 10회 실행에 걸친 데모 성공률은 약 30~50%였습니다.
결국 저는 Windows가 PAGE_GUARD가 제가 원하는 방식으로 동작하도록 설득하려는 시도를 포기하고, 대신 바이트를 덮어쓰는 방법을 시도했습니다.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
syscall 명령어의 바이트는 0F 05입니다. 첫 번째 바이트(0F)만으로는 SYSCALL, CPUID, RDTSC를 포함한 2바이트 opcode 계열의 접두사입니다. 그 자체로는 실행 가능하지 않습니다. CPU가 디코딩하려면 두 번째 바이트가 필요합니다. 첫 번째 바이트를 0xCC(INT3)로 바꾸면 쌍이 CC 05가 되고, CPU는 이를 INT3과 그 뒤에 절대 도달하지 않는 잡음 바이트로 디코딩합니다. 해당 주소에 도달하는 모든 코드 경로는 EXCEPTION_BREAKPOINT를 발생시킵니다.
이것이 전체 메커니즘입니다. VEH가 중단점을 잡고, 해당 주소가 어떤 스텁에 속하는지 확인하고(초기화 시 ntdll의 익스포트를 열거하여 맵을 구축), 나타난 스텁에 대해 세 가지 검사를 수행한 다음, Context->Rip을 실제 syscall을 수행하고 반환하는 개인 트램펄린으로 설정합니다. 바이트는 CC로 유지됩니다. 다음 호출자도 동일한 방식으로 적중하므로 페이지 보호를 앞뒤로 전환할 필요가 없습니다.
명확히 하자면: 예, 이것은 바이트 덮어쓰기를 통한 후킹입니다. 단지 사람들이 일반적으로 "ntdll 훅"이라고 말할 때 의미하는 바이트 위치가 아닙니다. 기존 EDR 훅은 스텁의 첫 번째 바이트를 JMP <my_func>로 덮어씁니다:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; "mov r10, rcx"를 덮어씀
...
0F 05 syscall
C3 ret
간접 syscall이 정확히 이것을 우회합니다. 로더가 SSN을 직접 읽고 0F 05로 직접 점프하므로 프롤로그(그리고 당신의 jmp)는 절대 실행되지 않고, 훅은 절대 발동되지 않습니다. 우리가 하는 것은 syscall 바이트 자체를 덮어쓰는 것입니다:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; 손대지 않음
B8 18 00 00 00 mov eax, 18h ; 손대지 않음
CC 05 int3 / 05 ; 0F 05였음, 0F 대신 CC를 썼음
C3 ret
이제 해당 주소에 어떻게 도달했는지는 중요하지 않습니다. 프롤로그를 통해, 프롤로그를 건너뛰는 간접 점프를 통해, RIP를 그곳에 떨어뜨리는 VEH-syscall 컨텍스트 재작성을 통해, 무엇이든 상관없습니다. CPU가 그 바이트를 실행하면 트랩이 발생합니다. 고전적인 훅과 동일한 계열의 기술이지만, 위치가 다르고 완전히 다른 범위를 포괄합니다.
트램펄린은 다음과 같습니다:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
세 가지 검사는 PAGE_GUARD 버전에서 변경되지 않았습니다. 올바른 세 가지 검사였기 때문입니다. 단지 안정적으로 살 수 있는 곳이 필요했을 뿐입니다.
리턴 주소. [RSP]는 syscall이 반환되었을 위치입니다. 진짜 호출의 경우 ntdll, kernel32, kernelbase 또는 관련 런타임 DLL 중 하나 내부입니다. Hell's Hall의 경우 로더가 실행 중인 RWX 페이지 내부입니다. 초기화 시 해당 이름들에 대해 GetModuleHandle을 호출하고 PE 헤더에서 .text 범위를 읽어 신뢰할 수 있는 리턴 대상의 짧은 목록을 구축합니다.
SSN. 스텁의 프롤로그가 syscall 바이트에 도달하기 전에 이미 실행되었으므로 eax에는 로드된 값이 들어 있습니다. 로더가 Tartarus 교체를 수행한 경우 해당 값은 열거 시 이 동일한 스텁에서 읽은 SSN과 일치하지 않습니다. 불일치를 로깅하고, 트램펄린은 자체 syscall 전에 올바른 SSN을 작성하므로 실행되는 커널 함수는 로더가 원했던 함수가 아니라 바이트에 속한 함수입니다. 기술이 로깅되고 동시에 무력화됩니다.
스택 워크. 현재 컨텍스트에서 RtlVirtualUnwind를 다섯 프레임 위로 수행합니다. 모든 프레임의 RIP는 알려진 모듈 내에 있어야 하고 RUNTIME_FUNCTION 항목이 있어야 합니다. 셸코드와 ROP 가젯은 [RSP] 자체가 신뢰할 수 있어 보일 때조차 실패합니다. 로더는 이를 위조할 수 있습니다(호출자가 메모리에서 대략 어디에 있을지 예측하고 그곳에 그럴듯한 리턴 주소를 생성).
어느 검사라도 실패하면 잠금 없는 링에 작은 구조체를 푸시하고, 드레인 스레드가 다음 깨어날 때 이를 출력합니다.
[!! hallwatch !!] indirect syscall (untrusted caller, wrong ssn for this stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub encodes 0x00000018)
thread : 26388
Hell's Gate는 INT3이 더 이상 도움이 되지 않는 경우입니다. 우리는 로더의 RWX 페이지를 패치한 적이 없습니다. 그 존재를 전혀 몰랐기 때문입니다.
대신 우리가 하는 것은 작동할 수 있는 가장 간단한 스캐너입니다. 250ms마다(기본적으로 너무 빈번하며 CPU 사이클을 낭비하지만 POC이므로) 무결성 작업자가 VirtualQuery로 주소 공간을 탐색하며, 실행 가능한 페이지 보호 속성이 있는 MEM_COMMIT 영역을 찾습니다. 영역이 로드된 모듈 내부에 있으면 건너뜁니다. 자체 트램펄린 풀 내부에 있으면 건너뜁니다. 나머지는 외부 실행 가능 메모리입니다. 최대 64KB를 스캔하여 0F 05 바이트 쌍을 찾고, 주소로 중복 제거한 후 각 고유 적중을 한 번씩 로깅합니다. 바보같고 CPU 사이클을 낭비한다는 것을 알지만 POC이므로 상황을 과소평가해서는 안 되지만 현재는 그렇습니다.
이것은 새로운 RWX 할당에서 Hell's Gate를 잡아내고, 섀도우 ntdll(로더가 NtMapViewOfSection을 사용하여 ntdll.dll의 개인 복사본을 새로운 주소에 얻는 경우)도 잡아냅니다. 섀도우 케이스는 재미있습니다. 매핑이 모듈 스냅샷에 없으므로 syscall 명령어가 외부 실행 가능 바이트로 나타나지만, 이는 디스크에 있는 완전히 합법적인 서명된 DLL에서 비롯된 것입니다.
다른 변종: 공격자가 0F 05를 자신의 바이너리 .text에 직접 포함시켜 syscall이 RWX 페이지 대신 로드된 모듈 내에 살게 하는 경우입니다. 외부-RWX는 로드된 모듈을 건너뛰므로 이전에는 이를 우회했습니다. 그래서 각 모듈의 실행 가능 바이트를 탐색하는 또 다른 스캐너가 있으며, ntdll, win32u, wow64cpu(합법적으로 syscall 명령어가 있는 세 모듈)를 건너뛰고, 0F 05 바로 다음 세 바이트 내에 C3가 있는 경우에만 보고합니다. ret 검사가 이를 실용적으로 만듭니다. 0F 05는 컴파일러 출력에서 더 큰 명령어의 일부로 자주 나타나지만 C3와 인접한 경우는 거의 없습니다. 실제 syscall 스텁은 항상 그렇습니다. 로그:
[hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate inside loaded module .text
무결성 작업자에게는 두 가지 다른 작은 작업이 있습니다. 매 틱마다 패치된 모든 스텁의 바이트를 읽고, CC가 아니면 다시 CC를 씁니다. 이것은 가능한 가장 간단한 언훅입니다. 샘플이 어딘가(새로운 ntdll 매핑, 디스크의 PE, 알려진 올바른 바이트 테이블)에서 원래 0F를 읽고 우리 패치 위에 다시 씁니다. 우리는 다음 틱에 이를 감지하고 다시 패치합니다.
매 50번째 틱(약 12.5초)마다 작업자가 체인 맨 앞에 VEH를 다시 설치하고 이전 핸들을 제거합니다. 샘플이 LdrpVectorHandlerList를 탐색하여 우리를 등록 해제할 수 있습니다. 작업자는 그 간격이 열려 있는 시간을 제한합니다. 12.5초는 공격을 받고 있을 때는 긴 시간이고, 샌드박스에서 멀웨어를 실행할 때는 짧은 시간입니다. 더 자주 수행하는 비용을 측정한 후에 아마 단축할 것입니다.
작업자 스레드는 VEH가 확인하는 TLS 플래그를 설정하여 실행됩니다. 작업자 자신의 syscall(패치용 NtProtect, 스캔용 NtQuery)은 다른 것과 마찬가지로 중단점을 트리거하지만, 플래그는 VEH가 탐지 로직을 건너뛰고 조용히 트램펄린을 통해 리디렉션하도록 지시합니다.
임계 섹션은 처음 누군가가 호출할 때 3단계 비교-교환(0=미초기화, 2=사용 중, 1=준비)을 통해 자체 초기화됩니다. 추하지만 DLL 내부에서 정적 생성자를 피할 수 있습니다. Windows에서는 로더 잠금 등과 관련된 별도의 문제 세트가 있기 때문입니다.
한 가지 ABI 문제를 언급할 필요가 있습니다. INT3은 트랩입니다. 즉, VEH가 호출될 때 Context->Rip은 트랩 자체가 아니라 다음 명령어를 가리킵니다. 0x7FF827660372에서 바이트를 패치한 경우, Context->Rip은 VEH에 0x7FF827660373로 도착합니다. Record->ExceptionAddress는 트랩을 가리키지만, 트램펄린으로 리디렉션하기 전에 Context->Rip = ExceptionAddress로 재설정해야 합니다. 그렇지 않으면 트램펄린이 한 바이트 늦게 시작하여 syscall이 아무것도 하지 않습니다. 이 버그를 찾는 데 많은 시간이 걸렸기 때문에 알려드립니다.
네 가지 익스포트가 있습니다. IscInitialize는 탐지기를 무장시킵니다. DllMain이 자동으로 호출하지만, 호스트 프로세스에서 호출하여 반환 값을 받을 수도 있습니다. IscGetDetectionCount는 단조 증가하는 카운터를 반환합니다. IscShutdown은 진행 중인 핸들러를 기다리고 0F 바이트를 복원합니다. IscFlush는 링을 동기적으로 비웁니다. 샘플이 종료되기 전에 이벤트를 추출해야 하는 샌드박스에 탐지기를 포함할 때 유용합니다.
최소 통합은 LoadLibrary입니다. DllMain이 초기화를 처리하고 두 백그라운드 스레드를 시작합니다.
Isc = Indirect Syscalls
아직 잡지 못하는 것들.
무결성 검사를 수행하는 샘플
우리가 패치하지 않은 스텁을 사용하는 샘플. 현재 허용 목록에는 공격적인 메모리, 프로세스, 스레드, 섹션, 토큰 및 파일 프리미티브를 포함한 약 40개의 이름이 있습니다. DllMain이 신속하게 완료되는 한 더 추가하는 것은 점진적입니다. 로더 잠금 내에서 488개 스텁을 모두 패치하는 것은... 음...
커널 권한으로 실행되는 샘플. 사용자 모드 문제가 아닙니다.
INT3이 우리가 최종 선택한 것이지만, 트랩 메커니즘 자체가 흥미로운 부분은 아닙니다. PAGE_GUARD보다 더 잘 작동하는 이유는 트랩이 OS와 지속적인 협상을 필요로 하지 않기 때문입니다. 바이트는 CC입니다. 계속 CC로 유지됩니다. OS는 ntdll에 어떤 바이트가 있는지에 대해 의견이 없습니다.