서론
버그(bug) : 실수로 발생한 프로그램의 결함
디버거(Debugger) : 버그를 없애기 위해 사용하는 도구
프로그램을 어셈블리 코드 단위로 실행하면서, 실행결과를 사용자에게 보여준다.
자신이 추상적으로 생각한 아이디어의 결과를 직관적으로 보여주기 때문에, 개발자는 디버거를 사용하여 자신이 작성한 코드의 문제점을 더욱 명확하게 찾을 수 있다.
gdb
gdb(GNU debugger) : 리눅스의 대표적인 디버거
오픈 소스로 개발되어 무료로 설치할 수 있으며 다양한 플러그인들이 개발되어 있다.
Ubuntu 18.04에는 기본적으로 설치되어 있다.
Github 링크 : https://github.com/scwuaptx/Pwngdb
GitHub - scwuaptx/Pwngdb: gdb for pwn
gdb for pwn. Contribute to scwuaptx/Pwngdb development by creating an account on GitHub.
github.com
위와 같은 실행 결과가 나오면 잘 설치된 것이다.
실습 예제
아래의 코드를 작성하고 컴파일했다.
// Name: debugee.c
// Compile: gcc -o debugee debugee.c -no-pie
#include <stdio.h>
int main(void) {
int sum = 0;
int val1 = 1;
int val2 = 2;
sum = val1 + val2;
printf("1 + 2 = %d\\n", sum);
return 0;
}
ELF
ELF(Executable and Linkable Format) : 리눅스의 실행파일 형식, 헤더와 섹션으로 구성된다.
헤더 : 실행에 필요한 정보
- 진입점(Entry Point, EP) : 헤더에 포함된 필드 중 하나, 운영체제는 ELF를 실행할 때 진입점의 값부터 프로그램을 실행한다.
섹션 : 컴파일된 기계어 코드, 프로그램 문자열을 비롯한 여러 데이터
readelf로 확인해본 결과, debugee의 진입점은 0x400400이다.
start 명령어를 이용해서 실행시켜보면 예상했던 것(breakpoint가 0x400400에 걸릴 것이다)와는 다르게 시작점이 0x55555555464e인 것을 확인할 수 있다. 왜 다를까?
gdb의 start 명령을 하면 프로그램이 최초로 실행하는 명령어에서 멈추게 된다.
강의에서는 최초로 실행되는 함수가 _start 함수이다.
사용자가 지정한 파일은 0x400000 에 로드되지만 0x400000부터 코드인 것이 아니라 해당 파일이 어떤 속성을 가지고 있는지 설명하기 위한 데이터가 들어있는 것이고, 실질적인 코드는 0x400000 다음부터이다.
그렇다면 _start 함수는 무슨 함수인지 의문을 가지게 될 수 있다. 살펴보면 main 함수밖에 없기 때문이다.
_start 함수는 컴파일 과정에서 추가되는 것으로, main 함수가 실행되기 위한 환경을 설정하는 함수이기 때문에 main보다 먼저 실행된다.
info func 명령어를 통해 함수들을 살펴보면 _init으로 시작된 뒤에 _start로 환경을 설정한 뒤에 main 함수가 호출되는 것을 확인할 수 있다.
그렇다고 바로 0x540에 브레이크 포인트를 설정하고 실행하면 안된다.
info func로 출력된 주소들은 함수 이름 목록이 들어있는 배열을 출력한 것이기 때문에 진짜 주소가 아니기 때문이다.
함수 이름으로 브레이크를 걸어서 실행하면 비로소 _start 함수를 확인할 수 있다.
context
pwndbg는 주요 메모리들의 상태를 프로그램이 실행되고 있는 맥락(Context)이라고 부르며, 이를 가독성 있게 표현할 수 있는 인터페이스를 갖추고 있다.
context는 크게 4개의 영역으로 구분된다.
1. registers: 레지스터의 상태를 보여준다.
2. disasm: rip부터 여러 줄에 걸쳐 디스어셈블된 결과를 보여준다.
3. stack: rsp부터 여러 줄에 걸쳐 스택의 값들을 보여준다.
4. backtrace: 현재 rip에 도달할 때까지 어떤 함수들이 중첩되어 호출됐는지 보여준다.
break & continue
break : 특정 주소에 중단점(breakpoint)을 설정하는 기능
continue : 중단된 프로그램을 계속 실행시키는 기능
break로 원하는 함수에 중단점을 설정하고, 프로그램을 계속 실행하면 해당 함수까지 멈추지 않고 실행한 다음 중단 → 세밀하게 분석 O

run
단순히 실행만 시키는 명령어
현재 main 함수에 중단점을 설정해 놓았기 때문에 main에서 실행이 멈춘다.

축약
|
명령어
|
기능
|
b ~
|
break
|
~에 브레이크 포인트 설정
|
c
|
continue
|
프로세스가 멈추어있는 상태에서 프로세스를 이어서 실행
|
r
|
run
|
프로그램 수행(재시작)
|
si
|
step into
|
한 줄씩 코드 진행, 함수 내부까지 진행 O
|
ni
|
next instruction
|
한 줄씩 코드 진행, 함수 내부까지 진행 X
|
i
|
info
|
브레이크가 걸린 곳을 출력
|
k
|
kill
|
call stack 목록
|
pd
|
pdisas
|
disassemble 출력
|
disassembly
gdb는 기계어를 디스어셈블(Disassemble)하는 기능을 기본적으로 탑재하고 있다.
disassemble : gdb가 기본적으로 제공하는 디스어셈블 명령어
함수 이름을 인자로 전달하면 해당 함수가 반환될 때 까지 전부 디스어셈블하여 보여준다.

u, nearpc, pdisassemble : pwndbg에서 제공하는 디스어셈블 명령어, 디스어셈블된 코드를 가독성 좋게 출력

navigate
ni와 si의 공통점 : 어셈블리 명령어를 한 줄 실행
ni(next instrution) : call 등을 통해 서브루틴을 호출하는 경우 서브루틴의 내부로 들어가지 않음
si(step into) : call 등을 통해 서브루틴을 호출하는 경우 서브루틴의 내부로 들어감

ni를 입력한 경우

printf 함수 바로 다음으로 이동한 것을 확인할 수 있다.
여기서 printf가 실행되었는데 왜 문자열이 출력되지 않았는가에 대해서 의문을 가질 수 있다.
printf가 출력하고자 하는 문자열은 stdout의 버퍼에서 잠시 대기한 뒤 출력된다.
여기서 버퍼는 '데이터가 목적지로 이동하기 전에 잠시 저장되는 장소'라는 의미이다.
stdout버퍼는 특정 조건이 만족됐을 때만 데이터를 목적지로 이동시키는데, 그 조건은
1. 프로그램이 종료될 때
2. 버퍼가 가득 찼을 때
3. fflush와 같은 함수로 버퍼를 비우도록 명시했을 때
4. 개행문자가 버퍼에 들어왔을 때
이다.
예시는 위 조건을 하나도 만족하지 않기 때문에, 프로그램이 종료될 때까지 문자열을 출력하지 않는다.
si를 입력한 경우

함수 내부로 rip이 이동한 것을 확인할 수 있다.
finish를 통해 함수의 끝까지 한번에 실행했다.

examine
x : 특정 주소에서 원하는 길이만큼의 데이터를 원하는 형식으로 인코딩
Format letters : o(octal), x(hex), d(decimal), u(unsigned decimal), t(binary), f(float), a(address), i(instruction), c(char), s(string) and z(hex, zero padded on the left).
Size letters : b(byte), h(halfword), w(word), g(giant, 8 bytes).
1. rsp부터 80바이트를 8바이트씩 hex형식으로 출력

x/(인코딩한다) 10(10개의) g(8bytes) (8bytes * 10번 = 80bytes) x(hex 형식으로)
2. rip부터 5줄의 어셈블리 명령어 출력

3. 특정 주소의 문자열 출력

x/(인코딩한다) s(주소를) 0x5555~(원하는 주소)
telescope
pwndbg가 제공하는 메모리 덤프 기능
특정 주소의 메모리 값과 메모리가 참조하고 있는 주소를 재귀적으로 탐색하여 값을 출력한다.

vmmap
가상 메모리의 레이아웃을 보여준다.
어떤 파일이 매핑된 영역일 경우, 해당 파일의 경로까지 보여준다.
파일 매핑 : 어떤 파일에 메모리를 적재하는 것
리눅스에서는 ELF를 실행할 때, 먼저 ELF의 코드와 여러 데이터를 가상 메모리에 매핑하고, 해당 ELF에 링크된 공유 오브젝트(Shared Object, so)를 추가로 메모리에 매핑한다.
공유 오브젝트 : 윈도우의 DLL과 대응되는 개념으로, 자주 사용되는 함수들을 미리 컴파일해둔 것
C언어의 printf, scanf 등이 리눅스에서는 libc(library C)에 구현되어 있고, 공유 오브젝트에 이미 구현된 함수를 호출할 때는 매핑된 메모리에 존재하는 함수를 대신 호출한다.

gdb / python
숫자와 알파벳이 아닌 값을 입력할 때는 파이썬으로 입력값을 생성하고 이를 이용한다.
// Name: debugee2.c
// Compile: gcc -o debugee2 debugee2.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
char name[20];
if( argc < 2 ) {
printf("Give me the argv[2]!\n");
exit(0);
}
memset(name, 0, sizeof(name));
printf("argv[1] %s\n", argv[1]);
read(0, name, sizeof(name)-1);
printf("Name: %s\n", name);
return 0;
}
프로그램의 인자로 전달된 값과 이용자로부터 입력받은 값을 출력하는 예제이다.
gdb debugee2로 디버깅을 시작한다.

gdb / python argv
run 명령어의 인자로 $()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다.

gdb / python input
$()와 함께 파이썬 코드를 입력하면 값을 입력할 수 있다.
입력값으로 전달하기 위해서는 '<<<' 문자를 사용한다.

앞서 배운 argv[1]에 임의의 값을 전달하고, 값을 입력해 보았다.

python3로도 해보았다. 처음에 python이 설치되어 있지 않아서 python3로 대체해서 했더니 다양한 오류들이 발생했다.
python3는 print를 함수로 호출하기 때문에 무조건 앞뒤로 ()가 있어야 한다.
Python 2 와 Python 3의 차이점
Python 2 와 Python 3의 차이점 코드를 유지보수 하다보면 Python2로 작성된 것을 Python3으로 Migration 할 때가 많다. 이 때 생각보다 두 버전간의 backward compatibility 를 지원하지 않는 부분이 많다는 것을..
goodtogreate.tistory.com
명령어 요약
- start: 진입점에 중단점을 설정하고, 실행
- break(b): 중단점 설정
- continue(c): 계속 실행
- disassemble: 디스어셈블 결과 출력
- u, nearpc, pd: 디스어셈블 결과 가독성 좋게 출력
- x: 메모리 조회
- run(r): 프로그램 처음부터 실행
- context: 레지스터, 코드, 스택, 백트레이스의 상태 출력
- nexti(ni): 명령어 실행, 함수 내부로는 들어가지 않음
- stepi(si): 명령어 실행, 함수 내부로 들어감
- telescope(tele): 메모리 조회, 메모리값이 포인터일 경우 재귀적으로 따라가며 모든 메모리값 출력
- vmmap: 메모리 레이아웃 출력