unLimit安全小組 · 2016/06/20 14:38
Author:超六、曲和
0x00 時間相關反除錯
通過計算某部分程式碼的執行時間差來判斷是否被除錯,在Linux核心下可以通過time、gettimeofday,或者直接通過sys call來獲取當前時間。另外,還可以通過自定義SIGALRM訊號來判斷程式執行是否超時。
0x01 檢測關鍵檔案
(1)/proc/pid/status、/proc/pid/task/pid/status
在除錯狀態下,Linux核心會向某些檔案寫入一些程式狀態的資訊,比如向/proc/pid/status或/proc/pid/task/pid/status檔案的TracerPid欄位寫入除錯程式的pid,在該檔案的statue欄位中寫入t(tracing stop):
(2)/proc/pid/stat、/proc/pid/task/pid/stat
除錯狀態下/proc/pid/stat、/proc/pid/task/pid/stat檔案中第二個欄位是t(T):
(3)/proc/pid/wchan、/proc/pid/task/pid/wchan
若程式被除錯,也會往/proc/pid/wchan、/proc/pid/task/pid/wchan檔案中寫入ptrace_stop。
0x02 檢測埠號
使用IDA動態除錯APK時,android_server預設監聽23946埠,所以通過檢測埠號可以起到一定的反除錯作用。具體而言,可以通過檢測/proc/net/tcp檔案,或者直接system執行命令netstat -apn
等。
0x03 檢測android_server、gdb、gdbserver
在對APK進行動態除錯時,可能會開啟android_server、gdb、gdbserver等除錯相關程式,一般情況下,這幾個開啟的程式名和檔名相同,所以可以通過執行狀態下的程式名來檢測這些除錯相關程式。具體而言,可以通過開啟/proc/pid/cmdline、/proc/pid/statue等檔案來獲取程式名。當然,這種檢測方法非常容易繞過――直接修改android_server、gdb、gdbserver的名字即可。
0x04 signal
訊號機制在apk除錯攻防中有著非常重要的作用,大部分主流加固廠商都會通過訊號機制來增加殼的強度。在反除錯中最常見的要數SIGTRAP訊號了,SIGTRAP原本是偵錯程式設定斷點時發出的訊號,為了能更好的理解SIGTRAP訊號反除錯,先讓我們看看一下偵錯程式設定斷點的原理:
和x86架構類似,arm架構下偵錯程式設定斷點先要完成兩件事:
- 儲存目標地址上的資料
- 將目標地址上頭幾個位元組替換成arm/thumb下的breakpoint指令
Arm架構下各類指令集breakpoint機器碼如下:
指令集 | Breakpoint機器碼(little endian) |
---|---|
Arm | 0x01, 0x00, 0x9f, 0xef |
Thumb | 0x01, 0xde |
Thumb2 | 0xf0, 0xf7, 0x00, 0xa0 |
偵錯程式設定完斷點之後程式繼續執行,直至命中斷點,觸發breakpoint,這時程式向作業系統傳送SIGTRAP訊號。偵錯程式收到SIGTRAP訊號後,會繼續完成以下幾件事:
- 在目標地址上用原來的指令替換之前的breakpoint指令
- 回退被跟蹤程式的當前pc值
當控制權回到原程式時,pc就恰好指向了斷點所在位置,這就是偵錯程式設定斷點的基本原理。在知道上述原理之後,再讓我們繼續分析SIGTRAP反除錯的細節,如果我們在程式中間插入一條breakpoint指令,而不做其他處理的話,作業系統會用原來的指令替換breakpoint指令,然而這個breakpoint是我們自定義插入的,該地址上並不存在原指令,所以作業系統就跳過這個步驟,進入下一步回退pc值,即breakpoint的前一條指令。這時就出現問題了,下一條指令還是breakpoint指令,這也就造成了無限迴圈。
為了能繼續正常執行,就需要模擬偵錯程式的操作――替換breakpoint指令,而完成這個步驟的最佳時機就是在自定義signal的handle中。Talk is cheap,show me the code,下面給出此原理的簡單例項:
#!cpp
char dynamic_ccode[] = {0x1f,0xb4, //push {r0-r4}
0x01,0xde, //breakpoint
0x1f,0xbc, //pop {r0-r4}
0xf7,0x46};//mov pc,lr
char *g_addr = 0;
void my_sigtrap(int sig){
char change_bkp[] = {0x00,0x46}; //mov r0,r0
memcpy(g_addr+2,change_bkp,2);
__clear_cache((void*)g_addr,(void*)(g_addr+8)); // need to clear cache
LOGI("chang bpk to nop\n");
}
void anti4(){//SIGTRAP
int ret,size;
char *addr,*tmpaddr;
signal(SIGTRAP,my_sigtrap);
addr = (char*)malloc(PAGESIZE*2);
memset(addr,0,PAGESIZE*2);
g_addr = (char *)(((int) addr + PAGESIZE-1) & ~(PAGESIZE-1));
LOGI("addr: %p ,g_addr : %p\n",addr,g_addr);
ret = mprotect(g_addr,PAGESIZE,PROT_READ|PROT_WRITE|PROT_EXEC);
if(ret!=0)
{
LOGI("mprotect error\n");
return ;
}
size = 8;
memcpy(g_addr,dynamic_ccode,size);
__clear_cache((void*)g_addr,(void*)(g_addr+size)); // need to clear cache
__asm__("push {r0-r4,lr}\n\t"
"mov r0,pc\n\t" //此時pc指向後兩條指令
"add r0,r0,#4\n\t"//+4 是的lr 地址為 pop{r0-r5}
"mov lr,r0\n\t"
"mov pc,%0\n\t"
"pop {r0-r5}\n\t"
"mov lr,r5\n\t" //恢復lr
:
:"r"(g_addr)
:);
LOGI("hi, i'm here\n");
free(addr);
}
複製程式碼
在程式碼中主動觸發breakpoint指令,然後在自定義SIGTRAP handle中將breakpoint替換成nop指令,於是程式可以正常執行完畢。
其中可使用r_debug-r_brk來觸發異常,其原理即是用到了linker中一些除錯特性。Linker中有一個和除錯相關的結構體r_debug,其定義如下:
#!cpp
struct r_debug {
int32_t r_version;
link_map_t* r_map;
void (*r_brk)(void);
int32_t r_state;
uintptr_t r_ldbase;
};
複製程式碼
r_debug是以靜態變數的形式存在於linker中,其初始化程式碼如下:
#!cpp
static r_debug _r_debug = {1, NULL, &rtld_db_dlactivity, RT_CONSISTENT, 0};
複製程式碼
在初始化時,r_debug中的r_brk函式指標被初始化成了rtld_db_dlactivity函式,該函式只是一個空的樁函式:
#!cpp
/*
* This function is an empty stub where GDB locates a breakpoint to get notified
* about linker activity. It canʼt be inlined away, can't be hidden.
*/
extern "C" void __attribute__((noinline)) __attribute__((visibility("default"))) rtld_db_dlactivity() {
}
複製程式碼
沒除錯下,該函式即為空函式,而在除錯狀態下會將該函式的內容改寫為相應指令集的breakpoint指令。所以先註冊自己的signal函式處理breakpoint異常(SIGTRAP),然後在執行時呼叫該函式,即可觸發自定義SIGTRAP的接管函式。而動態除錯時,SIGTRAP會先被偵錯程式接收,這樣不僅能迷惑偵錯程式,還能在自定義接管函式中做一些tricky的事。
0x05 檢測軟體斷點
上一節說了使用SIGTRAP反除錯的原理,由此可以衍生出另一種很常見的反除錯方法――檢測軟體斷點。軟體斷點通過改寫目標地址的頭幾位元組為breakpoint指令,只需要遍歷so中可執行segment,查詢是否出現breakpoint指令即可。實現大致如下:
#!cpp
unsigned long GetLibAddr() {
unsigned long ret = 0;
char name[] = "libanti_debug.so";
char buf[4096], *temp;
int pid;
FILE *fp;
pid = getpid();
sprintf(buf, "/proc/%d/maps", pid);
fp = fopen(buf, "r");
if (fp == NULL) {
puts("open failed");
goto _error;
}
while (fgets(buf, sizeof(buf), fp)) {
if (strstr(buf, name)) {
temp = strtok(buf, "-");
ret = strtoul(temp, NULL, 16);
break;
}
}
_error: fclose(fp);
return ret;
}
void anti5(){
Elf32_Ehdr *elfhdr;
Elf32_Phdr *pht;
unsigned int size, base, offset,phtable;
int n, i,j;
char *p;
//從maps中讀取elf檔案在記憶體中的起始地址
base = GetLibAddr();
if(base == 0){
LOGI("find base error\n");
return;
}
elfhdr = (Elf32_Ehdr *) base;
phtable = elfhdr->e_phoff + base;
for(i=0;i<elfhdr->e_phnum;i++){
pht = (Elf32_Phdr*)(phtable+i*sizeof(Elf32_Phdr));
if(pht->p_flags&1){
offset = pht->p_vaddr + base + sizeof(Elf32_Ehdr) + sizeof(Elf32_Phdr)*elfhdr->e_phnum;
LOGI("offset:%X ,len:%X",offset,pht->p_memsz);
p = (char*)offset;
size = pht->p_memsz;
for(j=0,n=0;j<size;++j,++p){
if(*p == 0x10 && *(p+1) == 0xde){
n++;
LOGI("### find thumb bpt %X \n",p);
}else if(*p == 0xf0 && *(p+1) == 0xf7 && *(p+2) == 0x00 && *(p+3) == 0xa0){
n++;
LOGI("### find thumb2 bpt %X \n",p);
}else if(*p == 0x01 && *(p+1) == 0x00 && *(p+2) == 0x9f && *(p+3) == 0xef){
n++;
LOGI("### find arm bpt %X \n",p);
}
}
LOGI("### find breakpoint num: %d\n",n);
}
}
}
複製程式碼
大家在使用IDA除錯的時候,也許會注意到IDA的程式碼視窗和hex view視窗在設定斷點的時候,目標地址的內容並沒有發生改變,其實這是IDA故意將其隱藏了,設定完斷點之後直接用dd dump記憶體就能看見設定斷點的地址頭幾位元組發生了改變。
0x06 程式間通訊
大部分加固會新建程式或者新建執行緒,在這些新建的執行緒和程式中完成反除錯操作,然而如果這些程式、執行緒相對獨立的話,很容易通過掛起、殺死的方式直接使得反除錯失效。為了保證反除錯執行緒、程式的存活,就需要一種通訊方式,定期確認反除錯執行緒、程式依然存活,所以程式間通訊是高階反除錯不可或缺的方式。在Linux下有很多程式間通訊的方式,比如管道、訊號、共享記憶體、套接字(socket)等,下面提供一個通過管道將反除錯程式和主程式聯絡起來的簡單例子:
#!cpp
int pipefd[2];
int childpid;
void *anti3_thread(void *){
int statue=-1,alive=1,count=0;
close(pipefd[1]);
while(read(pipefd[0],&statue,4)>0)
break;
sleep(1);
//這裡改為非阻塞
fcntl(pipefd[0], F_SETFL, O_NONBLOCK); //enable fd的O_NONBLOCK
LOGI("pip-->read = %d", statue);
while(true) {
LOGI("pip--> statue = %d", statue);
read(pipefd[0], &statue, 4);
sleep(1);
LOGI("pip--> statue2 = %d", statue);
if (statue != 0) {
kill(childpid,SIGKILL);
kill(getpid(), SIGKILL);
return NULL;
}
statue = -1;
}
}
void anti3(){
int pid,p;
FILE *fd;
char filename[MAX];
char line[MAX];
pid = getpid();
sprintf(filename,"/proc/%d/status",pid);// 讀取proc/pid/status中的TracerPid
p = fork();
if(p==0) //child
{
close(pipefd[0]); //關閉子程式的讀管道
int pt,alive=0;
pt = ptrace(PTRACE_TRACEME, 0, 0, 0); //子程式反除錯
while(true)
{
fd = fopen(filename,"r");
while(fgets(line,MAX,fd))
{
if(strstr(line,"TracerPid") != NULL)
{
int statue = atoi(&line[10]);
LOGI("########## tracer pid:%d", statue);
write(pipefd[1],&statue,4);//子程式向父程式寫 statue值
fclose(fd);
if(statue != 0)
{
return ;
}
break;
}
}
sleep(1);
}
}else{
childpid = p;
}
}
pipe(pipefd);
pthread_create(&id_0,NULL,anti3_thread,(void*)NULL);
anti3();
複製程式碼
傳統檢測TracerPid的方法是直接在子程式中迴圈檢測,一旦發現則主動殺死程式。本例項將迴圈檢測TracerPid和程式間通訊結合,一旦反除錯子程式被掛起或被殺死,父程式也會馬上終止,原理大致如下圖:
父程式的守護執行緒在從pipe中read到statue值之前,預設statue值為-1,收到子程式往pipe中寫的statue值之後,重置statue值,如果未被除錯,statue值為0,反之則為被除錯狀態。該做法的優勢在於,一旦反除錯程式被終止或被掛起,守護執行緒也能馬上發現。
當然,如果通過hook或者修改kernel同樣可以輕易的繞過這種反除錯。這種做法只是為了演示而寫的簡單例子,真實的程式間通訊反除錯可以寫的複雜的多,大家可以盡情發揮想象。
0x07 dalvik 虛擬機器內部相關欄位
在dalvik虛擬機器中自帶了檢測偵錯程式的程式碼,其本質是檢測DvmGlobals結構體中的相關欄位:
#!cpp
struct DvmGlobals {
…
bool debuggerConnected; /* debugger or DDMS is connected */
bool debuggerActive; /* debugger is making requests */
…
}
複製程式碼
檢測偵錯程式的函式:
#!cpp
/*
* static boolean isDebuggerConnected()
*
* Returns "true" if a debugger is attached.
*/
static void Dalvik_dalvik_system_VMDebug_isDebuggerConnected(const u4* args, JValue* pResult)
{
UNUSED_PARAMETER(args);
RETURN_BOOLEAN(dvmDbgIsDebuggerConnected());
}
複製程式碼
本質是檢測該dalvik虛擬機器中DvmGlobals結構體中的偵錯程式狀態欄位:
#!cpp
bool dvmDbgIsDebuggerConnected()
{
return gDvm.debuggerActive;
}
複製程式碼
知道原理之後可以更進一步,不通過這些Dalvik虛擬機器的自定義函式,而是直接獲取這些欄位值,這樣可以更好的隱藏反除錯資訊。
0x08 IDA arm、thumb指令識別缺陷
眾所周知,IDA採用遞迴下降演算法來反彙編指令,而該演算法最大的缺點在於它無法處理間接程式碼路徑,無法識別動態算出來的跳轉。而arm架構下由於存在arm和thumb指令集,就涉及到指令集切換,IDA在某些情況下無法智慧識別arm和thumb指令,比如下圖所示程式碼:
bx r3指令會切換指令集,而引數r3是動態計算出來的,IDA無法失敗r3的值,而預設將bx r3後面的指令當成跳轉地址,將後面地址的指令識別成了arm指令,而實際上其仍為thumb指令。
在IDA動態除錯時,仍然存在該問題,若在指令識別錯誤的地點寫入斷點,有可能使得偵錯程式崩潰。
0x09 Ptrace
Ptrace是gdb等偵錯程式實現的核心,通過ptrace可以監控、控制被除錯程式的狀態、訊號、執行等。而每個程式在同一時刻最多隻能被一個除錯程式ptrace,根據這個原理,可以主動ptrace自己的關鍵子程式,這樣可以在一定程度上防止子程式被除錯。
為了防止fork出來的反除錯子程式被直接掛起或殺死,可以通過Ptrace的PTRACE_PEEKTEXT、PTRACE_PEEKDATA、PTRACE_POKETEXT等引數來完成父子程式之間的通訊,比如子程式中使用的解密金鑰先存於父程式空間,父程式往ptrace的子程式中寫入金鑰後,再解密出關鍵資料。
總之,通過ptrace增加父子程式之間的聯絡,是十分有效並且廣泛存在於各類加固的反除錯方法。
0x0A Inotify 監控檔案
在Linux下,inotify可以實現監控檔案系統事件(開啟、讀寫、刪除等),加固方案可以通過inotify監控apk自身的某些檔案,某些記憶體dump技術通過/proc/pid/maps、/proc/pid/mem來實現記憶體dump,所以監控對這些檔案的讀寫也能起到一定的反除錯效果。
0x0B 總結
本文總結了主流加固廠商大部分反除錯技巧,APK下的反除錯技巧和win、linux下的大同小異,核心原理都是類似的。說到底,反除錯只能儘可能的增加逆向難度,APK的安全防護絕不能僅僅依靠反除錯,APK安全需要從整體架構上入手,在關鍵程式碼上加入強混淆,甚至通過vmp來增大關鍵程式碼的逆向難度。
0x0C Reference
- /bionic/linker/linker.h
- /bionic/linker/linker.cpp
- androidxref.com/4.4.4_r1/xr…
- androidxref.com/4.4.4_r1/xr…
- blog.jobbole.com/23632/
- www.spongeliu.com/165.html