Research

Simple Ptrace Notion

xdfyrj 2025. 1. 8. 18:56

(간단한 ptrace 개념이라는 뜻)


[0x0] Preface

[0x1] What is ptrace?

[0x2] How to use ptrace

  • 1. Synopsis
  • 2. Arguments]

[0x3] Usage1

  • 1. Example
  • 2. Practice
  • 3. Practice probs

[0x4] Usage2

  • 1. Creating the process
  • 2. Write data

[0x5] Conclusion


[0x00] Preface

Chip-VM:DBPC를 풀면서 ptrace의 사용법을 보았다.

문제를 풀고 싶어졌고, 이를 위해 ptrace를 공부하면서 이 글을 작성한다.

솔직히 ptrace라는 것도 함수여서 그렇게 방대한 기능을 가지고 있진 않지만, 프로세스에 직접 접근하여 제어하고 메모리에 데이터를 작성하는 것이 어렵다고 생각하여 공부해 볼 필요가 있다 생각한다.

[0x01] What is ptrace?

ptrace - process trace.

ptrace() 는 유닉스 계열 시스템에서 한 프로세스(추적자)가 다른 프로세스(추적 대상)의 실행을 관찰하고 제어할 수 있게하는 시스템 호출이다.

이를 통해서 디버깅, 시스템 호출 모니터링, 메모리 읽기/쓰기 등의 작업을 수행할 수 있고, 가장 많이 사용되는 곳은 디버깅 시스템 구현이다.

[0x02] How to use ptrace

1. Synopsis

#include <sys/ptrace.h>

long ptrace(enum __ptrace_request op, pid_t pid, void *addr, void *data);

2. Arguments

  • op: 수행할 작업.
    • PTRACE_TRACEME : 프로세스가 스스로 디버깅될 수 있도록 설정. 이 요청을 호출한 후에는 부모 프로세스가 디버거 역할을 할 수 있다.
    • PTRACE_ATTACH : 지정된 프로세스를 디버거가 추적할 수 있도록 연결. 디버거는 대상 프로세스의 실행을 중단시키고 제어권을 가져옴.
    • PTRACE_CONT : 중단된 프로세스를 재개.
    • PTRACE_PEEKDATA : 추적 중인 프로세스의 메모리에서 데이터를 읽음. 보통 디버거가 프로세스의 메모리 상태를 검사할 때 사용된다.
    • PTRACE_POKEDATA : 추적 중인 프로세스의 메모리에 데이터를 작성. 디버거가 프로세스의 동작을 조작하거나 데이터를 변경할 때 사용된다.
    • PTRACE_SYSCALL : 추적 중인 프로세스를 시스템 호출 시점(진입 또는 종료)에서 중단. 디버거가 시스템 호출을 추적하고 분석할 때 사용된다.
    • etc.
  • pid: 추적하려는 프로세스의 ID.
  • addr: 작업 대상의 주소.
  • data: 추가 데이터나 반환값으로 사용되는 포인터.

ptrace는 앞서 말한 것과 같이 다양한 곳에 사용할 수 있다.
다음은 그 다양한 ptrace 사용법이다.

[0x3] Usage 1: Anti debugging

CTF나 Wargame의 리버싱 문제에서 나오는 ptrace의 흔한 사용 방법이다. 자신의 프로세스를 추적하고, 디버거에 의해 이미 추적을 당하는 상태라면 -1을 반환한다. 이것을 이용하여 프로그램을 끝내거나 값을 바꾸는 식으로 디버깅을 방지할 수 있다.

하지만 간단한 만큼 ida 같은 리버싱 툴로 코드를 nop으로 패치하여 쉽게 우회할 수 있다.

자, 다음은 예시 코드이다.

1. Example

__int64 sub_17D0()
{
  int v0; // edx
  __int64 result; // rax

  if ( time(0LL) <= 1909094399 )
  {
    puts("Not yet !!! Please wait more time.");
    exit(0);
  }
  v0 = 1234 * (ptrace(PTRACE_TRACEME, 29808LL, 24946LL, 25955LL) + 1);
  result = v0 ^ (unsigned int)(unsigned __int16)word_4048;  // word_4048의 값은 1234이다.
  word_4048 ^= v0;
  return result;
}

ptracePTRACE_TRACEME를 사용하여 디버거를 탐지하고 있다.

  • 디버깅 되고 있음 : -1 반환 → 1234 * (-1+1) == 0
  • 디버깅 안 되고 있음 : 0 반환 → 1234 * (0+1) == 1234

따라서, 정상적인 프로그램 실행에서는 1234 ^ 1234가 되어 word_4048의 값은 0이 된다.

하지만 디버깅이 되고 있는 실행에서는 0 ^ 1234가 되어 변수의 값이 정상적인 실행과 달라지고, 프로그램 실행에 다른 결과를 만든다.

디버거를 방지하기 위해서 프로그램에 제일 처음에 탐지를 해야지 확실하게 막을 수 있다.

따라서 위의 sub_17D0과 같이 디버깅을 탐지하는 함수는 보통 main 함수보다 먼저 실행되는 .init_array에 정의 되어있다. ida에서 G를 누르고 .init_array라고 입력을 하면 볼 수 있다.

.init_array:0000000000003D40 _init_array     segment qword public 'DATA' use64
.init_array:0000000000003D40                 assume cs:_init_array
.init_array:0000000000003D40                 ;org 3D40h
.init_array:0000000000003D40 off_3D40        dq offset sub_1300      ; DATA XREF: LOAD:0000000000000168↑o
.init_array:0000000000003D40                                         ; LOAD:00000000000002F0↑o ...
.init_array:0000000000003D48                 dq offset **sub_17D0**
.init_array:0000000000003D48 _init_array     ends

2. Practice

자, 그러다면 한 번 실습을 해보자.

프로그래밍을 할 때 다음과 같이 코드를 짠다면 .init_arrayptrace를 사용하여 디버거를 추적하는 프로그램을 만들 수 있다.

코드

// g++ -o detectTest detectTest.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <unistd.h>

void function() __attribute__((constructor));

void function() {
    if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
        puts("Debugger detected!");
        exit(1);
    } else {
        puts("No debugger detected.");
    }
}

int main() {
    puts("Program running!");
    return 0;
}

이제 실행을 해본다면 아래와 같다.

일반 실행

➜  ~ ./detectTest
**No debugger detected.**
**Program running!**
➜  ~ 

디버거로 실행(pwndbg)

➜  ~ gdb ./detectTest
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
..(처음 메세지 생략)..
pwndbg> r
Starting program: /home/xdfyrj/detectTest
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
**Debugger detected!**
[Inferior 1 (process 26915) exited with code 01]
pwndbg> 

잘 되는 것을 볼 수 있다.

3. Practice probs

  1. Times
  2. Theori of Relativity

[0x4] Usage 2: Process control

사실 지금부터가 ptrace의 본 기능이자 주요 기능이다.

프로세스를 추적하고, 제어하는 기능을 Chip-VM:DBPC와 함께 알아보도록 하자.

1. Creating the process with fork

일단, 첫 번째로 fork()를 사용해서 프로세스를 생성 해보자.

fork() 함수의 설명은 아래와 같다.

  • 헤더 파일 : unistd.h
  • fork 성공부모프로세스 : 자식 프로세스 pid 반환 || 자식프로세스 : 0 반환
  • fork 실패 → -1 반환

다음은 위 설명과 같은 fork()의 프로세스 생성을 알아볼 수 있는 간단한 예제 코드와 그 결과이다.

// g++ -o forkTest forkTest.c
#include <stdio.h>
#include <unistd.h>

int main() {
    int a, b;
    a = 10, b=15;
    int pid = fork();
    a+=b;
    printf("PID: %d | %d || %d %d\n", getpid(), pid, a, b);
    return 0;
}                                    
➜  ~ ./forkTest
PID: **5772** | **5773** || 25 15
PID: **5773** | **0** || 25 15
➜  ~ 

첫 번째 줄 결과는 부모 프로세스의 실행 결과이고, 두 번째 줄 결과는 자식 프로세스의 실행 결과이다.

부모 프로세스에서 fork()의 리턴값은 자식 프로세스 pid이고, 자식 프로세스에서는 0인 것을 확인할 수 있다. 또 다른 코드들이 두 번 실행되는 것을 확인 할 수 있다.(fork 함수 제외)

이 사실을 이용하여 아래와 같이 코드를 짜면, 부모 프로세스와 자식 프로세스의 실행 코드를 다르게 만들 수 있다.

v4 = fork();
if ( v4 >= 0 )
{
  if ( !v4 )  // pid==0일 때 == 자식 프로세스일 때.
    sub_1CD4();
  sub_1493((unsigned int)v4, a2[1]);  // 부모 프로세스일 때.
  return 0LL;
}
else  // fork() 실패시 -1 반환.
{
  perror("fork failed");
  return 1LL;
}

2. Write data to the memory of the process

프로세스를 생성하고 실행코드가 다르도록 만들었으니, 이제 데이터를 작성 해볼 것이다. 우선 자식 프로세스에서 ptrace(TRACE_ME, 0, NULL, NULL)을 해줌으로써 부모 프로세스가 디버거 역할을 할 수 있도록 해줘야 한다.

PTRACE_PEEKDATA, PTRACE_POKEDATA 이 2개의 ptrace request로 데이터를 읽고 작성할 수 있다.

// g++ -o dataTest dataTest.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <errno.h>

int main() {
    pid_t child;
    long data;
    long modified_data = 0x12345678;
    int status;

    child = fork();
    if (child == -1) {
        perror("fork failed");
        exit(1);
    }
    if (child == 0) {  // 자식 프로세스
        printf("Child: My PID is %d\n", getpid());
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);

        int target_data = 0xdeadbeef;
        printf("Child: Original data at %p is 0x%x\n", &target_data, target_data);

        raise(SIGSTOP)
        printf("Child: Resuming execution\n");
        printf("Child: Modified data at %p is 0x%x\n", &target_data, target_data);
        exit(0);
    } 
    else {  // 부모 프로세스
        waitpid(child, &status, 0)
        if (WIFSTOPPED(status)) {
            printf("Parent: Child stopped, attaching...\n");

            void *addr = (void *)&data;
            data = ptrace(PTRACE_PEEKDATA, child, addr, NULL);
            if (data == -1 && errno) {
                perror("PTRACE_PEEKDATA failed");
                ptrace(PTRACE_DETACH, child, NULL, NULL);
                exit(1);
            }
            printf("Parent: Read data at %p: 0x%lx\n", addr, data);

            if (ptrace(PTRACE_POKEDATA, child, addr, (void *)modified_data) == -1) {
                perror("PTRACE_POKEDATA failed");
                ptrace(PTRACE_DETACH, child, NULL, NULL);
                exit(1);
            }
            printf("Parent: Modified data at %p to 0x%lx\n", addr, modified_data);

            ptrace(PTRACE_CONT, child, NULL, NULL);
        }
        waitpid(child, &status, 0);
        if (WIFEXITED(status)) {
            printf("Parent: Child exited.\n");
        }
    }
    return 0;
}
➜  ~ ./dataTest
Child: My PID is 8045
Child: Original data at 0x7ffe7dc00280 is 0xdeadbeef
Parent: Child stopped, attaching...
Parent: Read data at 0x7ffe7dc00280: 0xdeadbeef
Parent: Modified data at 0x7ffe7dc00280 to 0x12345678
Child: Resuming execution
Child: Modified data at 0x7ffe7dc00280 is 0x12345678
Parent: Child exited.
➜  ~ 

위 코드를 잘 읽어보면, 자식 프로세스의 &target_data == &data인 것을 볼 수 있다.

부모 자식 프로세스는 fork() 후 가상메모리를 공유한다.

저런 식으로 PTRACE_PEEKDATAPTRACE_POKEDATA를 이용해서 자식 프로세스의 메모리에 데이터를 작성할 수 있다. 이를 이용한 활용은 다음과 같다.

// 부모 프로세스
v17 = qword_5020[v23];
v16 = v10 + 8;
if ( ptrace(PTRACE_POKEDATA, a3, v10 + 8, v14) == -1 )  // 인자 작성
{
  perror("ptrace pokedata");
  sub_145C(a3);
}
if ( ptrace(PTRACE_POKEDATA, a3, v16 + 8, v12) == -1 )  // 인자 작성
{
  perror("ptrace pokedata");
  sub_145C(a3);
}
if ( ptrace(PTRACE_POKEDATA, a3, v16 + 16, v17) == -1 )  // 함수 작성
{
  perror("ptrace pokedata");
  sub_145C(a3);
}
if ( ptrace(PTRACE_POKEDATA, a3, v16 + 24, v13) == -1 )  // 인자 작성
{
  perror("ptrace pokedata");
  sub_145C(a3);
}
if ( ptrace(PTRACE_POKEDATA, a3, &qword_50E0, v11) == -1 )  // 전역 변수 수정
{
  perror("ptrace pokedata");
  sub_145C(a3);
}
// 자식 프로세스
__int64 __fastcall sub_1D5D(__int64 a1)
{
  __int64 v2; // [rsp+18h] [rbp-8h]

  if ( *(_QWORD *)a1 )
  {
    --*(_QWORD *)a1;
    sub_1D5D(a1);
  }
  else if ( qword_50E0 )
  {
    if ( *(_QWORD *)(a1 + 8) )
      return (*(__int64 (__fastcall **)(_QWORD, _QWORD))(a1 + 16))(*(_QWORD *)(a1 + 24), *(_QWORD *)(a1 + 8));
    else
      return (*(__int64 (__fastcall **)(_QWORD))(a1 + 16))(*(_QWORD *)(a1 + 24));
  }
  else if ( *(_QWORD *)(a1 + 8) )
  {
    (*(void (__fastcall **)(_QWORD, _QWORD))(a1 + 16))(*(_QWORD *)(a1 + 24), *(_QWORD *)(a1 + 8));
  }
  else
  {
    (*(void (__fastcall **)(_QWORD))(a1 + 16))(*(_QWORD *)(a1 + 24));
  }
  return v2;
}

qword_5020가 함수 offset이다. 인덱스 접근을 하여 v17에 함수를 저장하고, 그 위의 코드들이 다른 인자들을 작성한다. 자식 프로세스에서 함수로 사용되는 주소가 a1 + 16이므로 v17이 들어가는 v16 + 16이 그 주소와 같은 것을 알 수 있으므로 a1 == v16이라고 분석 가능하다.

이런 식으로 자식 프로세스의 메모리에 데이터를 작성하면 자식 프로세스에서 실행되는 함수와 인자들을 바꿀 수 있고, 그렇다면 보다 유동적으로 프로세스를 제어할 수 있다.

➜  chipvm ./chip_vm flag_checker.chip
load chip file successed.
Booting Chip VM...
This is a chip-vm based flag checker. len(flag) is 40. The flag validation process takes about a minute or two.
flag: 

chip_vm 문제 파일에서는 printf 함수를 실행하는 코드는 없지만 flag_checker.chip에 있는 데이터로 자식 프로세스가 제어당하여 실행되는 모습이다.

[0x5] Conclusion

ptrace 함수에 대해서 간단하게 알아보았다. 데이터 작성 부분에서 주소값이 같고 그것을 이용해 데이터를 수정하는 점이 굉장히 low-level이라 어려웠다. 계속 여러가지 프로그램을 짜고 실행해보며 결과를 봐서 이해를 해봐야겠다.

아직 난 Chip-VM을 못 풀었지만, ptrace를 배우며 이해되고, 신기하다고 느끼는 점이 많았다. low-level을 배워보는 것이 아주 어렵고 재밌었다.

코드를 직접 작성하여 실행해보고 ptrace 함수에 대해서 탐구하는 것도 재밌었다.
다른 코드도 열심히 분석하여 Chip-VM을 풀어보도록 하겠다.

이상이다.