Android應用安全開發之源碼安全

原文地址: http://drops.wooyun.org/mobile/12172


0x00 簡介


Android apk很容易通過逆向工程進行反編譯,從而是其代碼完全暴露給攻擊者,使apk面臨破解,軟件邏輯修改,插入惡意代碼,替換廣告商ID等風險。我們可以採用以下方法對apk進行保護.

0x01 混淆保護


混淆是一種用來隱藏程序意圖的技術,可以增加代碼閱讀的難度,使攻擊者難以全面掌控app內部實現邏輯,從而增加逆向工程和破解的難度,防止知識產權被竊取。

代碼混淆技術主要做了如下的工作:

  1. 通過對代碼類名,函數名做替換來實現代碼混淆保護
  2. 簡單的邏輯分支混淆

已經有很多第三方的軟件可以用來混淆我們的Android應用,常見的有:

  • Proguard
  • DashO
  • Dexguard
  • DexProtector
  • ApkProtect
  • Shield4j
  • Stringer
  • Allitori

這些混淆器在代碼中起作用的層次是不一樣的。Android編譯的大致流程如下:

Java Code(.java) -> Java Bytecode(.class) -> Dalvik Bytecode(classes.dex)

有的混淆器是在編譯之前直接作用於java源代碼,有的作用於java字節碼,有的作用於Dalvik字節碼。但基本都是針對java層作混淆。

相對於Dalvik虛擬機層次的混淆而言,原生語言(C/C++)的代碼混淆選擇並不多,Obfuscator-LLVM工程是一個值得關注的例外。

代碼混淆的優點是使代碼可閱讀性變差,要全面掌控代碼邏輯難度變大;可以壓縮代碼,使得代碼大小變小。但也存在如下缺點:

  1. 無法真正保護代碼不被反編譯;
  2. 在應對動態調試逆向分析上無效;
  3. 通過驗證本地簽名的機制很容易被繞過。

也就是說,代碼混淆並不能有效的保護應用自身。

http://www.jianshu.com/p/0c23e0a886f4

0x02 二次打包防護


2.1 Apk簽名校驗

每一個軟件在發佈時都需要開發人員對其進行簽名,而簽名使用的密鑰文件時開發人員所獨有的,破解者通常不可能擁有相同的密鑰文件,因此可以使用簽名校驗的方法保護apk。Android SDK中PackageManager類的getPackageInfo()方法就可以進行軟件簽名檢測。

public class getSign {
    public static int getSignature(PackageManager pm , String packageName){
    PackageInfo pi = null;
    int sig = 0;
    Signature[]s = null;
    try{
        pi = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
        s = pi.signatures;
        sig = s[0].hashCode();//s[0]是簽名證書的公鑰,此處獲取hashcode方便對比
    }catch(Exception e){
        handleException();
    }
    return sig;
    }
}

主程序代碼參考:

pm = this.getPackageManager();
int s = getSign.getSignature(pm, "com.hik.getsinature");
if(s != ORIGNAL_SGIN_HASHCODE){//對比當前和預埋簽名的hashcode是否一致
    System.exit(1);//不一致則強制程序退出
}

2.2 Dex文件校驗

重編譯apk其實就是重編譯了classes.dex文件,重編譯後,生成的classes.dex文件的hash值就改變了,因此我們可以通過檢測安裝後classes.dex文件的hash值來判斷apk是否被重打包過。

  1. 讀取應用安裝目錄下 /data/app/xxx.apk 中的classes.dex文件並計算其哈希值,將該值與軟件發佈時的classes.dex哈希值做比較來判斷客戶端是否被篡改。
  2. 讀取應用安裝目錄下 /data/app/xxx.apk 中的META-INF目錄下的MANIFEST.MF文件,該文件詳細記錄了apk包中所有文件的哈希值,因此可以讀取該文件獲取到classes.dex文件對應的哈希值,將該值與軟件發佈時的classes.dex哈希值做比較就可以判斷客戶端是否被篡改。

爲了防止被破解,軟件發佈時的classes.dex哈希值應該存放在服務器端。

private boolean checkcrc(){
    boolean checkResult = false;
    long crc = Long.parseLong(getString(R.string.crc));//獲取字符資源中預埋的crc值
    ZipFile zf;
    try{
        String path = getApplicationContext().getPackageCodePath();//獲取apk安裝路徑
        zf = new ZipFile(path);//將apk封裝成zip對象
        ZipEntry ze = zf.getEntry("classes.dex");//獲取apk中的classes.dex
        long CurrentCRC = ze.getCrc();//計算當前應用classes.dex的crc值
        if(CurrentCRC != crc){//crc值對比
            checkResult = true;
        }
    }catch(IOException e){
        handleError();
        checkResult = false;
    }
    return checkResult;
}

另外由於逆向c/c++代碼要比逆向Java代碼困難很多,所以關鍵代碼部位應該使用Native C/C++來編寫。

0x03 SO保護


Android so通過C/C++代碼來實現,相對於Java代碼來說其反編譯難度要大很多,但對於經驗豐富的破解者來說,仍然是很容易的事。應用的關鍵性功能或算法,都會在so中實現,如果so被逆向,應用的關鍵性代碼和算法都將會暴露。對於so的保護,可以纔有編譯器優化技術、剝離二進制文件等方式,還可以使用開源的so加固殼upx進行加固。

編譯器優化技術

爲了隱藏核心的算法或者其它複雜的邏輯,使用編譯優化技術可以幫助混淆目標代碼,使它不會很容易的被攻擊者反編譯,從而讓攻擊者對特定代碼的理解變得更加困難。如使用LLVM混淆。

剝離二進制文件

剝離本地二進制文件是一種有效的方式,使攻擊者需要更多的時間和更高的技能水平來查看你的應用程序底層功能的實現。剝離二進制文件,就是將二進制文件的符號表刪除,使攻擊者無法輕易調試或逆向應用。在Android上可以使用GNU/Linux系統上已經使用過的技術,如sstriping或者UPX。

UPX對文件進行加殼時會把軟件版本等相關信息寫入殼內,攻擊者可以通過靜態反彙編可查看到這些殼信息,進而尋找對應的脫殼機進行脫殼,使得攻擊難度降低。所以我們必須在UPX源碼中刪除這些信息,重新編譯後再進行加殼,步驟如下:

  1. 使用原始版本對文件進行加殼。
  2. 使用IDA反彙編加殼文件,在反彙編文件的上下文中查找UPX殼特徵字符串。
  3. 在UPX源碼中查找這些特徵字符串,並一一刪除。

https://www.nowsecure.com/resources/secure-mobile-development/coding-practices/code-complexity-and-obfuscation/

0x04 資源文件保護


如果資源文件沒有保護,則會使應用存在兩方面的安全風險:

  1. 通過資源定位代碼,方便應用破解 反編譯apk獲得源碼,通過資源文件或者關鍵字符串的ID定位到關鍵代碼位置,爲逆向破解應用程序提供方便.
  2. 替換資源文件,盜版應用 "if you can see something, you can copy it"。Android應用程序中的資源,比如圖片和音頻文件,容易被複制和竊取。

可以考慮將其作爲一個二進制形式進行加密存儲,然後加載,解密成字節流並把它傳遞到BitmapFactory。當然,這會增加代碼的複雜度,並且造成輕微的性能影響。

不過資源文件是全局可讀的,即使不打包在apk中,而是在首次運行時下載或者需要使用時下載,不在設備中保存,但是通過網絡數據包嗅探還是很容易獲取到資源url地址。

0x05 反調試技術


5.1 限制調試器連接

應用程序可以通過使用特定的系統API來防止調試器附加到該進程。通過阻止調試器連接,攻擊者干擾底層運行時的能力是有限的。攻擊者爲了從底層攻擊應用程序必須首先繞過調試限制。這進一步增加了攻擊複雜性。Android應用程序應該在manifest中設置 Android:debuggable=“false ,這樣就不會很容易在運行時被攻擊者或者惡意軟件操縱。

5.2 Trace檢查

應用程序可以檢測自己是否正在被調試器或其他調試工具跟蹤。如果被追蹤,應用程序可以執行任意數量的可能攻擊響應行爲,如丟棄加密密鑰來保護用戶數據,通知服務器管理員,或者其它類型自我保護的響應。這可以由檢查進程狀態標誌或者使用其它技術,如比較ptrace附加的返回值,檢查父進程,黑名單調試器進程列表或通過計算運行時間的差異來反調試。

a.父進程檢測

通常,我們在使用gdb調試時,是通過gdb 這種方式進行的。而這種方式是啓動gdb,fork出子進程後執行目標二進制文件。因此,二進制文件的父進程即爲調試器。我們可通過檢查父進程名稱來判斷是否是由調試器fork。示例代碼如下

#include <stdio.h>
#include <string.h>
 
int main(int argc, char *argv[]) {
   char buf0[32], buf1[128];
   FILE* fin;
 
   snprintf(buf0, 24, "/proc/%d/cmdline", getppid());
   fin = fopen(buf0, "r");
   fgets(buf1, 128, fin);
   fclose(fin);
 
   if(!strcmp(buf1, "gdb")) {
       printf("Debugger detected");
       return 1;
   }  
   printf("All good");
   return 0;
}

這裏我們通過getppid獲得父進程的PID,之後由/proc文件系統獲取父進程的命令內容,並通過比較字符串檢查父進程是否爲gdb。實際運行結果如下圖所示:

p1

b.當前運行進程檢測

例如對 android_server 進程檢測。針對這種檢測只需將 android_server 改名就可繞過

pid_t GetPidByName(const charchar *as_name) {  
        DIR *pdir = NULL;  
        struct dirent *pde = NULL;  
        FILEFILE *pf = NULL;  
        char buff[128];  
        pid_t pid;  
        char szName[128];  
        // 遍歷/proc目錄下所有pid目錄    
        pdir = opendir("/proc");  
        if (!pdir) {  
                perror("open /proc fail.\n");  
                return -1;  
        }  
        while ((pde = readdir(pdir))) {  
                if ((pde->d_name[0] < '0') || (pde->d_name[0] > '9')) {  
                        continue;  
                }  
                sprintf(buff, "/proc/%s/status", pde->d_name);  
                pf = fopen(buff, "r");  
                if (pf) {  
                        fgets(buff, sizeof(buff), pf);  
                        fclose(pf);  
                        sscanf(buff, "%*s %s", szName);  
                        pid = atoi(pde->d_name);  
                        if (strcmp(szName, as_name) == 0) {  
                                closedir(pdir);  
                                return pid;  
                        }  
                }  
        }  
        closedir(pdir);  
        return 0;  
}

c.讀取進程狀態(/proc/pid/status)

State屬性值T 表示調試狀態,TracerPid 屬性值正在調試此進程的pid,在非調試情況下State爲S或R, TracerPid等於0

p2

由此,我們便可通過檢查status文件中TracerPid的值來判斷是否有正在被調試。示例代碼如下:

#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
   int i;
   scanf("%d", &i);
   char buf1[512];
   FILE* fin;
   fin = fopen("/proc/self/status", "r");
   int tpid;
   const char *needle = "TracerPid:";
   size_t nl = strlen(needle);
   while(fgets(buf1, 512, fin)) {
       if(!strncmp(buf1, needle, nl)) {
           sscanf(buf1, "TracerPid: %d", &tpid);
           if(tpid != 0) {
                printf("Debuggerdetected");
                return 1;
           }
       }
    }
   fclose(fin);
   printf("All good");
   return 0;
}

實際運行結果如下圖所示:

p3

值得注意的是,/proc目錄下包含了進程的大量信息。我們在這裏是讀取status文件,此外,也可通過/proc/self/stat文件來獲得進程相關信息,包括運行狀態。

d.讀取 /proc/%d/wchan

下圖中第一個紅色框值爲非調試狀態值,第二個紅色框值爲調試狀態:

p4

static int getWchanStatus(int pid)  
{  
      FILEFILE *fp= NULL;  
      char filename;  
      char wchaninfo = {0};  
      int result = WCHAN_ELSE;  
      char cmd = {0};  
      sprintf(cmd,"cat /proc/%d/wchan",pid);  
      LOGANTI("cmd= %s",cmd);  
      FILEFILE *ptr;         
      if((ptr=popen(cmd, "r")) != NULL)  
      {  
                if(fgets(wchaninfo, 128, ptr) != NULL)  
                {  
                        LOGANTI("wchaninfo= %s",wchaninfo);  
                }  
      }  
      if(strncasecmp(wchaninfo,"sys_epoll\0",strlen("sys_epoll\0")) == 0)  
                result = WCHAN_RUNNING;  
      else if(strncasecmp(wchaninfo,"ptrace_stop\0",strlen("ptrace_stop\0")) == 0)  
                result = WCHAN_TRACING;  
      return result;  
}  

e.ptrace 自身或者fork子進程相互ptrace

if (ptrace(PTRACE_TRACEME, 0, 1, 0) < 0) {  
printf("DEBUGGING... Bye\n");  
return 1;  
}  
void anti_ptrace(void)  
{  
    pid_t child;  
    child = fork();  
    if (child)  
      wait(NULL);  
    else {  
      pid_t parent = getppid();  
      if (ptrace(PTRACE_ATTACH, parent, 0, 0) < 0)  
            while(1);  
      sleep(1);  
      ptrace(PTRACE_DETACH, parent, 0, 0);  
      exit(0);  
    }  
}

f.設置程序運行最大時間

這種方法經常在CTF比賽中看到。由於程序在調試時的斷點、檢查修改內存等操作,運行時間往往要遠大於正常運行時間。所以,一旦程序運行時間過長,便可能是由於正在被調試。

具體地,在程序啓動時,通過alarm設置定時,到達時則中止程序。示例代碼如下:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void alarmHandler(int sig) {
   printf("Debugger detected");
   exit(1);
}
void__attribute__((constructor))setupSig(void) {
   signal(SIGALRM, alarmHandler);
   alarm(2);
}
int main(int argc, char *argv[]) {
   printf("All good");
   return 0;
}

在此例中,我們通過 __attribute__((constructor)) ,在程序啓動時便設置好定時。實際運行中,當我們使用gdb在main函數下斷點,稍候片刻後繼續執行時,則觸發了SIGALRM,進而檢測到調試器。如下圖所示:

p5

順便一提,這種方式可以輕易地被繞過。我們可以設置gdb對signal的處理方式,如果我們選擇將SIGALRM忽略而非傳遞給程序,則alarmHandler便不會被執行,如下圖所示:

p6

g.檢查進程打開的filedescriptor

如2.2中所說,如果被調試的進程是通過gdb 的方式啓動,那麼它便是由gdb進程fork得到的。而fork在調用時,父進程所擁有的fd(file descriptor)會被子進程繼承。由於gdb在往往會打開多個fd,因此如果進程擁有的fd較多,則可能是繼承自gdb的,即進程在被調試。

具體地,進程擁有的fd會在/proc/self/fd/下列出。於是我們的示例代碼如下:

#include <stdio.h>
#include <dirent.h>
int main(int argc, char *argv[]) {
   struct dirent *dir;
   DIR *= opendir("/proc/self/fd");
   while(dir=readdir(d)) {
       if(!strcmp(dir->d_name, "5")) {
           printf("Debugger detected");
           return 1;
       }
    }
   closedir(d);
   printf("All good");
   return 0;
}

這裏,我們檢查/proc/self/fd/中是否包含fd爲5。由於fd從0開始編號,所以fd爲5則說明已經打開了6個文件。如果程序正常運行則不會打開這麼多,所以由此來判斷是否被調試。運行結果見下圖:

p7

h.防止dump

利用Inotify機制,對/proc/pid/mem和/proc/pid/pagemap文件進行監視。inotify API提供了監視文件系統的事件機制,可用於監視個體文件,或者監控目錄。具體原理可參考: http://man7.org/linux/man-pages/man7/inotify.7.html

僞代碼:

void __fastcall anitInotify(int flag)  
{  
      MemorPagemap = flag;  
      charchar *pagemap = "/proc/%d/pagemap";  
      charchar *mem = "/proc/%d/mem";  
      pagemap_addr = (charchar *)malloc(0x100u);  
      mem_addr = (charchar *)malloc(0x100u);  
      ret = sprintf(pagemap_addr, &pagemap, pid_);  
      ret = sprintf(mem_addr, &mem, pid_);  
      if ( !MemorPagemap )  
      {  
                ret = pthread_create(&th, 0, (voidvoid *(*)(voidvoid *)) inotity_func, mem_addr);  
                if ( ret >= 0 )  
                   ret = pthread_detach(th);  
      }  
      if ( MemorPagemap == 1 )  
      {  
                ret = pthread_create(&newthread, 0, (voidvoid *(*)(voidvoid *)) inotity_func, pagemap_addr);  
                if(ret > 0)  
                  ret = pthread_detach(th);  
      }  
}  
void __fastcall __noreturn inotity_func(const charchar *inotity_file)  
{  
      const charchar *name; // r4@1  
      signed int fd; // r8@1  
      bool flag; // zf@3  
      bool ret; // nf@3  
      ssize_t length; // r10@3  
      ssize_t i; // r9@7  
      fd_set readfds; // @2  
      char event; // @1  
      name = inotity_file;  
      memset(buffer, 0, 0x400u);  
      fd = inotify_init();  
      inotify_add_watch(fd, name, 0xFFFu);  
      while ( 1 )  
      {  
                do  
                {  
                        memset(&readfds, 0, 0x80u);  
                }  
                while ( select(fd + 1, &readfds, 0, 0, 0) <= 0 );  
                length = read(fd, event, 0x400u);  
                flag = length == 0;  
                ret = length < 0;  
                if ( length >= 0 )  
                {  
                        if ( !ret && !flag )  
                      {  
                              i = 0;  
                              do  
                              {  
                                        inotity_kill((int)&event);  
                                        i += *(_DWORD *)&event + 16;  
                              }  
                              while ( length > i );  
                        }  
                }  
                else  
                {  
                        while ( *(_DWORD *)_errno() == 4 )  
                        {  
                              length = read(fd, buffer, 0x400u);  
                              flag = length == 0;  
                              ret = length < 0;  
                              if ( length >= 0 )  
                        }  
                }  
      }  
}

i.對read做hook

因爲一般的內存dump都會調用到read函數,所以對read做內存hook,檢測read數據是否在自己需要保護的空間來阻止dump

j.設置單步調試陷阱

int handler()  
{  
    return bsd_signal(5, 0);  
}  
int set_SIGTRAP()  
{  
    int result;  
    bsd_signal(5, (int)handler);  
    result = raise(5);  
    return result;  
}

http://www.freebuf.com/tools/83509.html

0x06 應用加固技術

移動應用加固技術從產生到現在,一共經歷了三代:

  • 第一代是基於類加載器的方式實現保護;
  • 第二代是基於方法替換的方式實現保護;
  • 第三代是基於虛擬機指令集的方式實現保護。

第一代加固技術:類加載器

以梆梆加固爲例,類加載器主要做了如下工作:

  1. classes.dex被完整加密,放到APK的資源中
  2. 採用動態劫持虛擬機的類載入引擎的技術
  3. 虛擬機能夠載入並運行加密的classes.dex

使用一代加固技術以後的apk加載流程發生了變化如下:

p8

應用啓動以後,會首先啓動保護代碼,保護代碼會啓動反調試、完整性檢測等機制,之後再加載真實的代碼。

一代加固技術的優勢在於:可以完整的保護APK,支持反調試、完整性校驗等。

一代加固技術的缺點是加固前的classes.dex文件會被完整的導入到內存中,可以用內存dump工具直接導出未加固的classes.dex文件。

第二代加固技術:類方法替換

第二代加固技術採用了類方法替換的技術:

  1. 將原APK中的所有方法的代碼提取出來,單獨加密
  2. 運行時動態劫持Dalvik虛擬機中解析方法的代碼,將解密後的代碼交給虛擬機執行引擎

採用本技術的優勢爲:

  1. 每個方法單獨解密,內存中無完整的解密代碼
  2. 如果某個方法沒有執行,不會解密
  3. 在內存中dump代碼的成本代價很高

使用二代加固技術以後,啓動流程增加了一個解析函數代碼的過程,如下圖所示:

p9

第三代加固技術:虛擬機指令集

第三代加固技術是基於虛擬機執行引擎替換方式,所做主要工作如下:

  1. 將原APK中的所有的代碼採用一種自定義的指令格式進行替換
  2. 運行時動態劫持Dalvik虛擬機中執行引擎,使用自定義執行引擎執行自定義的代碼
  3. 類似於PC上的VMProtect採用的技術

三代技術的優點如下:

  1. 具有2.0的所有優點
  2. 破解需要破解自定義的指令格式,複雜度非常高
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章