포렌식
Step 242. 메모리 포렌식: Volatility 3 — 꺼지면 사라지는 증거를 붙잡는 법
Level 3 — Forensics 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 5시간
전제: Step 239(파일 시그니처)를 마쳤다. 프로세스가 무엇인지, 문자열 검색(strings)을 할 줄 안다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투 + 파이썬 3(실측: 3.12).
/proc접근 권한(루트). - 주의: 이 환경에는 Volatility와 연습용 메모리 이미지가 없습니다. Volatility 출력은 전부 "화면 예시"이고, 메모리 포렌식의 원리 — 프로세스 목록, 네트워크 연결, 메모리 속 문자열 — 는 리눅스의
/proc에서 직접 실측합니다.
사건 현장의 컴퓨터 전원을 끄는 순간, RAM의 내용은 증발합니다. 그런데 정작 가장 중요한 증거 — 지금 실행 중인 악성 프로세스, 방금 친 명령어, 복호화된 키, 평문으로 풀린 암호 — 는 디스크가 아니라 RAM에만 있습니다. 메모리 포렌식(memory forensics)은 이 휘발성 증거를 덤프로 고정하고 해부하는 기술입니다. 오늘은 표준 도구 Volatility 3의 지도를 익히고, 그 플러그인들이 하는 일의 원리를 살아있는 리눅스에서 직접 확인합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 메모리에만 존재하는 증거의 종류(프로세스, 연결, 명령줄, 평문 데이터)를 열거한다
/proc을 읽어 프로세스 목록과 네트워크 연결을 재구성한다- 다른 프로세스의 메모리에서 문자열을 직접 추출한다
- Volatility의 분석 순서(
windows.info→pslist/psscan→pstree→ 수집)를 설명한다 pslist와psscan의 차이가 루트킷 탐지에 쓰이는 원리를 안다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | WSL 우분투 bash + 파이썬 3 (실측: Python 3.12) |
| 오늘의 도구 | /proc/<pid>/ (comm, cmdline, maps, mem), /proc/net/tcp — 전부 실측 |
| 필요한 개념 | 휘발성, 프로세스 메모리 맵, 덤프, Volatility 플러그인 체계(화면 예시) |
| 오늘의 산출물 | /proc 관찰 스크립트 + "다른 프로세스 메모리에서 꺼낸 비밀 문자열" |
2-1. 왜 메모리인가 — 디스크에 없는 것들
디스크는 "저장된 것"의 세계이고, 메모리는 "지금 벌어지는 것"의 세계입니다. RAM에만 있는 증거의 대표 목록:
- 실행 중인 프로세스와 그 명령줄 — 파일로 존재하지 않는 fileless 악성코드 포함
- 살아있는 네트워크 연결 — 지금 이 순간 누구와 통신 중인지
- 복호화된 데이터 — 디스크에는 암호화돼 있지만 실행 중엔 평문인 키·문자열
- 명령 이력과 클립보드 — 사용자가 방금 한 행동
사고 대응(IR)에서 "일단 전원부터 끄는" 초보 실수가 치명적인 이유가 이것입니다. 전원이 꺼지면 이 목록 전체가 증발합니다.
2-2. 덤프와 해부 — 두 단계의 분업
메모리 포렌식은 두 단계로 나뉩니다. ① 덤프: 사건 시점의 RAM을 통째로 파일로 고정합니다 (윈도우의 WinPmem, 리눅스의 LiME 같은 도구가 커널 수준에서 읽습니다). ② 해부: 그 날것의 바이트 덩어리에서 의미를 복원합니다. 덤프는 그냥 수 GB의 바이트라서, "프로세스 목록이 어디 있고, 각 프로세스의 구조체가 어떻게 생겼는지"를 아는 도구가 필요합니다 — 그것이 Volatility입니다.
2-3. /proc — 살아있는 시스템의 메모리 창
리눅스에는 실습용으로 완벽한 창이 이미 열려 있습니다. /proc은 커널이 만들어 주는 가상 파일시스템으로, 지금 살아있는 시스템의 상태를 파일처럼 보여 줍니다:
| 경로 | 내용 | Volatility 대응 |
|---|---|---|
/proc/<pid>/comm, cmdline |
프로세스 이름·명령줄 | pslist, cmdline |
/proc/net/tcp |
현재 TCP 연결 표 | netstat |
/proc/<pid>/maps |
프로세스의 메모리 지도 | memmap |
/proc/<pid>/mem |
그 메모리의 실제 내용 | memmap --dump |
오늘 우리는 덤프 파일 대신 살아있는 시스템에서 같은 정보를 읽습니다. Volatility가 죽은 덤프에서 하는 일의 원리를, /proc이 산 시스템에서 그대로 보여 주기 때문입니다.
2-4. Volatility 3 — 플러그인 지도 (화면 예시)
이 환경에는 설치돼 있지 않지만, 실전 표준 워크플로는 이렇게 생겼습니다 (화면 예시 — 실제 실행하지 않았습니다):
# 화면 예시 — 0단계: 이미지의 OS 확인 (모든 분석의 첫 단계)
$ vol -f memory.raw windows.info
Variable Value
Kernel Base 0xf8054b600000
NTBuildLab 19041.329.amd64fre.vb_release.190602-1739
# 화면 예시 — 1단계: 프로세스 목록 두 가지
$ vol -f memory.raw windows.pslist # 커널의 "현재 목록"을 따라감
PID PPID ImageFileName
4 0 System
512 4 smss.exe
3844 512 powershell.exe
$ vol -f memory.raw windows.psscan # 메모리 전체를 훑어 구조체 흔적 탐색
PID PPID ImageFileName
...
5920 3844 evil.exe ← pslist에는 없던 프로세스!
두 목록의 차이가 핵심 단서입니다. pslist는 운영체제의 공식 명부를 읽고, psscan은 메모리 전체에서 프로세스 구조체의 흔적을 스캔합니다. 공식 명부에서 자기 이름을 지운 프로세스(루트킷)는 psscan에만 나타납니다.
# 화면 예시 — 2단계: 관계와 흔적 수집
$ vol -f memory.raw windows.pstree # 부모-자식: winword.exe 아래 powershell.exe → 매크로 감염의 얼굴
$ vol -f memory.raw windows.cmdline # 각 프로세스가 어떤 명령으로 떴는가
$ vol -f memory.raw windows.netstat # 덤프 시점의 네트워크 연결
$ vol -f memory.raw -o out/ windows.memmap --dump --pid 5920 # 수상한 프로세스의 메모리를 파일로
$ strings out/pid.5920.dmp | grep -i "DH{" # 거기서 문자열 찾기
마지막 두 줄 — 수상한 프로세스의 메모리를 덤프해 strings를 거는 것 — 이 오늘 우리가 /proc으로 직접 해 볼 작업입니다.
3. 따라 하기
3-1. 사건 조성 — 비밀을 품은 프로세스
메모리에만 비밀을 들고 있는 프로세스를 만듭니다 (이 챕터의 로컬 출력은 2026-09-09 WSL 실측입니다).
입력 (victim.py)
import time, sys, os
SECRET = "DH{m3m0ry_n3v3r_f0rg3ts}" # 이 값은 실행 중인 프로세스의 RAM에 실재한다
print(f"[victim] PID={os.getpid()} 비밀을 메모리에 들고 대기 중...")
sys.stdout.flush()
time.sleep(120)
python3 victim.py &
[victim] PID=616 비밀을 메모리에 들고 대기 중...
읽는 법: 이 프로세스는 아무 파일도 쓰지 않고, 네트워크도 쓰지 않습니다. 비밀은 오직 이 프로세스의 RAM에만 있습니다. 디스크를 아무리 뒤져도 실행 중인 이 프로세스의 변수 값은 나오지 않습니다 — 메모리를 봐야만 보이는 증거입니다.
3-2. 프로세스 목록 재구성 — pslist의 원리
/proc의 숫자 디렉터리 하나가 프로세스 하나입니다. 이름과 명령줄을 읽어 봅니다:
import os
for pid in sorted((p for p in os.listdir("/proc") if p.isdigit()), key=int):
try:
name = open(f"/proc/{pid}/comm").read().strip()
cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"