| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- AWS 3 Tier Architecture
- 프로그래머스
- operating system
- crackme
- Amazon S3
- 리버싱
- programmers
- TryHackMe
- AWS 침해사고 사례 분석
- python
- dreamhack
- terraform
- AWS
- 버퍼 오버플로우
- AWS 사고 사례 분석
- sql
- 파일 시스템
- network
- AWS 침해 사고 사례 분석
- 악성코드 분석
- 악성코드분석
- 네트워크
- AWS 보안 아키텍처 분석
- reversing
- reversing.kr
- 드림핵
- CrackMe 문제
- C
- Database
- 운영체제
- Today
- Total
lhywk 님의 블로그
[악성코드 분석] 악성코드의 방어 기법 본문
PE 구조의 "정상적인" 모습을 봤다면, 이제부터는 악성코드가 그 구조를 어떻게 위장하는지를 다룬다. 그 첫 번째가 패커(Packer)다. 정적 분석으로 파일을 열어봤는데 이상하게 코드도 문자열도 거의 안 보인다면, 패킹되어 있는 파일이다.
패커란 무엇인가
패커(Packer): PE 파일(Windows 실행 파일)을 실행 가능한 상태로 압축해주는 프로그램의 총칭이다. 파일을 압축하지만, 압축된 상태 그대로 실행이 가능하게 만든다는 게 핵심이다.
패커를 쓰는 두 가지 목적
- 파일 크기 축소 (정상적인 목적): PE 파일 크기를 줄여 배포를 용이하게 함
- 분석 방해 (악의적인 목적): PE 파일의 내부 코드와 리소스를 감추어 리버싱을 어렵게 만듦. 백신의 시그니처 기반 탐지를 우회하고, 분석가가 실제 코드를 파악하는 걸 방해한다.
대부분의 패킹 기술이 악성코드 분석을 방해할 목적으로 쓰인다는 점에서, 패커는 리버서가 가장 먼저 마주치는 장벽이다.
실행압축(Runtime Compression)이 동작하는 방식
정의: 실행 파일 내부에 압축된 원본 코드와 압축 해제 코드(디코딩 루틴)를 함께 포함하는 기술.
동작 3단계
- 파일이 실행되는 순간 (압축된 원본 코드가 아닌) 압축 해제 코드가 먼저 메모리에서 실행된다.
- 이 코드가 메모리상에서 압축된 원본 코드를 압축 해제(복원)한다.
- 압축 해제가 완료되면 복원된 원본 코드의 시작점(OEP)으로 점프하여 프로그램을 실행한다.
실행압축 파일의 구조
- 패킹된 파일도 여전히 PE 파일 형식을 따른다 (DOS 헤더, NT 헤더, 섹션 헤더).
- 내부에는 1. 압축된 원본 PE 파일 데이터와 2. 디코딩 루틴(Unpacking Stub)이 존재한다.
- 파일의 EP(Entry Point)가 원본 코드의 시작점이 아니라, 디코딩 루틴의 시작점을 가리키도록 조작되어 있다.
이 "EP가 원본 코드를 가리키지 않는다"는 사실이 바로 AddressOfEntryPoint 필드를 패킹 탐지에 활용하는 이유다. AEP가 .text가 아닌 이상한 섹션을 가리킨다면 패킹을 의심해야 한다.
용어 정리 - 패커 vs 프로텍터
| 용어 | 정의 |
| 패커 (Packer) | 일반 PE 파일을 실행압축 파일로 만들어주는 유틸리티(도구) |
| 프로텍터 (Protector) | 단순 패커 기능에 더해 안티 리버싱(Anti-Reversing) 기법(안티 디버깅, 코드 난독화 등)에 특화된 패커 |
쉽게 말해 패커는 "압축만 하는 도구", 프로텍터는 "압축도 하고 분석까지 적극적으로 방해하는 도구"다.
대표적인 패커들
| 분류 | 이름 | 특징 |
| 단순 패커 | UPX | 공개용(오픈소스), 주 목적은 파일 압축. 구조가 단순해서 악성코드 분석 공부 초반에 많이 다룸 |
| 단순 패커 | AsPack | 파일 크기 축소 + 단순 암호화를 제공하는 상용 패커. UPX보다 조금 복잡하지만 프로텍터 수준은 아님 |
| 프로텍터 | Armadillo, MoleBox, YodaCrypter, PECompact, Themida | 강력한 안티 리버싱 기능을 포함한 전문 프로텍터들 |
UPX 패킹의 구조

패킹 전 (Original File)
- 섹션 구조: .TEXT, .DATA, .RSRC 등 표준적인 PE 섹션들로 구성
- EP (Entry Point): 프로그램의 시작점. PE 로더는 이 주소(예: 0x100739D)부터 코드를 실행하며, 주로 .TEXT 섹션 내부에 있다.
패킹 후 (Packed File)
- 섹션 구조: 원본 섹션들이 사라지고, UPX0, UPX1, UPX2 같은 UPX 고유의 섹션들로 대체된다.
- 원본 코드: 기존 .TEXT, .DATA 섹션의 내용은 압축되어 UPX0나 UPX2 같은 새 섹션에 데이터 형태로 저장된다.
- EP 변경: 파일의 EP가 UPX1 섹션의 시작 주소(예: 0x1015350)로 변경된다. 이 UPX1 섹션에는 원본 코드를 압축 해제하는 코드, 즉 언패킹 스텁(Unpacking Stub)이 들어있다.
- OEP (Original Entry Point): 원본 프로그램의 진짜 시작 주소(예: 0x100739D)를 의미한다. 언패킹 스텁은 이 OEP 주소를 기억하고 있다가, 압축 해제가 끝나면 이 주소로 점프한다.
패킹 흔적
패킹된 파일을 PE 뷰어로 열었을 때 나타나는 전형적인 증상들을 정리하면 다음과 같다.
1) 섹션 관련 증상
- 이상한 섹션 이름: .text, .data 같은 표준 이름이 아니라 UPX0, UPX1, .aspack처럼 패커 고유의 이름으로 되어 있다.
- 비정상적인 섹션 크기: RawSize(파일에서의 크기)는 0인데 VirtualSize(메모리에서의 크기)는 매우 큰 섹션이 존재한다. 이 섹션은 파일 상태에서는 비어있지만, 실행 후 메모리에서 언패킹된 코드가 채워질 공간이기 때문이다.
- 비정상적인 섹션 권한: 정상 파일은 코드 섹션(.text)이 읽기/실행 권한만, 데이터 섹션(.data)이 읽기/쓰기 권한만 갖는 게 일반적이다. 그런데 UPX0처럼 읽기+쓰기+실행 권한을 동시에 가지는 섹션이 있다면 매우 의심스럽다 - 쓰기와 실행이 동시에 필요한 경우는 보통 "코드를 그 자리에 풀어서 곧바로 실행하겠다"는 언패킹 스텁뿐이기 때문이다.
- EP의 비정상적인 위치: EP가 첫 번째 섹션(.text)이 아닌 UPX1 같은 마지막 섹션이나 비정상적인 위치를 가리키는 경우가 많다.
2) Import/문자열 관련 증상
- 언패킹용 API만 보임: 임포트하는 API 목록(IAT)이 매우 적거나, LoadLibraryA, GetProcAddress처럼 다른 함수를 동적으로 로드하려는 API, 또는 VirtualAlloc, VirtualProtect처럼 메모리를 할당하고 권한을 바꾸는 API만 주로 보인다.
- 문자열의 정적/동적 차이: 정적 분석(파일 상태)에서는 패커 관련 문자열 외에 의미 있는 문자열이 거의 보이지 않는다(원본 코드가 압축/암호화되어 있기 때문). 반면 동적 분석(실행 후 메모리 덤프)에서는 원본 프로그램의 수많은 문자열(에러 메시지, 함수 이름 등)이 나타난다.
EP 시그니처로 패커 식별하기
언패킹 스텁이 시작되고 끝나는 부분에는 특정 패턴이 반복적으로 나타나는데, 이걸 알아두면 패커 종류를 빠르게 추정할 수 있다.

UPX의 시그니처
- Anti-Debugging: 없음. 분석을 방해하는 안티 디버깅 기능이 없기 때문에, 디버거(x64dbg 등)를 붙여도 특별한 저항 없이 분석할 수 있다.
- 섹션 이름: UPX0, UPX1
- EP 시그니처 (핵심: PUSHAD): 모든 범용 레지스터를 스택에 한 번에 저장하는 명령어다. 언패킹 코드가 레지스터를 마구 사용한 뒤, 원본 프로그램으로 돌아가기 전에 원래 레지스터 값들을 복원해야 하기 때문에 시작 부분에 항상 등장한다.
- 전달명령 (OEP로 점프하는 부분, 핵심: POPAD): PUSHAD로 저장했던 레지스터 값들을 복원하는 명령어. 이 POPAD 바로 뒤에 JMP Offset이 따라오는데, 이게 실제로 OEP로 점프하는 부분이다.
AsPack의 시그니처

- Anti-Debugging: 없음 (UPX와 마찬가지)
- 섹션 이름: .aspack - PE 뷰어로 열었을 때 이 이름이 보이면 AsPack 패킹을 의심할 수 있다.
- EP 시그니처 (OEP 계산 과정): UPX와 달리 OEP를 코드로 직접 계산해내는 패턴이 보인다.
mov eax, 0A870h ; eax에 OEP의 상대 주소(옵셋)를 넣는다
add eax, [ebp+422h] ; ImageBase를 더해서 절대 OEP 주소를 만든다
popa ; 레지스터 복원 (UPX의 popad와 같은 역할)
- 전달명령: push [계산된 OEP] → ret 조합. ret은 원래 "함수가 끝났으니 돌아가라"는 의미지만, 실제 동작은 스택에서 주소를 pop해서 그 주소로 jump하는 것이므로, push OEP 다음에 ret을 실행하면 결과적으로 OEP로 점프한 것과 동일한 효과를 낸다.
TEB와 PEB
FS 세그먼트 레지스터
│ (항상 가리킴)
▼
TEB (Thread Environment Block) ← 스레드마다 하나씩
│ (+0x30 오프셋에 포인터)
▼
PEB (Process Environment Block) ← 프로세스마다 하나
"FS -> TEB -> (+0x30) -> PEB"라는 체인을 따라가면, 스레드 레지스터 하나만으로 프로세스 전체의 정보에 접근할 수 있다.
TEB (Thread Environment Block)
정의: 윈도우 운영체제에서 각 스레드(Thread)의 정보를 저장하는 메모리 구조체. 디버깅 및 분석 시 이 구조체를 참조하여 스레드와 프로세스의 중요 정보에 접근할 수 있다.

NT_TIB - TEB의 맨 앞부분

TEB 구조체는 _NT_TIB(NT Thread Information Block) 구조체로 시작한다. 즉, TEB의 시작 주소 = NT_TIB의 시작 주소다.
| Offset | 멤버 | 설명 |
| 0x000 | ExceptionList | 스레드의 예외 처리기(Exception Handler) 체인의 시작을 가리킴 |
| 0x004 | StackBase | 현재 스레드 스택의 최상단(시작) 주소 |
| 0x008 | StackLimit | 현재 스레드 스택의 최하단(끝) 주소 |
| 0x018 | Self | TEB 구조체 자기 자신의 시작 주소를 가리키는 포인터 |
32비트 윈도우 환경에서 FS 세그먼트 레지스터는 항상 현재 실행 중인 스레드의 TEB 시작 주소를 가리킨다. 따라서 NT_TIB는 FS:[0x0]에서 바로 접근할 수 있다. FS:[0x18]에 접근하는 것은 NT_TIB 구조체 내의 Self 멤버에 접근하는 것이며, 이는 TEB 구조체의 시작 주소값을 동적으로 가져오는 행위와 동일하다. 악성코드는 FS:[0x18]을 읽어 TEB 주소를 동적으로 찾은 뒤, 그 주소를 기준으로 오프셋(예: +0x30)을 계산해 PEB 주소를 찾는 안티디버깅 기법을 자주 사용한다.
TEB의 핵심 멤버 (NT_TIB 이후)
| Offset | 멤버 | 설명 |
| 0x030 | ProcessEnvironmentBlock | 현재 스레드가 속한 프로세스의 PEB 주소(포인터) |
| 0x034 | LastErrorValue | 해당 스레드에서 마지막으로 발생한 오류 코드 |
PEB (Process Environment Block)
프로세스에 대한 전반적인 정보를 담고 있는 윈도우 커널의 핵심 데이터 구조체. 프로세스마다 고유하며, 로드된 모듈 목록, 디버거 탐지 플래그, 프로세스 힙 정보 등을 담고 있다. 프로세스 관련 정보는 PEB 내에서 연결 리스트(Linked List) 구조로 관리된다.

핵심 멤버 (안티리버싱 관점에서 가장 중요한 3개)
| Offset | 멤버 | 타입 | 설명 |
| 0x002 | BeingDebugged | UChar | 프로세스가 현재 디버거에 의해 디버깅 중인지를 나타내는 플래그 |
| 0x00C | Ldr | Ptr32_PEB_LDR_DATA | 로드된 DLL 모듈 목록을 관리하는 구조체를 가리키는 포인터 |
| 0x068 | NtGlobalFlag | Uint4B | 프로세스의 전역 상태 플래그. 디버깅 관련 힙 플래그 조합이 들어감 |
BeingDebugged (0x002) - 가장 직접적인 디버거 탐지 플래그
디버거로 프로세스를 실행하면 윈도우 커널이 이 값을 1로 설정한다. 악성코드는 실행 시 이 값을 직접 확인하여 1이면 디버거가 붙어있다고 판단하고, 스스로 종료하거나 비정상적인 동작을 수행한다.
Ldr (0x00C) - 로드된 DLL 목록의 입구
_PEB_LDR_DATA 구조체를 가리키는 포인터다. 분석가는 이 포인터를 따라가서 현재 프로세스에 어떤 DLL들이 로드되었는지, 그 베이스 주소(ImageBaseAddress) 등을 확인할 수 있다.
NtGlobalFlag (0x068) - 힙 플래그 조합으로 디버깅 판별
프로세스가 디버거 하에서 실행될 때 이 플래그에 특정 값(0x70)이 설정된다. 악성코드는 BeingDebugged 플래그와 더불어 이 값을 함께 검사하여 더욱 확실하게 디버깅 환경을 탐지한다.
악성코드는 안티리버싱을 위해 TEB를 통해 PEB 주소에 접근한 뒤, BeingDebugged(오프셋 0x002) 플래그와 NtGlobalFlag(오프셋 0x068) 값을 확인하여 디버거를 탐지한다.
PEB_LDR_DATA - 로드된 모듈을 추적하는 연결 리스트

PEB_LDR_DATA 구조체는 프로세스 로더(Loader)의 데이터를 관리하며, 프로세스에 로드된 DLL 모듈들에 대한 정보를 담고 있다. PEB의 +0x0C 오프셋에 이 구조체를 가리키는 포인터가 존재한다.
핵심 원리 - 모듈 목록 관리
프로세스에 DLL 모듈이 로드될 때마다, 해당 모듈의 정보를 담는 _LDR_DATA_TABLE_ENTRY 구조체가 하나씩 생성된다. PEB_LDR_DATA는 이 항목들을 연결 리스트(Linked List) 형태로 관리한다.
주요 멤버 (오프셋 기준)
| Offset | 멤버 | 설명 |
| 0x00C | InLoadOrderModuleList | 모듈이 로드된 순서대로 연결된 리스트의 시작점 |
| 0x014 | InMemoryOrderModuleList | 모듈이 메모리에 배치된 주소 순서대로 연결된 리스트의 시작점 |
| 0x01C | InInitializationOrderModuleList | 모듈의 DllMain 함수가 초기화(호출)된 순서대로 연결된 리스트의 시작점 |
분석 관점에서의 중요성
악성코드는 GetModuleHandle, GetProcAddress 같은 표준 API를 사용하지 않고도 현재 프로세스에 로드된 DLL(예: kernel32.dll)의 주소를 찾거나 특정 함수(예: LoadLibraryA)의 주소를 직접 찾기 위해, PEB를 통해 이 PEB_LDR_DATA 구조체에 접근한다. 그 후 이 3가지 연결 리스트 중 하나를 순회(parsing)하여 필요한 모듈 정보를 획득하는 기법을 사용하는데, 이를 PEB 워킹(PEB Walking)이라고 부른다.
이 기법이 중요한 이유는 정상적인 API 호출 흐름(GetProcAddress 등)을 거치지 않기 때문에 API 후킹 기반의 보안 솔루션을 우회할 수 있다는 점이다. 보안 솔루션이 GetProcAddress 호출을 감시하고 있어도, PEB를 직접 파싱하는 악성코드는 그 감시망에 걸리지 않는다.
안티리버싱의 큰 분류 - 정적 vs 동적
안티리버싱(Anti-Reversing): 디버거를 통한 프로그램 분석을 어렵게 만드는 기술 전체를 일컫는다.
- 정적 안티리버싱(Static Anti-Reversing): 프로그램 실행 전 정적 분석을 방해. 디버거를 탐지해서 프로그램이 정상적으로 실행되지 않도록 하거나, 디버거가 실행 중인 걸 감지하면 의도된 동작과 다른 동작을 수행한다.
- 동적 안티리버싱(Dynamic Anti-Reversing): 프로그램 실행 중 동적 분석을 방해. 디버거로 코드 실행 과정을 관찰하는 것 자체를 방해한다.
PEB의 BeingDebugged 플래그 직접 확인
가장 직관적이고 흔한 방법이다.
어셈블리 코드
mov eax, dword ptr fs:[30h] ; FS:[0x30] → TEB 내의 PEB 포인터를 eax로
mov eax, dword ptr ds:[eax+2] ; PEB+0x02 → BeingDebugged 플래그를 eax로
C 코드로 보면
bool AntiDebugging_PEB(void) {
_TEB* TEB = NtCurrentTeb();
UINT PEB = *(UINT*)((UINT)TEB + 0x30);
return *(UINT*)(PEB + 0x02) & 1;
// PEB.BeingDebugged(+0x2)의 값을 참조하여 안티디버깅
}
& 1 연산을 하는 이유는 BeingDebugged가 1바이트 플래그이기 때문에, 정확히 그 비트만 추출하기 위함이다.
핵심 원리 요약
- 현재 스레드의 TEB 주소를 획득한다.
- TEB의 +0x30 오프셋을 참조하여 PEB의 주소를 획득한다.
- PEB의 +0x02 오프셋을 참조하여 BeingDebugged 플래그의 값을 직접 읽는다.
- 이 값이 1인지 확인하여 디버거 탐지 여부를 판단한다.
우회법
- cmp 비교 결과를 강제로 1(또는 0)로 만들어 우회
- jmp를 사용해 무조건 분기시켜 우회
- ret 전에 eax에 1이 아닌 0을 넣어서 우회
실제 디버거 화면에서 BeingDebugged 리버싱을 따라가 보면 다음과 같은 패턴이 보인다.

0x4026A0 진입 장면이다.
fs세그먼트의 주소를 리턴하는 장면이다. 즉, 프로세스 관련 정보를 리턴하고 있다.
0x30을 더하면 PEB의 시작 주소이고 0x02를 더하면 BeingDebugged 플래그를 통해 디버그를 탐지한다.
그리고 and 1 연산을 하기 때문에 여기서 디버그 모드를 판단하는 연산 부분이다.

1로 바꿔서 우회하는 방법

jmp로 우회하는 방법

ZF 플래그를 조작하여 우회하는 방법

and 연산을 0으로 하게해서 무조건 0을 리턴해 우회하는 방법이 있다.
코드 흐름: fs 세그먼트의 주소를 리턴 -> +0x30을 더해 PEB 시작 주소 -> +0x02를 더해 BeingDebugged 플래그 -> and 1 연산으로 디버그 모드 판단. 이 패턴을 알고 있으면 디버거에서 cmp ... 1 직후의 je 분기점에서 값을 1로 바꿔 우회할 수 있다.
PEB의 NtGlobalFlags 확인
원리
- 위치: NtGlobalFlags는 PEB의 0x68 번째 오프셋에 존재한다.
- 판단 기준: 디버깅 중일 때는 0x70, 정상 실행 시는 0x00.
어셈블리 코드
mov eax, dword ptr fs:[30h] ; PEB 시작 주소를 eax로
mov eax, dword ptr ds:[eax+0x68] ; NtGlobalFlags 값을 eax로
cmp eax, 0x70 ; 0x70과 비교
je <NtGlobal routine> ; 같으면 디버거 탐지 루틴으로 점프

0x70 값의 의미
0x70은 디버깅 중일 때 ntdll.dll의 힙(Heap) 조작 루틴을 제어하는 세 플래그의 합이다.
| 플래그 | 값 | 의미 |
| FLG_HEAP_ENABLE_TAIL_CHECK | 0x10 | 힙의 끝부분 검사 |
| FLG_HEAP_ENABLE_FREE_CHECK | 0x20 | 해제된 힙 메모리 검사 |
| FLG_HEAP_VALIDATE_PARAMETERS | 0x40 | 힙 관련 파라미터 검증 |
0x10 + 0x20 + 0x40 = 0x70
C 코드로 보면
bool AntiDebugging_NtGlobalFlag(void) {
_TEB* TEB = NtCurrentTeb();
UINT PEB = *(UINT*)((UINT)TEB + 0x30);
UINT NtGlobalFlag = *(UINT*)(PEB + 0x68);
if (NtGlobalFlag == 0x70) return true;
return false;
}
우회법
PEB의 +0x68 오프셋에 있는 NtGlobalFlag 값을 디버거로 직접 0x70에서 0으로 수정해주면 된다.
디버그 힙 패턴 검사 (LDR 메모리 스캔)
원리
윈도우 힙 매니저는 디버거 연결 여부에 따라 메모리 해제 시 다른 패턴을 사용한다.
- Normal Mode: 힙 관리자가 해제된(freed) 메모리 영역을 0xFEEEFEEE("No-man's-land") 패턴으로 채운다.
- Debug Mode: 디버그 힙 관리자가 활성화되어, 버퍼 오버플로우 등을 탐지하기 위해 할당된 메모리 블록 앞뒤에 Guard Bytes(경계 바이트)를 둔다.
C 코드로 보면
bool AntiDebugging_LDR(void) {
_TEB* TEB = NtCurrentTeb();
UINT PEB = *(UINT*)((UINT)TEB + 0x30);
UINT LDR = *(UINT*)(PEB + 0x0C);
LDR = *(UINT*)(LDR + 0x10);
for (int i = 0; i < 0x200; i++) {
if (*(UINT*)(LDR + i) == 0xFEEEFEEE || *(UINT*)(LDR + i) == 0xABABABAB)
return true;
}
return false;
}
PEB의 Ldr(+0x0C) -> 그 안의 +0x10 위치부터 0x200바이트 영역을 스캔해서, 0xFEEEFEEE 또는 0xABABABAB 패턴이 발견되는지 확인하는 방식이다.
하드웨어 중단점 탐지 (GetThreadContext)
디버그 레지스터(DR0~DR7) 개념이 여기서 쓰인다.
핵심 원리
- 정상 실행 시 DR0~DR3 레지스터 값은 항상 0이다.
- 디버거가 하드웨어 중단점을 설정하면 이 중 하나에 0이 아닌 주소값이 저장된다.
- GetThreadContext API를 호출해 이 디버그 레지스터들의 값을 직접 읽어보고, 0이 아닌 값이 있는지 검사한다.
GetThreadContext API
- 용도: 현재 실행 중인 스레드의 컨텍스트 정보(CONTEXT 구조체)를 가져온다.
- CONTEXT 구조체: EAX, EBX 같은 일반 레지스터, EIP뿐만 아니라 DR0~DR3, DR6, DR7 같은 모든 디버그 레지스터의 현재 값을 포함하는 거대한 데이터 구조체다.
C 코드로 보면
CONTEXT ct;
ct.ContextFlags = CONTEXT_DEBUG_REGISTERS; // 디버그 레지스터 정보만 요청
GetThreadContext(GetCurrentThread(), &ct);
if (ct.Dr0 || ct.Dr1 || ct.Dr2 || ct.Dr3) {
result = TRUE; // 하나라도 0이 아니면 하드웨어 중단점 탐지
}
어셈블리 코드 분석
call <&GetCurrentThread>
call <&GetThreadContext>
test eax, eax
jne debugregister.401562 ; DR0 값이 0이 아니면 탐지 루틴으로
디버깅 관련 API로 바로 확인하기
가장 간단하지만 가장 쉽게 우회되는 방법들이다.
| API | 용도 | 탐지 원리 |
| IsDebuggerPresent() | 현재 프로세스가 디버깅 중인지 확인 | 디버깅 중이면 0이 아닌 값 반환 |
| CheckRemoteDebuggerPresent | 로컬 시스템의 다른 프로세스 디버깅 여부도 검사 가능 | IsDebuggerPresent와 유사 |
| NtQueryInformationProcess | ProcessDebugPort(0x7)를 매개변수로 디버깅 포트 정보 요청 | 디버깅 시 0이 아닌 값 반환 |
| OutputDebugString | 디버거 출력 창으로 문자열 전달 | 디버거 없을 때 호출하면 내부적으로 오류 발생 → GetLastError()의 오류 코드 변화로 판단 |
우회법: OutputDebugStringA 호출 직후 eax 값을 항상 1로 수정하거나, cmp eax, 1 및 je로 이어지는 조건 분기 로직을 nop으로 덮어써서 우회한다.
수행시간 검사 (RDTSC)
핵심 원리
프로세스는 디버거 하에서 동작할 때(한 단계씩 실행하거나 중단점에서 멈추는 등) 정상 실행보다 수행시간이 훨씬 더 길어진다는 특징을 이용한다. 코드의 A 지점과 B 지점 사이 실행 시간을 측정해서, 정해진 임계값(Threshold)을 초과하는지 확인한다.

RDTSC (Real Time Stamp Counter)
CPU가 부팅된 이후부터 현재까지의 CPU 클럭 사이클 수를 반환하는 어셈블리 명령어. 타임스탬프로 사용하면 매우 정밀한 시간 측정이 가능하다. 결과값은 64비트로, 상위 32비트는 EDX, 하위 32비트는 EAX에 저장된다.
어셈블리 코드 분석
rdtsc ; 시간측정(1). EAX에 타임스탬프(하위 32비트) 저장
xor ecx, ecx ; ECX 초기화
add ecx, eax ; 첫 번째 타임스탬프를 ECX에 보관
rdtsc ; 시간측정(2). EAX에 두 번째 타임스탬프 저장
sub eax, ecx ; 두 지점 간 시간 차이 = EAX
cmp eax, 0xFFF ; 임계값과 비교
jnb <TimeCheck routine> ; 임계값 이상이면 디버거로 판단하고 점프
우회법
- 코드 패치: jnb를 nop으로 바꿔서 점프가 일어나지 않게 하거나, jmp(무조건 점프)로 바꿔서 항상 정상 실행 루틴으로 가도록 수정
- 리턴 값 수정: RDTSC가 반환하는 EAX 값을 직접 수정하거나, 비슷한 역할을 하는 GetTickCount() 같은 함수의 리턴 값을 조작해서 시간 차이가 임계값 미만이 되도록 만든다.
안티-VM (Anti-VM) - 가상머신인지 확인하기
원리: 많은 분석가들이 VMware, VirtualBox 같은 가상 환경에서 악성코드를 분석한다(실제 시스템이 망가지면 안 되니까). 그래서 악성코드는 자신이 가상 환경에서 실행되고 있는지 확인하고 맞다면 정상적인 악성 행위를 멈추거나 다르게 동작한다 - 분석가에게는 "아무 짓도 안 하는 평범한 프로그램"으로 보이게 만드는 것이다.
안티VM은 "탐지 시 실행을 중단한다"는 점에서 안티디버깅과 목적이 똑같다. 차이는 검사 대상이다 - 안티디버깅은 "디버거가 붙어있는가"를, 안티VM은 "지금 이 컴퓨터 자체가 가상환경인가"를 묻는다.
코드 난독화 (Code Obfuscation) - 읽기 어렵게 꼬아놓기
정의: 코드의 로직을 알아보기 어렵게 복잡하게 꼬아놓는 기술. 실제로 동작은 똑같이 하면서, 분석가가 읽고 이해하는 시간을 최대한 늘리는 게 목적이다.
다형성 코드 (Polymorphic Code)
실행할 때마다 코드 형태가 바뀌어, 시그니처 기반 탐지(백신이 "이 바이트 패턴이면 악성코드"라고 판단하는 방식)를 우회하는 기술이다.
더미 코드 (Garbage Code)
실제 프로그램 실행과는 아무 상관없는 쓰레기 코드를 중간중간 삽입하여 분석가의 해석을 방해한다.
int func() {
int a, b;
a = 1; // 의미 없는 코드 (뒤에서 덮어씀)
b = 2; // 의미 없는 코드 (사용되지 않음)
a = 3;
return a;
}
a = 1;과 b = 2;는 최종 반환 값(a = 3)에 전혀 영향을 주지 않는다. 실행 결과는 그대로 두면서 코드 분량만 늘려서 분석가가 읽어야 할 양을 늘리는 전형적인 방식이다.
디버거 탐지 (Debugger Detection)
안티 디버깅과 유사하며, 특정 디버깅 관련 함수 호출이나 상태를 감지하는 것을 코드 난독화의 일종으로 분류하기도 한다.
패턴 기반 변환 - 같은 동작을 더 복잡한 코드로
정의: 특정 코드(패턴)를 그와 동일한 기능을 수행하는 다른 복잡한 명령어들의 집합으로 바꾸는 기법이다.
예시: "eax에 ebx 값 넣기"가 점점 복잡해지는 과정
; [원본 코드]
mov eax, ebx ; eax에 ebx의 값을 복사한다.
; [패턴 1 변환]
push ebx ; ebx를 스택에 넣는다.
pop eax ; 스택에서 값을 꺼내 eax에 넣는다.
; [패턴 2 변환 (더 복잡하게)]
sub esp, 4 ; 스택 공간을 4바이트 확보
mov dword ptr [esp], ebx ; 확보한 공간에 ebx 값을 씀
xor eax, eax ; eax를 0으로 초기화
add eax, dword ptr [esp] ; eax에 스택에 쓴 ebx 값을 더함 (결국 mov)
add esp, 4 ; 스택 포인터를 원상복구
세 코드 모두 결과는 "eax에 ebx의 값을 넣는다"로 완전히 동일하다. 하지만 아래로 갈수록 코드 줄 수가 늘고 의도를 파악하기 훨씬 어려워진다.
제어기반 난독화 (Control-flow Obfuscation) - 순서를 뒤섞기
코드의 실제 실행 순서는 그대로 유지하면서, 코드 블록이 메모리에 배치되는 순서를 의도적으로 뒤섞어 정적 분석(코드를 위에서 아래로 읽는 것)을 느리게 만드는 기법이다.
동작 원리
기존의 순차적인 코드를 여러 조각으로 분리하고, 이 조각들을 비순차적으로 배치한 뒤 각 조각의 끝에서 jmp로 다음 실행될 조각으로 강제 이동시킨다.
예시 - MessageBoxA 호출이 어떻게 흩어지는가
변경 전 (읽기 쉬운 순차 코드):
push 0
push offset_DialogCaption
push offset_DilogText
push 0
call MessageBoxA
변경 후 (코드가 여러 줄로 흩어지고 jmp로 연결됨):
01: jmp 08
08: push 0 ← (첫 번째 push 실행)
09: jmp 02
02: push offset_DialogCaption ← (두 번째 push 실행)
03: jmp 10
10: push offset_DilogText ← (세 번째 push 실행)
11: jmp 06
06: push 0 ← (네 번째 push 실행)
07: jmp 04
04: call MessageBoxA ← (함수 호출)
05: jmp 12 ← 다음 코드로 이동
결론: 분석가가 코드를 위에서 아래로 순차적으로 읽을 수 없게 만들어, 실제 제어 흐름을 파악하기 어렵게 만든다. 하지만 디버거로 한 줄씩 실행(F8)하면서 따라가면 실제 순서는 원래의 단순한 코드와 동일하다는 걸 알 수 있다 즉, 이 기법은 정적 분석을 방해하는 데 특화되어 있고 동적 분석(디버거 추적) 앞에서는 상대적으로 무력하다.
call과 ret을 이용한 jmp 위장
call은 "복귀 주소를 스택에 push하고 점프", ret은 "스택에서 주소를 pop해서 점프"다. 이 정확한 동작 방식을 거꾸로 이용하면 jmp처럼 보이지 않는 jmp를 만들 수 있다.

사용 목적과 주로 쓰이는 곳
이런 보호 기법들(안티디버깅+안티VM+난독화 묶음)이 실제로 어디서 쓰이는지 정리하면:
| 목적 | 사용 사례 |
| 크래킹 방지 | 불법 소프트웨어 복제나 인증 우회(크랙)를 막기 위해 |
| 코드/리소스 보호 | 핵심 알고리즘이나 내부 리소스(이미지, 데이터 등)를 숨기기 위해 |
| 게임 보안 | 온라인 게임 설치 시 함께 설치되는 보안 프로그램 (AhnLab HackShield, XIGNCODE3). 게임 해킹 툴(메모리 조작, 스피드핵)의 실행 방지가 주목적 |
| 악성코드 | 백신이나 분석가의 분석을 방해할 목적 |
| 소프트웨어 인증 | 정품 인증 과정이나 라이선스 키 로직을 숨기기 위해 |
이 표를 보면 알 수 있듯, 안티디버깅/난독화 기법 자체는 "악성"이 아니다. 게임 보안 프로그램이나 정품 인증 시스템도 똑같은 기법을 정상적인 목적으로 쓴다. 결국 같은 도구를 누가 어떤 목적으로 쓰느냐의 차이일 뿐이며, 분석가 입장에서는 코드를 보고 "이게 보호 코드인지 악성 행위인지"를 구분하는 안목이 중요하다.
'Malware Analysis' 카테고리의 다른 글
| [악성코드 분석] Procmon 동적분석 (0) | 2026.07.08 |
|---|---|
| [악성코드 분석] 프로세스/코드 인젝션 (0) | 2026.06.24 |
| [악성코드 분석] 메모리 공격 기법 (0) | 2026.06.24 |
| [악성코드 분석] PE 파일 구조 (0) | 2026.06.24 |
| [악성코드 분석] 리버싱을 위한 기초 지식 (0) | 2026.06.24 |