找回密码
 会员注册
查看: 32|回复: 0

由工作端反作弊而引发的对应用安全的思考

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64116
发表于 2024-9-20 03:04:55 | 显示全部楼层 |阅读模式
背景目前我所维护的项目是58到家工作端,定位是一款ToB的工具型应用,目的是帮助家政从业人员更方便的进行上户工作,随着业务的逐渐迭代,发现部分用户在日常的使用中存在作弊的现象,此现象的存在会导致未作弊阿姨可能接到的订单量减少,甚至在活动期间薅羊毛,影响派单的公平性以及增大公司的活动资金投入,因此需要我们对应用的安全性进行一定的提升以保证整体系统的安全性以及公平性。现阶段接入了梆梆加固,在接入过程中需要确定相关加固策略,因此需要对应用加固有系统的了解,本文主要是对此次安全升级的总结及以及在58到家工作端中的落地实践。Android应用安全防护原理与实践1.防护的基本策略1.1混淆1.1.1代码混淆在Android平台,源代码最终都会被编译成平台所需要的字节码,其中包含了很多源代码信息,如类名、方法名、变量名等,由于其具有语义信息,因此在逆向过程中很容易就被反编译成源代码,为了防止这种现象,我们可以使用混淆器来对代码进行混淆,目的是程序进行重新组织,使用等价的关系将类名、方法名、变量名等替换为简短的无意义的字符串,如a、b、c等,使得即使应用被反编译后也不会很容易的理解,增大阅读的难度。开启代码混淆//主工程build.gradleandroid{buildTypes{release{//配置release包的签名signingConfigsigningConfigs.key//混淆是否开启[true开启、false不开启]minifyEnabledtrue//配置混淆规则文件proguardFilesgetDefaultProguardFile(\'proguard-android-optimize.txt\'),\'proguard-rules.pro\'}}}注:具体代码混淆规则[1]不进行讲述,详见文章末尾参考资料。混淆前后对比:混淆前后包大小对比:通过混淆前后对比后明显可以看出,原来可以见名知意的方法名或变量名已经无法直观的看出其真实的含义。1.1.2资源混淆与代码混淆类似,将原来见名知意的资源名称等价替换为无意义的字符串,增加破解后查找资源的难度.具体接入方式及原理文中不进行阐述,见参考资料:资源混淆方案[2]、资源混淆原理[3]混淆前后对比:混淆前后包大小对比:混淆小结开启代码混淆及资源混淆后,代码及资源变的没有规则,无法见名知意,即使应用被破解,攻击者也无法很快的找到想要的内容,增加了阅读的难度.同时,混淆过程中会检查删除没有使用的类、方法、属性等,并且优化字节码,移除无用的指令,以及将较长的名字替换为较短的名字对减小应用包体积有很明显的帮助。1.2签名保护Android中的每个应用都有一个唯一的签名,如果一个应用没有签名是不允许安装到设备中的,开发过程中Debug版本使用的是默认的签名文件。上线发布Release版本时都需要使用我们自己创建的签名文件对apk进行签名。在未开启签名保护之前,逆向攻击者可能在反编译应用之后,对我们的代码逻辑进行修改,比如删除一些校验逻辑、增加一些广告,然后使用他们自己的签名文件重新签名后再发布出去,破坏了我们原有的生态。并且因为重签后的签名与我们自己的不一致,后续就无法进行版本升级。只能卸载重装。一般来说我们的签名,逆向攻击者是无法获取到的,根据Android系统签名唯一性校验的机制,我们可以利用该特性做一层防护。Java代码本地签名校验,通过PackageInfo得到Signature,此时即可获取到证书的hash值来进行对比,但此种方案过于捡漏,通过修改smali文件即可轻松绕过。NDK的形式,将校验逻辑下沉到C/C++代码中,并且将签名hash值进行相应算法处理,最后构建为so库,通过JNI接口调用,相较于纯Java层校验,此种方式增加了复杂度,反编译后不是以smali这种易于理解和修改的形式。1.3模拟器检测Android模拟器就是一种可以运行在PC端用以模拟真实手机运行环境的虚拟设备,并且目前市面上大部分模拟器软件(雷电、逍遥、夜神等)都提供一些用于修改设备参数,虚拟定位等功能,对于我们的应用来说,如果用户使用虚拟定位功能则属于严重的作弊行为。因此需要对此种行为进行严格的控制。检测虚拟机方案有很多,但大都是基于对比真机与模拟器的差异来进行的,由于我们应用中使用的模拟器检测功能支持来自于信安,非我们团队实现,因此不进行具体讲解,详见参考资料Android模拟器检测体系梳理[4]、检测Android虚拟机的方法和代码实现[5]。1.4Root检测对于逆向攻击者来说,想要对我们的代码进行hook操作的前提条件是拿到手机的Root权限,因此Root检测也是现在应用防护的一种方式。检测是否存在su目录以及使用which命令检测suobjectCheatDetection{privatevalsuperUserDictionaryPath=arrayOf("/system/bin/su","/system/xbin/su","/system/sbin/su","/sbin/su","/vendor/bin/su","/su/bin/su","/data/local/xbin/su","/data/local/bin/su","/system/sd/xbin/su","/system/bin/failsafe/su","/data/local/su",)funsuAvailable():Boolean{try{for(pathinsuperUserDictionaryPath){valfile=File(path)if(file.exists()orfile.canExecute()){Log.e("Root检测","命中path{file.absolutePath}")returntrue}}}catch(e:Exception){e.printStackTrace()}returnfalse}}valprocess=Runtime.getRuntime().exec(arrayOf("/system/xbin/which","su"))//当process执行没有结果时,则表示没有root。//注:需要注意缓冲区的数据,防止程序阻塞,具体处理方式不进行描述读取build.prop中关键属性,如ro.build.tags当手机系统是测试版时,默认是享有Root权限的,并且此时的tags值为"test-keys",正式版为"release-keys"。funisTestVersion():Boolean{valtags=android.os.Build.TAGSvaldebugVersionKey="test-keys"if(!tags.isNullOrEmpty()&tags.contains(debugVersionKey)){Log.e("Root检测","命中版本debugVersionKey")returntrue}returnfalse}检测Magisk或者Superuser.apk关于检测Magisk的方式针对于最新版暂未想到很好的办法,在老版本的时候可以进行检测包名com.topjohnwu.magisk,但新版本的提供了随机包名的方式进行绕过。funcheckCheatApk():Boolean{valsuperUserApkPath="/system/app/Superuser.apk"try{valfile=File(superUserApkPath)if(file.exists()){returntrue}}catch(e:Exception){e.printStackTrace()}returnfalse}执行busyboxAndroid系统由于安全的考虑,将一些可能带来风险的命令去掉了,如(su、find、mount等),busybox工具箱由此而来,其中集成了许多Linux命令和工具,所以如果设备root了,可能就会安装了busybox,由此我们可以采用调用busybox来进行检测,与使用which命令检测su类似,也需要进行缓冲区的处理.valprocess=Runtime.getRuntime().exec(arrayOf("busybox","df"))//当process执行没有结果时,则表示没有root。访问私有目录,如/data目录,查看读写权限Android系统中私有目录必须要有root权限才能进行访问,如/data、/system、/etc等,因此可以通过读写相关目录进行检测判断。检测xposed、frida等hook框架的特征Xposed是一个动态插桩的hook框架,通过替换app_process原始进程,将java函数注册为native函数,从而获得更早的运行时机。可以通过针对特征点修改来进行检测(详见参考资料Xposed分析[6])。frida与xposed原理类似,同样是动态插桩工具,frida最简单的检测方式就是检查运行的服务中是否有frida-server。具体方案请参照Frida源码分析[7]2.应用加固原理在实际场景中,即使使用了大量的基本防护策略,但对于专业逆向人员来说,这些防护策略还是能够进行绕过的,只是需要花费一些时间而已,由此在不断的博弈中,应用加固这个顺势而生,简单来说就是对原有应用进行改造,提高攻击者的破解难度,让攻击者从中获取的利益与所花费的时间和经历不成正比,以达到保护应用的目的。2.1常规加壳原理及实践Dex加壳可以理解为对原APK进行加密后并再其外部套上一层外壳。需要掌握的基本知识点auncher启动过程与系统启动流程[8]ActivityThread的理解和APP的启动过程[9]深入理解类加载器和动态加载[10]完整加固流程:注:打包过程中需要进行AndroidManifest文件的修改,将原apk中Application节点的类替换为我们的壳程序入口。壳应用执行过程采用伪代码分析//壳程序入口classShellApplication:Application(){overridefunattachBaseContext(base:Context){super.attachBaseContext(base)//1.解压加固后apk.valunzipApk=unzipApk()//2.对dex文件进行解密操作unzipApk.forEach{if(it.name.endsWith(".dex")){valoriginalBytes=decrypt(it.toBytes())valfileOutputStream=FileOutputStream(it)fileOutputStream.write(originalBytes)fileOutputStream.flush()fileOutputStream.close()}}//提取出解密后的dex文件valdexFiles=unzipApk.findDex()//使用类加载及动态加载机制完成原dex内容加载DispatchByVersion.install(classLoader,dexFiles)}}但此方案存在弊端,当应用安装运行后,会将真实的dex文件解密落地到文件系统中,攻击者仍然可以找到。针对上述攻击方案,第二代加固方案使用hook手段,在动态加载的时候将DexClassLoader执行时不进行真实dex文件落地,使用内存替换技术,但也存在被dump下来的风险。为了对抗该手段,第三代技术使用函数抽取的方式,让dex在内存中始终保持不完整的状态。对要保护的dex文件进行预处理,将需要进行保护的函数指令抽取加密,原位置使用nop指令填充,在虚拟机执行到被抽取的函数时使用hook手段对libdalvik.so/libart.so中的指令读取,将对应的真实指令解密替换让虚拟机正常执行下去。而随着内存脱壳机的出现,指令抽取的方式也不再有效.j2c技术开始引入到加固方案中,j2c也是对dex中的函数进行处理,将函数中的dalvik指令以JNI的方式等价转换为cpp代码,再编译成so库,这样当执行需要保护的方法时就会转入到native层执行对应的cpp代码。但如果需要保护的方法过多时,cpp代码编译出的so库体积也随之增大,会导致包体积过大的问题。针对包体积过大的问题,DEX-VMP方案有效的解决了该问题。2.2DEX-VMP方案代码指令虚拟化方案,原理是将代码编译为虚拟机指令,通过自定义虚拟机解释执行,其针对目标也是函数,通俗的讲就是自定义一套字节码指令,将函数替换为等价的自定义指令,然后使用一个解释器解释并运行字节码。自定义字节码enumOPCODES{MOV=0xa0,//mov指令对应0xa0XOR=0xa1,CMP=0xa2,RET=0xa3,....};自定义处理器typedefstructprocessor_t{intr0;//虚拟寄存器r0~r15intr1;....intFP;intIP;char*SP;intLR;unsignedchar*PC;//虚拟机寄存器PC,指向正在解释的字节码地址intcpsr;//虚拟标志寄存器flag,作用类似于eflagsvm_opcodeop_table[OPCODE_NUM];//字节码列表,存放了所有字节码与对应的处理函数}vm_processor;自定义解释器voidvm_CPU(vm_processor*proc,unsignedchar*Vcode){//PC指向被保护代码的第一个字节proc->C=Vcode;//循环判断PC指向字节码是否为返回指令,如果不是就解释执行while(*proc->C!=RET){intflag=0;inti=0;//查找PC指向的正在解释的字节码对应的处理函数while(!flag&iPC==proc->op_table[i].opcode){flag=1;//查找到之后,调用本条指令的处理函数proc->op_table[i].func((void*)proc);}}}}首先可以从上面看到解释器vm_CPU执行时pc会指向Vcode,也就是自定义的字节码第一个字节0xa0(对应指令为MOV),之后会判断pc指向的字节码是否为ret指令,ret指令是0xa3,如果pc指向的不是ret,则进行字节码解释,后面则会按照我们的定义规则来进行处理执行逻辑。具体流程加固流程:解释器执行流程:加固前:publicvoidonCreate(Bundlebundle){super.onCreate(bundle);setContentView(R.layout.activity);this.mPager=(ViewPager)findViewById(R.id.pager);this.mTitles=(PagerTitleStrip)findViewById(R.id.titles);this.mPager.setAdapter(this.mTermAdapter);}/*accessmodifierschangedfrom:protected*/publicvoidonStart(){super.onStart();bindService(newIntent(this,TerminalService.class),this.mServiceConn,1);}/*accessmodifierschangedfrom:protected*/publicvoidonStop(){super.onStop();unbindService(this.mServiceConn);}publicbooleanonCreateOptionsMenu(Menumenu){getMenuInflater().inflate(R.menu.activity,menu);returntrue;}publicbooleanonPrepareOptionsMenu(Menumenu){super.onPrepareOptionsMenu(menu);menu.findItem(R.id.menu_close_tab).setEnabled(this.mTermAdapter.getCount()>0);returntrue;}publicbooleanonOptionsItemSelected(MenuItemmenuItem){switch(menuItem.getItemId()){caseR.id.menu_close_tab/*{ENCODED_INT:2131165281}*/:this.mService.destroyTerminal(this.mService.getTerminals().keyAt(this.mPager.getCurrentItem()));this.mTermAdapter.notifyDataSetChanged();invalidateOptionsMenu();returntrue;caseR.id.menu_new_tab/*{ENCODED_INT:2131165282}*/:this.mService.createTerminal();this.mTermAdapter.notifyDataSetChanged();invalidateOptionsMenu();this.mPager.setCurrentItem(this.mService.getTerminals().size()-1,true);returntrue;default:returnfalse;}}加固后:privatefinalPagerAdaptermTermAdapter=newPagerAdapter(){/*classcom.android.terminal.TerminalActivity.AnonymousClass2*/privateSparseArray>mSavedState=newSparseArray();static{NativeUtil.classesInit0(629);}@Override//androidx.viewpager.widget.PagerAdapterpublicnativevoiddestroyItem(ViewGroupviewGroup,inti,Objectobj);@Override//androidx.viewpager.widget.PagerAdapterpublicnativeintgetCount();@Override//androidx.viewpager.widget.PagerAdapterpublicnativeintgetItemPosition(Objectobj);@Override//androidx.viewpager.widget.PagerAdapterpublicnativeCharSequencegetPageTitle(inti);@Override//androidx.viewpager.widget.PagerAdapterpublicnativeObjectinstantiateItem(ViewGroupviewGroup,inti);@Override//androidx.viewpager.widget.PagerAdapterpublicnativebooleanisViewFromObject(Viewview,Objectobj);};privatePagerTitleStripmTitles;static{NativeUtil.classesInit0(425);}/*accessmodifierschangedfrom:protected*/publicnativevoidonCreate(Bundlebundle);publicnativebooleanonCreateOptionsMenu(Menumenu);publicnativebooleanonOptionsItemSelected(MenuItemmenuItem);publicnativebooleanonPrepareOptionsMenu(Menumenu);/*accessmodifierschangedfrom:protected*/publicnativevoidonStart();/*accessmodifierschangedfrom:protected*/publicnativevoidonStop();如代码所示,Java方法已经替换为native方法,当代码真正执行的时候会执行到native侧,此时会进行方法的指令获取,类型判断,指令解析以及真正的的逻辑执行。至此,综合前几代加固方案,对静态代码,资源文件,内存,调试等几方面的保护,逆向攻击者已经无法轻松的破解我们的程序了。总结与展望反作弊是有没有终点的,当黑产付出的代价已经远超获得的利益时,我们就已经算是阶段性胜利了,在经过现阶段的相关安全技术升级,工作端作弊现象基本已经杜绝,能够很好的保证阿姨接单的公平性。应用加固的必要性在现如今越来越重要,因此后续将会逐步尝试进行加固工具的自研,取代外部采购。作者介绍刘思奇,LBG-终端技术部-工作端组高级研发工程师,主要负责58到家工作端日常开发维护工作。参考资料[1]代码混淆规则:https://www.guardsquare.com/manual/troubleshooting/troubleshooting[2]资源混淆方案:https://github.com/shwenzhang/AndResGuard[3]Android模拟器检测体系梳理:https://bbs.pediy.com/thread-255672.htm[4]检测Android虚拟机的方法和代码实现:https://bbs.pediy.com/thread-225717.htm[5]Xposed分析:https://bbs.pediy.com/thread-269627.htm[6]Frida源码分析:https://mabin004.github.io/2018/07/31/Mac%E4%B8%8A%E7%BC%96%E8%AF%91Frida/[7]Launcher启动过程与系统启动流程:https://blog.csdn.net/itachi85/article/details/56669808[8]ActivityThread的理解和APP的启动过程:https://blog.csdn.net/hzwailll/article/details/85339714[9]深入理解类加载器和动态加载:https://bbs.pediy.com/thread-271538.htm[10]ARM平台指令虚拟化初探:https://www.cnblogs.com/2014asm/p/6534897.html[11]自行实现一套DEX-VMP:https://github.com/maoabc/nmmp[12]签名机制:https://blog.csdn.net/chuyouyinghe/article/details/12580094
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 12:52 , Processed in 1.424482 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表