Step 215. Ghidra 심화: 함수 분석, 구조체 복원 — 기계가 지은 이름을 사람의 언어로

Step 215. Ghidra 심화: 함수 분석, 구조체 복원 — 기계가 지은 이름을 사람의 언어로

Level 3 — Reversing 트랙 | 난이도 ★★★☆☆ | 예상 소요 시간 5시간

전제: Step 178(CTF 맛보기 Reversing)을 마쳤다. strings, objdump, gdb로 crackme를 풀어 봤고, Ghidra가 무엇을 보여 주는 도구인지 안다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 분석 대상은 직접 컴파일한 연습용 바이너리입니다.

  • 준비물: WSL 우분투 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1). Ghidra는 이 환경에 설치돼 있지 않으므로 화면 예시로 안내하고, 같은 원리를 objdump·gdb로 실측합니다. Ghidra를 설치했다면 화면 예시를 그대로 따라 할 수 있습니다.
  • 주의: 스트립된 바이너리(심볼 없음)도 다룹니다. 주소는 여러분의 환경과 다를 수 있으며, 절차가 같으면 됩니다.

Step 178에서 Ghidra를 처음 열어 디컴파일 결과를 "읽어" 봤습니다. 실전 리버싱은 읽기가 아니라 정리입니다. FUN_00101169, param_1, local_48 같은 기계 이름을 의미 있는 이름으로 바꾸고, 변수의 타입을 고치고, 바이트 오프셋의 나열에서 구조체를 복원해 내는 작업 — 이 워크플로우가 몸에 배면 분석 속도가 배가 됩니다. 오늘은 디컴파일러를 "보는 도구"에서 "생각하는 책상"으로 바꾸는 날입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • Ghidra에서 함수 이름 바꾸기(L)와 시그니처 편집(Edit Function Signature)을 한다
  • param_1 + 0x14 같은 오프셋 접근 패턴에서 구조체 필드를 추론한다
  • 추론한 구조체를 Ghidra의 Data Type으로 만들어 변수에 적용한다
  • 디컴파일이 이상할 때 어셈블리 뷰로 돌아가 진실을 확인한다
  • 같은 바이너리를 objdump/gdb와 대조해 디컴파일러의 출력을 검증한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C(분석 대상 제작용) + WSL 우분투 bash (실측: gcc 13.3.0, gdb 15.1), Ghidra는 화면 예시
오늘의 명령어·키 objdump -d, strip, nm, gdb disassemble / Ghidra: L(이름 바꾸기), Ctrl+Shift+E 또는 우클릭(Edit Function Signature), Data Type Manager
필요한 개념 심볼과 스트립, 호출 규약(rdi = 첫 인자), 구조체 필드 오프셋, 디컴파일의 한계
오늘의 산출물 구조체 실습 바이너리의 "복원 노트" — 함수 이름표 + 구조체 설계도

2-1. 디컴파일러는 추측기다

디컴파일러는 기계어에서 C 코드를 "복원"하지만, 이것은 번역이 아니라 추측입니다. 변수의 타입, 함수의 인자 개수, 배열과 포인터의 구분은 원본에 없는 정보입니다 — 컴파일 과정에서 사라졌기 때문입니다. 그래서 디컴파일러는 undefined4, param_1 같은 중립적 이름을 붙여 둡니다.

분석가의 일은 이 추측을 교정하는 것입니다. "이 변수는 매번 4바이트 정수처럼 쓰이네", "이 포인터는 항상 +0x14를 더해 쓰네" — 관찰을 이름과 타입으로 고정해 가는 작업이 리버싱의 본체입니다.

2-2. 심볼과 스트립 — 이름이 있는 세계와 없는 세계

심볼(symbol)은 함수·변수의 이름표입니다. 개발자가 컴파일하면 남지만, 배포용 바이너리는 대부분 스트립(strip) — 이름표를 뜯어 냅니다. 스트립된 바이너리에서 Ghidra는 함수를 주소로 부릅니다: FUN_00101169.

오늘 실습에서 같은 바이너리의 "심볼 있음/없음" 두 판을 만들어 비교합니다. 실전에서 만날 모습은 후자입니다.

2-3. 구조체 복원의 단서 — 고정 오프셋

C의 구조체 필드는 베이스 주소 + 고정 오프셋으로 접근됩니다. p->level은 컴파일 후 0x14(%rdi) 같은 기계어가 됩니다. 반대로, 어셈블리에서 "같은 포인터에 대해 반복적으로 등장하는 오프셋들"을 모으면 구조체의 설계도가 됩니다.

  • 0x14(%rdi)에 4바이트 덧셈 → 오프셋 0x14에 4바이트 정수 필드
  • 0x18(%rdi)에 8바이트 뺄셈 → 오프셋 0x18에 8바이트 정수 필드
  • 0x4(%rdi)를 문자열 함수에 전달 → 오프셋 0x4에 문자 배열

이것이 구조체 복원의 전부입니다. 도구가 달라도(objdump든 Ghidra든) 읽는 것은 같은 오프셋입니다.

2-4. Ghidra의 세 작업대

화면 예시로 미리 봅니다. ① Symbol Tree(왼쪽) — 함수·레이블 목록. ② Listing(가운데) — 어셈블리. ③ Decompile(오른쪽) — 의사 C 코드. 이름 바꾸기는 어느 창에서든 대상을 고르고 L. 시그니처 편집은 Decompile 창에서 함수명 우클릭 → Edit Function Signature. 구조체는 Data Type Manager 창에서 우클릭 → New → Structure.

중요한 습관: Listing과 Decompile은 항상 함께 봅니다. 의사 코드가 이상하면 진실은 어셈블리에 있습니다.

2-5. 왜 정리에 시간을 쓰는가

이름을 바꾸는 작업은 겉치레가 아닙니다. FUN_00101169level_up으로 바꾸는 순간, 그 함수를 부르는 모든 곳의 의사 코드가 같이 읽히기 시작합니다. 정리는 복리로 쌓입니다 — 큰 바이너리일수록 처음 한 시간의 정리가 나중 열 시간을 줄입니다.


3. 따라 하기

3-1. 실험실 — 구조체를 쓰는 프로그램

분석 대상을 직접 만듭니다. 출제자의 눈으로 구조를 알고 만든 뒤, 분석가의 눈으로 잊는 훈련입니다.

입력 (structlab.c)

#include <stdio.h>
#include <string.h>

struct player {
    int  id;            /* +0x00 */
    char name[16];      /* +0x04 */
    int  level;         /* +0x14 */
    long gold;          /* +0x18 */
};

void level_up(struct player *p) {
    p->level += 1;
    p->gold -= 100;
}

void print_player(struct player *p) {
    printf("id=%d name=%s level=%d gold=%ld\n",
           p->id, p->name, p->level, p->gold);
}

int main(void) {
    struct player p = {7, "daimon", 3, 5000};
    level_up(&p);
    print_player(&p);
    return 0;
}

컴파일 — 두 판을 만듭니다:

cd ~/lab214_218
gcc -O1 -o structlab structlab.c
strip -o structlab_stripped structlab
./structlab
id=7 name=daimon level=4 gold=4900

(2026-09-09 실측.)

strip은 심볼을 뜯은 사본을 만듭니다. 확인해 보면:

nm structlab_stripped
nm: structlab_stripped: no symbols

(2026-09-09 실측.) 이름표가 전부 사라졌습니다. 실전에서 받는 바이너리의 모습입니다.

3-2. 분석가의 눈 — 오프셋에서 구조체 읽기 (objdump 실측)

이제 소스를 잊고, 스트립된 쪽을 봅니다. 함수 이름이 없으니 주소로 찾습니다 — 스트립 전 바이너리와 대조해 가며 읽는 연습을 겸합니다.

objdump -d structlab | sed -n "/<level_up>:/,/ret/p"
0000000000001169 <level_up>:
    1169:	f3 0f 1e fa          	endbr64
    116d:	83 47 14 01          	addl   $0x1,0x14(%rdi)
    1171:	48 83 6f 18 64       	subq   $0x64,0x18(%rdi)
    1176:	c3                   	ret

(2026-09-09 실측.)

출력 읽는 법 — 심볼이 없었다면 이 함수는 FUN_00101169입니다. 내용을 읽습니다:

  • addl $0x1, 0x14(%rdi) — 첫 인자(rdi)가 가리키는 곳의 +0x14에 있는 4바이트(addl = long, 32비트) 정수에 1을 더합니다.
  • subq $0x64, 0x18(%rdi) — +0x18의 8바이트(subq = quad, 64비트) 정수에서 100(0x64)을 뺍니다.

이 함수 하나만으로 구조체의 두 필드가 드러났습니다. 이름을 지어 봅시다: "+0x14에 1을 더하는 함수" — 레벨업처럼 보입니다.

3-3. 두 번째 함수 — 필드 지도 완성 (objdump 실측)

objdump -d structlab | sed -n "/<print_player>:/,/ret/p"
0000000000001177 <print_player>:
    117f:	48 8d 4f 04          	lea    0x4(%rdi),%rcx
    1183:	8b 17                	mov    (%rdi),%edx
    1185:	4c 8b 4f 18          	mov    0x18(%rdi),%r9
    1189:	44 8b 47 14          	mov    0x14(%rdi),%r8d
    118d:	48 8d 35 74 0e 00 00 	lea    0xe74(%rip),%rsi        # 2008
    119e:	e8 cd fe ff ff       	call   1070 <__printf_chk@plt>

(2026-09-09 실측.)

출력 읽는 법: 같은 포인터(rdi)에서 네 필드를 읽어 printf에 나열합니다.

오프셋 접근 추론
+0x00 mov (%rdi),%edx — 4바이트 int 필드 (id?)
+0x04 lea주소를 넘김 문자 배열 (name? 포인터를 넘기니까)
+0x14 4바이트 int (3-2에서 1 증가하던 그 자리 — level)
+0x18 8바이트 long (100을 빼던 자리 — gold)

+0x04에서 +0x14까지의 간격은 16바이트 — name[16]이 들어갈 자리입니다. 두 함수의 관찰을 합치면 구조체 설계도가 완성됩니다. 원본 소스와 정확히 일치합니다 — 여러분은 방금 구조체 복원을 손으로 한 것입니다.

3-4. 같은 일을 Ghidra로 — 화면 예시

이 환경에는 Ghidra가 없어 화면 예시로 안내합니다. structlab_stripped를 Import하고 분석(Analyze)하면, Symbol Tree의 Functions에 주소 이름들이 나옵니다. FUN_00101169를 더블클릭하면 Decompile 창에 대략 이런 의사 코드가 뜹니다:

# 화면 예시 — Ghidra Decompile 창 (FUN_00101169)

void FUN_00101169(long *param_1)
{
  *(int *)(param_1 + 2) = *(int *)(param_1 + 2) + 1;   /* +0x14 = long[2] */
  param_1[3] = param_1[3] - 100;                        /* +0x18 = long[3] */
  return;
}

읽는 법: 디컴파일러는 param_1long *로 추측했습니다. +0x14를 param_1 + 2(long 기준 2×8=16=0x10이 아니라… 정확히는 바이트 오프셋 환산 결과)로, +0x18을 param_1[3]으로 표현했네요. 읽기 어색합니다 — 타입 추측이 틀렸기 때문입니다. 진실은 Listing 창의 어셈블리(addl $0x1,0x14(%rdi))에 있고, 3-2에서 우리는 이미 진실을 알고 있습니다.

3-5. 정리 워크플로우 — 화면 예시

이제 추측을 교정하는 네 동작입니다:

# 화면 예시 — Ghidra 정리 절차
1. 함수 이름: FUN_00101169 선택 → L 키 → level_up 으로 변경
   FUN_00101177 → print_player 로 변경
2. 시그니처: Decompile 창에서 level_up 우클릭 → Edit Function Signature
   → 인자 타입을 struct player * 로 바꿀 준비
3. 구조체 생성: Data Type Manager 창 우클릭 → New → Structure
   → 필드 추가: +0x00 int id / +0x04 char name[16] / +0x14 int level / +0x18 long gold
   (오프셋은 3-2, 3-3의 objdump 실측과 같은 값)
4. 타입 적용: Edit Function Signature에서 param_1의 타입을 player * 로 지정

타입을 적용하는 순간 Decompile 창이 이렇게 바뀝니다:

# 화면 예시 — 구조체 적용 후

void level_up(player *p)
{
  p->level = p->level + 1;
  p->gold = p->gold - 100;
  return;
}

기계어 한 줄도 바뀌지 않았는데, 코드가 사람의 언어가 됐습니다. 이것이 정리의 힘입니다. 그리고 이 결과가 3-1의 원본 소스와 같다는 것 — 복원이 정확했다는 증거입니다.

3-6. 디컴파일이 틀릴 때 — 어셈블리가 진실

3-4에서 봤듯 디컴파일러는 타입을 틀립니다. 판단 기준은 하나입니다: 타입을 바꿨는데 코드가 더 이상해지면, 그 타입이 아닙니다. 반대로 바꾸자마자 사방의 코드가 깔끔해지면 맞습니다.

의심스러울 때의 확인 절차: Decompile 창에서 이상한 줄을 클릭하면 Listing 창의 해당 어셈블리로 커서가 같이 움직입니다. 어셈블리의 opcode(addl이냐 addq냐, mov냐 lea냐)가 타입의 진실을 말해 줍니다. l은 4바이트, q는 8바이트, lea는 "주소를 계산" — 이 세 힌트만으로 오늘 구조체의 전부를 읽었습니다.

3-7. 복원 노트 — 산출물 정리

주소 기계 이름 붙인 이름 근거
0x1169 FUN_00101169 level_up +0x14 증가, +0x18 감소
0x1177 FUN_00101177 print_player 4필드를 printf로 출력
오프셋 크기 필드명 근거
+0x00 4 id mov (%rdi) — 4바이트 읽기
+0x04 16 name lea로 주소 전달 — 문자 배열
+0x14 4 level addl $0x1 — 1 증가
+0x18 8 gold subq $0x64 — 100 감소

이 두 표가 오늘의 산출물, "복원 노트"입니다. 실전에서는 이 노트가 수십 함수·수십 필드로 커집니다.


4. 미션과 연습문제

미션 — 미지의 함수 하나를 C 수준으로 복원

  1. structlab.c에 함수를 하나 추가합니다 — 예: void buy_item(struct player *p, int price) { if (p->gold >= price) p->gold -= price; } (필드 구성은 바꾸지 마세요)
  2. 다시 컴파일하고 스트립합니다
  3. 심볼 없는 상태에서 새 함수를 찾아 objdump로 읽고, 하는 일을 C 코드 한 단락으로 복원합니다 — 조건 분기(jge/jl 류)까지 읽어야 합니다
  4. Ghidra가 있다면 같은 함수를 열어 이름과 타입을 적용하고, 내 손 복원과 디컴파일 결과를 비교합니다

연습문제

문제 1. 3-2의 addlsubq에서 끝글자 lq는 무엇을 뜻하며, 이것이 구조체 복원에서 왜 중요한가요?

문제 2. 3-3에서 +0x04 필드는 mov가 아니라 lea로 접근됐습니다. 이 차이가 "정수 필드가 아니라 배열"이라는 추론에 어떻게 쓰이나요?

문제 3. 스트립된 바이너리에서 Ghidra가 함수를 FUN_00101169로 부르는 이유를, 심볼의 관점에서 설명하세요.

문제 4. "디컴파일러는 추측기"라고 했습니다. 추측이 틀렸는지 판별하는 실용적인 기준은 무엇이며, 최종적인 진실은 어디서 확인하나요?


5. 모범 답안과 완료 기준

미션 모범 답안

buy_item을 추가해 컴파일·스트립한 뒤의 복원 과정 예:

[발견] main 디스어셈블리에서 모르는 call 주소 확인 → 해당 주소 함수를 objdump로 읽기

[관찰 — 출력 예시, 환경에 따라 주소는 다름]
  mov    0x18(%rdi),%rax        ; +0x18 (8바이트)을 읽고
  cmp    %rsi,%rax              ; 두 번째 인자(rsi)와 비교
  jl     리턴                    ; 작으면 아무것도 안 함
  movsxd %rsi,%rsi
  sub    %rsi,0x18(%rdi)        ; 크거나 같으면 +0x18에서 rsi만큼 뺌
  ret

[복원한 C]
void buy_item(player *p, long price) {
    if (p->gold >= price) p->gold -= price;
}

검증하는 법: ① 복원한 C의 필드 접근(+0x18, 8바이트)이 3-3의 구조체 지도와 일치하는가. ② 분기 방향이 맞는가 — jl은 "작으면 점프"이니, 빼기를 하지 않는 쪽으로 가는 분기입니다. 방향이 반대면 "돈이 모자랄 때 결제되는" 반전된 코드가 됩니다. ③ 원본과 대조했을 때 의미가 같은가 (문법이 달라도 동작이 같으면 복원 성공).

연습문제 해답

문제 1 해답. AT&T 문법의 크기 접미사입니다 — l(long)은 4바이트, q(quad)는 8바이트 연산. 같은 오프셋이라도 접미사가 필드의 크기를 결정해 주므로, 구조체에서 intlong을 구분하는 직접적 근거가 됩니다. 크기를 모르면 구조체의 경계를 그을 수 없습니다.

문제 2 해답. mov는 그 자리의 을 가져오고, lea는 그 자리의 주소를 계산합니다. printf 계열에 문자열을 넘길 때는 값이 아니라 주소가 필요하므로, lea 0x4(%rdi)는 "+0x04부터 시작하는 연속 공간(배열)"이 있다는 뜻입니다. 값 읽기가 아니라 주소 넘기기 — 이 한 opcode 차이로 "정수 필드"와 "문자 배열"을 구분합니다.

문제 3 해답. 심볼은 함수 이름과 주소의 대응표인데, strip이 이 표를 제거하기 때문입니다. Ghidra는 분석으로 코드의 시작점(함수 경계)은 찾을 수 있지만 이름은 복구할 수 없어, 주소를 넣은 임시 이름 FUN_<주소>를 붙입니다. 이름표가 사라졌을 뿐 함수 자체는 그대로라는 것 — 그래서 분석가가 동작을 읽고 이름을 다시 지어 주는 작업이 가능합니다.

문제 4 해답. 실용적 기준은 "타입을 적용했을 때 관련 코드 전체가 더 읽기 쉬워지는가"입니다 — 한 곳만 예뻐지고 다른 곳이 깨지면 그 타입이 아닐 가능성이 높습니다. 최종 진실은 어셈블리(Listing)에서 확인합니다. 디컴파일은 편의를 위한 번역이고, 실제로 CPU가 실행하는 것은 어셈블리이므로 둘이 어긋나면 어셈블리가 맞습니다.

완료 기준 체크리스트

  • [ ] 디컴파일러의 출력이 "추측"임을 설명할 수 있다
  • [ ] strip 전후 바이너리의 차이(nm 결과)를 확인했다
  • [ ] objdump 출력에서 오프셋 접근 패턴으로 구조체 필드 네 개를 추론했다
  • [ ] opcode 접미사(l/q)와 mov/lea 차이로 필드 크기와 종류를 구분할 수 있다
  • [ ] Ghidra의 이름 바꾸기(L), 시그니처 편집, 구조체 생성 절차를 말할 수 있다
  • [ ] 디컴파일이 이상할 때 어셈블리로 돌아가 확인하는 습관을 안다
  • [ ] 미션: 새 함수를 스트립 상태에서 C로 복원했다

6. 흔한 실수와 해결

벽 1. Ghidra 분석(Analyze)이 끝나지 않는다

증상: Import 후 분석이 수 분째 돌아갑니다.
원인: 파일이 크거나 분석 옵션을 많이 켰을 때 정상입니다.
해결: 기다리면 됩니다. 연습용 작은 바이너리는 수 초면 끝납니다. 너무 오래 걸리면 분석 옵션에서 Decompiler Switch Analysis 등 무거운 항목을 끄고 다시 시도하세요.

벽 2. 의사 코드에 param_1 + 2처럼 이상한 오프셋이 나온다

증상: 우리가 아는 +0x14가 param_1 + 2로 표시됩니다.
원인: 디컴파일러가 인자를 long *로 추측하면 바이트 오프셋이 포인터 산술(8바이트 단위)로 표현됩니다. 추측이 틀린 것입니다 (3-4, 3-6).
해결: Listing의 어셈블리에서 진짜 바이트 오프셋(0x14)을 확인하고, 구조체 타입을 만들어 적용하세요. 적용 후에도 이상하면 그 구조체가 틀린 것입니다.

벽 3. 이름을 바꿨는데 다른 창에서는 그대로다

증상: Listing에서 바꿨는데 Decompile의 호출부가 예전 이름입니다.
원인: 커서를 잘못된 대상(레이블이 아닌 주소 리터럴 등)에 두고 바꿨거나, 함수가 아니라 함수 안 레이블을 바꾼 경우입니다.
해결: Symbol Tree의 Functions에서 함수를 골라 L로 바꾸는 것이 가장 확실합니다. 바꾼 뒤 Decompile 창은 자동으로 갱신됩니다.

벽 4. 스트립한 바이너리에서 main을 못 찾겠다

증상: nm이 no symbols라고 하고, 어디가 main인지 모르겠습니다.
원인: 이름표가 없는 것이 정상 상태입니다.
해결: 진입점(entry)에서 시작하세요 — ELF의 entry는 _start이고, 그곳에서 __libc_start_main에 넘기는 인자가 main입니다. objdump 실측에서는 entry 디스어셈블리의 lea ...(%rip),%rdi가 main의 주소를 가리킵니다. Ghidra는 entry를 자동 분석하므로 그 호출 인자를 따라가면 됩니다.

벽 5. 구조체 필드 경계를 어디까지로 잡아야 할지 모르겠다

증상: +0x04가 배열인 건 알겠는데 길이를 모르겠습니다.
원인: 배열 길이 정보는 기계어에 없는 경우가 많습니다.
해결: 다음 필드의 오프셋이 곧 경계입니다. +0x04 다음 필드가 +0x14에서 접근되므로 배열은 최대 16바이트. 이렇게 "관찰된 오프셋들의 간격"으로 경계를 긋는 것이 구조체 복원의 기본기입니다. 정렬 패딩(보이지 않는 틈)이 있을 수 있음도 기억하세요 — 오늘의 구조체에서 +0x14와 +0x18 사이가 그 예입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
심볼 / 스트립 함수 이름표 / 그 이름표를 뜯어 낸 배포 상태
FUN_0010xxxx 심볼 없는 함수에 Ghidra가 붙이는 주소 기반 임시 이름
구조체 복원 고정 오프셋 접근 패턴을 모아 필드 설계도를 되찾는 작업
opcode 접미사 l=4바이트, q=8바이트 — 필드 크기의 증거
mov vs lea 값 읽기 vs 주소 계산 — 정수와 배열의 구분선
시그니처 편집 함수의 인자 타입을 교정해 의사 코드 전체를 사람 언어로 바꾸는 일

오늘의 명령어·키

명령/키 하는 일
strip -o 출력 입력 심볼을 뜯은 사본 만들기
nm 바이너리 심볼 유무 확인 (no symbols = 스트립됨)
objdump -d 바이너리 오프셋 접근 패턴 수집 — 구조체 복원의 원료
Ghidra L 함수·변수 이름 바꾸기
Ghidra 우클릭 → Edit Function Signature 인자 타입 교정
Data Type Manager → New → Structure 추론한 구조체 만들기

명령어보다 중요한 감각

리버싱 실력은 "읽는 속도"가 아니라 "정리의 습관"에서 갈립니다. 수상한 함수를 만나면 세 가지를 바로 합니다 — 이름을 짓고, 인자 타입을 정하고, 반복되는 오프셋을 구조체로 묶는다. 한 번 정리한 것은 그 바이너리 어디에서든 다시 읽을 필요가 없습니다.

그리고 디컴파일러를 믿되 의심하세요. 의사 코드가 어색하면 그것은 분석가에게 보내는 신호입니다 — "내 추측이 틀렸으니 고쳐 달라". 고치는 재료는 언제나 어셈블리에 있습니다. 오늘 objdump로 손수 복원해 본 것은, 내일 Ghidra가 틀렸을 때 흔들리지 않기 위한 근육입니다.


전부 체크되면 Step 215 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.