(간단한 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;
}
ptrace
의 PTRACE_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_array
에 ptrace
를 사용하여 디버거를 추적하는 프로그램을 만들 수 있다.
코드
// 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
[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_PEEKDATA
와 PTRACE_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을 풀어보도록 하겠다.
이상이다.