좋은 CLR 호스트 되기 - 공격적인 .NET 트레이드크래프트 현대화하기

어두운 방 안에서 컴퓨터로 타이핑하는 여성

현대의 레드팀은 엔드포인트를 타협하고 목표를 완수하기 위한 조치를 취하는 능력으로 정의됩니다. 전자를 달성하기 위해 많은 팀에서 자체 커스텀 명령 및 제어(C2)를 구현하거나 오픈 소스 옵션을 사용합니다. 후자의 경우 Windows, Active Directory 및 타사 애플리케이션의 다양한 기능을 활용하는 악용 후 도구가 지속적으로 릴리스되고 있습니다. 이 툴링의 실행 메커니즘은 지난 몇 년 동안 메모리에서 .NET 어셈블리를 실행하는 데 크게 의존해 왔습니다.

최신 레드팀 무기고의 큰 부분을 차지하고 있음에도 불구하고 손상된 엔드포인트에서 .NET 어셈블리를 실행하는 기술은 크게 정체되어 있습니다. 이 블로그 게시물에서는 레드팀이 어떻게 .NET 실행 하네스를 이 10년 안에 도입할 수 있는지에 대해 설명합니다.

주요 특징

  • 운영자는 메모리에서 .NET 어셈블리를 실행할 때 "CLR 사용자 지정"을 사용하여 CLR의 여러 측면을 제어할 수 있습니다.
  • 운영자는 CLR에 대한 메모리 관리를 인수하여 CLR에서 수행한 모든 할당을 제어하고 추적할 수 있으며, 프로세스에 로드되는 어셈블리를 쉽게 추적할 수 있습니다.
  • 맞춤형 어셈블리 로딩 관리자를 구현하면 바이트 패치나 프로세스 해킹 없이 '의도한' 기능만 사용하여 새로운 AMSI 우회가 가능합니다.

어셈블리 실행의 간략한 역사

얼마 전까지만 해도 많은 레드팀은 악용 후 툴링을 위해 PowerShell에 의존했습니다. Cobalt Strike는 2018년에 실행-조립 모듈을 도입하여 이를 바꾸기 위한 조치를 취했습니다. 실행-어셈블리는 희생 프로세스를 생성하고 여기에 반사 DLL을 주입하여 CLR(공용 언어 런타임)을 로드하고 운영자가 제공한 .NET 어셈블리를 실행합니다.

그 결과 많은 익스플로잇 후 툴링이 .NET 어셈블리로 전환되었습니다. 잠시 후 방어자들은 어셈블리 실행의 '포크 앤 런' 동작, 즉 반사형 DLL 인젝션에 대한 탐지 기능을 구축하기 시작했습니다. 이를 해결하기 위해 IBM® Adversary Simulation 팀의 Shawn JonesInlineExecute-Assembly Beacon Object File(BOF)을 개발했습니다. 이를 통해 운영자는 실행-조립의 '포크 앤 런' 동작에서 벗어나 임플란트 프로세스 내에 머물 수 있었습니다. 그 이후로 많은 C2 프레임워크가 이 동작을 기본적으로 채택했습니다.

CLR을 호스팅하고 .NET 어셈블리를 실행하는 방법에 아직 익숙하지 않다면 위에 링크된 Shawn의 블로그 게시물을 읽어보시기 바랍니다.

Mixture of Experts | 12월 12일, 에피소드 85

AI 디코딩: 주간 뉴스 요약

세계적인 수준의 엔지니어, 연구원, 제품 리더 등으로 구성된 패널과 함께 불필요한 AI 잡음을 차단하고 실질적인 AI 최신 소식과 인사이트를 확인해 보세요.

공용 언어 런타임을 호스팅하는 방법

실행 어셈블리 기법은 “관리되지 않는 CLR 호스팅“이라는 Windows 기능을 활용합니다. 공용 언어 런타임(CLR)은 .NET의 런타임 환경입니다. 사용자는 중간 언어(IL)로 컴파일된 다양한 언어(C#, F# 등)로 .NET 어셈블리를 작성할 수 있습니다. CLR은 IL이 포함된 어셈블리를 가져와 실행합니다.

전통적으로 execute-assembly 기법은 더 이상 사용되지 않는 ICorRuntimeHost 인터페이스에 의존해 왔습니다.공격 연구자들이 이 인터페이스를 사용하는 이유는 앱 도메인을 생성한 뒤 Load_3 메서드를 사용하여 메모리 내 바이트 배열에서 어셈블리를 로드할 수 있기 때문입니다. 바이트 배열에서 직접 로드함으로써 파일 시스템에 코드를 기록하지 않아도 되며, 이로 인해 보안 솔루션의 파일 기반 스캔을 회피할 수 있습니다.

ICorRuntimeHost 는 이후 ICLRRuntimeHost 인터페이스로 대체되었습니다.

ICLRRuntimeHost 인터페이스에 대한 MSDN 페이지

그림 1: ICLRRuntimeHost 인터페이스에 대한 MSDN 페이지

ICLRRuntimeHost 인터페이스에 대한 MSDN 문서에는 해당 인터페이스가 SetHostControl 메서드를 추가하지만, ICorRuntimeHost에서 제공하는 일부 메서드는 제외한다고 명시되어 있습니다. Microsoft가 “일부 메서드의 제외”라고 표현할 때 이는 리플렉션 방식으로 어셈블리를 로드할 수 있게 해주는 핵심 메서드들이 모두 제외된다는 의미입니다. 그 대신 SetHostControl 메서드를 통해 CLR 사용자 정의 기능에 접근할 수 있습니다.

참고: ICLRRuntimeHost을 직접 사용해 리플렉티브 어셈블리를 로드할 수는 없지만, ICLRRuntimeHost을 사용해 CLR을 시작한 후 GetInterface를 호출하여 ICorRuntimeHost 인터페이스를 획득할 수 있습니다. 그 후 ICorRuntimeHost 인터페이스를 사용하면 CLR 사용자 정의 기능을 활성화한 상태에서 리플렉티브 어셈블리를 로드할 수 있습니다.

CLR 사용자 지정이란 무엇인가요?

SetHostControl 메서드를 통해 IHostControl COM 인터페이스의 자체 구현을 제공할 수 있으며, 이를 통해 CLR이 다양한 사용자 정의 기능을 사용하도록 지정할 수 있습니다. CLR 사용자 정의 기능은 비교적 잘 알려지지 않았지만, 개발자가 CLR의 동작 일부를 직접 제어할 수 있도록 해주는 기능입니다. 사용자 정의는 개발자가 구현할 수 있는 여러 “매니저” 인터페이스를 통해 동작하며, IHostControl 구현을 통해 CLR에 어떤 매니저를 사용할지 전달합니다. 구현하지 않은 부분은 CLR이 기본 동작 방식대로 처리합니다. 지원되는 매니저 목록은 아래와 같습니다.

CLR 사용자 지정이 지원되는 일부 인터페이스

그림 2: CLR 사용자 지정이 지원되는 일부 인터페이스

이 블로그 게시물에서 다룰 두 개의 매니저 구성 요소인 IHostMemoryManagerIHostAssemblyManager을 빨간 상자로 강조 표시했습니다. 먼저 IHostControl 인터페이스를 구현하겠습니다.

IHostControl 구현

초기 CLR 사용자 정의 개념 증명은 C++로 작성했지만, 최종적으로는 순수 C로 재구현했으며, 본 글에서는 그 구현을 살펴봅니다. C++의 불필요한 코드 증가를 피하기 위해 C로 작성된 임플란트를 선호하므로, 이 어셈블리 실행 하니스 역시 C로 구현했습니다. C에서 COM 인터페이스를 구현하는 것은 매우 번거로운 작업이지만, 아래 내용을 통해 향후 구현이 조금 더 수월해지길 바랍니다. 아래는 “MyHostControl”이라고 명명한 IHostControl 인터페이스 정의 방법입니다.

IHostControl 인터페이스를 구현하는 헤더 파일

그림 3: IHostControl 인터페이스를 구현하는 헤더 파일

COM 인터페이스를 구현하려면 다음 구성 요소가 있어야 합니다(위의 순서대로 표시).

  1. 우리 인터페이스의 함수 포인터들을 포함하는 구조체에 대한 typedef입니다. QueryInterface, AddRef, Release 함수는 기본 골격으로, 모든 COM 인터페이스에 공통적으로 포함됩니다. 아래의 GetHostManagerSetAppDomainManager 함수는 이 인터페이스에 특화된 함수입니다.
  2. 가상 테이블(VTBL)과 카운트가 있는 실제 인터페이스를 정의하는 구조체의 typedef입니다.
  3. 별도로 구현할 특정 함수들에 대한 정의입니다. 모든 COM 인터페이스마다 QueryInterface/AddRef/Release를 정의해야 하므로, 이를 구분하기 위해 함수 이름 앞에 “MyHostControl_” 접두사를 붙였습니다.
  4. 앞서 정의했지만 위에서 정의한 함수로 채워진 VTBL의 const입니다.

실제 메서드 구현은 아래와 같이 조금 더 간단합니다.

QueryInterface, AddRef 및 Release 메서드 구현

그림 4: QueryInterface, AddRef 및 Release 메서드 구현

앞서 언급했듯이 QueryInterface/AddRef/Release 메서드는 기본 골격입니다. 다른 인터페이스를 구현하려면 QueryInterface 메서드 내의 “xIID_IHostControl” 값만 변경하면 됩니다.

GetHostManager 메서드 구현

그림 5: GetHostManager 메서드 구현

여기서는 SetAppDomainManager 메서드를 실제로 구현할 필요가 없으며, 이후에 호출하지 않는다면 단순히 E_NOTIMPL을 반환해도 됩니다. 이 인터페이스의 핵심인 GetHostManager 메서드는 사실상 일련의 “if” 문으로 구성되어 있으며, CLR이 우리가 관심 있는 매니저를 요청하는지 확인하는 역할을 합니다. 위 코드에서는 전달된 IID가 IHostMemoryManager 인터페이스인지 확인한 뒤, 새로운 메모리 매니저를 생성하고 ppObject 인자가 이를 가리키도록 설정합니다.

CLR 시작하기

이제 IHostControl 인터페이스를 구현했으므로 SetHostControl을 호출하여 CLR을 시작할 수 있습니다. 아래는 일반적인 CLR 호스팅 절차(CLRCreateInstance, GetRuntime, GetInterface)를 수행한 뒤, 사용자 정의 호스트 제어 인터페이스를 사용해 SetHostControl을 호출하는 코드 스니펫입니다. 그 후 CLR을 시작합니다.

SetHostControl 호출 및 CLR 시작

그림 6: SetHostControl 호출 및 CLR 시작

CLR의 메모리 인수

이제 C에서 COM 인터페이스를 구현하는 방법을 알았으므로, 특정 매니저를 구현하는 과정은 비교적 단순합니다. IHostMemoryManager 인터페이스를 통해 CLR의 메모리 관리 동작을 제어할 수 있습니다. 아래는 IHostMemoryManager 구현에 필요한 전체 함수 목록입니다.

IHostMemoryManager 인터페이스의 메서드 목록

그림 7: IHostMemoryManager 인터페이스의 메서드 목록

몇 가지 흥미로운 동작을 가능하게 하는 메서드, 즉 Windows에서 대부분의 메모리 관리를 수행하는 데 사용되는 Virtual* 메서드(VirtualAlloc, VirtualProtect, VirtualQuery, VirtualFree)를 보셨을 것입니다. 아래는 이러한 메서드를 매우 간략하게 구현한 것입니다.

VirtualAlloc, VirtualFree, VirtualQuery 및 VirtualProtect 구현

그림 8: VirtualAlloc, VirtualFree, VirtualQuery 및 VirtualProtect 구현

메모리 할당을 제어할 수 있게 되면 공격자는 원하는 수준까지 정교하게 동작을 구성할 수 있습니다. 예를 들어 메모리 할당 API 호출을 간접 시스템 호출 방식으로 수행할 수 있습니다. 또한 CLR이 수행하는 모든 메모리 할당을 추적한 뒤, 임플란트가 슬립 상태로 전환될 때 이를 암호화할 수 있습니다. 단, CLR 할당 메모리를 암호화하는 방식은 안정성이 높지 않습니다. Virtual* 계열 메서드 외에도IHostMalloc 인터페이스의 구현을 반환하는CreateMAlloc 메서드가 존재합니다. 이 인터페이스를 통해 CLR이 수행하는 모든 힙 할당을 제어할 수 있습니다.

어셈블리 아티팩트 추적 및 지우기

위에서 CLR 메모리 할당을 암호화하려는 시도가 정확히 어떻게 수행되는지에 따라 '약간 불안정'에서 '매우 불안정'으로 바뀔 수 있다고 언급했습니다. CLR이 나중에 참조하려고 하는 메모리를 암호화하거나 해제하면 CLR에서 오류가 발생하고 프로세스가 중단되기 때문입니다. 하지만 제가 발견한 한 가지 예외가 있습니다. 초기 어셈블리 로드 중에 이루어진 할당입니다.

어셈블리를 메모리에서든 디스크에서든 로드하면 CLR은 메모리 공간을 할당하고 해당 어셈블리를 프로세스 주소 공간에 매핑합니다. 제가 아는 한, 이 메모리 영역을 식별하고 제거하는 공개적으로 알려진 효과적인 방법은 없었으며, 프로세스 메모리에서 특정 바이트 패턴이나 예상 크기의 할당을 검색하는 방식 외에는 마땅한 방법이 없었습니다. CLR 사용자 정의 기능은 AcquiredVirtualAddressSpace 메서드를 통해 이러한 메모리 할당을 추적할 수 있는 간편한 메커니즘을 제공합니다. 이 메서드는 CLR이 프로세스에 어셈블리를 로드할 때마다 트리거되는 알림 콜백이며, 콜백 인자로 해당 할당의 주소와 크기가 전달됩니다. 테스트 결과 이 콜백은 어셈블리가 프로세스에 로드될 때에만 트리거되었으며, 이를 통해 어셈블리 로드로 인해 발생하는 메모리 할당을 효과적으로 추적할 수 있습니다. 신뢰성을 높이기 위해 크기를 확인하거나 메모리를 파싱하여 예상한 어셈블리가 맞는지 검증할 수 있습니다. 아래는 이 메서드를 구현하는 예시입니다. 이 메서드는 단순한 알림 콜백이므로 원하는 작업을 수행한 뒤 S_OK를 반환하면 됩니다..

AcquiredVirtualAddressSpace 메서드 구현

그림 9: AcquiredVirtualAddressSpace 메서드 구현

CLR에서 수행한 다른 할당과 달리 어셈블리 실행이 완료된 후 이 메모리 영역을 암호화하거나 삭제하는 데 아무런 문제가 없었습니다. CLR이 현재 유효하지 않은 캐시된 어셈블리를 사용하려고 할 수 있으므로 동일한 앱 도메인에서 동일한 어셈블리를 다시 실행하려고 하면 문제가 발생할 수 있습니다. 대부분의 실행-어셈블리 구현은 새로운 앱 도메인을 생성하고 실행 후 이를 파괴하므로, 반드시 구현 환경에서 테스트해 보세요.

이 알림 기능에는 사소한 방어 애플리케이션도 있습니다. 일반적으로 방어 제품은 CLR에서 어셈블리 부하를 추적하기 위해 Windows 이벤트 트레이싱(ETW)을 사용하지만, 이는 어셈블리가 프로세스에 로드되었을 때 알림을 받을 수 있는 또 다른 방법을 제공합니다. 메모리 주소와 크기가 포함되어 있으므로 방어 제품이 해당 영역에서 메모리 스캔을 수행하는 것은 사소한 일입니다.

어셈블리 로드 관리

이제 살펴볼 다른 매니저는 IHostAssemblyManagerIHostAssemblyStore입니다. IHostAssemblyManager는 두 가지 역할을 담당합니다. 첫째, CLR 호스트인 우리가 아닌 CLR이 직접 로드해야 할 어셈블리 목록을 제공하고, 둘째, CLR에 IHostAssemblyStore 인터페이스를 반환합니다. IHostAssemblyStore에는 ProvideAssemblyProvideModule 두 가지 메서드가 있습니다.

ProvideAssembly은 CLR이 직접 로드 대상으로 관리하는 어셈블리 목록(IHostAssemblyManager이 반환한 목록)에 포함되지 않은 어셈블리를 로드하라는 요청을 받을 때마다 호출됩니다. CLR은 ProvideAssembly를 호출하면서 어셈블리의 식별자 문자열을 전달하며, ProvideAssembly는 해당 어셈블리의 바이트 데이터를 반환하는 역할을 합니다. 어셈블리 식별자 문자열은 보통 “Seatbelt, Version=0.0.0.0, PublicKeyToken=null, Culture=neutral”와 같은 형식을 가집니다.

ProvideAssembly이 어셈블리를 확인한 뒤, 인자로 전달된 포인터를 설정하여 어셈블리 내용을 반환합니다. 아래 스크린샷에서는 관련 인자인 ppStmAssemblyImage을 강조 표시했습니다.

IHostAssemblyStore 인터페이스의 ProvideAssembly 메서드에 대한 인수

그림 10: IHostAssemblyStore 인터페이스의 ProvideAssembly 메서드에 대한 인수

어셈블리는 인메모리 IStream의 주소에 포인터를 설정하여 반환됩니다. 일반적으로 CLR은 일반 Windows 프로세스에서 DLL을 로드하는 것과 유사하게 디스크의 디렉토리 검색 순서에 따라 어셈블리를 찾으려고 시도합니다. 하지만 우리는 자체 구현을 제공할 수 있기 때문에 일반적으로 디스크에서 로드되는 어셈블리에 대한 요청을 받아 대신 메모리에 있는 어셈블리를 제공할 수 있습니다. 어셈블리 바이트는 IStream에 있어야 하며, 이는 바이트 배열을 받아 IStream을 반환하는 SHCreateMemStream 함수를 사용하여 수행할 수 있습니다.

단순히 메모리에 어셈블리를 로드하는 또 다른 방법일 뿐인데 왜 이것이 중요한지 궁금할 수 있습니다. 안티 멀웨어 스캔 인터페이스(AMSI)는 어떤가요?

AMSI 우회

AMSI는 반사적으로 로드된 모든 어셈블리에 악성 콘텐츠가 있는지 검사하는 역할을 합니다. Windows Defender는 AMSI를 사용하며, AMSI는 다른 EDR이 메모리에 로드된 어셈블리의 내용을 연결하고 스캔할 수도 있습니다. 어떤 사람들은 AMSI를 우회할 수 있다고 비웃는 경향이 있지만, 저는 AMSI가 Windows에 기본적으로 설치되는 것의 경우 상당히 효과적인 보안 기능이라고 생각합니다. 최소한 메모리에서 실행되는 많은 악성 .NET 툴링(예: Seatbelt)을 잡아낼 수 있습니다. 레드 팀원들 사이에서 AMSI 우회에 대한 풍부한 고양이와 쥐의 역사가 있지만, 많은 AMSI 우회는 주요 함수(예: AmsiScanBuffer)에 대한 바이트 패치를 사용하여 실행에 실패하거나 “좋은” 값을 반환하도록 하는 데 의존합니다. 전통적인 AMSI 우회는 .text 내에 Copy-on-Write 바이트를 남기기 때문에 복잡합니다 AMSI.dll 메모리의 한 부분으로, 의심스러운 프로세스를 보고 있는 모든 방어자에게 큰 도움이 될 것입니다. 하드웨어 중단점 후크와 같은 보다 정교한 AMSI 바이패스에는 디버그 레지스터 사용량을 찾기 위해 스레드 컨텍스트를 검사하는 등 다른 IOC도 관련되어 있습니다.

ProvideAssembly 메서드의 자체 구현을 통해 AMSI를 완전히 우회할 수 있습니다. 전통적으로 Load_3 메서드는 바이트 배열에서 어셈블리를 로드하는 데 사용되며, Load_3 는 AMSI에 의해 계측됩니다. 그러나 잘 사용되지 않는 다른 Load_* 함수들도 존재한다는 사실을 알고 계셨나요?

Load 메서드 패밀리

그림 11: Load 메서드 패밀리

Load_2 이는 Load_3과 달리 바이트 배열이 아닌 어셈블리 식별자 문자열을 인자로 받습니다. 일반적으로 이는 CLR이 찾을 수 있는 디스크 상의 어셈블리가 필요함을 의미하지만, CLR이 식별자 기반으로 어셈블리 로드를 요청할 경우 우리 ProvideAssembly 구현에 해당 어셈블리를 제공하도록 요청한다는 점을 알고 있습니다. 또한 ProvideAssembly에서 메모리 내 바이트 배열(IStream 형태)을 반환할 수 있다는 점도 알고 있습니다. 즉, Load_2을 호출하여 CLR에 메모리 내 어셈블리를 전달하고 이를 로드하도록 할 수 있습니다.

여기서 중요한 점은 Load_2을 호출하기 때문에 CLR은 어셈블리를 디스크에서 로드하는 것으로 인식하며, AMSI는 해당 어셈블리 바이트를 검사하지 않는다는 것입니다. 실제로 AMSI 자체가 프로세스에 로드되지도 않습니다.

아래는 AMSI가 프로세스에 로드되지 않은 상태에서 Load_2 호출을 통해 Seatbelt를 실행하는 예시입니다.

안전벨트 실행 및 AMSI 우회하기

그림 12: 안전벨트 실행 및 AMSI 우회하기

AMSI.DLL이 로드되지 않은 프로세스 모듈 목록

그림 13: AMSI.DLL이 로드되지 않은 프로세스 모듈 목록

MSDN 매거진 2006년 8월호에 게재된 CLR 인사이드 아웃 기사에 따르면, Microsoft는 SQL Server 2005에서 디스크가 아닌 데이터베이스에서 .NET 어셈블리를 로드하는 데 유사한 기술을 사용했습니다. SQL 데이터베이스에서 .NET 어셈블리를 저장하고 실행하는 기능은 개인적으로 가장 좋아하는 수평 이동 기술이며, Sanjiv Kawa가 작성한 또 다른 X-Force Red 도구인 SQLRecon을 사용하여 쉽게 수행할 수 있습니다.

개념 증명 및 추가 운영화

이 기법에 대한 개념 증명(PoC) 코드를 GitHub 여기에서 확인할 수 있도록 배포합니다. 이 개념 증명은 IHostControl, IHostMemoryManager, IHostMalloc, IHostAssemblyStore, IHostAssemblyManager COM 인터페이스를 구현하는 방법을 보여줍니다. SetHostControl를 호출하고 ICLRRuntimeHost 인터페이스를 사용해 CLR을 시작합니다

메모리 관리자 구현은 적절한 Windows API(예: VirtualAlloc)를 호출하는 방식으로 구성되어 있으며, CLR이 수행하는 모든 메모리 할당을 추적하는 예제도 포함되어 있습니다. 또한 앞서 설명한 AcquiredVirtualAddressSpace 콜백을 통해 확인된 어셈블리 로드 흔적을 제거하는 예제도 포함되어 있습니다.

AMSI 우회를 위한 전체 개념 증명도 포함되어 있습니다. 이 우회를 자체 툴에 구현하려는 경우 한 가지 주의사항이 있습니다. Load_2 메서드를 사용해 로드하려는 어셈블리의 식별자는 최종적으로 CLR에 반환하는 어셈블리의 식별자와 반드시 일치해야 합니다. 예를 들어 Load_2을 “Seatbelt, Version=0.0.0.0, PublicKeyToken=null, Culture=neutral” 인자로 호출했다면, 최종적으로 실행하려는 어셈블리 역시 동일한 식별자를 가져야 합니다. mscorlib을 로드하려 시도한 뒤 Seatbelt를 반환하는 것은 허용되지 않으며, CLR이 이를 검사하여 오류를 발생시킵니다. 어떤 어셈블리 이름을 로드하려는지 주의해야 합니다. 이 글을 읽는 시점이 언제이든 여전히 Seatbelt라는 이름의 어셈블리를 리플렉티브 방식으로 로드하려 하고 있다면, 이 글을 닫고 더 생산적인 활동을 하는 것이 좋겠습니다.

이 개념 증명에서는 실행될 어셈블리의 식별자 문자열을 얻기 위해 ICLRAssemblyIdentityManager 인터페이스의 GetBindingIdentityFromStream 메서드를 사용합니다. 이 작업을 임플란트 측이 아니라 클라이언트나 팀서버에서 수행하여 어셈블리 식별자를 획득한 뒤, 해당 식별자 문자열을 임플란트에 인자로 전달하는 방식으로 변경할 수도 있습니다.

방어 관점에서의 고려 사항

이것은 새로운 AMSI 우회 방법이지만, 궁극적으로 AMSI 우회 방법일 뿐입니다. 방어형 제품은 악성 어셈블리를 탐지하기 위해 단순히 AMSI에 의존하는 것이 아니라 심층 방어 전략을 활용해야 합니다. 이 기술을 사용하여 로드된 모든 어셈블리는 다른 인메모리 어셈블리와 동일한 ETW(Event Tracing for Windows) 이벤트도 생성합니다. 악성 어셈블리는 메모리 스캔을 통해 탐지할 수 있으며, 앞서 살펴본 여러 고급 EDR 플랫폼에서도 확인할 수 있습니다. 많은 악용 후 도구에도 고유한 IOC가 있습니다.

앞서 언급했듯이, 이 연구는 방어 측면에서도 활용 가능성이 있습니다. AcquiredVirtualAddressSpace 콜백은 어셈블리가 프로세스에 로드될 때 이를 감지할 수 있는 또 다른 메커니즘을 제공합니다. 방어자가 IHostAssemblyStore 인터페이스를 구현한다면, 어셈블리 로딩 체인에 개입하여 어셈블리 로드를 완전히 차단하거나 프로세스에 로드되기 전에 어셈블리의 바이트를 수정할 수 있습니다. 이 영역에서는 향후 추가적인 발전이 이루어질 가능성이 매우 높다고 생각합니다.

지금 공개해야 하는 이유

이번 연구의 진행 일정과 지금 공개하는 이유에 대해 간략히 설명하겠습니다. 해당 연구는 2023년 6월에 수행되었으며, 이후 여러 컨퍼런스 CFP에 제출되었지만 지금까지는 내부적으로만 공유해 왔습니다. 연구 수행 당시 유사 사례가 있는지 확인하기 위해 검색 엔진을 통해 자료를 광범위하게 조사했으며, 처음에는 참고 자료를 찾기 위함이었고 이후에는 해당 동작을 최초로 식별한 것인지 확인하기 위한 목적이었습니다. 이후에도 유사 연구가 공개되었는지 주기적으로 검색해 왔습니다. 2025년 1월 초, “NTT Data” 매거진에 게재된 Marcos González Hermida의 Using CLR Hosting to Evade AMSI라는 글을 발견했습니다.

해당 글 역시 동일한 우회 기법을 공개했으며, 잡지에 따르면 2024년 6월에 최초 게재되었습니다(위에 연결된 보충 자료는 2024년 7월자입니다). 매우 잘 작성된 글이므로 읽어보기를 권합니다. 다만 저자가 어셈블리를 실행하려면 반드시 서명되어야 한다고 결론 내린 점은 언급하고 싶습니다. 해당 개념 증명에서는 Rubeus를 로드한 뒤 GetType_2 메서드를 사용해 Main 메서드를 수동으로 가져오는 방식을 사용합니다. 이 기법으로 서명되지 않은 어셈블리를 로드하는 데 문제를 겪은 적은 없으며, 많은 execute-assembly 구현에서 사용하는 것과 동일한 Entrypoint 메서드를 통해 네임스페이스나 클래스 이름을 알지 못해도 로드된 어셈블리의 진입점을 획득할 수 있습니다.

Marcos의 글과 개념 증명이 공개됨에 따라 이 AMSI 우회 방법은 이제 대중에게 공개될 수 있게 되었으므로, 우리는 우리가 발견한 내용의 개념 증명과 함께 연구 결과를 공개하기로 결정했습니다.

마무리

이 게시물에서는 CLR 사용자 지정을 사용하여 메모리에서 .NET 툴을 실행하면서 OPSEC를 개선하는 방법을 살펴보았습니다. 또한 우리는 잘 알려지지 않은 이러한 CLR 기능을 활용하여 전체 AMSI 바이패스를 시연했습니다. .NET 도구의 사용은 공격자 및 위협 행위자에게 여전히 효과적입니다. 이러한 이유로 방어자는 CLR의 작동 방식을 이해하고 악용 후 툴링을 탐지하기 위한 심층 방어 전략을 구축하는 것이 중요합니다.

감사 인사

이 연구를 리뷰해 준 Brett와 Valentina에게 감사드립니다.

  • Brett Hawkins (@h4wkst3r)
  • Valentina Palmiotti (@chompie1337)

관련 연구

장애 대응: CLR 호스트의 장애 에스컬레이션 정책 – 이 연구를 처음 시작할 때 CLR 사용자 지정을 사용하는 공격적인 기술을 찾을 수 있는 유일한 실제 예입니다.

호스팅된 호박 – 여러 CLR 사용자 지정을 구현하기 위한 개념 증명이 포함된 GitHub 리포지토리입니다.

셸코드: 메모리에서 .NET 어셈블리 로드 — Donut은 C의 모든 관련 데이터 구조 및 정의를 정리하는 데 큰 도움이 되었습니다.

Steven Pratschner의 Microsoft .NET 프레임워크 공용 언어 런타임 사용자 지정 — CLR 사용자 지정에 대한 최종 텍스트입니다. 이 분야에 관심이 있으시다면 꼭 읽어보시기 바랍니다.