| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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
- 네트워크
- terraform
- reversing.kr
- crackme
- AWS 침해사고 사례 분석
- 드림핵
- operating system
- dreamhack
- sql
- CrackMe 문제
- Amazon S3
- python
- C
- AWS 침해 사고 사례 분석
- TryHackMe
- Database
- AWS 보안 아키텍처 분석
- 악성코드 분석
- 프로그래머스
- 리버싱
- 버퍼 오버플로우
- programmers
- AWS
- 운영체제
- AWS 사고 사례 분석
- 파일 시스템
- 악성코드분석
- network
- reversing
- Today
- Total
lhywk 님의 블로그
[악성코드 분석] 리버싱을 위한 기초 지식 본문
리버싱과 악성코드 분석을 시작하기 전에 가장 먼저 짚어야 할 질문이 있다. "내가 작성한 .c 파일은 어떻게 더블클릭으로 실행되는 .exe 파일이 되는가?" 이 흐름을 모르면 PE 구조나 디버거 분석을 봐도 "그래서 이게 왜 필요한 거지?"라는 의문이 계속 남는다. 그래서 가장 기초인 컴파일 파이프라인부터 시작한다.
소스 코드는 전처리 → 컴파일 → 어셈블 → 링킹의 4단계를 거쳐 실행 파일이 되고, 사용자가 그 실행 파일을 실행시키는 순간 로더가 개입해 메모리에 올리면서 비로소 "프로세스"라는 생명을 얻는다.

1. 전처리 (Preprocessing)
- 담당: 전처리기(Preprocessor)
- 입력: 원본 C 소스 코드 (.c)
- 하는 일
- #include 지시문을 만나면 해당 헤더 파일의 내용을 소스 코드 안에 그대로 풀어서 붙여넣는다.
- #define으로 정의한 매크로를 실제 값으로 치환한다.
- /* ... */, // ... 같은 주석을 전부 제거한다.
- 출력: 전처리 지시문이 모두 처리된 확장 C 소스 코드 (.i)
전처리 단계는 코드를 "이해"하는 게 아니라 단순 치환·삽입 작업만 한다는 점이 핵심이다. 아직 문법 검사도 하지 않는다.
2. 컴파일 (Compilation)
- 담당: 컴파일러(Compiler)
- 입력: 확장된 C 소스 코드 (.i)
- 하는 일
- C 언어 문법을 검사하고, 오류가 없으면 기계가 이해하기 쉬운 저수준 언어인 어셈블리어로 번역한다.
- 코드 최적화를 수행한다.
- 출력: 어셈블리 프로그램 (.s 또는 .asm)
여기서부터 우리가 디버거에서 보게 되는 mov, push, call 같은 어셈블리 명령어의 뼈대가 만들어진다.
3. 어셈블 (Assembly)
- 담당: 어셈블러(Assembler)
- 입력: 어셈블리 프로그램 (.s)
- 하는 일: 어셈블리 코드를 실제 CPU가 읽을 수 있는 0과 1의 **기계어 코드(바이너리)**로 변환한다.
- 출력: 목적 프로그램, 즉 오브젝트 파일 (.o 또는 .obj)
이 시점의 오브젝트 파일은 아직 "완전한" 실행 파일이 아니다. printf 같은 외부 함수가 어디 있는지는 아직 모르는 상태다.
4. 링킹 (Linking)
- 담당: 링커(Linker)
- 입력
- 내가 작성한 코드의 목적 파일(들) (.o)
- printf() 같은 함수가 들어있는 라이브러리 파일 (.lib, .a)
- 하는 일: 여러 목적 파일과 라이브러리 파일을 하나로 합치고, 함수를 호출하는 부분과 함수가 실제로 정의된 부분을 연결한다.
- 출력: 결합된 목적 프로그램, 즉 최종 실행 파일 (.exe, a.out 등)
링킹까지 끝나면 우리가 흔히 아는 .exe 파일이 완성된다. 하지만 이 파일은 아직 하드디스크에 가만히 놓여 있는 "데이터 덩어리"일 뿐이다.
5. 로딩 (Loading) - 파일이 프로세스가 되는 순간
- 담당: 로더(Loader, 윈도우에서는 PE 로더)
- 하는 일: 사용자가 실행 파일을 실행시키면, 로더가 이 파일을 메모리에 적재(load)한다. 운영체제는 메모리에 올라온 프로그램 코드를 CPU에 전달하여 실제 실행을 시작시킨다.
이 로딩 단계부터가 사실상 리버싱/악성코드 분석의 본 무대다. 정적 분석이 "디스크 위의 파일"을 보는 것이고, 동적 분석이 "메모리에 올라가 실행되는 모습"을 보는 것이기 때문에, 로더가 정확히 무엇을 하는지는 뒤에 나올 PE 구조 챕터에서 더 자세히 다룬다.
프로그램(Program) vs 프로세스(Process)
리버싱 공부를 하면서 가장 헷갈리기 쉬운 용어 두 개를 여기서 확실히 구분해두자.
| 프로그램 | 하드디스크에 저장된 정적인 코드 덩어리 | Chrome.exe 파일 자체 |
| 프로세스 | 프로그램이 실행되어 메모리에 올라가고, CPU에 의해 실행되는 능동적인 개체 | 실행된 크롬 브라우저 창 |
"프로세스는 메모리에 올라가 실행되는 능동적인 개체"라고 정의했다. 그런데 이 능동적인 개체를 운영체제는 어떻게 추적하고, 여러 개를 동시에(처럼) 돌릴까? 그리고 프로세스 안에서 실제로 일을 하는 더 작은 단위는 무엇일까?
PCB (Process Control Block) - 프로세스의 신분증
운영체제는 동시에 수십~수백 개의 프로세스를 관리한다. 이걸 가능하게 하는 것이 PCB다.
- 정의: 운영체제가 각 프로세스를 관리하기 위해 만든 "프로세스 신분증" 또는 "관리 대장"
- 프로세스가 생성될 때마다 하나씩 만들어지며, 운영체제는 이 PCB를 보고 모든 프로세스의 상태를 파악하고 제어한다.
PCB에 저장되는 주요 정보
| 정보 | 설명 |
| 프로세스 상태 (Process State) | 현재 CPU를 사용 중인지(running), CPU를 기다리는지(ready), 입출력을 기다리는지(waiting) |
| 프로세스 주소공간 (Address Space) | 해당 프로세스가 사용하는 메모리 위치 정보 (코드, 데이터, 스택 영역 등) |
| 레지스터 정보 (Register Information) | 어디까지 실행했는지 저장하는 프로그램 카운터(PC) 포함, CPU 레지스터의 상태 |
이 표를 보면 알 수 있듯, PCB는 "프로세스를 잠깐 멈췄다가 나중에 정확히 그 지점부터 다시 이어서 실행할 수 있게" 해주는 스냅샷 역할을 한다. 그리고 이 스냅샷 개념이 바로 다음에 나올 문맥 교환의 핵심이다.
문맥 교환 (Context Switching)
하나의 CPU 코어가 여러 프로세스를 번갈아 실행하는 것처럼 보이게 만드는(멀티태스킹) 핵심 동작이다.
정의: CPU가 현재 실행하던 프로세스 A의 작업을 중단하고, 다음으로 실행할 프로세스 B로 전환하는 과정.
문맥 교환의 3단계
- 프로세스 A의 현재 상태(레지스터 값 등)를 A의 PCB에 저장한다.
- 프로세스 B의 PCB에서 이전에 저장했던 상태를 CPU 레지스터로 복원한다.
- 프로세스 B의 실행을 이어간다.
왜 중요한가: 디버거로 프로그램을 한 줄씩 실행(Single-step)시키는 것도 본질적으로는 OS가 그 프로세스를 멈추고 디버거에게 제어권을 넘기는 것과 같은 메커니즘 위에서 동작한다. PCB와 문맥 교환의 개념을 알고 있으면, 이후 안티디버깅에서 다룰 "디버거가 어떻게 멈춘 시점의 레지스터 값을 들여다보는가"를 훨씬 직관적으로 이해할 수 있다.
스레드 (Thread) - 프로세스 안의 일꾼
정의
스레드는 프로세스 내에서 실행되는 작업의 흐름 단위다. 흔히 경량 프로세스(Lightweight Process)라고도 부른다.
- CPU 사용의 기본 단위: 사실 운영체제는 프로세스 단위가 아니라 스레드 단위로 CPU를 할당하고 실행을 제어한다.
- 자원 공유: 스레드는 자신이 속한 프로세스의 자원을 공유한다.
스레드가 공유하는 자원
- 코드(Code) 영역
- 데이터(Data) 영역
- 힙(Heap) 영역
- 파일 등 OS 자원
스레드만의 고유 정보
같은 프로세스에 속한 스레드라도, 각 스레드는 자신만의 독립적인 작업 공간을 가진다.
- 스택 (Stack): 함수 호출, 지역 변수 등 자신만의 작업 기록을 저장하는 독립적인 메모리 공간
- 실행 상태 및 환경 정보
- 프로그램 카운터 (Program Counter, PC): 자신이 어디까지 코드를 실행했는지 기억
- 레지스터 집합 (Registers): 현재 계산 중인 값을 잠시 저장
- TLS (Thread-Local Storage): 각 스레드만이 접근할 수 있는 고유한 저장 공간
다중 스레드(Multi-threading)의 장점
1) 높은 자원 효율성 (응답성 향상)
- 프로세스의 자원을 공유하므로, 새로운 스레드를 생성하는 것은 새로운 프로세스를 생성하는 것보다 훨씬 비용이 적고 빠르다.
- 하나의 스레드가 입출력 등으로 잠시 멈추더라도 다른 스레드는 계속 작업을 수행할 수 있어 프로그램 전체의 응답성이 향상된다.
2) 다중 코어 활용 (병렬성 향상)
- CPU 코어가 여러 개인 환경에서 각 코어가 다른 스레드를 동시에 실행할 수 있어 작업 처리 속도를 크게 높일 수 있다.
프로세스 메모리 구조 미리보기 - 코드/데이터와 IAT
프로세스가 메모리에 올라갔을 때 내부는 대략 다음과 같은 영역으로 나뉜다.
- 코드 공간 (Code): 프로그램의 실제 명령어들이 저장되는 곳
- 데이터 공간 (Data): 전역 변수 등이 저장되는 곳
- IAT (Import Address Table) 영역: 외부 DLL 함수의 실제 주소를 담아두는 "주소록"
DLL(동적 연결 라이브러리)은 리눅스의 공유 객체(.so)와 비슷한 개념이다. printf나 MessageBox 같이 자주 쓰는 함수를 모든 프로그램에 일일이 포함시키면 용량이 너무 커지기 때문에, 이런 코드들을 msvcrt.dll, user32.dll 같은 DLL에 넣어두고 여러 프로그램이 공유해서 쓴다.
문제는 프로그램이 실행될 때 이 외부 함수들이 메모리의 정확히 어디에 있는지 알 수 없다는 점이다. 그래서 운영체제는 프로세스를 시작할 때 필요한 DLL들을 프로세스 공간에 로딩하고, "어떤 함수의 실제 주소는 여기다"라는 정보를 IAT에 기록해준다. 프로세스는 이 IAT를 참조해서 커널 영역의 기능을 호출하게 된다.
x64dbg를 처음 켜면 화면 오른쪽에 EAX, EBX, ESP, EIP 같은 글자들이 줄줄이 떠 있다. 어셈블리 코드를 읽으려면 이 레지스터들이 각각 뭘 하는 녀석인지부터 알아야 한다. 32비트(x86) 환경 기준으로 정리한다.
레지스터, 큰 그림부터
CPU 레지스터는 크게 네 그룹으로 나눠서 외우면 헷갈리지 않는다.
- 데이터/연산용 레지스터 : 값을 담고 계산하는 그릇
- 포인터 레지스터 : 스택의 위치를 가리키는 손가락
- 명령어 포인터 : "다음에 뭘 실행할지" 알려주는 내비게이션
- 세그먼트 레지스터 : 메모리 구역의 시작 주소를 가리키는 표지판
- 플래그 레지스터(EFLAGS) : 방금 연산이 어떻게 끝났는지 적어두는 메모장
- 디버그 레지스터 : 디버거가 하드웨어 중단점을 걸 때 쓰는 특수 레지스터
데이터 및 연산용 레지스터
| 레지스터 | 이름 | 주요 역할 |
| EAX | Extended Accumulator | 산술 연산(덧셈/뺄셈/곱셈)의 중심. 함수의 반환 값을 저장하는 용도로 가장 널리 쓰인다 |
| EBX | Extended Base | 메모리 주소를 저장하는 포인터 역할로 자주 사용. 범용 데이터 저장도 가능 |
| ECX | Extended Counter | for문, while문 같은 반복문(loop)의 횟수를 세는 데 사용 |
| EDX | Extended Data | 범용 데이터 저장. 특히 EAX와 함께 곱셈/나눗셈처럼 결과값이 커지는 연산을 보조하고, 입출력(I/O) 연산에도 사용 |
| ESI | Extended Source Index | 메모리 복사 시 원본(Source) 주소를 가리킴 |
| EDI | Extended Destination Index | 메모리 복사 시 목적지(Destination) 주소를 가리킴 |
ESI/EDI는 주로 문자열을 옮기거나 메모리 블록을 통째로 복사할 때 짝을 이뤄 사용된다.
분석할 때 가장 먼저 보게 되는 레지스터가 EAX다. call 명령어로 함수를 호출한 직후 EAX 값을 확인하면 "이 함수가 무엇을 반환했는지"를 바로 알 수 있기 때문이다.
주소 저장용 포인터 레지스터
| 레지스터 이름 | 이름 | 역할 |
| ESP | Extended Stack Pointer | 스택의 최상단(Top)을 항상 가리키는 포인터. 데이터가 들어오고 나갈 때마다 값이 계속 변한다 |
| EBP | Extended Base Pointer | 함수가 호출될 때 만들어지는 스택 프레임의 시작점(Base)을 고정해서 가리킴 |
ESP는 push/pop이 일어날 때마다 계속 움직이지만, EBP는 함수가 끝날 때까지 변하지 않는다. 그래서 함수 내부에서 지역 변수나 인자에 접근할 때는 계속 움직이는 ESP보다 고정된 EBP를 기준으로 삼는 게 안정적이다. 이 EBP 기준 접근 방식은 함수 호출 규약과 스택 프레임 챕터에서 가장 핵심적으로 다시 등장한다.
명령어 포인터 레지스터 (Instruction Pointer)
EIP (Extended Instruction Pointer) - CPU가 다음에 실행해야 할 명령어의 메모리 주소를 저장한다. PC(Program Counter)라고도 부른다.
- CPU는 EIP가 가리키는 주소의 명령어를 읽어온 뒤, EIP 값을 자동으로 증가시켜 다음 명령어를 가리키게 한다.
- JMP나 CALL 같은 명령어를 만나면 EIP의 값이 해당 주소로 바뀌어 프로그램의 실행 흐름이 바뀐다.
EIP는 리버싱에서 가장 자주 언급되는 레지스터 중 하나다. 버퍼 오버플로우 공격이 결국 노리는 것이 "EIP에 공격자가 원하는 주소를 강제로 넣는 것"이기 때문이다.
세그먼트 레지스터 (Segment Registers)
메모리를 '세그먼트'라는 논리적인 구역으로 나누어 관리할 때, 각 구역의 시작 주소를 가리키는 레지스터다.
| CS | Code Segment | 실행 가능한 기계어 코드가 저장된 구역의 시작 주소. CPU는 항상 CS:EIP 조합으로 다음 실행할 명령어를 찾아간다 |
| SS | Stack Segment | 지역 변수·함수 호출 정보가 저장되는 스택 구역의 시작 주소. CPU는 SS:ESP 조합으로 스택을 읽고 쓴다 |
| DS | Data Segment | 전역 변수·정적 변수 같은 데이터가 저장된 구역의 시작 주소 |
| ES, FS, GS | Extra Segments | 추가적인 데이터 구역을 가리키는 여분의 레지스터 |
특히 FS는 현대 윈도우 환경에서 특별한 용도로 쓰인다 — 32비트 환경에서 FS는 항상 현재 실행 중인 스레드의 TEB(Thread Environment Block) 시작 주소를 가리킨다.
이 FS 레지스터는 안티디버깅 챕터에서 핵심 역할을 한다. 악성코드는 FS:[0x18]로 TEB 주소를 찾고, 거기서 다시 PEB(Process Environment Block) 주소로 이동해 BeingDebugged 플래그를 확인하는 식으로 디버거 존재 여부를 탐지한다.
플래그 레지스터 (EFLAGS)
CPU가 방금 실행한 명령어의 결과(상태)를 기록하는 레지스터다. JMP 계열의 분기 명령어는 이 플래그들의 상태를 보고 "점프할지 말지"를 결정한다.
상태 플래그 (Status Flags)
| 플래그 | 이름 | 켜지는 조건 |
| CF | Carry Flag | 부호 없는 연산에서 자리 올림/빌림 발생 시 (예: 8비트에서 255+1) |
| ZF | Zero Flag | 연산 결과가 정확히 0일 때. a == b 비교는 내부적으로 a - b를 계산해서 그 결과가 0이면 ZF가 켜지는 원리를 이용한다 |
| SF | Sign Flag | 연산 결과가 음수일 때 (정확히는 결과의 최상위 비트(MSB)가 1일 때) |
| OF | Overflow Flag | 부호 있는 정수 연산 결과가 표현 가능한 범위를 넘어설 때 (예: 8비트에서 127+1 = -128 오버플로우) |
| PF | Parity Flag | 연산 결과에서 1인 비트의 개수가 짝수일 때 (주로 통신 에러 검출용) |
| AF | Adjust Flag | BCD(이진화 십진법) 연산 시 사용되는 보조 캐리 플래그 |
제어 플래그 (Control Flags)
| 플래그 | 이름 | 역할 |
| DF | Direction Flag | 문자열 복사 같은 메모리 처리 방향 결정. 0이면 순방향(주소 증가), 1이면 역방향(주소 감소) |
| IF | Interrupt Flag | 이 플래그가 켜져 있어야 CPU가 외부 장치의 인터럽트 요청을 받아들인다 |
시스템 플래그 (System Flags)
| 플래그 | 이름 | 역할 |
| TF | Trap Flag | 켜면 CPU가 한 명령어 실행할 때마다 멈춘다(single-step mode). 디버거가 중단점 기능을 구현할 때 이 원리를 사용한다 |
ZF는 디버거 분석에서 가장 자주 보게 되는 플래그다. 크랙미(CrackMe) 류 실습에서 패스워드 비교 직후 je/jne 분기를 따라가다 보면 거의 항상 ZF 값을 조작해서 분기를 우회하는 패턴을 쓰게 된다.
디버그 레지스터 (Debug Registers)
디버거가 하드웨어 중단점(Hardware Breakpoint)을 사용하기 위해 CPU에 내장된 특수 레지스터다. 코드 자체를 수정하는 소프트웨어 중단점과 달리, 메모리 주소를 감시하는 방식으로 동작한다.
| 레지스터 | 역할 |
| DR0 ~ DR3 | 중단점을 설정할 메모리 주소를 직접 저장. 총 4개이므로 동시에 최대 4개의 하드웨어 중단점 설정 가능 |
| DR7 | 디버그 제어 레지스터. DR0~DR3에 설정된 중단점을 활성화/비활성화하고, 중단 조건(실행 시/쓰기 시/읽기 또는 쓰기 시)을 지정하는 메인 컨트롤 패널 |
| DR6 | 디버그 상태 레지스터. 중단점이 발생했을 때 어떤 이유로 멈췄는지 알려주는 상태 보고서. 디버거는 이 값을 보고 원인을 파악한 후 0으로 초기화한다 |
이 디버그 레지스터들은 그대로 안티디버깅 탐지 기법의 핵심 재료가 된다. 정상 실행 시 DR0~DR3는 항상 0인데, 디버거가 하드웨어 중단점을 걸면 이 중 하나가 0이 아닌 값을 갖게 된다. 악성코드는 GetThreadContext() API로 이 값을 읽어서 디버거 존재 여부를 판단한다.
레지스터의 이름을 알았다면, 이제 그 레지스터들을 실제로 움직이는 명령어를 알아야 한다. x64dbg 화면에 떠 있는 mov, push, cmp, jmp 같은 코드들을 하나씩 분류해서 정리한다.
명령어를 6개 그룹으로 나눠보기
- 데이터 이동 - MOV, LEA
- 스택 조작 - PUSH, POP, PUSHAD/POPAD
- 산술 연산 - ADD, SUB, MUL, DIV, INC/DEC, NEG
- 비트 연산 - AND, OR, XOR, NOT, SHL/SHR, ROL/ROR
- 비교와 분기 - CMP, TEST, JMP, Jcc 계열
- 함수 호출/복귀 - CALL, RET, LEAVE, NOP
데이터 이동 명령어
mov dst, src
데이터를 한 위치에서 다른 위치로 복사한다. 이름은 '이동(move)'이지만 실제로는 원본 데이터가 사라지지 않고 목적지에 복제되는 것이다. 데이터 흐름은 항상 오른쪽(src) → 왼쪽(dst)이다.
| 오퍼랜드 | 조합 예시 | 의미 |
| 레지스터 → 레지스터 | mov eax, ebx | ebx 값을 eax로 복사 |
| 값 → 레지스터 | mov eax, 1C | 16진수 1C를 eax에 저장 |
| 메모리 → 레지스터 | mov eax, [4053dc] | 주소 4053dc에 있는 값을 eax로 복사 |
| 레지스터 → 메모리 | mov [esp+4], eax | eax 값을 esp+4 주소에 복사 |
lea dst, src
LEA(Load Effective Address)는 MOV와 헷갈리기 쉽지만 결정적인 차이가 있다.
- MOV: mov eax, [ebx+8] → [ebx+8]이라는 주소에 저장된 값을 eax로 복사
- LEA: lea eax, [ebx+8] → [ebx+8]이라는 주소값 자체(ebx 값 + 8)를 eax에 저장
쉽게 말해 MOV는 "그 집에 사는 사람을 데려오는 것"이고, LEA는 "그 집의 주소만 적어오는 것"이다. 배열의 시작 주소를 구하거나 포인터 연산을 할 때 LEA가 자주 등장한다.
스택 조작 명령어 (PUSH & POP)
스택(Stack) 메모리 영역에 데이터를 넣고 꺼내는 명령어다. 스택은 낮은 주소 방향으로 자란다는 점을 항상 기억해야 한다.
PUSH - 스택에 데이터 저장
push value
- 스택 포인터(ESP)를 4만큼 감소시켜 새 공간을 확보한다.
- 새로 확보된 공간(ESP가 가리키는 곳)에 value를 저장한다.
POP - 스택에서 데이터 꺼내기
pop register
- 현재 ESP가 가리키는 스택 최상단의 데이터를 register로 읽어온다.
- ESP를 4만큼 증가시켜 데이터가 있던 공간을 스택에서 제거한다.
POP vs MOV - 헷갈리지 말아야 할 차이
| 명령어 | 설명 | ESP 변화 |
| pop eax | 스택 최상단 값을 eax에 저장하고, 스택에서 해당 데이터를 제거 | 4만큼 증가 |
| mov eax, [esp] | 스택 최상단 값을 eax에 복사만 하고, 데이터는 그대로 둠 | 변화 없음 |
PUSHAD / POPAD — 레지스터 일괄 저장/복원
- PUSHAD: 32비트 범용 레지스터 8개(eax, ecx, edx, ebx, esp, ebp, esi, edi)를 정해진 순서대로 모두 스택에 저장
- POPAD: PUSHAD로 저장된 8개의 값을 꺼내 레지스터들을 복원 (저장 순서의 정반대로 복원되며, 스택의 LIFO 원칙을 따른다)
PUSHAD/POPAD는 패커(Packer) 분석에서 결정적인 단서가 된다. UPX 같은 실행압축 파일은 언패킹 스텁 시작 부분에 PUSHAD로 레지스터를 백업하고, 압축 해제가 끝난 뒤 POPAD로 복원한 다음 원본 코드(OEP)로 점프하는 패턴을 거의 항상 보인다.
산술 연산 명령어
| 명령어 | 의미 |
| add dst, src | dst = dst + src (덧셈) |
| sub dst, src | dst = dst - src (뺄셈) |
| inc operand | operand = operand + 1 |
| dec operand | operand = operand - 1 |
| mul src | 부호 없는(unsigned) 곱셈 |
| imul src | 부호 있는(signed) 곱셈 |
| div src | 부호 없는 나눗셈 |
| idiv src | 부호 있는 나눗셈 |
| neg operand | 2의 보수를 구해 부호 반전 |
곱셈(mul)과 나눗셈(div)의 결과 저장 위치
곱셈/나눗셈은 결과가 32비트를 넘어갈 수 있기 때문에 EDX:EAX 레지스터 쌍을 사용한다.
- 곱셈 mul ebx → EDX:EAX = eax * ebx (EDX: 상위 32비트, EAX: 하위 32비트)
- 나눗셈 div ebx → EAX = (EDX:EAX / ebx)의 몫, EDX = 나머지
비트 연산 명령어
논리 연산
| 명령어 | 설명 | 주요 용도 |
| and | 두 비트가 모두 1일 때만 1 | 특정 비트를 0으로 만들기 (마스킹) |
| or | 두 비트 중 하나라도 1이면 1 | 특정 비트를 1로 만들기 (세팅) |
| xor | 두 비트가 서로 다르면 1 | 특정 비트를 뒤집기 (토글링) |
| not | 모든 비트 반전 | 1의 보수 계산 |
xor eax, eax처럼 같은 레지스터를 XOR 연산하면 결과는 항상 0이 된다. 코드에서 mov eax, 0 대신 xor eax, eax가 보이면 그냥 "eax를 0으로 초기화"하는 흔한 관용구라고 이해하면 된다.
이동(Shift) & 순환(Rotate)
| 구분 | 명령어 | 설명 |
| 이동 (Shift) | shl(왼쪽), shr(오른쪽) | 비트를 밀어냄. 밀려난 비트는 사라지고 반대편은 0으로 채워짐 |
| 순환 (Rotate) | rol(왼쪽), ror(오른쪽) | 비트를 돌림. 밀려난 비트가 반대편 빈 공간으로 다시 들어옴 |
비교와 분기 명령어
CMP, TEST - 분기를 위한 사전 판단
이 명령어들은 직접 실행 흐름을 바꾸지는 않지만, 바로 뒤에 따라올 조건부 분기 명령어를 위한 판단 근거(EFLAGS)를 만든다.
| 명령어 | 동작 방식 | 주요 용도 |
| cmp op1, op2 | op1 - op2 뺄셈을 수행하고 결과는 버리되 EFLAGS만 변경 | 두 값이 같은지/큰지/작은지 비교 |
| test op1, op2 | op1 & op2 AND 연산을 수행하고 결과는 버리되 EFLAGS만 변경 | 특정 값이 0인지, 특정 비트가 켜져 있는지 확인 |
test eax, eax는 매우 흔한 패턴이다. eax와 자기 자신을 AND 연산하면 값 그대로가 나오고, 그 결과가 0이면 ZF가 1로 세팅된다. 즉 "eax가 0인지 아닌지"를 검사하는 표준적인 방법이다.
분기 명령어
무조건 분기: jmp address - 조건 없이 무조건 지정된 주소로 실행 흐름을 옮긴다.
조건부 분기 (Jcc): cmp나 test의 결과로 변경된 EFLAGS 상태가 특정 조건을 만족할 때만 점프한다.
같음/0 비교
| 명령어 | 의미 | 점프 조건 |
| jz / je | Zero / Equal | 결과가 0 (같음), ZF=1 |
| jnz / jne | Not Zero / Not Equal | 결과가 0이 아님 (다름), ZF=0 |
부호 있는 수 비교 (Signed)
| 명령어 | 의미 |
| jg / jnle | Greater (>) |
| jl / jnge | Less (<) |
| jge / jnl | Greater or Equal (>=) |
| jle / jng | Less or Equal (<=) |
부호 없는 수 비교 (Unsigned)
| 명령어 | 의미 |
| ja / jnbe | Above (>) |
| jb / jnae | Below (<) |
| jae / jnb | Above or Equal (>=) |
| jbe / jna | Below or Equal (<=) |
메모리 주소처럼 음수 개념이 없는 값들은 부호 없는(Unsigned) 비교 명령어를 쓴다는 점이 부호 있는 비교와의 구분 포인트다.
CrackMe 류 실습에서 가장 많이 마주치는 패턴: cmp 또는 strncmp 호출 직후 je/jne로 분기하는 구조다. ZF 플래그를 강제로 뒤집어서 분기를 반대로 보내면 패스워드 검증을 그대로 통과시킬 수 있다.
함수 호출과 복귀 명령어
| 명령어 | 동작 |
| call address | 1. 다음 줄 주소(복귀 주소)를 스택에 push 2. address로 점프하여 함수 실행 시작 |
| ret | 1. 스택에 저장된 복귀 주소를 pop 2. 그 주소로 점프하여 원래 흐름 이어감 |
| leave | 스택 프레임을 정리. 내부적으로 mov esp, ebp + pop ebp와 동일 |
| nop | 아무 동작도 하지 않음 (기계어 0x90). 코드 패치 시 기존 코드를 지우는 대신 nop으로 채우거나, 주소를 맞추기 위한 패딩으로 사용 |
call과 ret의 동작은 다음 챕터 함수 호출 규약과 스택 프레임에서 그림으로 훨씬 자세히 풀어본다. 여기서는 "call은 복귀 주소를 스택에 쌓고 점프, ret은 그 주소를 다시 꺼내서 점프"라는 한 쌍의 동작이라는 것만 기억해두면 된다.
디버거로 함수 안에 들어가면 항상 비슷한 패턴이 보인다. push ebp -> mov ebp, esp -> ... -> mov esp, ebp -> pop ebp -> ret. 이게 왜 필요한지, 그리고 함수가 호출되는 그 순간 스택에서 정확히 무슴 일이 일어나는지를 끝까지 따라가본다.
스택 프레임이란
스택 프레임(Stack Frame): 함수 하나가 호출될 때 사용하는 스택 메모리 영역의 단위. 이 안에는 함수의 인자, 돌아갈 주소, 이전 EBP 값, 지역 변수 등이 포함된다.
함수가 호출될 때마다 스택 위에 이 프레임이 하나씩 쌓이고, 함수가 끝나면 그 프레임이 깨끗하게 정리(해제)된다.
함수 A가 함수 B를 호출하는 전체 과정
A() -> B() 상황을 가정하고 단계별로 따라가보자. 스택은 높은 주소에서 낮은 주소 방향으로 자란다는 점을 계속 염두에 두자.
1단계: 함수 호출 준비 (함수 A 안에서)
B를 호출하기 직전, A는 B에게 전달할 인자(Argument)들을 스택에 쌓는다.
push argument2
push argument1
| ... A의 지역 변수 ... |
+-----------------------+
| argument2 |
+-----------------------+
| argument1 | <-- ESP
+-----------------------+ (높은 주소)
ESP는 인자가 쌓일 때마다 낮은 주소로 이동한다.
2단계: 함수 호출 (call B)
call B 명령어를 실행하는 순간, CPU는 두 가지 일을 자동으로 수행한다.
- Return Address를 스택에 push: call B 바로 다음 줄의 주소, 즉 B가 끝나고 돌아와서 실행을 이어갈 A의 코드 주소를 스택에 넣는다.
- B 함수의 시작 주소로 점프한다.
| argument2 |
+-----------------------+
| argument1 |
+-----------------------+
| Return Address | <-- ESP
+-----------------------+
3단계: 새로운 스택 프레임 생성 - 함수 프롤로그(Prologue)
막 시작된 B는 자신의 스택 프레임을 구축한다. 이 과정이 EBP와 ESP의 관계를 정의하는 가장 중요한 부분이다.
1. push ebp - 이전 EBP(A의 EBP) 값을 스택에 백업한다. B가 끝난 후 A의 스택 프레임으로 정확히 복귀하기 위함이다.
2. mov ebp, esp - 현재 ESP의 위치를 EBP에 복사한다. 이 순간 B 함수의 스택 프레임 기준점이 확정되며, B가 끝날 때까지 이 EBP 값은 변하지 않는다.
| argument1 |
+-----------------------+
| Return Address |
+-----------------------+
| A의 EBP (Old EBP) | <-- EBP, ESP
+-----------------------+
4단계: 지역 변수 할당
B는 자신이 사용할 지역 변수들을 위해 스택 공간을 할당한다. 주로 sub esp, N으로 필요한 크기만큼 ESP를 빼서 공간을 확보한다.
| ... (높은 주소) ... |
+-----------------------+
| argument1 | [ebp + 8] <-- 파라미터 접근
+-----------------------+
| Return Address | [ebp + 4] <-- 복귀 주소
+-----------------------+
| A의 EBP (Old EBP) | [ebp] <-- 기준점 (Base)
+-----------------------+
| B의 지역 변수 1 | [ebp - 4]
+-----------------------+
| B의 지역 변수 2 | [ebp - 8] <-- 지역 변수 접근
+-----------------------+ <-- ESP
| ... (낮은 주소) ... |
이 구조 덕분에 B 함수 내에서 ESP가 다른 push/pop으로 잠시 변하더라도, EBP는 고정되어 있으므로 EBP를 기준으로 +오프셋을 통해 인자에, -오프셋을 통해 지역 변수에 항상 정확하게 접근할 수 있다.
이 EBP 기준 주소 계산은 리버싱의 핵심 독해 스킬이다. 디버거에서 [ebp+8]이 보이면 첫 번째 인자, [ebp-4]가 보이면 첫 번째 지역 변수라는 걸 즉시 알아챌 수 있어야 한다. 버퍼 오버플로우에서 buffer, SFP, Return Address의 정확한 메모리 위치를 파악하는 것도 결국 이 구조를 이해하는 데서 시작한다.
5단계: 스택 프레임 해제 - 함수 에필로그(Epilogue)
B의 실행이 끝나면 스택 프레임을 해제하고 A로 돌아갈 준비를 한다.
1. mov esp, ebp - ESP를 EBP 위치로 되돌린다. 이 한 번의 명령으로 B가 사용했던 모든 지역 변수 공간이 즉시 해제된다.
2. pop ebp - 스택에 백업해 두었던 A의 EBP 값을 다시 EBP 레지스터로 복원한다. 이제 EBP는 다시 A의 스택 프레임을 가리킨다.
3. ret - ESP가 가리키는 곳(스택 최상단)에서 Return Address를 꺼내 EIP 레지스터에 넣는다. CPU는 즉시 A의 코드로 돌아가 실행을 재개한다.
leave 명령어는 1과 2를 한 번에 처리하는 축약형이다. (leave = mov esp, ebp + pop ebp)
printf 같은 함수를 호출할 때의 파라미터 전달 패턴
실제 디버거 화면에서 흔히 보는 인자 준비 패턴을 어셈블리로 보면 이렇다.
mov dword ptr ss:[esp+1C], 64 ; 64라는 값을 준비
mov eax, dword ptr ss:[esp+1C] ; eax로 복사
mov dword ptr ss:[esp+4], eax ; printf의 두 번째 인자로 세팅
mov dword ptr ss:[esp], test2.404000 ; 포맷 문자열 주소를 첫 번째 인자로 세팅
call <JMP.&printf> ; IAT를 거쳐 printf 실제 주소로 점프
여기서 <JMP.&printf>라는 표기가 등장하는데, 이는 프로그램이 라이브러리 함수(printf)의 실제 주소를 찾기 위해 IAT(Import Address Table)를 거쳐 점프하는 것을 의미한다. (IAT는 "주소록" 개념이 여기서 실제로 사용되는 장면이다.)
함수가 끝날 때는 반환 값을 관례적으로 EAX 레지스터에 저장한다 (mov eax, 0은 C의 return 0;에 해당).
'Malware Analysis' 카테고리의 다른 글
| [악성코드 분석] Procmon 동적분석 (0) | 2026.07.08 |
|---|---|
| [악성코드 분석] 프로세스/코드 인젝션 (0) | 2026.06.24 |
| [악성코드 분석] 메모리 공격 기법 (0) | 2026.06.24 |
| [악성코드 분석] 악성코드의 방어 기법 (0) | 2026.06.24 |
| [악성코드 분석] PE 파일 구조 (0) | 2026.06.24 |