Ptrace--Linux中一種程式碼注入技術的應用
Ptrace–Linux中一種程式碼注入技術的應用
在以往的工作中,曾遇到以下需求:可以隨意的開啟或是遮蔽已執行程序的輸出。
通過查詢相關部落格以及開源專案,最終選擇ptrace
作為最終的實現手段。從理解到最終應用的過程中,以下部落格:Playing with ptrace (作者:Pradeep Padaia)起到了答疑解惑的作用,本文介紹了該博文的主要內容,並在此基礎上加入了個人的一些理解與修改。
此外,原文程式碼是在i386上執行,而本人所用為64位機,因此對原有程式碼進行了移植。
一、摘要
你曾對系統呼叫是怎樣被中斷的而感到好奇麼?你曾嘗試通過改變系統呼叫的引數,來愚弄核心麼?你曾經想過偵錯程式是如何暫停正在執行的程序,轉而由你獲得控制權麼?
如果你正在嘗試用複雜的核心程式設計來完成任務,可以重新考慮使用Linux提供的一種優雅的機制——ptrace
ptrace
提供了一種使得 Tracer 可以觀察並控制 Tracee 的機制。該機制可以檢測並改變 Tracee 的核心映象以及暫存器,其主要被用來實現斷點除錯以及對系統呼叫的追蹤。
在本篇文章中,我們將會了解如何阻止一個系統呼叫,同時改變它的引數。而在下篇文章中,我們將學習更高階的技術——向一個執行中的程式中設定斷點或是注入一段程式碼。如此一來,我們就可以一窺 Tracee 的暫存器以及資料段,並修改內容。此外,本文也會介紹使得 Tracee 可以被停止並執行任意指令的方式。
二、基礎
作業系統通過一組被稱為系統呼叫的標準機制來提供服務。系統呼叫提供了標準的API用以訪問底層的硬體及服務(例如檔案系統)。當一個程序想要執行一個系統呼叫時,它將系統呼叫的引數傳入暫存器,並呼叫0x80軟中斷。該軟中斷就像是進入核心模式的一扇大門,核心會在檢測完引數後,執行相應的系統呼叫。在i386架構上,系統呼叫號是被放在%eax
%ebx,%ecx, %edx, %esi
以及%edi
。例如:
write(2, "Hello", 5)
大致會轉化為以下彙編語句:
movl $4, %eax //在i386中__NR_write的系統呼叫號是4
movl $2, %ebx
movl $hello,%ecx
movl $5, %edx
int $0x80
其中 $hello
指向了字串 "Hello"
。
那麼 ptrace
應出現在哪裡呢?在執行系統呼叫之前,核心會檢測該程序是否被追蹤。對於已被追蹤的程式—— Tracee ,核心會暫停該程式,並將控制權給到 Tracer
如果是64位系統則有所不同, 使用者層的應用使用暫存器
%rdi
,%rsi
,%rdx
,%rcx
,%r8
以及%r9
來傳參,而核心介面用%rdi
,%rsi
,%rdx
,%r10
,%r8
以及%r10
來傳參. 並且用syscall指令而不是80軟中斷來進行系統呼叫. 相同之處是都用暫存器%rax
來儲存呼叫號和返回值. 更多關於32位和64位彙編指令的區別可以參考stack overflow關於[What are the calling conventions for UNIX & Linux system calls on i386 and x86-64]的總結, 因為我當前環境是64位Linux,所以下文的操作都以64位系統為例. ——Linux Hook 筆記
我們通過一個簡單的例子來闡明其是怎樣工作的:
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/reg.h> /* For constants ORIG_RAX etc */
#include <stdio.h>
int main()
{ pid_t child;
long orig_rax;
child = fork();
if(child == 0)
{
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
printf("Try to call: execl\n");
execl("/bin/ls", "ls", NULL);
printf("child exit\n");
}
else
{
wait(NULL);
orig_rax = ptrace(PTRACE_PEEKUSER,
child, 8 * ORIG_RAX,
NULL);
printf("The child made a "
"system call %ld\n", orig_rax);
ptrace(PTRACE_CONT, child, NULL, NULL);
printf("Try to call:ptrace\n");
}
return 0;
}
程式輸出如下: Try to call: execl The child made a system call 59 Try to call:ptrace Press to close this window… main.o Makefile Ptarce。
注: 1.原始碼在移植到64位機時進行了修改,具體原因見:將ptrace移植到64位機
系統呼叫59是指exeve,它是子程序執行的第一個系統呼叫。作為參考,64位機的系統呼叫號可以在 /usr/include/asm/unistd_64.h
路徑下找到。
正如你在例子中看到的,父程序fork
了一個子程序,由子程序執行了我們想要追蹤的程序。在執行execl()
前,子程序呼叫了ptrace
(使用PTRACE_TRACEM
作為第一個引數)。該操作告訴核心,這個程序將會被追蹤,而當子程序執行execv
系統呼叫時,它就將控制權移交給了它的父程序(父程序通過wiat()
呼叫獲取通知)。
接下來,父程序就可以檢測子程序系統呼叫的引數,或是做些其它的事情,例如檢視子程序的暫存器等等。
當系統呼叫發生的時候,核心將%eax
暫存器中原始的系統呼叫號儲存下來。正如上邊所展示的那樣,我們可以呼叫ptrace
(將PTRACE_TRACEME
作為第一個引數)從子程序的使用者段中讀出該值。
當我們檢查完系統呼叫後,父程序可以呼叫ptrace
(將PTRACE_CONT
作為第一個引數),使子程序得以繼續執行。
在系統呼叫追蹤中,常見的流程如下圖所示:
三、ptrace 引數
ptrace
的函式宣告如下:
long ptrace(enum __ptrace_request request,
pid_t pid,
void *addr,
void *data);
第一個引數決定了code
的行為以及後續的引數是如何被使用的。具體的值可以是以下中的一個:
第一個引數 | 說明 |
---|---|
PTRACE_TRACEME | 指明該程序將要被其父程序追蹤。後續的引數可以被忽略。 該請求只可以被 Tracee 使用;剩餘的請求則只能被Tracer使用。其餘的請求中,pid 指定了 Tracee 的程序ID。 |
PTRACE_PEEKTEXT | 從 Tracee 儲存空間中的addr 地址處讀取一個字,並將其作為返回值。由於Linux不區分文字與資料段的地址空間,因此PTRACE_PEEKTEXT ,PTRACE_PEEKTEXT 這兩個請求無異(data 將被忽略)。 |
PTRACE_PEEKDATA | 同上 |
PTRACE_PEEKUSER | 在 Tracee 使用者空間(儲存了關於程序的暫存器,以及其他資訊)的偏移地址——addr 處讀取一個字。該值將作為結果返回。因架構而異,偏移地址addr 通常是是字對齊的(data 將被忽略)。 |
PTRACE_POKETEXT | 將一個字長的資料data 複製到 Tracee 儲存空間中的地址addr 去。由於Linux不區分文字與資料段的地址空間,PTRACE_PEEKTEXT ,PTRACE_PEEKTEXT 因此這兩個請求無異 |
PTRACE_POKEDATA | 同上 |
PTRACE_POKEUSER | 將一個字長的資料data 複製到 Tracee 使用者空間的偏移地址addr 處去。對於該請求而言,偏移地址addr 通常是要求位元組對其的。 為了維護核心的完整性,針對使用者區的某些修改,通常是禁止的。 |
PTRACE_GETREGS | 複製 Tracee 的通用暫存器內容給data 。具體的資料格式可以參考<sys/user.h> (其中addr 是被忽略的)。 注意:在SPARC系統中data 和addr 的語意是反轉的——data 被忽略,暫存器資料被儲存在addr 地址處。PTRACE_GETREGS 以及PTRACE_GETFPREGS 並非在所有的架構上都有實現。 |
PTRACE_GETFPREGS | 用法與PTRACE_GETREGS 相同,只是取 Tracee 浮點暫存器的值。 |
PTRACE_SETREGS | 取 Tracer data 地址處的資料,用以修改 Tracee 通用暫存器的值。至於PTRACE_POKEUSER ,針對一些通用暫存器的修改可能是禁止的(addr 是被忽略的)。 注意:在SPARC系統中data 和addr 的語意是反轉的——data 被忽略,Tracee 的暫存器從addr 地址處獲取資料。PTRACE_SETREGS 與PTRACE_SETFPREGS 並非在所有的裝置上都有實現。 |
PTRACE_SETFPREGS | 用法與PTRACE_SETREGS 相同,只是修改 Tracee 浮點暫存器的值。 |
PTRACE_CONT | 重新啟動已被停止的 Tracee —— 使其繼續執行。如果data 是非空的,它被解釋為要傳送給 Tracee 的訊號,否則,不傳送任何訊號。換句話說,Tracer 可以控制是否要傳送一個訊號給到 Tracee (addr 是被忽略的)。 |
PTRACE_SYSCALL、PTRACE_SINGLESTEP | 像PTRACE_CONT 一樣,使得已經停止的 Tracee 重新執行,但是 Tracee 會在每次進入或是離開一個系統呼叫,或是執行完一條訊號指令後被停止。 從 Tracer 的角度而言, Tracee 就如同受到了SIGTRAP 被停止了一樣。因此PTRACE_SYSCALL 一種用法是:在進入系統呼叫時檢測引數;在離開此次呼叫時,檢測其返回值。data 引數的用法與PTRACE_CONT 中的一致(addr 是被忽略的)。 |
PTRACE_DETACH | 像PTRACE_CONT 一樣,使 Tracee 繼續執行,但會使 Tracee 脫離被追蹤狀態。在Linux中,無論是使用何種方式建立追蹤狀態的,都可以通過此方式使 Tracee 擺脫追蹤狀態(addr 是被忽略的)。 |
四、讀取系統呼叫引數
使用PTRACE_PEEKUSER
作為呼叫ptrace
的第一個引數,我們能夠檢測包含暫存器內容以及其它資訊的使用者區域——USER area
。核心將暫存器資訊儲存在該區域,以便父程序(Tracer)能夠通過ptrace
檢測它。
具體的使用方式詳見下例:
#include <sys/wait.h>
#include <unistd.h> /* For fork() */
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h> /* For constants ORIG_RAX etc */
#include <sys/user.h>
#include <sys/syscall.h> /* SYS_write */
#include <stdio.h>
int main() {
pid_t child;
long orig_rax;
int status;
int iscalling = 0;
struct user_regs_struct regs;
child = fork();
if(child == 0)
{
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", "-l", "-h", NULL);
}
else
{
while(1)
{
wait(&status);
if(WIFEXITED(status))
{
break;
}
orig_rax = ptrace(PTRACE_PEEKUSER,
child, 8 * ORIG_RAX,
NULL);
if(orig_rax == SYS_write)
{
ptrace(PTRACE_GETREGS, child, NULL, ®s); //獲取暫存器引數
if(!iscalling) //進入系統呼叫
{
iscalling = 1;
printf("[Enter SYS_write call] with regs.rdi [%ld], regs.rsi[%ld], regs.rdx[%ld], regs.rax[%ld], regs.orig_rax[%ld]\n",
regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax);
}
else //離開此次系統呼叫
{
printf("[Leave SYS_write call] return regs.rax [%ld], regs.orig_rax [%ld]\n", regs.rax, regs.orig_rax);
iscalling = 0;
}
}
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
}
}
return 0;
}
程式的輸出如下所示:
[Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[10], regs.rax[-38], regs.orig_rax[1]
total 40K
[Leave SYS_write call] return regs.rax [10], regs.orig_rax [1]
[Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[49], regs.rax[-38], regs.orig_rax[1]
-rw-r–r--. 1 root root 7.5K Oct 7 16:56 main.o
[Leave SYS_write call] return regs.rax [49], regs.orig_rax [1]
[Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[51], regs.rax[-38], regs.orig_rax[1]
-rw-r–r--. 1 root root 17K Oct 6 16:58 Makefile
[Leave SYS_write call] return regs.rax [51], regs.orig_rax [1]
[Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[49], regs.rax[-38], regs.orig_rax[1]
-rwxr-xr-x. 1 root root 11K Oct 7 16:56 Ptarce
[Leave SYS_write call] return regs.rax [49], regs.orig_rax [1]
Press to close this window…
在以上程式中,我們追蹤了 Tracee 的write
系統呼叫,可以看到在此次的ls -l -h
命令中,共發生了四次write
呼叫。在讀取暫存器的時候,我們可以使用之前介紹的PTRACE_PEEKUSER
引數來獲取某個暫存器的值,也可以直接使用PTRACE_GETREGS
請求,將所有暫存器的值讀到結構體user_regs_struct
中,該結構體的定義在sys/user.h
中。
在上例中,從wait
呼叫獲取的狀態值被用來檢測 Tracee 是否已經退出。這是用來檢測程序是被ptarce
停止還是退出的典型用法。關於WIFEXITED
巨集的用法,可以參見the wait(2) man page。
需要引起我們注意的一點是:從程式的返回值中我們可以發現,無論是在進入還是退出系統呼叫,%orig_rax
暫存器中儲存的都是系統呼叫號,而 %.rax
則是在呼叫返回時,儲存了返回值。關於該內容具體的介紹,詳見Why is orig_eax provided in addition to eax?。文中主要講解了在32位機中已經有了 %eax
暫存器的情況下,為何還需要 %orig_eax
。這兩個暫存器其實就對應了64位機中的 %rax
以及 %orig_rax
,以下是援引文中的一段話:
Ptrace needs to be able to read both all registers state before syscall and the return value of syscall; but the return value is written to %eax. Then original eax, used before syscall will be lost. To save it, there is a orig_eax field. 系統希望
ptrace
能夠讀取系統呼叫前各暫存器的值,同時包括呼叫的返回值;但是在系統呼叫後,返回值被寫在了%eax
暫存器中,而原來寫在%eax
中的系統呼叫號,則被丟棄了。為了能夠儲存呼叫號,因此才有了%orig_eax
——Why is orig_eax provided in addition to eax?
五、一些有趣的嘗試
現在是時候找點樂子了。在接下來的例子中,我們會嘗試將傳給write
系統呼叫的字串進行倒置。
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <sys/reg.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
using namespace std;
const int long_size = sizeof(long);
void reverse(char *str)
{ int i, j;
char temp;
for(i = 0, j = strlen(str) - 2; //跳過結尾的 '\0'
i <= j; ++i, --j)
{
temp = str[i];
str[i] = str[j];
str[j] = temp;
}
}
void getdata(pid_t child, long addr,
char *str, int len)
{ char *laddr;
int i, j;
union u
{
long val;
char chars[long_size];
}data;
i = 0;
j = len / long_size;
laddr = str;
while(i < j)
{
data.val = ptrace(PTRACE_PEEKDATA,
child, addr + i * 8,
NULL); //從響應的資料段取出資料
memcpy(laddr, data.chars, long_size);
++i;
laddr += long_size;
}
j = len % long_size;
if(j != 0)
{
data.val = ptrace(PTRACE_PEEKDATA,
child, addr + i * 8,
NULL);
memcpy(laddr, data.chars, j);
}
str[len] = '\0';
}
void putdata(pid_t child, long addr,
char *str, int len)
{ char *laddr;
int i, j;
union u
{
long val;
char chars[long_size];
}data;
i = 0;
j = len / long_size;
laddr = str;
while(i < j)
{
memcpy(data.chars, laddr, long_size);
ptrace(PTRACE_POKEDATA, child,
addr + i * 8, data.val);
++i;
laddr += long_size;
}
j = len % long_size;
if(j != 0)
{
memcpy(data.chars, laddr, j);
ptrace(PTRACE_POKEDATA, child,
addr + i * 8, data.val);
}
}
int main()
{
pid_t child;
printf("******Get Arch Info Begin******\n");
if ( -1 == system("cat /proc/version "))
{
exit(-1);
}
printf("******Get Arch Info End******\n");
printf("sizeof(long) is [%d]\n", sizeof(long));
child = fork();
if(child == 0)
{
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execlp("/bin/ls", "ls", NULL);
printf("child exit\n");
}
else
{
long orig_rax;
long params[3];
int status;
char *str, *laddr;
int toggle = 0; //用來判斷是進入還是離開系統呼叫
while(1)
{
wait(&status);
if(WIFEXITED(status)) //子程序是否退出
{
break;
}
orig_rax = ptrace(PTRACE_PEEKUSER,
child, 8 * ORIG_RAX,
NULL); //獲取系統呼叫號
if(orig_rax == SYS_write)
{
if(toggle == 0)
{
toggle = 1;
//獲取傳遞給系統呼叫的引數:正如前文所介紹的X86_64而言,使用者層的應用使用%rdi,%rsi,%rdx暫存器,依次儲存系統呼叫的引數
params[0] = ptrace(PTRACE_PEEKUSER,
child, 8 * RDI,
NULL);
params[1] = ptrace(PTRACE_PEEKUSER,
child, 8 * RSI,
NULL);
params[2] = ptrace(PTRACE_PEEKUSER,
child, 8 * RDX,
NULL);
printf("[Enter SYS_write call] with regs.rdi [%ld], regs.rsi[0X%X], regs.rdx[%ld], regs.orig_rax[%ld]\n",
params[0], params[1], params[2],orig_rax);
str = (char *)calloc((params[2]+1), sizeof(char));
getdata(child, params[1], str,
params[2]);
printf("Original str is: [%s]\n", str);
reverse(str);
putdata(child, params[1], str,
params[2]);
}
else
{
toggle = 0;
}
}
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
}
}
return 0;
}
程式的輸出如下所示:
Get Arch Info Begin Linux version 2.6.32-431.el6.x86_64 ([email protected]) (gcc version 4.4.7 20120313 (Red Hat 4.4.7-4) (GCC) ) #1 SMP Fri Nov 22 03:15:09 UTC 2013 Get Arch Info End sizeof(long) is [8] [Enter SYS_write call] with regs.rdi [1], regs.rsi[0X994CE000], regs.rdx[54], regs.orig_rax[1] Original str is: [Eclipse hello.asm Reverse Reverse.cpp~ Test.cpp ] ppc.tseT ~ppc.esreveR esreveR msa.olleh espilcE [Enter SYS_write call] with regs.rdi [1], regs.rsi[0X994CE000], regs.rdx[70], regs.orig_rax[1] Original str is: [gnome-terminal.desktop hello.asm~ Reverse.cpp Test Test.cpp~ ] ~ppc.tseT tseT ppc.esreveR ~msa.olleh potksed.lanimret-emong
該示例程式在使用了前文已討論的概念的同時,還使用了其它一些概念。我們使用PTRACE_POKEDATA
作為引數來呼叫ptrace
,用以改變 Tracee 資料段的值;使用PTRACE_PEEKDATA
作為引數來呼叫ptrace
用以獲取 Tracee 資料段的值。
六、單步除錯
ptrace
提供了單步除錯 Tracee 程式碼的功能。呼叫ptrace
(使用PTRACE_SINGLESTEP
作為引數)告知核心:在 Tracee 執行每條指令時,都被停止,並讓 Tracer 獲得控制權。下述例程展示了一種讀取正在執行的指令的方式。
為了讓讀者更好的理解發生了什麼事情,以一個簡單的程式作為被除錯的程式:
#include <stdio.h>
int main(void)
{
printf("Hello World\n");
return 0;
}
原文此處是使用一段彙編程式碼進行舉例,但是對應彙編無法在x86_64上執行,因此此處使用相似的C程式代替。 我們使用下述程式碼對其進行單步除錯:
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <string.h>
int main()
{ pid_t child;
const int long_size = sizeof(long);
child = fork();
if(child == 0)
{
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("./hello", "hello", NULL);
}
else {
int status;
union u
{
long val;
char chars[long_size];
}data;
struct user_regs_struct regs;
int start = 0;
long ins;
while(1)
{
wait(&status);
if(WIFEXITED(status))
{
break;
}
ptrace(PTRACE_GETREGS,
child, NULL, ®s);
if(start == 1)
{
ins = ptrace(PTRACE_PEEKTEXT,
child, regs.rip,
NULL);
printf("RIP: %lx Instruction "
"executed: %lx\n",
regs.rip, ins);
}
//%orig_rax儲存了系統呼叫號
if(regs.orig_rax == SYS_write)
{
start = 1;
ptrace(PTRACE_SINGLESTEP, child,
NULL, NULL);
}
else
{
ptrace(PTRACE_SYSCALL, child,
NULL, NULL);
}
}
}
return 0;
}
執行後,程式的輸出如下所示:
Hello World
RIP: 3ac1adb790 Instruction executed: 3173fffff0013d48
RIP: 3ac1adb796 Instruction executed: e808ec8348c33173
RIP: 3ac1aad028 Instruction executed: e076fffff0003d48
程式中對於%RIP
指標的用法,本人還不夠了解,此處僅貼出相應程式碼(在x86_64上測試通過),具體的原理,還有待進一步查證。
倘若想要理解指令的意思,可能需要我們去檢視相應的使用者手冊。要想對複雜的程式實現單步除錯,則相求更周密的設計以及更復雜的程式碼。
以上即為本篇文章第一部分的內容。在下篇文章中,我們將瞭解如何插入斷點,以及在正在執行的程式中注入一段程式碼。
上述所有的程式都在x86_64上測試通過。本人所學有限,對於文中翻譯或是解釋不當之處,希望各位批評指正,感謝。
參考與連結
文件資訊
註釋
Tracer:追蹤程序 Tracee:被追蹤,被觀察程序
相關文章
暫無