For the complete documentation index, see llms.txt. This page is also available as Markdown.

5. Barrel-Kit UDRL

Struggle & UDRL - Barrel Kit

이번 페이지에서는 Struggle과 오퍼레이션 더블 배럴에서 사용됐던 Reflective Loading 담당의 UDRL (User-Defined Reflective Loader), 그리고 그 안에 들어가는 다양한 기법들에 대해서 알아본다.

코드

https://github.com/ChoiSG/Operation-Triple-Barrel/tree/main/barrel-kit

데모

Struggle --> Adaptix C2

공격자들이 사용한 메인 백도어 코드. 일반적인 공격자 시뮬레이션에서는 기본적으로 상용 C2 프레임워크를 사용하지만, 공개적인 프로젝트인 만큼 많은 사람들이 확인할 수 있도록 오픈소스 C2 프레임워크 Adaptix C2를 차용했다. 이후 설명할 UDRL 및 쉘코드 로더만 제대로 제작해도 업계 EDR 솔루션들을 우회하는데에는 큰 문제가 없었기 때문에 Adaptix C2는 있는 그대로 사용했다.

AI를 활용해 백도어/C2 프레임워크 자체를 만드것도나쁘진 않았겠지만, 비슷한 시도도 많이 있었고, 이미 실제 공격자들 또한 오픈소스 C2를 그대로 사용하거나 개조해서 사용하는 경우도 많이 때문에 본 프로젝트에서도 그 방향을 택했다.

Adaptix C2의 소스 코드는 크게 바꾸지 않았다. 하지만, SOCKS Proxy implementation에 있어서 CDN을 사용했을 때의 문제가 있어 프록시 및 터널링 할 때의 트래픽

UDRL (User-Defined Reflective Loader)

오퍼레이션 더블 배럴에서 간단하게 Reflective DLL Loading 이라고 나오지만, 현대 AV/EDR 솔루션들을 우회하는데 있어서 가장 중요한 부분은 UDRL이라고 볼 수 있다. 이 부분은 AI를 이용해서 빠르게 제작해봤다.

UDRL는 꽤 복잡하기 때문에 모든 개념과 기법에 대해 설명할 수 없다. 따라서, 이 글에서는 아래의 개념들만 간단하게 다룬다.

  • Reflective Loading

  • Module Stomping

  • Sleep Masking

  • Callstack Spoofing

UDRL 개발에는 Cobalt Strike의 제작자이자, 전설적인(?) Maldev/Red teamer인 Raphael MudgeCrystal Palace (CPL) 프로젝트를 활용한다. CPL과 관련된 내용은 너무 길어져 생략한다.

전체구조

앞서 몇 번 나왔지만, 오퍼레이션 트리플 배럴에서 개발한 UDRL + 호스트 PE 파일의 구조는 다음과 같다.

이 중, Barrel Kit을 통해 만들어진 UDRL은 #4번부터 #17번까지를 담당한다. 본격적으로 쉘코드가 실행될 때 가장 앞에 PIC 형태로 있는 UDRL이 희생양 DLL 2개를 로드하고, PICO라는 또 다른 PIC와, Adaptix C2의 Beacon DLL를 각각의 DLL에 로드한다. 모든 준비가 끝나면 Beacon DLL로 Execution을 넘기면 된다.

Barrel Kit은 이 UDRL을 만들어주는 키트로서, UDRL 그 자체와 슬립 마스킹 및 다양한 방어 회피 기법을 탑재하는 PICO 제작을 자동화 해준다. UDRL과 PICO에는 다양한 기법들이 있지만, 오퍼레이션 더블 배럴 보고서에 나온 "Reflective Loading"에 대해서 집중적으로 알아본다. 하지만 그 전에, 희생양 DLL들을 불러와 안전한 .text 섹션에 Beacon DLL과 PICO를 올리는 모듈 스톰핑 기법과, 쉘코드 (UDRL + BeaconDLL)의 형태에서 UDRL이 어떻게 Beacon DLL을 찾아서 복호화 하는지에 대해서 알아보자.

0. UDRL - 리소스 추출 & DLL 복호화

본격적인 모듈스톰핑과 Reflective Loading에 앞서, 쉘코드 데이터 안에 임베디드된 비컨 DLL을 찾아 추출하고 복호화해야 한다. 이를 위해 Crystal Palace(CPL)는 링크 타임에 세 개의 섹션을 PIC 안에 삽입한다:

배열의 길이가 0처럼 보이지만, 링크 타임 중 CPL이 실제 데이터를 해당 섹션에 채워넣기 때문에 런타임에는 해당 주소에서 바이너리 데이터를 읽을 수 있다. loader.spec에서 128바이트 랜덤 키를 생성하고, 에이전트 DLL을 XOR 마스킹한 뒤 링크한다. go()에서는 이걸 다시 XOR해서 스테이징 버퍼에 풀어놓는다:

복호화된 DLL은 dll_src에 올라간다. 이제 이 Beacon DLL과 PICO를 로드할 안전한 메모리 공간을 확보해야 한다. 이를 위에서는 모듈 스톰핑 (Module Stomping) 기법에 대해서 알아보자.

1. UDRL - Module Stomping

모듈 스톰핑은 왜 쓸까?

일반적인 VirtualAlloc(PAGE_EXECUTE_READWRITE)로 메모리를 할당하면 메모리 스캐너가 즉시 탐지한다. VirtualAlloc으로 할당된 메모리는 MEM_PRIVATE이고, MEM_PRIVATE + PAGE_EXECUTE* 조합은 "여기 쉘코드 있습니다"라고 광고하는 것과 다름없다. 상용 EDR은 물론이거니와, 요즘에는 간단한 AV 솔루션들까지 이를 탐지한다. 이를 어찌저찌 우회했다고 하더라도 추후 악성 행위를 할 때 콜 스택 트레이싱(Call Stack Tracing)을 통해 행위가 어디서 시작됐는지를 보면, 굉장히 수상해보이는 MEM_PRIVATE이 출발지점으로 찍히게 되고, 이렇게 메모리상의 쉘코드가 탐지된다.

모듈 스톰핑은 이 문제를 우회하는 기법 중 하나다. LoadLibrary로 정상 DLL을 로드하고, 그 DLL의 .text 섹션 위에 공격자의 코드를 올린다. 이렇게 하면 메모리 타입이 MEM_IMAGE(SEC_IMAGE 기반)로 유지되고, PE 헤더, .rdata, .pdata도 건드리지 않으니 메모리 스캐너가 보기에 유효한 PE 구조가 그대로 남는다.

Beacon DLL과 PICO는 서로 다른 희생 DLL에 올라간다. 크라켄 슬립(Kraken Sleep) 때문인데, 이에 대해서는 추후 섹션에서 알아본다.

HWBP + VEH

그런데, 모듈 스톰핑을 할 때 냅다 그냥 LoadLibrary winapi를 사용해 DLL 불러온 다음 .text 섹션에다가 쉘코드를 올려버리면 몇 가지 문제가 발생한다.

  1. Copy-on-Write: SEC_IMAGE 페이지에 데이터를 쓰면 CoW로 인해 private page가 되며, QueryWorkingSetEx의 Shared bit가 해제되며 바로 탐지된다.

  2. DllMain 실행: LoadLibrary가 완료되면 DLL의 DllMain이 실행되면서 원치 않는 초기화나 스레드 생성이 일어난다.

  3. Clean 섹션 핸들: 나중에 크라켄 슬립에서 에이전트를 언맵한 뒤, 같은 주소에 깨끗한 DLL을 다시 맵핑하려면 on-disk DLL의 SEC_IMAGE 섹션 핸들이 필요하다.

이 세 가지 문제를 해결하기 위해 Hardware Breakpoint (HWBP) + Vectored Exception Handler (VEH) 기법을 사용한다.

VEH는 프로세스 전역 예외 핸들러다. AddVectoredExceptionHandler로 VEH를 등록하면, 프로세스 내에서 예외가 발생할 때 호출된다. HWBP는 CPU의 디버그 레지스터(DR0~DR3)에 특정 주소를 설정해두면, 해당 주소의 명령어가 실행될 때 EXCEPTION_SINGLE_STEP 예외를 발생시키는 메커니즘이다. 소프트웨어 breakpoint (INT3)와 달리 메모리의 바이트를 수정하지 않기 때문에, 무결성 검사를 우회할 수 있다.

이 둘을 조합하면, LoadLibraryW 실행 도중 ntdll 내부 함수 세 곳에 HWBP를 걸어두고, 각 브레이크포인트가 트리거될 때 VEH가 인자를 조작하거나 핸들을 가로채는 방식으로 LoadLibrary의 동작을 중간에서 바꿀 수 있다.

실제 코드를 보자. try_stomp() 안에서 VEH 등록 → HWBP 설치 → LoadLibraryW 호출 → HWBP 해제 → VEH 해제가 순차적으로 일어난다:

LoadLibraryW 내부에서 NtCreateSectionNtMapViewOfSectionRtlImageNtHeaderEx가 순서대로 호출되고, 각각 DR0, DR1, DR2 HWBP가 트리거되며 VEH 핸들러(stomp_exception_handler)가 개입한다.

각 브레이크포인트가 트리거됐을 때 VEH 핸들러가 하는 일은 다음과 같다.

DR0: NtCreateSection - 접근 마스크를 SECTION_ALL_ACCESS로 업그레이드한다. 기본적으로 LoadLibrarySECTION_MAP_EXECUTE 정도만 요청하는데, 나중에 Kraken sleep에서 이 섹션을 NtMapViewOfSection으로 리맵하려면 더 넓은 권한이 필요하다.

DR1: NtMapViewOfSection - LoadLibrary가 만든 섹션 핸들을 DuplicateHandle로 복제해서 보관한다. 이 핸들(hSacSection)이 Kraken sleep의 열쇠다.

DR2: RtlImageNtHeaderEx - LDR 엔트리를 찾아서 EntryPointret 가젯으로, TlsIndex-1로 패치한다. EntryPointret이 되면 DllMain이 호출되더라도 아무것도 안 하고 바로 리턴하고, TlsIndex-1이면 TLS 콜백도 방지된다.

Chunked VirtualProtect

DLL을 로드했다면 쉘코드를 쓰기 위해 .text 섹션의 권한을 RW로 바꾼다. Drip loading처럼, 메모리의 권한을 조금씩 바꾸는 Drip protect를 실행한다.

PICO 복사

LoadLibraryW + VEH/HWBP로 희생 DLL을 안전하게 로드하고, Chunked VirtualProtect로 .text를 RW로 만들었으면, 이제 PICO를 복사할 차례다. PICO는 PE가 아니라 PIC이기 때문에 rDLL 같은 복잡한 로딩이 필요 없다. Crystal Palace의 PicoLoad()를 사용한다.

Beacon DLL은 PE 구조를 가지고 있으니, PICO처럼 단순 복사가 아니라 rDLL (Reflective DLL Loading)로 처리해야 한다. 이건 바로 다음 섹션에서 다룬다.

정리 작업

모듈 스톰핑이 끝나면 후처리를 한다:

  1. 희생 DLL이 생성했을 수 있는 스레드들을 kill_module_threads()로 종료 (.text 범위 안에 시작 주소가 있는 스레드 탐색)

  2. .text를 제로 초기화 (PICO BSS 영역 필요)

  3. ADV_STOMP_DATA 구조체를 채워서 MEMORY_LAYOUT에 저장 - Kraken sleep이 이 데이터를 사용한다

2. UDRL - Reflective DLL Loading (rDLL)

모듈 스톰핑으로 안전한 메모리 공간을 확보했으면, 본격적으로 rDLL을 진행한다. Barrel-Kit에서는 Crystal Palace의 LibTCG에 있는 코드를 사용해 rDLL을 하지만, rDLL이 정확히 어떻게 이뤄지는지 간단하게 소스코드를 훑어본다.

PE파일 구조와 rDLL은 잘 알려진 지식/정보이기 때문에 빠르게 넘어간다.

rDLL은 다음과 같이 이뤄진다:

  1. PE 구조 파싱

  2. 헤더 + 섹션 복사

  3. Relocation

  4. IAT 처리

  5. 섹션 권한 설정

  6. DLLMain() 실행

1. PE Parsing

DLL 바이너리에서 DOS 헤더 → NT 헤더 → Optional 헤더를 추출한다. 이후 모든 단계가 이 구조체들을 참조한다.

2. 헤더 + 섹션 복사

LoadDLL()은 먼저 PE 헤더를 목적지에 복사한 뒤, 각 섹션(.text, .data, .rdata, 등)을 하나씩 올린다.

3. Relocation

Relocation을 통해 DLL PE 파일에서 의도했던 베이스 주소 (ImageBase)와 실제 로드된 주소 (dst)가 다를거기 때문에, 절대 주소를 참조하는 모든 코드를 Relocation Table에서 찾아 재배치 해준다.

newBaseAddress는 원래 주소와 실제 주소의 차이(delta)다. Relocation 테이블의 각 엔트리에 이 delta를 더해주면 된다. x64에서는 대부분 IMAGE_REL_BASED_DIR64 타입이다.

4. Import 처리 (IAT)

DLL이 사용하는 외부 API들의 주소를 리졸브(resolve)해서 IAT(Import Address Table)에 채워넣는다.

여기서 중요한 건 funcs->GetProcAddress다. 이 시점에서 PICO의 setup_hooks()가 이미 실행된 상태이기 때문에, funcs->GetProcAddress는 진짜 GetProcAddress가 아니라 PICO의 _GetProcAddress를 가리킨다. 즉, IAT에 API 주소를 채우는 과정에서 자동으로 PICO의 훅 함수들이 끼워들어간다. Sleep_KrakenSleep, HeapAlloc_HeapAlloc, WaitForSingleObject_WaitForSingleObject 등, 에이전트가 호출하는 34개의 API가 PICO를 경유하게 된다.

5. 섹션 권한 설정

마지막으로, 각 섹션에 적절한 메모리 permission을 부여한다.

6. DllMain 실행

모든 준비가 끝나면, Optional 헤더의 AddressOfEntryPoint를 통해 DllMain을 호출한다.

이 시점에서 Adaptix C2 에이전트가 시작된다. 스테이징 버퍼(복호화된 DLL 원본)는 더 이상 필요 없으므로 해제한다.

3. PICO - Kraken Sleeping

Adaptix C2 에이전트 (Beacon DLL)이 실행되면, 악성 행위를 진행한다. 그 뒤, Sleep을 한다. 에이전트가 Sleep을 호출하면, pico.spec에서 선언되었던 IAT 훅을 통해 PICO의 _KrakenSleep이 실행된다.

대략적인 크라켄 슬립의 큰 그림은 다음과 같이 이뤄진다:

Ekko vs. Kraken

공개적으로 가장 널리 알려진 sleep masking 기법은 CreateTimerQueueTimer를 사용하는 Ekko다. 타이머 큐(Timer Queue)에 NtContinue() 기반의 ROP 체인을 등록하고, VirtualProtect(RW)SystemFunction032(암호화)WaitForSingleObjectEx(대기)SystemFunction032(복호화)VirtualProtect(RX) - 이 순서대로 OS 타이머 스레드가 체인을 실행하는 방식이다. 메인 스레드는 중간에 멈추지 않고, 다른 스레드가 전부 처리해주기 때문에 구현이 깔끔하다.

하지만 Ekko 스타일에는 한계가 있다. 타이머 콜백의 인자가 하나(PVOID)뿐이라 복잡한 로직을 넣기 어렵고, 콜백 체인의 실행 순서에 의존하는 구조라 디버깅이 까다롭다. 무엇보다 CreateTimerQueueTimerVirtualProtect, SystemFunction032 같은 함수 포인터를 콜백으로 등록하는 패턴 자체가 잘 탐지된다.

Kraken은 타이머 큐 대신 에이전트의 메인 스레드가 PICO 코드를 경유해서 직접 sleep masking 로직을 실행한다. 에이전트가 Sleep이나 WaitForSingleObject를 호출하면, CPL의 IAT 훅을 통해 PICO의 _KrakenSleepkraken_wait()run_kraken()이 같은 스레드에서 순차 실행된다. 별도 스레드에 위임하지 않기 때문에 타이밍 이슈가 없고, 확장도 자유롭다.

중요한 점은 실제 크라켄 코드가 실행되는 메모리상의 주소는 PICO가 맵핑된 곳이며, 여긴 별도의 희생 DLL 이라는 점이다. run_kraken()이 에이전트 이미지를 NtUnmapViewOfSection으로 언맵(Unmap) 하는 순간, 실행 중인 코드가 그 이미지 안에 있으면 크래시한다. 하지만 PICO가 다른 DLL에 있으니까 에이전트가 로드되어 있던 이미지가 날아가도 PICO 코드는 살아남아 슬립을 계속 실행할 수 있다. UDRL의 구조를 Loader / PICO 두 개로 나눈 이유이자, 위 모듈 스톰핑에서 희생양 DLL을 2개 따로따로 맵핑한 이유이기도 하다.

Kraken Sleep: 백업 → 암호화 → 언맵 → 리맵 → 대기

run_kraken()의 전반부. Beacon DLL의 메모리와 힙을 백업 및 암호화하고, 로드되어 있던 DLL를 언맵(Unmap) 한 뒤, 완전히 깨끗한 실제 DLL을 다시 리맵(Remap)한다. 그 뒤, 슬립에 들어간다.

4번의 NtUnmapViewOfSection으로 에이전트 코드가 있던 메모리를 날리고, 5번의 NtMapViewOfSection으로 같은 주소에 클린 DLL을 리맵한다. 여기서 사용하는 p->hSection이 바로 모듈 스톰핑 때 VEH/HWBP로 LoadLibrary 도중에 캡처해둔 깨끗한 섹션 핸들이다. Sleep 중에는 메모리 스캐너가 봐도 정상적인 System32 DLL만 보이고, 힙 데이터는 RC4로 암호화되어 있다.

Sleep 복구: 복호화 → 권한 변경 → 복원 → 섹션 권한 복구

run_kraken()의 후반부. 대기가 끝나면 에이전트를 원래 상태로 되살린다.

RC4는 XOR 기반이라 같은 키로 다시 돌리면 복호화된다 (7~8번). 이후 NtMapViewOfSection으로 리맵된 클린 이미지의 .textVirtualProtect(PAGE_READWRITE)를 건 뒤 (9번), 백업해둔 에이전트 바이트를 다시 써넣는다 (10번). 마지막으로 .textPAGE_EXECUTE_READ, .rdataPAGE_READONLY 등 원래 섹션 권한을 복구한다 (11번).

복구가 끝나면 run_kraken()TRUE를 리턴하고, 제어가 _WaitForSingleObject 또는 _KrakenSleep으로 돌아온다. 에이전트 입장에서는 그냥 Sleep이나 WaitForSingleObject를 호출했다가 돌아온 것과 다를 게 없다. 하지만 실제로 뒤에서는 PICO가 에이전트가 슬립하고 있는 사이, 희생양 DLL을 통째로 언맵했다가 리맵한 것이다.

4. PICO - Callstack Spoofing

모듈 스톰핑으로 MEM_IMAGE에 숨고, 크라켄 슬립으로 sleep 중 메모리를 깨끗하게 만들었다. 하지만 에이전트가 깨어 있을 때 API를 호출하는 순간, EDR은 콜스택(Call Stack)을 추적한다.

EDR은 API 호출 시 콜스택을 트레이싱해서 "이 호출이 어디서 시작됐는지"를 확인한다. 모듈 스톰핑 덕분에 코드 자체는 MEM_IMAGE 영역에 있지만, 콜스택 프레임이 수상하면 여전히 탐지된다. 특히 리턴 주소가 정상적인 call/ret 쌍이 아닌 경우, 혹은 리턴 주소가 가리키는 곳이 알려진 코드 영역과 맞지 않는 경우가 그렇다.

전통적 방법: Return Address Spoofing

전통적인 콜스택 스푸핑은 스택 프레임의 리턴 주소를 정상적인 주소로 덮어쓰는 방식이다. API를 호출하기 전에 스택에 가짜 프레임을 쌓아서, EDR이 콜스택을 읽었을 때 정상적인 호출 체인처럼 보이게 만든다.

문제는 인텔 CET(Control-flow Enforcement Technology)등의 방어 기법들이 도입되면서 전통적인 접근이 막히고 있다는 것이다. 실제 call 명령어 없이 스택에 쌓인 리턴 주소는 CET의 Shadow Stack과 불일치가 생기고, 이를 탐지할 수 있게 됐다.

Barrel-Kit: Worker Thread Proxy

Barrel Kit의 콜 스택 스푸핑은 프레임 조작을 하지 않고, 그냥 별도의 Worker Thread (워커 스레드)를 만들어 API를 대신 호출하게 한다.

구조는 간단하다. PICO가 초기화될 때 워커 스레드를 하나 만든다. 이 워커는 이벤트를 기다리다가, 메인 스레드가 요청을 넘기면 API를 호출하고, 결과를 돌려준다.

메인 스레드(에이전트)에서는 spoof_call()을 통해 워커에게 요청을 위임한다.

이렇게 하면 워커 스레드의 콜스택은 다음과 같이 나온다:

프레임을 조작한 게 아니다. 진짜 callret 쌍이다. Shadow Stack과의 불일치도 없다. EDR이 콜스택 무결성을 아무리 검사해도, 정당한 워커 스레드가 정당한 API를 호출한 것이니 문제될 게 없다.

에이전트가 호출하는 34개의 API가 PICO의 IAT 훅을 거치고, 각 훅 함수 안에서 spoof_call()을 호출하기 때문에, 에이전트 입장에서는 평소대로 CreateProcessA, VirtualProtect 등을 호출하지만 실제로는 전부 워커 스레드를 경유하게 된다.

Impersonation Fallback

한 가지 예외가 있다. 스레드가 토큰을 임퍼소네이션(impersonation) 중일 때다. 예를 들어, getsystem 이나 make token과 같이 에이전트가 ImpersonateLoggedOnUser로 다른 사용자의 토큰을 임퍼소네이션한 상태에서 API를 호출하면, 그 토큰의 권한으로 실행되어야 한다. 하지만 워커 스레드에는 해당 토큰이 없다. 이 경우 워커를 건너뛰고 메인 스레드에서 직접 호출한다.

Handle Rate Limiting

SOCKS Proxy를 sleep 0으로 돌리면, CloseHandle이나 DuplicateHandle 같은 핸들 관련 API가 초당 수백~수천 회 호출된다. 매번 워커 스레드를 경유하면 이벤트 시그널링 오버헤드가 쌓여서 성능이 크게 떨어진다.

이를 위해 핸들 API들은 spoof_call_handle()을 거친다. 1초 윈도우 안에 200회 이상 호출되면, 3초간 워커를 건너뛰고 직접 호출하는 fast path로 전환한다.

콜스택 스푸핑을 포기하는 거 아닌가 싶을 수 있지만, 핸들 API는 프로세스 내부에서 워낙 빈번하게 호출되는 함수라서 EDR도 이 정도의 호출 빈도에서 콜스택을 일일이 검사하지는 않는다. 이정도는 감수할만하다.

WinInet Gate: Gadget Stitching

WinInet API 세 개(HttpSendRequestA, InternetOpenA, InternetConnectA)는 위의 워커 스레드 대신 wininet_gate라는 별도 프록시를 거친다. WinInet 호출은 네트워크 I/O를 수반하기 때문에 콜스택이 오래 남는다. 메인 워커 스레드와 다른 콜스택 identity를 가지게 해서, "모든 API가 같은 워커 스레드에서 나온다"는 탐지 패턴을 깨뜨리기 위함이다.

spoof_stitch.c는 KernelBase.dll과 ntdll.dll에서 call [reg+disp32]; jmp [reg+disp8] 패턴의 가젯을 찾고, RUNTIME_FUNCTION + UNWIND_INFO로 가젯의 unwind 메타데이터까지 검증한 뒤, 해당 가젯의 프레임 레이아웃을 재현하는 방식으로 콜 체인을 구성한다. CET Shadow Stack과도 호환되도록 실제 call 명령어를 사용한다. 구현이 상당히 복잡하기 때문에 자세한 설명은 소스코드를 참고하자.

UDRL 끝!

재밌지 않은가? AI가 뚝딱 뚝딱 만들어줬지만 UDRL에는 사실 꽤나 복잡한 개념들이 많이 들어가 있다. 복잡한 코드가 워낙 많이 때문에 중간중간 디버깅을 하거나, 다시 align을 맞춰줘야 했지만, 그럼에도 나름 빠른 시간안에 잘 만들어줬다.

Last updated