DreamHack의 System Hacking 로드맵을 기반으로 정리한 글입니다.
Tool: gdb
디버거
프로그램을 개발할 때는 일반적으로 코드의 논리구조를 신중하게 설계하고, 코드를 작성해 나간다. 그런데 개발 초기에 아무리 신중하게 설계해도, 코드의 규모가 커지다 보면 실수가 발생하기 마련이다. 컴퓨터 과학에서는 이렇게 실수로 발생한 프로그램의 결함을 버그(bug)라고 한다.
개발자들은 농담처럼 개발에 투자한 시간만큼을 버그를 잡는 데 사용한다고 말한다. 그 정도로 이미 완성된 코드에서 버그를 찾는 것은 어렵다. 그래서 이런 어려움을 해소하고자 디버거(Debugger)라는 도구가 개발되었다.
디버거는 문자 그대로 버그를 없애기 위해 사용하는 도구이다. 프로그램을 어셈블리 코드 단위로 실행하면서, 실행 결과를 사용자에게 보여준다. 그러나 이러한 디버거의 효용은 개발자만 얻는 것이 아니라, 해커, 리버스 엔지니어 등을 비롯하여 소프트웨어에서 버그를 찾고자 하는 모두가 이 도구를 사용하여 버그 탐색의 효율을 높일 수 있게 됐다. 버그 발견이 쉬워짐으로써, 개발자들은 버그를 고치기가 쉬워졌고, 해커들은 취약점을 발견하기 쉬워졌다.
gdb
gdb(GNU debugger)는 리눅스의 대표적인 디버거이다.
※ GNU: "GNU is Not UNIX"의 약자로, 유닉스와 완벽하게 호환 가능 자유 소프트웨어 모음집이다.
pwndbg
gdb의 플러그인(소프트웨어에 추가 기능 제공 위한 확장 모듈) 중에서 바이너리 분석 용도로 널리 사용되는 플러그인 중 하나이다.
$ gdb debugee
▶ 디버깅 시작
entry
리눅스는 실행 파일의 형식으로 ELF(Executable and Linkable Format)를 규정하고 있다. ELF는 크게 헤더와 여러 섹션들로 구성되어 있다. 헤더에는 실행에 필요한 여러 정보가 적혀 있고, 섹션들에는 컴파일된 기계어 코드, 프로그램 문자열을 비롯한 여러 데이터가 포함되어 있다.
ELF의 헤더 중에 진입점(Entry Point, EP)이라는 필드가 있는데, 운영체제는 ELF를 실행할 때 진입점의 값부터 프로그램을 실행한다.
$ readelf -h debugee
▶ readelf로 진입점(Entry point address) 확인
pwndbg> entry
▶ entry 명령어
gdb의 entry 명령어는 진입점부터 프로그램을 분석할 수 있게 해주는 gdb의 명령어이다. DISASM 영역의 화살표(▶)가 가리키는 주소는 현재 rip의 값인데, 이는 프로그램 진입점의 주소와 일치한다.
※ DISASM: 기계어 코드(binary code)를 사람이 읽을 수 있는 어셈블리어(assembly language)로 변환하는 과정이다.
context
프로그램은 실행되면서 레지스터를 비롯한 여러 메모리에 접근한다. 따라서 디버거를 이용하여 프로그램의 실행 과정을 자세히 관찰하려면 컴퓨터의 각종 메모리를 한눈에 파악할 수 있는 것이 좋다. pwndbg는 주요 메모리들의 상태를 프로그램이 실행되고 있는 맥락(Context)이라고 부르며, 이를 가독성있게 표현할 수 있는 인터페이스를 갖추고 있다.
context는 크게 4개의 영역으로 구분된다.
- REGISTERS: 레지스터의 상태를 보여준다.
- DISASM: rip부터 여러 줄에 걸쳐 디스어셈블된 결과를 보여준다.
- STACK: rsp부터 여러 줄에 걸쳐 스택의 값들을 보여준다.
- BACKTRACE: 현재 rip에 도달할 때까지 어떤 함수들이 중첩되어 호출됐는지 보여준다.
이들은 어셈블리를 실행할 때마다 갱신되어 방금 실행한 어셈블리 명령어가 메모리에 어떤 영향을 줬는지 쉽게 파악할 수 있게 돕는다.
▶ context 예시: REGISTERS / DISASM / STACK / BACKTRACE
break & continue / run
break & continue
gdb를 이용하여 프로그램을 분석할 때, 일반적으로 전체 프로그램 중 아주 일부분의 동작에만 관심이 있다. 위 예시 코드에서 main 함수가 분석의 대상이라고 가정한 상황일 때, 진입점부터 main 함수까지, 코드를 한 줄씩 실행시켜가며 main 함수에 도달해야 한다면, 디버깅은 그렇게 효율적인 분석 방법이 아닐 것이다.
그래서 많은 디버거에는 break와 continue라는 기능이 있다.
- break: 특정 주소에 중단점(breakpoint)을 설정하는 기능
- continue: 중단된 프로그램을 계속 실행시키는 기능
break로 원하는 함수에 중단점을 설정하고, 프로그램을 계속 실행하면 해당 함수까지 멈추지 않고 실행한 다음 중단됩니다. 그러면 중단된 지점부터 다시 세밀하게 분석할 수 있다.
pwndbg> b *main
pwndbg> c
▶ start 함수부터 main 함수까지 실행
- b: breakpoint 설정하는 명령어
- *main: 프로그램의 main 함수 주소에 breakpoint 설정하겠다는 의미, 이때 *은 디버깅 대상의 메모리 주소를 직접 명시할 때 사용
- c: 프로그램 실행을 계속(continue)하는 명령어, 이때 breakpoint에 도달하면 프로그램 중단
※ pwndbg> delete n: n번 breakpoint 제거
run
앞의 start가 진입점부터 프로그램을 분석할 수 있도록 자동으로 중단점을 설정해줬다면, run은 단순히 실행만 시킨다. 따라서 중단점을 설정해놓지 않았다면 프로그램이 끝까지 멈추지 않고 실행된다. 하지만 중단점을 설정해놓으면 run 명령어를 실행해도, 중단점에서 실행이 멈춘다.
pwndbg> r
▶ 실행
- r: run
gdb는 명령어 축약 기능을 제공한다. 어떤 명령어를 특정할 수 있는 최소한의 문자열만 입력하면 자동으로 명령어를 찾아 실행해준다. 몇몇 대표적인 명령어들(break, continue, run 등)은 특정할 수 없더라도 우선으로 실행해준다. 다음 표는 자주 사용되는 명령어들의 단축키이다.
gdb의 명령어 축약 | ||
b | break | 브레이크포인트 설정, 특정 지점에서 프로그램 실행 중단 |
c | continue | 현재 중단된 지점에서 실행을 계속 진행 |
r | run | 프로그램을 처음부터 실행, 브레이크포인트에서 멈춤 |
si | step into | 한 단계 실행하며 함수 호출 시 함수 내부로 진입 |
ni | next instruction | 한 단계 실행하되 함수 호출 시 내부로 들어가지 않음 |
i | info | 디버깅 정보를 출력 |
k | kill | 실행 중인 프로그램 강제 종료 |
pd | pdisas | 지정된 주소나 함수의 어셈블리 코드를 디스 어셈블링 |
disassembly
gdb는 프로그램을 어셈블리 코드 단위로 실행하고, 결과를 보여준다. 프로그램의 코드는 기계어로 이루어져 있으므로, gdb는 기계어를 디스어셈블(Disassemble)하는 기능을 기본적으로 탑재하고 있다. 추가로, pwndbg에는 디스어셈블된 결과를 가독성 좋게 출력해주는 기능이 있다.
pwndbg> disassemble
▶ disassemble은 gdb가 기본적으로 제공하는 디스어셈블 명령어
아래 코드처럼 함수 이름을 인자로 전달하면 해당 함수가 반환될 때까지 전부 디스 어셈블하여 보여준다.
▶ gdb diassembly
u, nearpc, pdisas는 pwndbg에서 제공하는 디스어셈블 명령어이다. 이들은 디스어셈블된 코드를 가독성 좋게 출력해준다.
▶ pwndbg diassembly
- u: 현재 PC(프로그램 카운터)에서부터 디스어셈블리된 명령어 출력, 주로 코드 흐름 확인 위해 사용
- nearpc: 현재 PC로부터 상대적으로 가까운 범위 내에서 디스어셈블리된 명령어 출력
- pdisas: 특정 주소나 현재 PC로부터 시작하여 디스어셈블리된 명령어 출력, 주로 코드 분석시 사
navigate
관찰하고자 하는 함수의 중단점에 도달했으면, 그 지점부터는 명령어를 한 줄씩 자세히 분석해야 한다. 이때 사용하는 명령어로 ni와 si가 있다.
ni와 si는 모두 어셈블리 명령어를 한 줄 실행한다는 공통점이 있다. 그러나 만약 call 등을 통해 서브루틴을 호출하는 경우 ni는 서브루틴의 내부로 들어가지 않지만, si는 서브루틴의 내부로 들어간다는 차이점이 있다.
※ 서브루틴(Subroutine): 특정 작업을 수행하는 코드의 일부분을 독립적으로 작성하고, 필요할 때마다 호출하여 실행할 수 있는 코드 블록을 의미한다.
next instruction
ni를 입력하면, call한 함수 바로 다음으로 rip가 이동한다.
pwndbg> ni
step into
si를 입력하면, call한 함수 내부로 rip가 이동한다.
pwndbg> si
프로그램을 분석하다가, 어떤 함수의 내부까지 궁금할 때는 si를, 그렇지 않을 때는 ni를 사용하면 된다.이때 만약 main 함수에서 printf를 호출한 것을 예로 들어 context 하단의 BACKTRACE를 보면, main 함수에서 printf를 호출했으므로 main 함수 위에 printf 함수가 쌓인 것을 볼 수 있다.
finish
step into로 함수 내부에 들어가서 필요한 부분을 모두 분석했는데, 함수의 규모가 커서 ni로는 원래 실행 흐름으로 돌아가기 어려울 수 있다. 이럴 때는 finish라는 명령어를 사용하여 함수의 끝까지 한 번에 실행할 수 있다.
pwndbg> finish
examine
프로그램을 분석하다 보면 가상 메모리에 존재하는 임의 주소의 값을 관찰해야 할 때가 있다. 이를 위해 gdb에서는 기본적으로 x라는 명령어를 제공한다. x를 이용하면 특정 주소에서 원하는 길이만큼 데이터를 원하는 형식으로 인코딩하여 볼 수 있다.
format letters | size letters | ||
o(octal) | 8진수로 출력 | b(byte) | 1바이트 크기 |
x(hexadecimal) | 16진수로 출력 | h(halfword) | 2바이트 크기 |
d(decimal) | 10진수로 출력 | w(word) | 4바이트 크기 |
u(unsigned decimal) | 부호 없는 10진수로 출력 | g(giant) | 8바이트 크기 |
t(binary) | 2진수로 출력 | ||
f(float) | 부동소수점으로 출력 | ||
a(address) | 주소 형식으로 출력 | ||
i(instruction) | 명령어 형식으로 출력 | ||
c(char) | 문자로 출력 | ||
s(string) | 문자열로 출력 | ||
z(hex, zero padded on the left) | 16진수로 출력하며, 왼쪽에 0으로 패딩 |
※ z는 16진수로 출력하되, 고정된 자리 수로 출력하며 빈 자리는 0으로 채우는 포맷 문자다.
아래는 예시이다.
pwndbg> x/10gx $rsp
▶ rsp부터 80바이트를 8바이트씩 hex 형식으로 출력
pwndbg> x/5i $rip
▶ rip부터 5줄의 어셈블리 명령어 출력
pwndbg> x/s 0x400000
▶ 특정 주소의 문자열 출력
telescope
telescope은 pwndbg가 제공하는 강력한 메모리 덤프 기능이다. 특정 주소의 메모리 값들을 보여주는 것에서 그치지 않고, 메모리가 참조하고 있는 주소를 재귀적으로 탐색하여 값을 보여준다.
이때 메모리 덤프란 system의 물리 memory를 file 형태로 저장하는 방법으로, 해당 file의 구조는 실제 memory 구조와 동일하다.
※ 덤프: 프로그램에 장애가 발생했을 때 오류 수정이나 데이터 검사(디버깅)를 위해 메모리, 레지스터, 또는 데이터의 내용을 출력하거나 저장하는 작업이다.
pwndbg> tele
▶ tele 명령어
vmmap
vmmap은 가상 메모리의 레이아웃을 보여준다. 어떤 파일이 매핑된 영역일 경우, 해당 파일의 경로까지 보여준다.
pwndbg> vmmap
▶ vmmap 명령어
※ 파일 매핑: 어떤 파일을 메모리에 적재하는 것이다. 리눅스에서는 ELF를 실행할 때, 먼저 ELF의 코드와 여러 데이터를 가상 메모리에 매핑하고, 해당 ELF에 링크된 공유 오브젝트(Shared Object, so)를 추가로 메모리에 매핑한다. 공유 오브젝트는 윈도우의 DLL(Dynamic Link Library)과 대응되는 개념으로, 자주 사용되는 함수들을 미리 컴파일해둔 것이다. C 언어의 printf(출력), scanf(입력) 등이 리눅스에서는 libc(library C)에 구현되어 있다. 공유 오브젝트에 이미 구현된 함수를 호출할 때는 매핑된 메모리에 존재하는 함수를 대신 호출한다.
※ 바이너리에 main() 심볼이 존재할 때 유용한 명령어
- start: main() 심볼이 존재하면 main()에 중단점을 설정한 후 실행, main() 심볼이 없으면 진입점에 중단점을 설정한 후 실행
- main: start 명령어와 동일
gdb / python
때로는 gdb를 사용하여 프로그램을 디버깅할 때, 키보드로 직접 타이핑하기 어려운 복잡한 값을 입력하고 싶은 순간이 있다. 예를 들어, 숫자도 아니고, 알파벳도 아니며, 특수 문자도 아닌 값을 입력하는 상황이다. 이러한 값은 이용자가 직접 입력할 수 없는 값이기 때문에 파이썬으로 입력값을 생성하고, 이를 프로그램 입력으로 넘겨주는 방식을 사용해야 한다.
gdb / python argv
run 명령어의 인자로 $()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다.
pwndbg> r $(python3 -c "print('\xff' * 100)")
▶ 파이썬에서 print 함수를 통해 출력한 값을 run(r) 명령어의 인자로 전달하는 명령
gdb/ python input
이전과 같이 $()와 함께 파이썬 코드를 입력하면 값을 입력할 수 있다. 입력값으로 전달하기 위해서는 <<<를 사용해야 한다.
pwndbg> r $(python3 -c "print('\xff' * 100)") <<< $(python3 -c "print('dreamhack')")
▶ argv[1]에 임의의 값을 전달하고, 값을 입력하는 명령어
참고 자료
'Security > System Hacking' 카테고리의 다른 글
System Hacking: Shellcode - 1 (1) | 2025.01.23 |
---|---|
System Hacking: Tool Installation - 2 (0) | 2025.01.21 |
System Hacking: Background - Computer Science (2) | 2025.01.19 |