查看原文
其他

对安卓反调试和校验检测的一些实践与结论

Dyingchen 看雪学苑 2022-07-01
本文为看雪论坛优秀文章
看雪论坛作者ID:Dyingchen



在学习过程中对网上的一些反调试手段进行了实践,以下为产生的结论和一些问题。

反调试来源参考:https://bbs.pediy.com/thread-223324.htm


测试机器:小米6x开发版 



 调试端口检测


原理:读取/proc/net/tcp,查找IDA远程调试所用的23946端口,若发现说明进程正在被IDA调试。

这里需要说明的是由于安卓安全性更新,/proc等目录在app里面已经没法直接访问了,导致通过查找/proc/net/tcp这个方法直接宣告无效,但是通过别的办法查找端口还是可行的。

实现代码:

public static boolean isPortUsing(String host,int port) throws UnknownHostException{ boolean flag = false; InetAddress Address = InetAddress.getByName(host); try { Socket socket = new Socket(Address,port); //建立一个Socket连接
if(socket.isClosed()==true) { flag = true; } else//不管断没断开连接都认为是已经有一个Socket存在了 { flag =true; } } catch (IOException e) { e.printStackTrace(); } return flag;}


这里需要注意的是对网络相关使用不能放在主线程里面否则会报错,建议配合线程使用。

new Thread(new Runnable(){ @Override public void run() { try { portEvent = getPortUsingEvent.isPortUsing("127.0.0.1",23946); outputView.setText("端口23946占用状态"+String.valueOf(portEvent)); } catch (UnknownHostException e) { e.printStackTrace(); }
}}).start();

相关文章:https://bbs.pediy.com/thread-268080.htm


结论:尽管可以通过检测端口的方式进行反调试,但是IDA的服务端也是可以更改端口的。



 调试进程名检测


遍历进程,查找固定的进程名,找到说明调试器在运行(比如说固定的进程名android_server gdb_server等等)。


实现代码:
int SearchObjProcess(){ FILE* pfile=NULL; char buf[0x1000]={0};
pfile=popen("ps","r"); if(NULL==pfile) { //LOGA("SearchObjProcess popen打开命令失败!\n"); return -1; } // 获取结果 //LOGA("popen方案:\n"); while(fgets(buf,sizeof(buf),pfile)) {
char* strA=NULL; char* strB=NULL; char* strC=NULL; char* strD=NULL; strA=strstr(buf,"android_server");//通过查找匹配子串判断 strB=strstr(buf,"gdbserver"); strC=strstr(buf,"gdb"); strD=strstr(buf,"fuwu"); if(strA || strB ||strC || strD) { return 1; // 执行到这里,判定为调试状态
} } pclose(pfile); return 0;}

测试效果:无论是打开了android_server 还是没有打开都返回了0,即没有检测到,关掉seLinux之后可行。


结论:可能是写法比较过时了,这个代码实现需要关掉seLinux才有效那便形同虚设,待补充可行的方案。



 父进程名检测


有的时候不使用apk附加调试的方法进行逆向,而是写一个.out可执行文件直接加载so进行调试,这样程序的父进程名和正常启动apk的父进程名是不一样的。

读取/proc/pid/cmdline,查看内容是否为zygote。

实现代码:
int CheckParents()//检查父进程是不是zygote{ // 设置buf char strPpidCmdline[0x100]={0}; FILE *file; snprintf(strPpidCmdline, sizeof(strPpidCmdline), "/proc/%d/cmdline", getppid()); // 打开文件 file = fopen(strPpidCmdline,"r"); if(!file) { //LOGA("CheckParents open错误!\n"); return 1; } // 文件内容读入内存 memset(strPpidCmdline,1,sizeof(strPpidCmdline)); ssize_t ret=fread(strPpidCmdline,1,sizeof(strPpidCmdline),file); if(-1==ret) { //LOGA("CheckParents read错误!\n"); return 2; } // 没找到返回0 char *sRet=strstr(strPpidCmdline,"zygote"); if(NULL==sRet) { // 执行到这里,判定为调试状态 //LOGA("父进程cmdline没有zygote子串!\n"); return 3; } return 4;//正常}

测试效果:模拟器上面测试正常,但是实机就无效了。


同1,已经失效,由于安全性更新无法访问此目录。


这里感谢pareto的指正,app层在实机下是只能访问自身/proc/pid下的文件,并非完全无法访问,这使得我们可以通过检查Tracerpid这个属性进行反调试。



 APK线程检测


正常apk进程一般会有十几个线程在运行(比如会有jdwp线程),自己写可执行文件加载so一般只有一个线程,可以根据这个差异来进行调试环境检测。

实现代码:
void CheckTaskCount(){ char buf[0x100]={0}; char* str="/proc/%d/task"; snprintf(buf,sizeof(buf),str,getpid()); // 打开目录: DIR* pdir = opendir(buf); if (!pdir) { perror("CheckTaskCount open() fail.\n"); return; } // 查看目录下文件个数: struct dirent* pde=NULL; int Count=0; while ((pde = readdir(pdir))) { // 字符过滤 if ((pde->d_name[0] <= '9') && (pde->d_name[0] >= '0')) { ++Count; //LOGB("%d 线程名称:%s\n",Count,pde->d_name); } } LOGB("线程个数为:%d",Count); if(1>=Count) { // 此处判定为调试状态. //LOGA("调试状态!\n"); } int i=0; return;}

测试效果:可以先测试自身app产生的线程数目,再判断是否被调试,如果线程数目显著大于测试得到的结果,那可以认为遭到了注入或者调试。



 安卓系统自带调试检测函数


分析android自带调试检测函数isDebuggerConnected(),返回是否处于调试。

public boolean getDebugEvent(){ return Debug.isDebuggerConnected();}


测试效果:系统自带函数,很好用,几行代码就可以实现,简单粗暴。


结论:由于是在java层实现的,容易被静态去除掉,当然,也可以hook系统的这个方法直接绕过。



 ptrace检测


每个进程同时刻只能被1个调试进程ptrace,主动ptrace本进程可以使得其他调试器无法调试。

实现代码:
int ptrace_protect()//ptrace附加自身线程 会导致此进程TracerPid 变为父进程的TracerPid 即zygote{ return ptrace(PTRACE_TRACEME,0,0,0);;//返回-1即为已经被调试}

非常简单粗暴,通过占坑的方式使得其他调试器无法调试,应该是用得最多的反调试之一了。

然而,测试效果:使用ptrace对自身进行附加,在模拟器上效果。
返回值为0,正常(一般调试so也不会用模拟器啊)。

在实机上面效果:

返回值为-1,为了排除返回值的问题,在此状态下使用IDA进行调试,发现可以调试。

关闭seLinux之后:


返回值变为0,尝试使用IDA附加。

无法附加,效果正常。

结论:在之前做题的时候就发现了,ptrace反调试的效果一下有一下没有,不是很稳定,也容易被静态修改,所以有使用内联汇编的方式使用。而且由于其被大量使用,许多修改的镜像直接在安卓源码层面对ptrace进行了反反调试。不过ptrace占坑的思路非常有用,也就衍生出了一些利用占坑的方式的反调试。总的来说有好过没有。



 TracerPid检测


被成功ptrace的app的/proc/pid/status文件中的Tracerpid会变为ptrace它的父进程(一般来说是zygote),而在正常情况下,这个值为0。


实现代码:
int get_app_TracerPid(){ char strPpidCmdline[0x300]={'0'}; FILE *file;
snprintf(strPpidCmdline, sizeof(strPpidCmdline), "/proc/%d/status", getpid()); file = fopen(strPpidCmdline,"r"); if(!file) { //printf("文件不存在\n"); return 1; } memset(strPpidCmdline,0,sizeof(strPpidCmdline));//清空之前使用的数据 fread(strPpidCmdline,1,sizeof(strPpidCmdline),file);
int TracerPid_pos=0; int i=0; while(i!=300) { if(strPpidCmdline[i]=='T') { if(strPpidCmdline[i+1]=='r') { if(strPpidCmdline[i+2]=='a') { if(strPpidCmdline[i+3]=='c') { if(strPpidCmdline[i+4]=='e') { if(strPpidCmdline[i+5]=='r') { if(strPpidCmdline[i+6]=='P') { if(strPpidCmdline[i+7]=='i') { if(strPpidCmdline[i+8]=='d') { TracerPid_pos=i; break; } } } } } } } } } i++; }
//TracerPid_pos = TracerPid_pos+10;//+10进入到末尾
int digits=0;//记录一共有几位 int on=0;//是否找到数字 int result=0; i=0; int j=0; while(on==0) {
if(strPpidCmdline[TracerPid_pos+i]>47&&strPpidCmdline[TracerPid_pos+i]<58) { digits++; if(strPpidCmdline[TracerPid_pos+i+1]<47||strPpidCmdline[TracerPid_pos+i]>58) { on=1; while(j!=digits) {
result += (strPpidCmdline[TracerPid_pos+i-j]-0x30)*pow(10.0,j); j++; } break; } } i++; if(i>200) { break; } } return result;}

结论:这个反调试由于之前提到的ptrace的不稳定,使用这个反调试和ptrace反调试共同使用的情况可能会遇到ptrace成功,Tracerpid变为zygote的进程号,ptrace失败,Tracerpid为0,这里就需要使用者自行判断应用场景了,根据不同的场景进行不同的检测。



 断点指令检测


如果函数被下软件断点,则断点地址会被改写为bkpt指令,可以在函数体中搜索bkpt指令来检测软件断点。

代码实现:
typedef uint8_t u8;typedef uint32_t u32;int checkbkpt(u8* addr,u32 size){ // 结果 u32 uRet=0; // 断点指令 // u8 armBkpt[4]={0xf0,0x01,0xf0,0xe7}; // u8 thumbBkpt[2]={0x10,0xde}; u8 armBkpt[4]={0}; armBkpt[0]=0xf0; armBkpt[1]=0x01; armBkpt[2]=0xf0; armBkpt[3]=0xe7; u8 thumbBkpt[2]={0}; thumbBkpt[0]=0x10; thumbBkpt[1]=0xde; // 判断模式 int mode=(u32)(size_t)addr%2; if(1==mode) { //LOGA("checkbkpt:(thumb mode)该地址为thumb模式\n"); u8* start=(u8*)((u32)(size_t)addr-1); u8* end=(u8*)((u32)(size_t)start+size); // 遍历对比 while(1) { if(start >= end) { uRet=0; // LOGA("checkbkpt:(no find bkpt)没有发现断点.\n"); break; } if( 0==memcmp(start,thumbBkpt,2) ) { uRet=1; //LOGA("checkbkpt:(find it)发现断点.\n"); return -1; } start=start+2; }//while }//if else { //LOGA("checkbkpt:(arm mode)该地址为arm模式\n"); u8* start=(u8*)addr; u8* end=(u8*)((u32)(size_t)start+size); // 遍历对比 while(1) { if (start >= end) { uRet = 0; //LOGA("checkbkpt:(no find)没有发现断点.\n"); break; } if (0 == memcmp(start,armBkpt , 4)) { uRet = 1; //LOGA("checkbkpt:(find it)发现断点.\n"); return -1; } start = start + 4; }//while }//else return 0;}

测试效果:通过查找有没有断点指令进行反调试,反调试效果很不错,但是性能上面就有点问题了。其实现就是不断遍历对比,这里出现两个问题:

1、会花费大量资源进行这个查找的行为;

2、什么时候进行查找?(如果只在相对较早的时机进行一次,那么后面的时机下了断点则没有效果,这意味着需要进行循环查找——性能花销太大。)

结论:效果不错,但是对性能开销较大。这里有必要提一下和这种方法有点相似的方法,即smc自解密,如果在自解密的代码上面下了断点,被解密之后还原出来的代码就是错误的。



 签名校验


顾名思义,app被重打包之后签名就会出现改变,可以检测签名防止重打包。

实现代码:
public int getSignature(String packageName, PackageManager pm){
PackageInfo pi = null; int sig=0; try { pi = pm.getPackageInfo(packageName,PackageManager.GET_SIGNATURES); Signature[] signatures = pi.signatures; sig = signatures[0].hashCode(); } catch (Exception errno) { sig =- 1; errno.printStackTrace(); } return sig;}

结论:签名校验的效果很强,但是市面上已经有很多过签名的通用方法了(参考np管理器,mt管理器)。


10  debuggable属性


这个属性是manifest文件中的属性,没有这个属性的话就没法调试java层,ddms也看不到没有这个属性的app进程(刷过机改过的镜像可以看到)。

这里给出一个检测debuggable被设置为true的反调试实现代码:
public boolean getAppCanDebug(Context context)//上下文对象为xxActivity.this{ boolean isDebug = context.getApplicationInfo() != null && (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; return isDebug;}

测试效果:我感觉这个也不能算反调试了应该,但是很多新手会踩到这个坑。

结论:效果很强,即使知道这个属性也经常忘记导致找半天原因(主要还是调试机器有的直接在源码层面忽略了这个属性,而有的则没有),这里给出一种便捷的绕过方法。

https://bbs.pediy.com/thread-267675.htm



11  哈希校验


对需要保护的一块区域的代码进行哈希检测。


实现代码:待补充。

相关文章:https://www.52pojie.cn/thread-1429241-1-1.html

结论:比起上面说到过的断点指令检测效果好一些,可以循环使用,但也容易被跟踪哈希函数调用找到关键位置从而静态修改。



12  利用IDA先截获信号特性的检测


IDA会首先截获信号,导致进程无法接收到信号,导致不会执行信号处理函数。将关键流程放在信号处理函数中,如果没有执行,就是被调试状态。

实现代码:
void myhandler(int sig){ //signal(5, myhandler); printf("myhandler.\n"); return;}int g_ret = 0;int main(int argc, char **argv){ // 设置SIGTRAP信号的处理函数为myhandler() g_ret = (int)signal(SIGTRAP, myhandler); if ( (int)SIG_ERR == g_ret ) printf("signal ret value is SIG_ERR.\n"); // 打印signal的返回值(原处理函数地址) printf("signal ret value is %x\n",(unsigned char*)g_ret); // 主动给自己进程发送SIGTRAP信号 raise(SIGTRAP); raise(SIGTRAP); raise(SIGTRAP); kill(getpid(), SIGTRAP); printf("main.\n"); return 0;}

结论:这个我认为是比较靠谱的反调试,因为IDA在调试过程中可能会遇到大量的信号,尽管可以设置忽略,但真假难辨,需要很多时间进行分析。


13  利用IDA解析缺陷反调试


IDA采用递归下降算法来反汇编指令,而该算法最大的缺点在于它无法处理间接代码路径,无法识别动态算出来的跳转。而arm架构下由于存在arm和thumb指令集,就涉及到指令集切换,IDA在某些情况下无法智能识别arm和thumb指令,进一步导致无法进行伪代码还原。

在IDA动态调试时,仍然存在该问题,若在指令识别错误的地点写入断点,有可能使得调试器崩溃。

参考题目:https://ctf.pediy.com/game-season_fight-174.htm

参考wp:https://bbs.pediy.com/thread-267627.htm


其他:待补充。


14  代码执行时间检测


原理:


一段代码,在a处获取一下时间,运行一段后,再在b处获取下时间,然后通过(b时间­a时间)求时间差,正常情况下这个时间差会非常小,如果这个时间差比较大,说明正在被单步调试。


做法:

五个能获取时间的api:
time()函数
time_t结构体
clock()函数
clock_t结构体
gettimeofday()函数
timeval结构
timezone结构
clock_gettime()函数
timespec结构
getrusage()函数
rusage结构

代码实现:待补充。

结论:效果很不错,但是容易被跟踪相关api找到关键点。

写在最后:


参考借鉴了很多前辈的代码,不过更重要的是自己去跟着实现一遍,确定能不能用和问题所在才是关键,这便是学习的过程。过程难免有出现错误,如果有发现我的错误的话,希望大家能够给我指出,共同学习。


 


看雪ID:Dyingchen

https://bbs.pediy.com/user-home-857117.htm

  *本文由看雪论坛 Dyingchen 原创,转载请注明来自看雪社区


《安卓高级研修班》2021年秋季班火热招生中!



# 往期推荐





公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com



球分享

球点赞

球在看



点击“阅读原文”,了解更多!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存