콘텐츠로 이동하기
위협 인텔리전스

.NET 프로세스 할로잉 사례 연구로 알아보는 시간 여행 디버깅(Time Travel Debugging) 입문

2025년 11월 13일
Mandiant

Google Threat Intelligence

Visibility and context on the threats that matter most.

Contact Us & Get a Demo

해당 블로그의 원문은 2025년 11월 14일 Google Cloud 블로그(영문)에 게재되었습니다. 


작성자: Josh Stroschein, Jae Young Kim


오늘날의 악성코드에서 흔히 볼 수 있는 난독화와 다단계 계층화는 분석가들이 지루하고 수동적인 디버깅 세션을 수행하도록 강요하는 경우가 많습니다. 예를 들어, AgentTesla와 같이 널리 퍼진 상용 정보 탈취 악성코드를 분석할 때의 주된 과제는 악성코드를 식별하는 것이 아니라, 난독화된 전달 체인을 신속하게 뚫고 최종 페이로드(payload)에 도달하는 것입니다.

전통적인 실시간 디버깅과 달리, 시간 여행 디버깅(Time Travel Debugging, TTD)은 프로그램 실행 과정을 결정적이고 공유 가능한 기록으로 캡처합니다. TTD의 강력한 데이터 모델과 시간 여행 기능을 활용하면, 최종 페이로드로 이어지는 핵심 실행 이벤트를 효율적으로 찾아낼 수 있습니다.

이 게시물은 여러분의 분석에 TTD를 도입하기 시작하는 데 필요한 WinDbg와 TTD의 모든 기본 사항을 소개합니다. 프로세스 할로잉(process hollowing)을 수행하는 난독화된 다단계 .NET 드로퍼(dropper)를 단계별로 살펴보면서, TTD가 왜 여러분의 툴킷에 포함될 가치가 있는지 보여드리겠습니다.

시간 여행 디버깅(Time Travel Debugging)이란 무엇인가?

Microsoft가 WinDbg의 일부로 제공하는 기술인 시간 여행 디버깅(TTD)은 프로세스의 실행 과정을 추적 파일(trace file)에 기록하여 앞으로 또는 뒤로 재생할 수 있게 합니다. 실행 과정을 빠르게 되감고 다시 재생하는 기능은 디버깅 세션을 계속해서 다시 시작하거나 가상 머신 스냅샷을 복원할 필요성을 없애 분석 시간을 단축시킵니다. 또한 TTD는 사용자가 기록된 실행 데이터를 쿼리하고 LINQ(Language Integrated Query)로 필터링하여, 모듈 로드나 셸코드 실행 또는 프로세스 주입과 같은 악성코드 기능을 구현하는 API 호출과 같은 특정 관심 이벤트를 찾을 수 있도록 지원합니다.

기록 중에 TTD는 운영 체제와의 완전한 상호 작용을 허용하는 투명한 계층으로 작동합니다. 추적 파일은 완전한 실행 기록을 보존하며, 이를 동료와 공유하여 협업을 촉진하고 실시간 디버깅 결과에 영향을 줄 수 있는 환경적 차이를 피할 수 있습니다.

TTD가 상당한 이점을 제공하지만, 사용자는 특정 제한 사항을 인지해야 합니다. 현재 TTD는 사용자 모드(user-mode) 프로세스에만 제한되며 커널 모드(kernel-mode) 디버깅에는 사용할 수 없습니다. TTD에 의해 생성된 추적 파일은 독점적인 형식을 가지고 있어, 분석이 대부분 WinDbg에 의존하게 됩니다. 마지막으로, TTD는 프로그램의 과거 실행 흐름을 변경한다는 의미의 "진정한" 시간 여행을 제공하지 않습니다. 만약 조건이나 변수를 변경하여 다른 결과를 보고 싶다면, 기존 추적은 발생한 일에 대한 고정된 기록이므로 완전히 새로운 추적을 캡처해야 합니다.

프로세스 할로잉 징후가 있는 다단계 .NET 드로퍼

Microsoft .NET 프레임워크는 오랫동안 위협 행위자들 사이에서 고도로 난독화된 악성코드를 개발하는 데 인기가 있었습니다. 이러한 프로그램들은 종종 코드 평탄화(code flattening), 암호화, 다단계 어셈블리를 사용하여 분석 과정을 복잡하게 만듭니다. 이러한 복잡성은 관리형(managed) .NET 코드가 비관리형(unmanaged) Windows API에 직접 접근할 수 있게 해주는 플랫폼 호출(P/Invoke)에 의해 증폭됩니다. 이를 통해 악성코드 제작자들은 프로세스 할로잉과 같이 검증된 회피 기술을 자신들의 코드에 이식할 수 있습니다.

프로세스 할로잉은 악성 코드가 다른 프로세스로 위장하여 실행되는, 널리 퍼져 있고 효과적인 코드 주입 형태입니다. 이 기술은 주입된 코드가 정상적인 프로세스의 합법성을 가장하게 하여 기본 모니터링 도구로는 악성코드를 발견하기 어렵게 만들기 때문에, 다운로더 체인의 마지막 단계에서 흔히 사용됩니다.

이 사례 연구에서는 TTD를 사용하여 프로세스 할로잉을 통해 최종 단계를 실행하는 .NET 드로퍼를 분석할 것입니다. 이 사례 연구는 TTD가 어떻게 관련된 Windows API 함수를 신속하게 표면으로 드러내어, 우리가 수많은 .NET 난독화 계층을 우회하고 페이로드를 정확히 찾아낼 수 있게 함으로써 매우 효율적인 분석을 가능하게 하는지 보여줍니다.

기본적인 분석은 잠재적인 프로세스 할로잉 활동을 식별할 수 있는 중요한 첫 단계입니다. 예를 들어, 샌드박스를 사용하면 의심스러운 프로세스 실행을 발견할 수 있습니다. 악성코드 제작자들은 정상적인 시스템 작업과 매끄럽게 섞이는 합법적인 .NET 바이너리를 할로잉 대상으로 자주 삼습니다. 이 경우, VirusTotal에서 프로세스 활동을 검토해 보면, 샘플이 InstallUtil.exe(%windir%\Microsoft.NET\Framework\<version>\에 위치)를 실행하는 것을 보여줍니다. InstallUtil.exe는 합법적인 유틸리티이지만, 의심스러운 악성 샘플의 자식 프로세스로 실행된다는 점은 우리의 초기 조사를 잠재적인 프로세스 주입에 집중시키는 데 도움이 되는 지표입니다.

https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig1a.max-600x600.png

그림 1: VirusTotal 샌드박스에 기록된 프로세스 활동

프로세스 도플갱잉(Process Doppelgänging)과 같이 더 새롭고 은밀한 기술들이 등장했음에도 불구하고, 공격자들은 프로세스 주입(process injection)을 사용할 때 여전히 고전적인 방식의 프로세스 할로잉(process hollowing)을 자주 사용합니다. 이는 신뢰성이 높고, 비교적 간단하며, 덜 정교한 보안 솔루션을 여전히 효과적으로 우회할 수 있기 때문입니다. 고전적인 프로세스 할로잉 단계는 다음과 같습니다.

  1. CreateProcess (CREATE_SUSPENDED 플래그 사용): 대상 프로세스(InstallUtil.exe)를 시작하지만, 실행 전에 주 스레드를 일시 중단시킵니다.

  2. ZwUnmapViewOfSection 또는 NtUnmapViewOfSection: 메모리에서 원본의 합법적인 코드를 제거하여 프로세스를 "비워냅니다(hollows out)".

  3. VirtualAllocEx 및 WriteProcessMemory: 원격 프로세스에 새 메모리를 할당하고 악성 페이로드를 주입합니다.

  4. GetThreadContext: 일시 중단된 주 스레드의 컨텍스트(상태 및 레지스터 값)를 검색합니다.

  5. SetThreadContext: 검색된 컨텍스트 내의 진입점 레지스터를 수정하여 새로 주입된 악성 코드의 주소를 가리키도록 실행 흐름을 리디렉션합니다.

  6. ResumeThread: 스레드를 재개하여 악성 코드가 마치 합법적인 프로세스인 것처럼 실행되게 합니다.

TTD를 사용하여 샘플에서 이 활동을 확인하기 위해, 우리는 프로세스 생성과 그 후 자식 프로세스의 주소 공간에 대한 쓰기 작업에 초점을 맞춥니다. 이 검색에서 보여준 접근 방식은 TTD 쿼리를 해당 기술과 관련된 API를 검색하도록 조정함으로써 다른 기술을 분석하는 데에도 적용할 수 있습니다.

악성코드의 시간 여행 추적 기록하기

TTD를 사용하려면 먼저 프로그램 실행 추적을 기록해야 합니다. 추적을 기록하는 두 가지 주요 방법은 WinDbg UI를 사용하거나 Microsoft에서 제공하는 명령줄 유틸리티를 사용하는 것입니다. 명령줄 유틸리티는 추적을 기록하는 가장 빠르고 사용자 정의가 가능한 방법을 제공하며, 이 글에서 우리가 살펴볼 내용입니다.

경고: 악성 실행 파일의 TTD 추적을 기록할 때는 악성코드 동적 분석을 수행할 때의 모든 일반적인 예방 조치를 취하십시오. TTD 기록은 샌드박스 기술이 아니며, 악성코드가 방해 없이 호스트 및 환경과 상호 작용하도록 허용합니다.

TTD.exe는 추적 기록에 선호되는 명령줄 도구입니다. Windows에는 내장 유틸리티(tttracer.exe)가 포함되어 있지만, 해당 버전은 기능이 축소되어 있으며 주로 일반적인 사용이나 자동화가 아닌 시스템 진단용으로 제작되었습니다. 모든 WinDbg 설치가 TTD.exe 유틸리티를 제공하거나 시스템 경로에 추가하는 것은 아닙니다. TTD.exe를 얻는 가장 빠른 방법은 Microsoft에서 제공하는 독립 실행형 설치 프로그램을 사용하는 것입니다. 이 설치 프로그램은 TTD.exe를 시스템의 PATH 환경 변수에 자동으로 추가하여 명령 프롬프트에서 사용할 수 있도록 보장합니다. 사용법 정보를 보려면 TTD.exe -help를 실행하십시오.

추적을 기록하는 가장 빠른 방법은 단순히 적절한 인자와 함께 대상 실행 파일을 호출하는 명령줄을 제공하는 것입니다. 우리는 다음 명령을 사용하여 샘플의 추적을 기록합니다:

C:\Users\FLARE\Desktop\> ttd.exe 0b631f91f02ca9cffd66e7c64ee11a4b.bin
Microsoft (R) TTD 1.01.11 x64
Release: 1.11.532.0
Copyright (C) Microsoft Corporation. All rights reserved.

Launching '0b631f91f02ca9cffd66e7c64ee11a4b.bin'
    Initializing the recording of process (PID:2448) on trace file: C:\Users\FLARE\Desktop\0b631f91f02ca9cffd66e7c64ee11a4b02.run
    Recording has started of process (PID:2448) on trace file: C:\Users\FLARE\Desktop\0b631f91f02ca9cffd66e7c64ee11a4b02.run

TTD가 기록을 시작하면, 추적은 다음 두 가지 방법 중 하나로 종료됩니다. 첫째, 악성코드가 종료되면(예: 프로세스 종료, 처리되지 않은 예외 등) 추적이 자동으로 중지됩니다. 둘째, 사용자가 수동으로 개입할 수 있습니다. 기록하는 동안 TTD.exe는 두 가지 제어 옵션이 있는 작은 대화 상자(그림 2 참조)를 표시합니다.

  • Tracing Off: 추적을 중지하고 프로세스에서 분리하여 프로그램이 계속 실행되도록 합니다.

  • Exit App: 추적을 중지하고 프로세스도 함께 종료합니다.

https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig2a.max-700x700.png

그림 2: TTD 추적 실행 제어 대화 상자

TTD 추적을 기록하면 다음과 같은 파일이 생성됩니다.

  • <trace>.run: 추적 파일은 압축된 실행 데이터가 포함된 독점적인 형식입니다. 추적 파일의 크기는 프로그램의 크기, 실행 길이 및 로드되는 추가 리소스 수와 같은 기타 외부 요인의 영향을 받습니다.

  • <trace>.idx: 인덱스 파일을 사용하면 디버거가 전체 추적을 순차적으로 검색하지 않고도 추적 중 특정 시점을 신속하게 찾을 수 있습니다. 인덱스 파일은 WinDbg에서 추적 파일을 처음 열 때 자동으로 생성됩니다. 일반적으로 Microsoft는 인덱스 파일이 일반적으로 추적 파일 크기의 두 배라고 제안합니다.

  • <trace>.out: 추적 기록 중에 생성된 로그가 포함된 추적 로그 파일입니다.

추적이 완료되면 WinDbg로 .run 파일을 열 수 있습니다.

TTD 추적 분석: 데이터에 초점 맞추기

TTD의 근본적인 이점은 수동 코드 단계 실행에서 실행 데이터 분석으로 초점을 전환할 수 있다는 점입니다. 이러한 데이터 기반 접근 방식으로 신속하고 효과적인 분석을 수행하려면 기본 TTD 탐색과 디버거 데이터 모델 쿼리 모두에 대한 숙련도가 필요합니다. 탐색의 기본과 디버거 데이터 모델을 살펴보는 것부터 시작하겠습니다.

추적 탐색하기

기본 탐색 명령은 WinDbg UI의 홈 탭에서 사용할 수 있습니다.

https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig3.max-500x500.png

그림 3: 기본 WinDbg TTD 탐색 명령

실행을 제어하기 위한 표준 WinDbg 명령 및 바로 가기는 다음과 같습니다.

  • gGo (F5) – 실행 재개

  • guGo Up / Step Out (Shift+F11) – 현재 함수가 완료될 때까지 실행

  • tTrace / Step Into (F11 또는 F8) – 한 단계씩 코드 안으로 실행(Step Into)

  • pStep / Step Over (F10) – 한 단계씩 코드 건너뛰기(Step Over)

TTD 추적을 재생하면 일반적인 흐름 제어 명령을 보완하는 역방향 흐름 제어 명령을 사용할 수 있습니다. 각 역방향 흐름 제어 보완 명령은 일반 흐름 제어 명령에 대시(-)를 추가하여 형성됩니다.

  • g-: 뒤로 가기 – 추적을 역방향으로 실행

  • g-u: 뒤로 나가기 - 마지막 호출 명령어까지 추적을 역방향으로 실행

  • t-: 뒤로 한 단계씩 코드 안으로 실행 – 역방향으로 한 단계씩 코드 안으로 실행

  • p-: 뒤로 한 단계씩 코드 건너뛰기 – 역방향으로 한 단계씩 코드 건너뛰기

시간 여행(!tt) 명령어

기본 탐색 명령을 사용하면 추적을 단계별로 이동할 수 있지만, 시간 여행 명령어(!tt)를 사용하면 특정 추적 위치로 정밀하게 이동할 수 있습니다. 이러한 위치는 종종 다양한 TTD 명령어의 출력으로 제공됩니다. TTD 추적의 위치는 #:# 형식의 두 16진수로 표시됩니다(예: E:7D5).

  • 첫 번째 부분은 일반적으로 모듈 로드나 예외와 같은 주요 실행 이벤트에 해당하는 시퀀싱 번호입니다.

  • 두 번째 부분은 해당 주요 실행 이벤트 이후 실행된 이벤트 또는 명령어 수를 나타내는 단계 수입니다.

이 글의 뒷부분에서는 시간 여행 명령어를 사용하여 프로세스 할로잉 예제의 중요한 이벤트로 직접 이동하여 수동 명령어 추적을 완전히 건너뛸 것입니다.

TTD 디버거 데이터 모델

WinDbg 디버거 데이터 모델은 디버거 정보를 탐색 가능한 객체 트리로 노출하는 확장 가능한 객체 모델입니다. 디버거 데이터 모델은 사용자가 WinDbg에서 디버거 정보에 접근하는 방식을 원시 텍스트 기반 출력을 다루는 것에서 구조화된 객체 정보와 상호 작용하는 것으로 근본적으로 전환합니다. 데이터 모델은 쿼리 및 필터링을 위해 LINQ를 지원하여 사용자가 방대한 양의 실행 정보를 효율적으로 정렬할 수 있도록 합니다. 또한 디버거 데이터 모델은 명령을 통해 디버거 데이터 모델에 액세스하는 방식을 미러링하는 API를 사용하여 JavaScript를 통한 자동화를 단순화합니다.

디버거 개체 모델 표현식 표시((dx)) 명령어는 WinDbg의 명령 창에서 디버거 데이터 모델과 상호 작용하는 주요 방법입니다. 이 모델은 검색 가능성에 적합하며, 루트 Debugger 객체에서 시작하여 탐색할 수 있습니다.

0:000> dx Debugger
Debugger
    Sessions
    Settings
    State
    Utility
    LastEvent

명령어 출력은 Debugger 객체의 속성인 5개의 객체를 나열합니다. 출력에 있는 이름들은 링크처럼 보이며, 디버거 마크업 언어(DML)를 사용하여 마크업되어 있다는 점에 유의하십시오. DML은 관련된 명령어를 실행하는 링크로 출력을 풍부하게 만듭니다. 출력에서 Sessions 객체를 클릭하면 해당 객체를 확장하기 위해 다음 dx 명령어가 실행됩니다.

0:000> dx -r1 Debugger.Sessions
Debugger.Sessions                
    [0x0]            : Time Travel Debugging: 0b631f91f02ca9cffd66e7c64ee11a4b.run

-r# 인수는 # 수준까지의 재귀를 지정하며, 지정하지 않으면 기본 깊이는 1입니다. 예를 들어, 이전 명령어에서 재귀 수준을 2로 높이면 다음과 같은 출력이 생성됩니다.

0:000> dx -r2 Debugger.Sessions
Debugger.Sessions                
    [0x0]            : Time Travel Debugging: 0b631f91f02ca9cffd66e7c64ee11a4b.run
        Processes       
        Id               : 0
        Diagnostics     
        TTD             
        OS              
        Devices         
        Attributes

-g 인수는 반복 가능한 객체를 데이터 그리드에 표시하며, 이 그리드에서 각 요소는 행이 되고 각 요소의 자식 속성은 열이 됩니다.

0:000> dx -g Debugger.Sessions
https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig4a.max-500x500.png

그림 4: 열이 잘린 세션 그리드 뷰

디버거 및 사용자 변수

WinDbg는 편의를 위해 DebuggerVariables 속성을 통해 나열할 수 있는 몇 가지 미리 정의된 디버거 변수를 제공합니다.

0:000> dx Debugger.State.DebuggerVariables
Debugger.State.DebuggerVariables                
   cursession       : Time Travel Debugging: 0b631f91f02ca9cffd66e7c64ee11a4b.run
    curprocess       : 0b631f91f02ca9cffd66e7c64ee11a4b.exe [Switch To]
    curthread        [Switch To]
    scripts         
    scriptContents   : [object Object]
    vars            
    curstack        
    curframe         : ntdll!LdrInitializeThunk [Switch To]
    curregisters    
    debuggerRootNamespace

자주 사용되는 변수는 다음과 같습니다.

  • @$cursession: 현재 디버거 세션. Debugger.Sessions[<session>]와 동일합니다. 일반적으로 사용되는 항목은 다음과 같습니다.

    • @$cursession.Processes: 세션의 프로세스 목록.

    • @$cursession.TTD.Calls: 추적 중에 발생한 호출을 쿼리하는 메서드.

    • @$cursession.TTD.Memory: 추적 중에 발생한 메모리 작업을 쿼리하는 메서드.

  • @$curprocess: 현재 프로세스. @$cursession.Processes[<pid>]와 동일합니다. 자주 사용되는 항목은 다음과 같습니다.

    • @$curprocess.Modules: 현재 로드된 모듈 목록.

    • @$curprocess.TTD.Events: 추적 중에 발생한 이벤트 목록.

디버거 데이터 모델을 조사하여 프로세스 할로잉 식별하기

TTD 개념에 대한 기본적인 이해와 조사를 위한 추적이 준비되었으므로, 이제 프로세스 할로잉의 증거를 찾을 수 있습니다. 먼저, Calls 메서드를 사용하여 특정 Windows API 호출을 검색할 수 있습니다. 이 검색은 .NET 샘플에서도 효과적입니다. 왜냐하면 관리 코드가 프로세스 할로잉과 같은 기술을 수행하기 위해 P/Invoke를 통해 비관리 Windows API와 인터페이스해야 하기 때문입니다.

프로세스 할로잉은 생성 플래그 값 0x4를 사용하여 CreateProcess를 호출하여 일시 중단된 상태로 프로세스를 생성하는 것으로 시작됩니다. 다음 쿼리는 Calls 메서드를 사용하여 추적에서 kernel32 모듈의 CreateProcess*에 대한 각 호출의 테이블을 반환합니다. 와일드카드(*)는 쿼리가 CreateProcessA 또는 CreateProcessW에 대한 호출과 일치하도록 보장합니다.

0:000> dx -g @$cursession.TTD.Calls("kernel32!CreateProcess*")
https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig5a.max-500x500.png

이 쿼리는 여러 필드를 반환하지만, 모든 필드가 조사에 유용한 것은 아닙니다. 이 문제를 해결하기 위해, 원래 쿼리에 Select LINQ 쿼리를 적용하여 표시할 열을 지정하고 이름을 바꿀 수 있습니다.

0:000> dx -g @$cursession.TTD.Calls("kernel32!CreateProcess*").Select(c => new { TimeStart = c.TimeStart, Function = c.Function, Parameters = c.Parameters, ReturnAddress = c.ReturnAddress})
https://storage.googleapis.com/gweb-cloudblog-publish/images/time-travel-triage-fig6a.max-500x500.png

결과는 58243:104D 위치에서 시작하는 CreateProcessA에 대한 하나의 호출을 보여줍니다. 반환 주소에 주목하십시오. 이는 .NET 바이너리이므로 JIT(Just-In-Time) 컴파일러가 실행하는 네이티브 코드는 (비 .NET 이미지의 경우와 같이) 애플리케이션의 주 이미지 주소 공간에 위치하지 않습니다. 일반적으로 효과적인 분석 단계는 Where LINQ 쿼리로 결과를 필터링하여, 악성코드에서 비롯되지 않은 API 호출을 걸러내기 위해 반환 주소를 주 모듈로 제한하는 것입니다. 그러나 이 Where 필터는 JIT 컴파일된 코드의 동적인 실행 공간 특성으로 인해 분석 시 신뢰성이 떨어집니다.

다음으로 주목할 점은 Parameters 필드입니다. 축소된 값 {..}에 있는 DML 링크를 클릭하면 해당 dx 명령어를 통해 Parameters가 표시됩니다.

0:000> dx -r1 @$cursession.TTD.Calls("kernel32!CreateProcess*").Select( c => new { TimeStart = c.TimeStart, Parameters = c.Parameters, ReturnAddress = c.ReturnAddress})[0].Parameters

@$cursession.TTD.Calls("kernel32!CreateProcess*").Select( c => new { TimeStart = c.TimeStart, Parameters = c.Parameters, ReturnAddress = c.ReturnAddress})[0].Parameters
    [0x0]            : 0x55de700055de74
    [0x1]            : 0x55e0780055e0ac
    [0x2]            : 0x808000400000000
    [0x3]            : 0x55de4000000000

함수 인수는 특정 Calls 객체 아래에서 값의 배열로 사용할 수 있습니다. 하지만 매개변수를 조사하기 전에 TTD가 하는 몇 가지 가정을 살펴볼 가치가 있습니다. 전반적으로 이러한 가정은 프로세스가 32비트인지 64비트인지에 따라 영향을 받습니다. 프로세스의 비트 수를 확인하는 쉬운 방법은 DebuggerInformation 객체를 검사하는 것입니다.

0:00> dx Debugger.State.DebuggerInformation
Debugger.State.DebuggerInformation                
    ProcessorTarget  : X86 <--- Process Bitness
    Bitness          : 32
    EngineFilePath   : C:\Program Files\WindowsApps\<SNIPPED>\x86\dbgeng.dll
    EngineVersion    : 10.0.27871.1001

출력의 핵심 식별자는 ProcessorTarget입니다. 이 값은 디버거를 실행하는 호스트 운영 체제가 64비트인지 여부에 관계없이 추적된 게스트 프로세스의 아키텍처를 나타냅니다.

TTD는 프로그램 데이터베이스(PDB) 파일에 제공된 심볼 정보를 사용하여 매개변수의 수, 유형 및 함수의 반환 유형을 결정합니다. 그러나 이 정보는 PDB 파일에 개인 심볼이 포함된 경우에만 사용할 수 있습니다. Microsoft는 많은 라이브러리에 대해 PDB 파일을 제공하지만, 이는 종종 공개 심볼이므로 매개변수를 올바르게 해석하는 데 필요한 함수 정보가 부족합니다. 이 지점에서 TTD는 잘못된 결과를 초래할 수 있는 또 다른 가정을 합니다. 주로, 최대 4개의 QWORD 매개변수를 가정하고 반환 값도 QWORD이라고 가정합니다. 이 가정은 인수가 일반적으로 스택에 전달되는 32비트(4바이트) 값인 32비트 프로세스(x86)에서 불일치를 만듭니다. TTD는 스택에서 인수를 올바르게 찾지만, 인접한 두 개의 32비트 인수를 단일 64비트 값으로 잘못 해석합니다.

이를 해결하는 한 가지 방법은 스택에서 인수를 수동으로 조사하는 것입니다. 먼저 !tt 명령을 사용하여 CreateProcessA에 대한 관련 호출의 시작 부분으로 이동합니다.

0:000> !tt 58243:104D

(b48.12a4): Break instruction exception - code 80000003 (first/second chance not available)
Time Travel Position: 58243:104D
eax=00bed5c0 ebx=039599a8 ecx=00000000 edx=75d25160 esi=00000000 edi=03331228
eip=75d25160 esp=0055de14 ebp=0055df30 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
KERNEL32!CreateProcessA:
75d25160 8bff            mov     edi,edi

함수 호출 시작 시 반환 주소는 스택의 맨 위에 있으므로, 다음 dd 명령어는 함수 인수를 올바르게 정렬하기 위해 ESP 레지스터에 4의 오프셋을 추가하여 이 값을 건너뜁니다.

0:000> dd /c 1 esp+4 L0A
0055de18  0055de74  <-- Application Name
0055de1c  0055de70
0055de20  0055e0ac
0055de24  0055e078
0055de28  00000000
0055de2c  08080004  <-- Creation Flags - 0x4 (CREATE_SUSPENDED)
0055de30  00000000
0055de34  0055de40
0055de38  0055e0c0
0055de3c  0055e068

dwCreationFlags 인수(6번째 인수)의 비트마스크에 설정된 0x4(CREATE_SUSPENDED) 값은 프로세스가 일시 중단된 상태로 생성될 것임을 나타냅니다.

다음 명령어는 poi 연산자를 통해 esp+4를 역참조하여 애플리케이션 이름 문자열 포인터를 검색한 다음, da 명령어를 사용하여 ASCII 문자열을 표시합니다.

0:000> da poi(esp+4)
0055de74  "C:\Windows\Microsoft.NET\Framewo"
0055de94  "rk\v4.0.30319\InstallUtil.exe"

이 명령어는 대상 애플리케이션이 InstallUtil.exe임을 보여주며, 이는 기본 분석 결과와 일치합니다.

새로 생성된 프로세스에 대한 핸들을 검색하여 이후에 수행되는 작업을 식별하는 것도 유용합니다. 핸들 값은 마지막 인수로 전달된 PROCESS_INFORMATION 구조체에 대한 포인터(0x55e068 - 이전에 참조된 출력)를 통해 반환됩니다. 이 구조체는 다음과 같은 정의를 가집니다.

typedef struct _PROCESS_INFORMATION {
  HANDLE hProcess;
  HANDLE hThread;
  DWORD  dwProcessId;
  DWORD  dwThreadId;
}

CreateProcessA 호출 후, 이 구조체의 첫 번째 멤버는 프로세스에 대한 핸들로 채워져야 합니다. gu(Go Up) 명령을 사용하여 호출에서 빠져나와 채워진 구조체를 검사합니다.

0:000> gu
Time Travel Position: 58296:60D

0:000> dd /c 1 0x55e068 L4
0055e068  00000104 <-- handle to process
0055e06c  00000970
0055e070  00000d2c
0055e074  00001c30

이 추적에서 CreateProcess는 일시 중단된 프로세스의 핸들로 0x104를 반환했습니다.

분석을 목적으로 하는 프로세스 할로잉에서 가장 흥미로운 작업은 메모리 할당과 그 메모리에 대한 후속 쓰기이며, 이는 일반적으로 WriteProcessMemory 호출을 통해 수행됩니다. 이전 Calls 쿼리를 업데이트하여 WriteProcessMemory에 대한 호출을 식별할 수 있습니다.

0:000> dx -g @$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})
=============================================================
=          = (+) TimeStart = (+) ReturnAddress = (+) Params =
=============================================================
= [0x0]    - 6A02A:4B4     - 0x15032e2         - {...}      =
= [0x1]    - 6E516:A91     - 0x15032e2         - {...}      =
= [0x2]    - 729A2:511     - 0x15032e2         - {...}      =
= [0x3]    - 76E2D:750     - 0x15032e2         - {...}      =
= [0x4]    - 7B2DF:C1C     - 0x15032e2         - {...}      =
=============================================================

쿼리는 네 가지 결과를 반환합니다. 다음 쿼리는 WriteProcessMemory에 대한 각 호출의 인수를 확장합니다.

0:000> dx -r1 @$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[0].Params
@$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[0].Params                
    [0x0]            : 0x104        <-- Target process handle
    [0x1]            : 0x400000     <-- Target Address
    [0x2]            : 0x9810af0    <-- Source buffer
    [0x3]            : 0x200        <-- Write size

0:000> dx -r1 @$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[1].Params
@$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[1].Params                
    [0x0]            : 0x104
    [0x1]            : 0x402000
    [0x2]            : 0x984cb10
    [0x3]            : 0x3b600

0:000> dx -r1 @$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[2].Params
@$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[2].Params                
    [0x0]            : 0x104
    [0x1]            : 0x43e000
    [0x2]            : 0x387d9d0
    [0x3]            : 0x600

0:000> dx -r1 @$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[3].Params
@$cursession.TTD.Calls("kernel32!WriteProcessMemory*").Select( c => new { TimeStart = c.TimeStart, ReturnAddress = c.ReturnAddress, Params = c.Parameters})[3].Params                
    [0x0]            : 0x104
    [0x1]            : 0x440000
    [0x2]            : 0x3927a78
    [0x3]            : 0x200

WriteProcessMemory는 다음과 같은 함수 시그니처를 가집니다.

BOOL WriteProcessMemory(
  [in]  HANDLE  hProcess,
  [in]  LPVOID  lpBaseAddress,
  [in]  LPCVOID lpBuffer,
  [in]  SIZE_T  nSize,
  [out] SIZE_T  *lpNumberOfBytesWritten
);

WriteProcessMemory에 대한 이러한 호출들을 조사해보면, 대상 프로세스 핸들이 0x104임을 알 수 있으며, 이는 일시 중단된 프로세스를 나타냅니다. 두 번째 인수는 대상 프로세스의 주소를 정의합니다. 이러한 호출들의 인수들은 PE 로딩에 일반적인 패턴을 드러냅니다. 즉, 악성코드는 PE 헤더를 쓴 다음, 가상 오프셋에 관련 섹션들을 씁니다.

이 추적에서는 대상 프로세스의 메모리를 분석할 수 없다는 점에 유의해야 합니다. 자식 프로세스의 실행을 기록하려면 TTD.exe 유틸리티에 -children 플래그를 전달해야 합니다. 이렇게 하면 실행 중에 생성된 모든 자식 프로세스를 포함하여 각 프로세스에 대한 추적 파일이 생성됩니다.

대상 프로세스의 기본 주소(0x400000)로 추정되는 곳에 대한 첫 번째 메모리 쓰기는 0x200 바이트입니다. 이 크기는 PE 헤더와 일치하며, 소스 버퍼(0x9810af0)를 검사하면 그 내용을 확인할 수 있습니다.

0:000> db 0x9810af0
09810af0  4d 5a 90 00 03 00 00 00-04 00 00 00 ff ff 00 00  MZ..............
09810b00  b8 00 00 00 00 00 00 00-40 00 00 00 00 00 00 00  ........@.......
09810b10  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
09810b20  00 00 00 00 00 00 00 00-00 00 00 00 80 00 00 00  ................
09810b30  0e 1f ba 0e 00 b4 09 cd-21 b8 01 4c cd 21 54 68  ........!..L.!Th
09810b40  69 73 20 70 72 6f 67 72-61 6d 20 63 61 6e 6e 6f  is program canno
09810b50  74 20 62 65 20 72 75 6e-20 69 6e 20 44 4f 53 20  t be run in DOS 
09810b60  6d 6f 64 65 2e 0d 0d 0a-24 00 00 00 00 00 00 00  mode....$.......

!dh 확장을 사용하여 이 헤더 정보를 파싱할 수 있습니다.

0:000> !dh 0x9810af0

File Type: EXECUTABLE IMAGE
FILE HEADER VALUES
     14C machine (i386)
       3 number of sections
66220A8D time date stamp Fri Apr 19 06:09:17 2024

----- SNIPPED -----

OPTIONAL HEADER VALUES
     10B magic #
   11.00 linker version
         ----- SNIPPED -----
       0 [       0] address [size] of Export Directory
   3D3D4 [      57] address [size] of Import Directory
   ----- SNIPPED -----
       0 [       0] address [size] of Delay Import Directory
    2008 [      48] address [size] of COR20 Header Directory

SECTION HEADER #1
   .text name
   3B434 virtual size
    2000 virtual address
   3B600 size of raw data
     200 file pointer to raw data
----- SNIPPED -----

SECTION HEADER #2
   .rsrc name
     546 virtual size
   3E000 virtual address
     600 size of raw data
   3B800 file pointer to raw data
----- SNIPPED -----

SECTION HEADER #3
  .reloc name
       C virtual size
   40000 virtual address
     200 size of raw data
   3BE00 file pointer to raw data
----- SNIPPED -----

COR20 헤더 디렉터리(.NET 헤더에 대한 포인터)의 존재는 이것이 .NET 실행 파일임을 나타냅니다. .text(0x2000), .rsrc(0x3E000) 및 .reloc(0x40000)에 대한 상대 가상 주소 또한 WriteProcessMemory 호출의 대상 주소와 일치합니다.

이제 writemem 명령어를 사용하여 새로 발견된 PE 파일을 메모리에서 추출할 수 있습니다.

0:000> .writemem c:\users\flare\Desktop\headers.bin 0x9810af0 L0x200
Writing 200 bytes.

0:000> .writemem c:\users\flare\Desktop\text.bin 0x984cb10 L0x3b600
Writing 3b600 bytes.......................................................................................................................

0:000> .writemem c:\users\flare\Desktop\rsrc.bin 0x387d9d0 L0x600
Writing 600 bytes.

0:000> .writemem c:\users\flare\Desktop\reloc.bin 0x3927178 L0x200
Writing 200 bytes.

헥스 에디터를 사용하여 각 섹션을 원시 오프셋에 배치하여 파일을 재구성할 수 있습니다. dnSpy에서 결과 .NET 실행 파일(SHA256: 4dfe67a8f1751ce0c29f7f44295e6028ad83bb8b3a7e85f84d6e251a0d7e3076)을 간략하게 분석하면 구성 데이터가 나타납니다.

----- SNIPPED -----

// Token: 0x0400000E RID: 14
public static bool EnableKeylogger = Convert.ToBoolean("false");
// Token: 0x0400000F RID: 15
public static bool EnableScreenLogger = Convert.ToBoolean("false");
// Token: 0x04000010 RID: 16
public static bool EnableClipboardLogger = Convert.ToBoolean("false");
// Token: 0x0400001C RID: 28
public static string SmtpServer = "<REDACTED";
// Token: 0x0400001D RID: 29
public static string SmtpSender = "<REDACTED>";
// Token: 0x04000025 RID: 37
public static string StartupDirectoryName = "eXCXES";
// Token: 0x04000026 RID: 38
public static string StartupInstallationName = "eXCXES.exe";
// Token: 0x04000027 RID: 39
public static string StartupRegName = "eXCXES";
----- SNIPPED -----

결론: 분석 가속기로서의 TTD

이 사례 연구는 TTD 실행 추적을 검색 가능한 데이터베이스로 취급할 때의 이점을 보여줍니다. 페이로드 전달을 캡처하고 특정 API 호출에 대해 디버거 데이터 모델을 직접 쿼리함으로써, 우리는 .NET 드롭퍼의 다층적인 난독화를 신속하게 우회했습니다. 대상 데이터 모델 쿼리와 LINQ 필터(CreateProcess* 및 WriteProcessMemory*용) 및 저수준 명령어(!dh.writemem)의 조합을 통해 숨겨진 AgentTesla 페이로드를 분리하고 추출하여 몇 분 만에 중요한 구성 세부 정보를 얻을 수 있었습니다.

이 분석에 사용된 도구와 환경(최신 버전의 WinDbg 및 TTD 포함)은 FLARE-VM 설치 스크립트를 통해 쉽게 사용할 수 있습니다. 이 사전 구성된 환경으로 분석 워크플로우를 간소화하는 것을 권장합니다.

TTD 추적은 원본 샘플과 함께 VirusTotal에서 다운로드할 수 있습니다.

게시 위치