Intro
안녕하세요. 이번 글에서는 리눅스에서 제공하는 시스템 콜인 ptrace를 활용하여 프로세스를 제어하는 방법을 다루어보려 합니다. ptrace는 프로세스(process)를 추적(trace)하고 디버깅하는 데 사용되는 강력한 기능을 제공합니다. 우리가 흔히 사용하는 GDB(GNU Debugger)도 내부적으로 ptrace를 사용하여 디버깅 기능을 수행합니다.
본 글에서는 ptrace가 제공하는 여러 기능을 살펴본 후, 이를 응용하여 간단한 예제 프로그램을 만들어 보겠습니다.
본 글은 기본적인 시그널 제어 및 프로세스 핸들링을 이해하고 있다는 가정 하에 작성되었습니다. 시그널과 프로세스에 대한 기본적인 이해가 없는 경우, 먼저 해당 내용을 숙지하신 후 읽어주시기 바랍니다.
ptrace
리눅스 환경에서 디버깅, 즉 프로세스의 내부 동작을 확인하고 수정하기 위해서는 운영 체제가 제공하는 다양한 기능을 활용해야 합니다. 그중 하나가 바로 ptrace 시스템 콜입니다. 이 시스템 콜은 사용자가 특정 프로세스를 추적(trace) 할 수 있도록 해주며, 브레이크포인트 설정이나 메모리 읽기/쓰기, 레지스터 값 변경, 단일 스텝(single-step) 실행 등 다양한 기능을 제공합니다.
해당 기능을 이용해 다음과 같은 상황에서 주로 사용합니다.
- 디버깅: GDB, strace 같은 디버깅 툴들이 내부적으로
ptrace를 사용합니다. - 보안: 임의의 프로그램을 실행할 때
ptrace를 사용해 프로그램의 동작을 제한할 수 있습니다. - 프로파일링: 프로세스의 동작을 기록하여 성능 분석에 활용할 때도 쓰일 수 있습니다.
[주의]
ptrace를 사용하면 프로세스 내부의 거의 모든 정보에 접근이 가능하기 때문에, 잘못된 사용은 보안상 큰 문제가 될 수 있습니다. 예를 들어, 다른 사용자의 프로세스에 임의로 접속해 메모리를 변경하는 것은 보안상 허용되어서는 안 됩니다. 따라서 보통ptrace를 사용할 때에는 루트 권한이 요구되거나, 혹은 추적하려는 프로세스와 같은 사용자의 소유여야 하는 등의 제한이 있습니다.
기본 동작 원리
프로세스 트레이싱 흐름
ptrace의 간단한 흐름은 다음과 같이 요약할 수 있습니다.
- 부모 프로세스(Tracer)가 자식 프로세스(Tracee)를 생성하거나, 이미 실행 중인 프로세스에 attach 하여 추적을 시작합니다.
- 자식 프로세스의 수행 흐름이 특정 이벤트(시스템 콜 진입/종료, 시그널 발생, 단일 스텝 등)에 도달하면, 자식 프로세스는 중단(stop) 상태가 됩니다.
- 이때, 부모 프로세스는
ptrace시스템 콜을 통해 자식 프로세스의 메모리나 레지스터를 읽고 쓰는 등 필요한 작업을 수행할 수 있습니다. - 이후, 부모 프로세스가 다시 자식 프로세스를 실행(
PTRACE_CONT등)하게 하면 자식 프로세스는 중단된 지점부터 계속 실행됩니다.
즉, 부모 프로세스가 자식 프로세스의 실행 흐름을 일시 정지시키고(ptrace), 내부 상태를 들여다보거나 수정한 다음, 다시 실행을 재개시키는 방식입니다.
시그널 처리
리눅스 프로세스가 실행 중 시그널을 받으면, 일반적으로 해당 시그널을 처리하거나 종료하게 됩니다. 그러나 ptrace로 추적 중인 프로세스에 시그널이 들어오면, 우선 부모 프로세스(디버거)가 이를 확인하고, 그 후에 자식 프로세스에 시그널 전달 여부를 결정할 수 있습니다. 예를 들어, 디버거는 SIGSEGV 시그널이 왔을 때 추적을 중단해 디버거에게 제어를 넘긴 후, 실제로 자식 프로세스를 종료시킬지, 아니면 시그널 전달을 무시하고 계속 실행할지 선택할 수 있습니다.
주요 ptrace 요청(Request)
ptrace 사용 시, ptrace(request, pid, addr, data) 형식의 함수를 호출하게 됩니다(일반적으로 C/C++에서 #include <sys/ptrace.h>를 통해 사용). 주요 request 값들은 다음과 같습니다.
PTRACE_TRACEME- 현재 프로세스가 부모 프로세스에 의해 추적될 수 있도록 설정합니다. 보통 자식 프로세스가 이 옵션을 스스로 호출하고, 이후 부모 프로세스가 자식을 기다리면서(
waitpid)ptrace를 통해 본격적인 디버깅을 시작합니다.
- 현재 프로세스가 부모 프로세스에 의해 추적될 수 있도록 설정합니다. 보통 자식 프로세스가 이 옵션을 스스로 호출하고, 이후 부모 프로세스가 자식을 기다리면서(
PTRACE_ATTACH- 이미 실행 중인 프로세스에 attach(추적)합니다. GDB로 특정 프로세스를 attach 하는 것이 이 메커니즘과 동일합니다.
PTRACE_DETACH- 추적을 중단(detach)하고, 자식 프로세스가 정상적으로 실행되도록 합니다.
PTRACE_CONT- 중단된 자식 프로세스를 계속 실행(continue)시킵니다.
PTRACE_SINGLESTEP- 자식 프로세스를 한 명령어씩 단일 스텝으로 실행시킵니다. 이때 자식 프로세스는 한 번의 CPU 명령어가 실행된 후 다시 중단됩니다.
PTRACE_SYSCALL- 자식 프로세스가 시스템 콜 진입 또는 종료 시점마다 중단되도록 하여 부모가 개입할 수 있도록 합니다.
PTRACE_GETREGS,PTRACE_SETREGS- 자식 프로세스(Tracee)의 일반 레지스터 값을 가져오거나 설정합니다.
PTRACE_PEEKTEXT,PTRACE_PEEKDATA- 자식 프로세스의 메모리를 읽어옵니다. 텍스트 영역, 데이터 영역 구분 없이 사용할 수 있지만 전통적인 매크로 이름에 따라 구분 지어져 있습니다.
PTRACE_POKETEXT,PTRACE_POKEDATA- 자식 프로세스의 메모리에 데이터를 써넣습니다.
이 외에도 여러 가지 옵션이 존재하지만, 상기 나열한 것들이 대부분의 디버깅 시나리오에서 자주 쓰이는 핵심적인 요청들입니다.
Requirements
ptrace는 프로세스를 직접적으로 제어하는 함수이므로 운영체제 및 CPU 아키텍쳐에 따라 동작이 달라지거나, 지원하지 않을 수 있습니다. 따라서, 본 글에서는 아래 요구사항을 가정하고 진행하겠습니다.
개발 환경
- 운영 체제: Ubuntu, Debian, Fedora 등 리눅스 계열(본 글에서는 Ubuntu 20.04/22.04 정도를 예시로 가정)
- CPU 아키텍쳐: x86-64
- 컴파일러: GCC
권한 문제
ptrace는 보안상의 이유로 제한이 있을 수 있습니다. 예를 들어, Ubuntu에서는 실행 중인 프로세스에ptrace attach사용을 기본적으로 제한할 수 있습니다. 이를 해결하기 위해서 root 권한으로 사용하거나,/proc/sys/kernel/yama/ptrace_scope값을 조정해야 합니다.sudo sysctl -w kernel.yama.ptrace_scope=0- 위와 같은 명령어를 실행하면 전역적으로
ptrace제한이 풀립니다(단, 보안 위험이 있으므로 주의).
Example 1: 자식 프로세스 추적하기 및 시그널 핸들링
먼저, 가장 기본적인 시나리오인 “자식 프로세스를 생성(fork)해서 부모 프로세스가 자식 프로세스를 추적 및 시그널 핸들링”하는 방식을 살펴보겠습니다.
코드 예시
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/user.h>
int main() {
pid_t child = fork();
if (child == -1) {
perror("fork");
return -1;
}
if (child == 0) { // 자식 프로세스
// 부모가 자신을 추적할 수 있도록 설정
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
perror("ptrace");
exit(1);
}
// 부모가 attach하도록 대기하기 위해 시그널 발생시킴
// 보통 raise(SIGSTOP)을 쓰거나, execve로 새 프로그램을 실행하기도 함
raise(SIGSTOP);
// 자식이 하는 임의 작업
for (int i = 0; i < 5; ++i) {
printf("Child: i = %d\n", i);
sleep(1);
raise(SIGINT); // 매 카운터 출력 마다 SIGINT 시그널 임의 발생
}
return 0;
} else { // 부모 프로세스
int status;
// 자식이 SIGSTOP으로 중단될 때까지 대기
waitpid(child, &status, 0);
if (WIFSTOPPED(status)) {
printf("Parent: child has stopped, start tracing...\n");
}
// 자식 프로세스가 종료될 때까지 추적
for (; ;) {
// 자식 프로세스 실행 계속
if (ptrace(PTRACE_CONT, child, NULL, NULL) == -1) {
perror("ptrace");
break;
}
// 자식 프로세스의 이벤트 대기
waitpid(child, &status, 0);
// 자식 프로세스가 죽었는지 확인
if (WIFSIGNALED(status)) {
printf("Parent: child killed with signal: %d\n", WTERMSIG(status));
break;
}
// 자식 프로세스가 종료되었는지 확인
if (WIFEXITED(status)) {
printf("Parent: child exited with status %d\n", WEXITSTATUS(status));
break;
}
// 중단된 경우, 부모 프로세스에서 추가 작업 가능
if (WIFSTOPPED(status)) {
printf("Parent: child stopped by signal %d\n", WSTOPSIG(status));
}
}
}
return 0;
}
코드 설명
fork()로 자식 프로세스를 생성합니다.- 자식 프로세스는
ptrace(PTRACE_TRACEME, ...)를 통해 스스로 “나를 추적해도 좋아” 상태로 만듭니다. - 자식 프로세스는
raise(SIGSTOP)으로 스스로 중단시킵니다. 이렇게 해야 부모 프로세스가 자식에게ptrace요청을 보낼 준비가 됩니다. - 부모 프로세스는
waitpid(child, &status, 0)로 자식이 중단(SIGSTOP)될 때까지 기다립니다. - 이후 부모 프로세스는
for루프에서 계속ptrace(PTRACE_CONT, child, NULL, NULL)로 자식을 재개시키고, 자식이 시그널 등의 이유로 중단될 때마다(또는 종료될 때까지)waitpid로 상태를 확인합니다. - 이 예제를 컴파일하고 실행해 보면, 부모 프로세스가 자식을 중단시킨 뒤 계속 실행하면서, 자식의 종료 시점까지 추적함을 확인할 수 있습니다.
이벤트(시그널, 종료 등) 발생 시, 부모 프로세스가 이를 확인하고 실행할지 무시할지 결정할 수 있습니다. 위 예제에서는, 자식 프로세스가 SIGINT 시그널을 받을 때마다 부모 프로세스가 이를 확인하고, 이를 무시하고 있습니다. 고로 자식 프로세스가 죽지 않고 끝까지 실행되는 모습을 확인할 수 있습니다.
Parent: child has stopped, start tracing...
Child: i = 0
Parent: child stopped by signal 2
Child: i = 1
Parent: child stopped by signal 2
Child: i = 2
Parent: child stopped by signal 2
Child: i = 3
Parent: child stopped by signal 2
Child: i = 4
Parent: child stopped by signal 2
Parent: child exited with status 0
raise(SIGINT)대신raise(SIGKILL)등으로 시그널을 변경하면, 자식 프로세스가 종료될 수 있습니다.SIGKILL은 무조건적으로 프로세스를 종료시키는 시그널이므로,ptrace로도 제어할 수 없습니다.
Example 2: 메모리 읽기 및 쓰기
이제, ptrace의 더 강력한 기능인 메모리 읽기/쓰기를 활용해 보겠습니다.
메모리 읽기(PTRACE_PEEKTEXT, PTRACE_PEEKDATA)
addr인자로 자식 프로세스의 가상 주소를 전달하면, 해당 주소의 워드(word) 단위를 읽어올 수 있습니다.- 일반적으로 텍스트(코드) 영역을 읽는 데
PTRACE_PEEKTEXT, 데이터(스택/힙) 영역을 읽는 데PTRACE_PEEKDATA를 사용하지만, 내부 구현상 큰 차이는 없습니다.
메모리 쓰기(PTRACE_POKETEXT, PTRACE_POKEDATA)
- 자식 프로세스의 메모리를 수정할 수 있습니다.
- 주의할 점은, 코드를 수정하면 바로 세그먼트 오류(Segmentation Fault)가 발생할 수도 있고, 보안 측면에서도 매우 위험할 수 있으므로 신중하게 사용해야 합니다.
코드 예시
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/user.h>
char secret[20] = "HelloWorld";
int main() {
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) { // 자식 프로세스
printf("Child: secret = %s / b(secret) = 0x%lx\n", secret, *(unsigned long*)secret);
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
raise(SIGSTOP);
printf("Child: secret after = %s / b(secret) = 0x%lx\n", secret, *(unsigned long*)secret);
return 0;
} else {
// 부모 프로세스
int status;
waitpid(child, &status, 0);
if (WIFSTOPPED(status)) { // 자식 프로세스의 memory를 peek/poke
long word;
unsigned long addr;
// secret 배열의 주소를 알기 위해서는
// 실제 바이너리 분석 또는 고정된 오프셋 등을 활용해야 합니다.
// 여기서는 예시를 위해 "임의의 주소를 이미 안다"고 가정합니다.
// 따라서, fork 특성 상 같은 주소를 공유하는 전역변수를 생성해서 테스트 합니다.
// 실제 상황에서는 symbol table, DWARF 디버그 정보 등을 사용해야 합니다.
addr = (unsigned long)secret; // 가정: secret 배열의 시작 주소
// 자식 프로세스의 secret 메모리를 읽고 쓰는 예시
// 실제로는 이 주소가 맞지 않으면 세그먼트 오류가 발생할 수 있음
errno = 0;
word = ptrace(PTRACE_PEEKDATA, child, (void*)addr, NULL);
// "Bye"라는 문자열을 쓰고 싶다고 가정
// '\0', '\0', '\0', '\0', '\0', 'e', 'y', 'B'
// 문자열을 리틀 엔디안으로 인코딩해서 long에 넣는 과정이 필요
long newWord = 0x0000000000657942;
if (word == -1 && errno) {
perror("ptrace peekdata");
} else {
printf("Parent: read word = 0x%lx / new word = 0x%lx\n", word, newWord);
}
if (ptrace(PTRACE_POKEDATA, child, (void*)addr, (void*)newWord) == -1) {
perror("ptrace pokedata");
}
// 자식 프로세스 재개
ptrace(PTRACE_CONT, child, NULL, NULL);
}
waitpid(child, &status, 0);
if (WIFEXITED(status)) {
printf("Parent: child exited.\n");
}
}
return 0;
}
코드 설명
fork()로 자식 프로세스를 생성합니다.- 자식 프로세스는
secret배열에 존재하는 문자열 (HelloWorld)을 출력합니다. - 이후
raise(SIGSTOP)으로 스스로 중단시킵니다. - 부모 프로세스는
waitpid(child, &status, 0)로 자식이 중단(SIGSTOP)될 때까지 기다립니다. - 이후 부모 프로세스는 자식의
secret배열의 값을 읽고 변경합니다. - 이후 자식 프로세스를 재개시키고, 자식 프로세스가 종료될 때까지 대기합니다.
- 자식 프로세스는 바뀐
secret배열의 값을 출력합니다. - 이 예제를 실행해 보면, 다음과 같은 결과를 확인할 수 있습니다.
정상적으로 메모리를 읽고 수정하는 것을 확인할 수 있습니다.
Child: secret = HelloWorld / b(secret) = 0x726f576f6c6c6548
Parent: read word = 0x726f576f6c6c6548 / new word = 0x657942
Child: secret after = Bye / b(secret) = 0x657942
Parent: child exited.
실제로는 위 방법처럼 메모리 주소를 가져오기보단, DWARF 디버그 정보나 symbol table 등을 활용해야 합니다. 혹은 디버깅 기법을 활용해 메모리 주소를 찾아야 합니다.
Example 3: break point 설정하기
마지막으로, ptrace를 활용해 간단한 브레이크포인트를 설정해 보겠습니다. 브레이크포인트는 특정 메모리 주소에 SIGTRAP 시그널을 발생시켜 원하는 곳에서 프로세스를 중단시키는 기능입니다. 디버거를 만들기 위해 필요한 필수적인 기능 중 하나입니다.
레지스터 읽기/쓰기 (PTRACE_GETREGS, PTRACE_SETREGS)
struct user_regs_struct(x86-64 기준) 같은 구조체에 CPU 레지스터 값들이 담깁니다.ptrace(PTRACE_GETREGS, child, NULL, ®s)형태로 호출하면, 레지스터 값을regs구조체에 복사해 줍니다.ptrace(PTRACE_SETREGS, child, NULL, ®s)로 레지스터를 원하는 값으로 변경할 수 있습니다.
개요
- 부모 프로세스가 자식 프로세스를 실행시키되, 특정 함수 시작 지점 등에 소프트웨어 브레이크포인트를 생성합니다. (일반적으로 x86-64에서
int 3(0xCC)를 삽입) - 자식 프로세스가 해당 브레이크포인트에 도달하면
SIGTRAP시그널로 중단됩니다. - 부모 프로세스가 레지스터, 메모리 상태를 확인하거나 수정한 뒤, 다시
PTRACE_CONT또는PTRACE_SINGLESTEP를 통해 실행을 재개합니다.PTRACE_CONT를 사용하면 다음 중단 지점까지 실행됩니다.PTRACE_SINGLESTEP를 사용하면 한 명령어씩 실행됩니다.
소프트웨어 브레이크포인트 구현
- 브레이크포인트를 설치할 위치의 원본 명령어를 읽어(
PTRACE_PEEKTEXT), 1바이트를0xCC(x86의int 3)로 교체(PTRACE_POKETEXT)합니다. - 프로세스 실행이 해당 위치에 도달하면,
SIGTRAP이 발생하면서 프로세스가 중단됩니다. - 부모 프로세스가 원하는 작업을 진행한 뒤, 원본 명령어로 복원하고,
EIP/RIP레지스터를 조정한 뒤, 실행을 재개합니다.
코드 예시
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
// 간단히, 자식이 실행할 함수
void test_function() {
printf("Test Function Start\n");
for (int i = 0; i < 3; ++i) {
printf("In loop: i=%d\n", i);
sleep(1);
}
printf("Test Function End\n");
}
int main() {
pid_t child = fork();
if (child == 0) { // 자식 프로세스
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
raise(SIGSTOP);
// test_function 실행
test_function();
test_function();
return 0;
} else { // 부모 프로세스
int status;
waitpid(child, &status, 0);
// 브레이크포인트를 걸고자 하는 주소(이 예시에서는 test_function의 시작 주소)
// 실제로는 심볼 테이블에서 주소를 구하거나, 동적 분석을 해야 합니다.
unsigned long breakpoint_addr = (unsigned long)test_function;
// 원본 명령어 저장용
long original_data;
// 1) 원본 명령어 읽기
errno = 0;
original_data = ptrace(PTRACE_PEEKTEXT, child, (void*)breakpoint_addr, NULL);
if (original_data == -1 && errno) {
perror("ptrace peektext");
return 1;
}
// 2) 브레이크포인트(0xCC) 삽입
long data_with_break = (original_data & 0xFFFFFFFFFFFFFF00) | 0xCC;
// x86-64 리틀 엔디안에서, 첫 바이트가 0xCC가 되도록 수정
if (ptrace(PTRACE_POKETEXT, child, (void*)breakpoint_addr, (void*)data_with_break) == -1) {
perror("ptrace poketext");
return 1;
}
// 자식 실행
ptrace(PTRACE_CONT, child, NULL, NULL);
for (; ;) {
waitpid(child, &status, 0);
if (WIFEXITED(status)) {
printf("Child exited\n");
break;
}
if (WIFSTOPPED(status)) {
int sig = WSTOPSIG(status);
if (sig == SIGTRAP) { // 브레이크 포인트 도달
printf("Hit breakpoint at 0x%lx\n", breakpoint_addr);
// 3) 브레이크포인트를 복원
// 원본 명령어 복원
ptrace(PTRACE_POKETEXT, child, (void*)breakpoint_addr, (void*)original_data);
// 4) RIP를 한 바이트 뒤로 되돌리기
// x86-64 환경에서 브레이크포인트 명령어(0xCC)가 1바이트이므로
// RIP에서 1만큼 빼서 원래 명령어 재실행
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child, NULL, ®s);
regs.rip -= 1; // 브레이크포인트 명령어가 있는 위치
ptrace(PTRACE_SETREGS, child, NULL, ®s);
// 단일 스텝으로 명령어 한 번만 실행
ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
waitpid(child, &status, 0);
// 5) 다시 브레이크포인트 설치 (재진입 확인용)
ptrace(PTRACE_POKETEXT, child, (void*)breakpoint_addr, (void*)data_with_break);
// 이후 재개
ptrace(PTRACE_CONT, child, NULL, NULL);
} else {
// 다른 시그널이면 계속
ptrace(PTRACE_CONT, child, NULL, sig);
}
}
}
}
return 0;
}
코드 설명
fork()로 자식 프로세스를 생성합니다.- 자식 프로세스는
test_function을 총 2회 실행합니다. - 부모 프로세스는
test_function시작점에 브레이크포인트를 걸고, 자식 프로세스를 실행합니다. - 자식 프로세스가 브레이크포인트에 도달하면, 변경된 명령어에 의해
SIGTRAP시그널이 발생되어 중단됩니다. - 부모 프로세스는 브레이크포인트를 복원하고,
RIP레지스터를 조정한 뒤, 다시 실행을 재개합니다. - 이후 자식 프로세스가 종료될 때까지 이를 반복합니다.
정상적으로 브레이크 포인트를 만나 중단되는 것을 확인할 수 있습니다.
Hit breakpoint at 0x5baaac737269
Test Function Start
In loop: i=0
In loop: i=1
In loop: i=2
Test Function End
Hit breakpoint at 0x5baaac737269
Test Function Start
In loop: i=0
In loop: i=1
In loop: i=2
Test Function End
Child exited
실제로는 동적 분석을 통해 브레이크 포인트를 설정해야 합니다. 이 예시는 단순한 구현 예시입니다.
Conclusion
이상으로 ptrace를 활용한 간단한 디버깅 및 프로세스 제어 방법에 대해 알아보았습니다. ptrace는 프로세스의 내부 상태를 확인하고 수정하는 데 강력한 기능을 제공하므로, 디버깅 툴이나 보안 솔루션 등 다양한 분야에서 활용됩니다.
본 글에서 소개한 내용을 바탕으로 개인의 입맛에 맞는 디버거를 제작하거나, 유저의 코드를 제한하는 등 다양한 활용 방법이 존재합니다.