UPX 패킹 악성코드 분석 (UPX Unpacker)
사용 시점
- 정적 분석 결과 높은 엔트로피 섹션과 최소한의 임포트가 확인되어 바이너리가 패킹되었음을 나타낼 때
- PEiD, Detect It Easy, PEStudio에서 UPX 또는 다른 알려진 패커를 식별했을 때
- 임포트 테이블에 LoadLibrary와 GetProcAddress만 존재할 때 (런타임 임포트 해결은 패킹된 바이너리의 일반적인 특징)
- Ghidra나 IDA에서 올바른 디스어셈블리 및 디컴파일을 위해 원본 바이너리를 복구해야 할 때
- 악성코드 제작자가 UPX 매직 바이트나 헤더를 수정하여 자동 UPX 압축 해제가 실패할 때
사용하지 말아야 할 경우: 커스텀 패커, VM 기반 프로텍터(Themida, VMProtect), 디버깅을 통한 동적 언패킹이 더 적합한 샘플을 다룰 때.
사전 요구 사항
- UPX(Ultimate Packer for eXecutables) 설치 (
apt install upx-ucl또는 https://upx.github.io/에서 다운로드) - 패커 식별을 위한 Detect It Easy (DIE)
- 수동 헤더 복구를 위한 Python 3.8+ 및
pefile라이브러리 - 자동 도구 실패 시 수동 언패킹을 위한 x64dbg 또는 x32dbg
- PE 헤더 검사 및 복구를 위한 PE-bear 또는 CFF Explorer
- 네트워크 연결이 없는 격리된 분석 VM
워크플로우
1단계: 패커 식별
샘플이 패킹되었는지 확인하고 패커를 식별합니다:
# Detect It Easy로 확인
diec suspect.exe
# UPX로 확인 (언패킹 없이 테스트)
upx -t suspect.exe
# Python 기반 엔트로피 및 패커 탐지
python3 << 'PYEOF'
import pefile
import math
pe = pefile.PE("suspect.exe")
print("섹션 분석:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
raw = section.SizeOfRawData
virtual = section.Misc_VirtualSize
print(f" {name:8s} 엔트로피: {entropy:.2f} 원시 크기: {raw:>8} 가상 크기: {virtual:>8}")
# UPX 섹션 이름 확인
section_names = [s.Name.decode().rstrip('\x00') for s in pe.sections]
if 'UPX0' in section_names or 'UPX1' in section_names:
print("\n[!] UPX 섹션 이름 감지됨")
elif '.upx' in [s.lower() for s in section_names]:
print("\n[!] UPX 변형 섹션 이름 감지됨")
# 임포트 개수 확인 (패킹된 바이너리는 매우 적음)
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
total_imports = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f"\n총 임포트: {total_imports}")
if total_imports < 10:
print("[!] 임포트가 매우 적음 - 패킹되었을 가능성 높음")
else:
print("\n[!] 임포트 디렉터리 없음 - 심하게 패킹됨")
PYEOF
2단계: 표준 UPX 압축 해제 시도
내장된 UPX 압축 해제 기능을 시도합니다:
# 표준 UPX 압축 해제
upx -d suspect.exe -o unpacked.exe
# UPX가 "UPX로 패킹되지 않음" 오류를 반환하면 헤더가 수정된 것일 수 있음
# 디버깅을 위한 상세 출력
upx -d suspect.exe -o unpacked.exe -v 2>&1
# 언패킹된 파일 확인
file unpacked.exe
diec unpacked.exe
3단계: 수정된 UPX 헤더 복구
표준 압축 해제가 실패하면 변조된 매직 바이트를 복구합니다:
# 수정된 UPX 헤더 복구
import struct
with open("suspect.exe", "rb") as f:
data = bytearray(f.read())
# UPX 매직 바이트: "UPX!" (0x55505821)
# 악성코드 제작자는 자동 언패킹을 방지하기 위해 이를 수정하는 경우가 많음
# 수정된 UPX 시그니처 검색
upx_magic = b"UPX!"
modified_patterns = [b"UPX0", b"UPX\x00", b"\x00PX!", b"UPx!"]
# PE 오프셋 찾기 및 섹션 이름 복원
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
num_sections = struct.unpack_from("<H", data, pe_offset + 6)[0]
section_table_offset = pe_offset + 0x18 + struct.unpack_from("<H", data, pe_offset + 0x14)[0]
print(f"PE 오프셋: 0x{pe_offset:X}")
print(f"섹션 개수: {num_sections}")
print(f"섹션 테이블 오프셋: 0x{section_table_offset:X}")
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8]
print(f"섹션 {i}: {name}")
# 바이너리 내 UPX 매직 바이트 복원
# (일반적으로 패킹된 데이터 끝 부분 근처)
for i in range(len(data) - 4):
if data[i:i+3] == b"UPX" and data[i+3] != ord("!"):
print(f"오프셋 0x{i:X}에서 수정된 UPX 매직 발견: {data[i:i+4]}")
data[i:i+4] = b"UPX!"
print(f"다음으로 복원됨: UPX!")
# 수정된 섹션 이름도 복원
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8].rstrip(b'\x00')
if name in [b"UPX0", b"UPX1", b"UPX2"]:
continue # 이미 올바름
# 일반적인 변형 확인
if name.startswith(b"UP") or name.startswith(b"ux"):
original = f"UPX{i}".encode().ljust(8, b'\x00')
data[offset:offset+8] = original
print(f"오프셋 0x{offset:X}에서 섹션 이름을 {original}(으)로 복원")
with open("suspect_fixed.exe", "wb") as f:
f.write(data)
print("\n수정된 파일이 기록되었습니다. 재시도: upx -d suspect_fixed.exe -o unpacked.exe")
4단계: 디버거를 사용한 수동 언패킹
자동 언패킹이 완전히 실패하면 동적 언패킹을 사용합니다:
x64dbg를 사용한 수동 UPX 언패킹:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 패킹된 샘플을 x64dbg에 로드합니다.
2. 진입점까지 실행합니다 (시스템 중단점 후 F9).
3. UPX 언패킹 스텁 패턴:
a. PUSHAD (모든 레지스터 저장)
b. 압축 해제 루프 (패킹된 섹션 처리)
c. 임포트 해결 (LoadLibrary/GetProcAddress 호출)
d. POPAD (레지스터 복원)
e. JMP to OEP (원본 진입점)
4. PUSHAD 후 ESP에 하드웨어 중단점 설정:
- PUSHAD 후, 레지스터 창에서 ESP를 마우스 오른쪽 버튼으로 클릭 -> Follow in Dump
- [ESP] 주소에 액세스 하드웨어 중단점 설정
- 실행 (F9) - POPAD에서 중단, 이후 JMP to OEP
5. OEP로의 JMP에 도달할 때까지 전진 (F7/F8)
6. OEP에서: Scylla 플러그인을 사용하여 덤프 및 임포트 수정:
- Plugins -> Scylla -> OEP = 현재 EIP
- "IAT Autosearch" 클릭 -> "Get Imports" 클릭
- "Dump"를 클릭하여 언패킹된 바이너리 저장
- "Fix Dump"를 클릭하여 임포트 테이블 복구
5단계: 언패킹된 바이너리 검증
언패킹된 샘플이 유효하고 완전한지 확인합니다:
# 언패킹된 PE가 유효한지 확인
python3 << 'PYEOF'
import pefile
pe = pefile.PE("unpacked.exe")
# 섹션이 정상인지 확인
print("언패킹된 섹션 분석:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
print(f" {name:8s} 엔트로피: {entropy:.2f}")
# 임포트가 해결되었는지 확인
print(f"\n임포트 개수:")
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
dll = entry.dll.decode()
count = len(entry.imports)
print(f" {dll}: {count}개 함수")
total = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f" 총계: {total}개 임포트")
# 파일 크기 비교
import os
packed_size = os.path.getsize("suspect.exe")
unpacked_size = os.path.getsize("unpacked.exe")
print(f"\n패킹됨: {packed_size:>10} 바이트")
print(f"언패킹됨: {unpacked_size:>10} 바이트")
print(f"비율: {unpacked_size/packed_size:.1f}x")
PYEOF
주요 개념
| 용어 | 정의 |
|---|---|
| 패킹 | 실행 코드를 압축하거나 암호화하여 파일 크기를 줄이고 정적 분석을 방해하는 것; 바이너리에는 런타임에 코드를 복원하는 언패킹 스텁이 포함됨 |
| UPX | Ultimate Packer for eXecutables; 악성코드 제작자들이 무료이고 효과적이기 때문에 자주 악용하는 오픈소스 실행 파일 패커 |
| OEP (Original Entry Point) | 패킹되기 전 악성코드 코드의 실제 시작 주소; 언패킹 스텁이 코드를 압축 해제한 후 OEP로 점프 |
| 임포트 재구성 | Scylla나 ImpRec과 같은 도구를 사용하여 메모리에서 언패킹된 프로세스를 덤프한 후 임포트 주소 테이블을 재구축하는 과정 |
| PUSHAD/POPAD | 모든 범용 레지스터를 저장/복원하는 x86 명령어; UPX는 언패킹 중 레지스터 상태를 보존하기 위해 이 패턴을 사용 |
| 섹션 엔트로피 | PE 섹션 데이터의 무작위성 측정; 패킹된 섹션은 엔트로피가 7.0을 초과하는 반면, 일반 코드 섹션은 평균 5.0-6.5 |
| 매직 바이트 | 파일 형식을 식별하는 시그니처 바이트; UPX는 "UPX!"를 사용하며 악성코드 제작자는 자동 압축 해제를 방지하기 위해 이를 수정함 |
도구 및 시스템
- UPX: 오픈소스 실행 파일 패커로, 올바르게 패킹된 파일에 대해 내장된 압축 해제 기능 제공
- Detect It Easy (DIE): PE, ELF, Mach-O 파일의 보호를 식별하는 패커, 컴파일러, 링커 탐지 도구
- x64dbg/x32dbg: 동적 실행과 중단점 기반 OEP 찾기를 통한 수동 언패킹에 사용되는 오픈소스 Windows 디버거
- Scylla: x64dbg와 통합된 임포트 재구성 도구로, 메모리 덤프 후 IAT를 재구축
- PE-bear: PE 파일 뷰어 및 편집기로, 언패킹 후 PE 헤더 검사 및 복구에 사용
일반적인 시나리오
시나리오: 수정된 UPX 헤더를 가진 악성코드 언패킹
상황: 악성코드 샘플이 섹션 이름(UPX0, UPX1)으로 UPX 패킹으로 식별되었지만, upx -d가 "CantUnpackException: header corrupted" 오류와 함께 실패합니다. 악성코드 제작자가 자동 압축 해제를 방지하기 위해 UPX 매직 바이트를 수정했습니다.
접근 방법:
- 바이너리를 16진수 편집기로 열고 UPX 헤더 영역(일반적으로 패킹된 데이터 끝 부분)을 검색합니다.
- 수정된 매직 바이트를 식별합니다 (예: "UPX!"가 "UPX\x00"으로 변경되거나 완전히 0으로 설정).
- Python 복구 스크립트를 사용하여 "UPX!" 매직을 복원하고 섹션 이름을 수정합니다.
- 수정된 바이너리에서
upx -d를 재시도합니다. - 복구가 실패하면 x64dbg를 사용한 수동 언패킹으로 전환합니다 (PUSHAD -> ESP 하드웨어 BP -> POPAD -> JMP OEP).
- 언패킹된 바이너리가 적절한 임포트와 합리적인 엔트로피 값을 가지고 있는지 검증합니다.
- Ghidra 또는 IDA로 가져와 완전한 정적 분석을 수행합니다.
주의사항:
- UPX가 유일한 패커라고 가정하지 마십시오; 바이너리가 이중 패킹(UPX + 커스텀 레이어)되어 있을 수 있습니다.
- 원본 패킹된 샘플을 수정하지 말고 복사본을 작업에 사용하십시오.
- 수동 메모리 덤프 후 임포트를 재구성하는 것을 잊지 마십시오 (IAT 수정 없이는 덤프된 바이너리가 충돌합니다).
- UPX 패킹된 PE 섹션 뒤에 추가된 오버레이 데이터가 있는지 확인하는 것을 잊지 마십시오.
출력 형식
언패킹 분석 보고서
===========================
샘플: suspect.exe
SHA-256: e3b0c44298fc1c149afbf4c8996fb924...
패커: UPX 3.96 (수정된 헤더)
패킹된 바이너리
섹션: UPX0 (엔트로피: 0.00) UPX1 (엔트로피: 7.89) .rsrc (엔트로피: 3.45)
임포트: 2개 (kernel32.dll: LoadLibraryA, GetProcAddress)
파일 크기: 98,304 바이트
언패킹 방법
방법: 헤더 복구 + UPX -d
헤더 수정: 오프셋 0x1F000에서 UPX! 매직 복원
명령어: upx -d suspect_fixed.exe -o unpacked.exe
결과: 성공
언패킹된 바이너리
섹션: .text (엔트로피: 6.21) .rdata (엔트로피: 4.56) .data (엔트로피: 3.12) .rsrc (엔트로피: 3.45)
임포트: 147개 (kernel32, user32, advapi32, wininet, ws2_32)
파일 크기: 245,760 바이트 (2.5배 확장)
OEP: 0x00401000
검증
PE 유효: 예
임포트 해결: 예 (8개 DLL에 걸쳐 147개 함수)
실행 가능: 예 (샌드박스에서 충돌 없이 실행)
다음 단계
- unpacked.exe를 Ghidra로 가져와 전체 디스어셈블리 수행
- 언패킹된 바이너리에 대해 YARA 규칙 실행
- 언패킹된 바이너리를 VirusTotal에 제출하여 탐지율 향상