Android应用安全开发之源码安全

Android应用安全开发之源码安全

 

0x00 简介


Android apk很容易经过逆向工程进行反编译,从而是其代码彻底暴露给攻击者,使apk面临破解,软件逻辑修改,插入恶意代码,替换广告商ID等风险。咱们能够采用如下方法对apk进行保护.html

0x01 混淆保护


混淆是一种用来隐藏程序意图的技术,能够增长代码阅读的难度,使攻击者难以全面掌控app内部实现逻辑,从而增长逆向工程和破解的难度,防止知识产权被窃取。java

代码混淆技术主要作了以下的工做:linux

  1. 经过对代码类名,函数名作替换来实现代码混淆保护
  2. 简单的逻辑分支混淆

已经有不少第三方的软件能够用来混淆咱们的Android应用,常见的有:android

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

这些混淆器在代码中起做用的层次是不同的。Android编译的大体流程以下:c++

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

有的混淆器是在编译以前直接做用于java源代码,有的做用于java字节码,有的做用于Dalvik字节码。但基本都是针对java层做混淆。git

相对于Dalvik虚拟机层次的混淆而言,原生语言(C/C++)的代码混淆选择并很少,Obfuscator-LLVM工程是一个值得关注的例外。github

代码混淆的优势是使代码可阅读性变差,要全面掌控代码逻辑难度变大;能够压缩代码,使得代码大小变小。但也存在以下缺点:算法

  1. 没法真正保护代码不被反编译;
  2. 在应对动态调试逆向分析上无效;
  3. 经过验证本地签名的机制很容易被绕过。

也就是说,代码混淆并不能有效的保护应用自身。安全

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

0x02 二次打包防御


2.1 Apk签名校验

每个软件在发布时都须要开发人员对其进行签名,而签名使用的密钥文件时开发人员所独有的,破解者一般不可能拥有相同的密钥文件,所以可使用签名校验的方法保护apk。Android SDK中PackageManager类的getPackageInfo()方法就能够进行软件签名检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
     }
}

主程序代码参考:

1
2
3
4
5
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哈希值应该存放在服务器端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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。示例代码以下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#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更名就可绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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的值来判断是否有正在被调试。示例代码以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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设置定时,到达时则停止程序。示例代码以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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/下列出。因而咱们的示例代码以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <dirent.h>
int  main( int  argc,  char  *argv[]) {
    struct  dirent *dir;
    DIR *d = 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

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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.设置单步调试陷阱

1
2
3
4
5
6
7
8
9
10
11
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. 破解须要破解自定义的指令格式,复杂度很是高
相关文章
相关标签/搜索