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

2023/04/13 09:47
阅读数 343

1

背景

目前我所维护的项目是58到家工作端,定位是一款ToB的工具型应用,目的是帮助家政从业人员更方便的进行上户工作,随着业务的逐渐迭代,发现部分用户在日常的使用中存在作弊的现象,此现象的存在会导致未作弊阿姨可能接到的订单量减少,甚至在活动期间薅羊毛,影响派单的公平性以及增大公司的活动资金投入,因此需要我们对应用的安全性进行一定的提升以保证整体系统的安全性以及公平性.

现阶段接入了梆梆加固,在接入过程中需要确定相关加固策略,因此需要对应用加固有系统的了解,本文主要是对此次安全升级的总结及以及在58到家工作端中的落地实践.


2

Android应用安全防护原理与实践

2.1 防护的基本策略

2.1.1 混淆

2.1.1.1 代码混淆

在Android平台,源代码最终都会被编译成平台所需要的字节码,其中包含了很多源代码信息,如类名、方法名、变量名等,由于其具有语义信息,因此在逆向过程中很容易就被反编译成源代码,为了防止这种现象,我们可以使用混淆器来对代码进行混淆,目的是程序进行重新组织,使用等价的关系将类名、方法名、变量名等替换为简短的无意义的字符串,如a、b、c等,使得即使应用被反编译后也不会很容易的理解,增大阅读的难度.

开启代码混淆
// 主工程 build.gradleandroid {    buildTypes {        release {            // 配置release包的签名            signingConfig signingConfigs.key              // 混淆是否开启 [true 开启 、 false 不开启]            minifyEnabled true              // 配置混淆规则文件            proguardFiles getDefaultProguardFile(\'proguard-android-optimize.txt\'), \'proguard-rules.pro\'        }    }}
注: 具体代码混淆规则不进行讲述,详见文章末尾参考资料.
  • 混淆前后对比:

  • 混淆前后包大小对比:

    通过混淆前后对比后明显可以看出,原来可以见名知意的方法名或变量名已经无法直观的看出其真实的含义.

2.1.1.2 资源混淆

与代码混淆类似,将原来见名知意的资源名称等价替换为无意义的字符串,增加破解后查找资源的难度.
具体接入方式及原理文中不进行阐述,见参考资料:
资源混淆方案资源混淆原理

  • 混淆前后对比:

  • 混淆前后包大小对比:

混淆小结
  • 开启代码混淆及资源混淆后,代码及资源变的没有规则,无法见名知意,即使应用被破解,攻击者也无法很快的找到想要的内容,增加了阅读的难度.

  • 同时,混淆过程中会检查删除没有使用的类、方法、属性等,并且优化字节码,移除无用的指令,以及将较长的名字替换为较短的名字对减小应用包体积有很明显的帮助.

2.1.2 签名保护

Android中的每个应用都有一个唯一的签名,如果一个应用没有签名是不允许安装到设备中的,开发过程中Debug版本使用的是默认的签名文件.上线发布Release版本时都需要使用我们自己创建的签名文件对apk进行签名.
在未开启签名保护之前,逆向攻击者可能在反编译应用之后,对我们的代码逻辑进行修改,比如删除一些校验逻辑、增加一些广告,然后使用他们自己的签名文件重新签名后再发布出去,破坏了我们原有的生态.并且因为重签后的签名与我们自己的不一
致,后续就无法进行版本升级.只能卸载重装.
一般来说我们的签名,逆向攻击者是无法获取到的,根据Android系统签名唯一性校验的机制,我们可以利用该特性做一层防护.

  • Java代码本地签名校验,通过PackageInfo得到Signature,此时即可获取到证书的hash值来进行对比,但此种方案过于捡漏,通过修改smali文件即可轻松绕过.

  • NDK的形式,将校验逻辑下沉到C/C++代码中,并且将签名hash值进行相应算法处理,最后构建为so库,通过JNI接口调用,相较于纯Java层校验,此种方式增加了复杂度,反编译后不是以smali这种易于理解和修改的形式.

2.1.3 模拟器检测

Android模拟器就是一种可以运行在PC端用以模拟真实手机运行环境的虚拟设备,并且目前市面上大部分模拟器软件(雷电、逍遥、夜神等)都提供一些用于修改设备参数,虚拟定位等功能,对于我们的应用来说,如果用户使用虚拟定位功能则属于严重的作弊行为.因此需要对此种行为进行严格的控制.
检测虚拟机方案有很多,但大都是基于对比真机与模拟器的差异来进行的,由于我们应用中使用的模拟器检测功能支持来自于信安,非我们团队实现,因此不进行具体讲解,详见参考资料
Android模拟器检测体系梳理检测Android虚拟机的方法和代码实现.

2.1.4 Root检测

对于逆向攻击者来说,想要对我们的代码进行hook操作的前提条件是拿到手机的Root权限,因此Root检测也是现在应用防护的一种方式.

  • 检测是否存在su目录以及使用which命令检测su

    object CheatDetection {        private val superUserDictionaryPath = 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",    )
    fun suAvailable(): Boolean { try { for (path in superUserDictionaryPath) { val file = File(path) if (file.exists() or file.canExecute()) { Log.e("Root检测", "命中path: ${file.absolutePath}") return true } } } catch (e: Exception) { e.printStackTrace() } return false }}
    val process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))// 当process执行没有结果时,则表示没有root.// 注: 需要注意缓冲区的数据,防止程序阻塞,具体处理方式不进行描述
  • 读取build.prop中关键属性,如ro.build.tags

当手机系统是测试版时,默认是享有Root权限的,并且此时的tags值为"test-keys",正式版为"release-keys".
    fun isTestVersion(): Boolean {        val tags = android.os.Build.TAGS        val debugVersionKey = "test-keys"        if (!tags.isNullOrEmpty() && tags.contains(debugVersionKey)) {            Log.e("Root检测", "命中版本: $debugVersionKey")            return true        }        return false    }

检测Magisk或者Superuser.apk

关于检测Magisk的方式针对于最新版暂未想到很好的办法,在老版本的时候可以进行检测包名com.topjohnwu.magisk,但新版本的提供了随机包名的方式进行绕过.

   fun checkCheatApk()Boolean {        val superUserApkPath = "/system/app/Superuser.apk"        try {            val file = File(superUserApkPath)            if (file.exists()) {                return true            }        } catch (e: Exception) {            e.printStackTrace()        }
return false }

执行busybox

Android系统由于安全的考虑,将一些可能带来风险的命令去掉了,如(su、find、mount等),busybox工具箱由此而来,其中集成了许多Linux命令和工具,所以如果设备root了,可能就会安装了busybox,由此我们可以采用调用busybox来进行检测,与使用which命令检测su类似,也需要进行缓冲区的处理.

val process = Runtime.getRuntime().exec(arrayOf("busybox", "df"))// 当process执行没有结果时,则表示没有root.
  • 访问私有目录,如/data目录,查看读写权限
    Android系统中私有目录必须要有root权限才能进行访问,如/data、/system、/etc等,因此可以通过读写相关目录进行检测判断.

  • 检测xposed、frida等hook框架的特征
    Xposed是一个动态插桩的hook框架,通过替换app_process原始进程,将java函数注册为native函数,从而获得更早的运行时机.可以通过针对特征点修改来进行检测(详见参考资料
    Xposed分析).
    frida与xposed原理类似,同样是动态插桩工具,frida最简单的检测方式就是检查运行的服务中是否有frida-server. 具体方案请参照
    Frida源码分析

2.2 应用加固原理

在实际场景中,即使使用了大量的基本防护策略,但对于专业逆向人员来说,这些防护策略还是能够进行绕过的,只是需要花费一些时间而已,由此在不断的博弈中,应用加固这个顺势而生,简单来说就是对原有应用进行改造,提高攻击者的破解难度,让攻击者从中获取的利益与所花费的时间和经历不成正比,以达到保护应用的目的.

2.2.1 常规加壳原理及实践

Dex加壳可以理解为对原APK进行加密后并再其外部套上一层外壳.

需要掌握的基本知识点:

  • Launcher启动过程与系统启动流程

  • ActivityThread的理解和APP的启动过程

  • 深入理解类加载器和动态加载

完整加固流程:

注: 打包过程中需要进行AndroidManifest文件的修改,将原apk中Application节点的类替换为我们的壳程序入口.

壳应用执行过程采用伪代码分析

  class ShellApplication : Application() {
override fun attachBaseContext(base: Context) { super.attachBaseContext(base)
// 1. 解压加固后apk. val unzipApk = unzipApk() // 2. 对dex文件进行解密操作 unzipApk.forEach { if (it.name.endsWith(".dex")) { val originalBytes = decrypt(it.toBytes()) val fileOutputStream = FileOutputStream(it) fileOutputStream.write(originalBytes) fileOutputStream.flush() fileOutputStream.close() } } // 提取出解密后的dex文件 val dexFiles = 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.2.2 DEX-VMP方案

代码指令虚拟化方案,原理是将代码编译为虚拟机指令,通过自定义虚拟机解释执行,其针对目标也是函数,通俗的讲就是自定义一套字节码指令,将函数替换为等价的自定义指令,然后使用一个解释器解释并运行字节码.

自定义字节码

enum OPCODES{MOV = 0xa0, // mov指令对应 0xa0XOR = 0xa1,CMP = 0xa2,RET = 0xa3,....};

自定义处理器

typedef struct processor_t{int r0; // 虚拟寄存器r0~r15int r1;....int FP;int IP;char* SP;int LR;unsigned char* PC; // 虚拟机寄存器PC,指向正在解释的字节码地址int cpsr; // 虚拟标志寄存器flag,作用类似于eflagsvm_opcode op_table[OPCODE_NUM]; // 字节码列表,存放了所有字节码与对应的处理函数} vm_processor;

自定义解释器

   void vm_CPU(vm_processor *proc, unsigned char* Vcode){      // PC指向被保护代码的第一个字节      proc -> PC = Vcode;      // 循环判断PC指向字节码是否为返回指令,如果不是就解释执行      while(*proc ->PC != RET){        int flag = 0;        int i = 0;        // 查找PC指向的正在解释的字节码对应的处理函数        while(!flag && i <OPCODE_NUM){          if(*proc->PC == 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,则进行字节码解释,后面则会按照我们的定义规则来进行处理执行逻辑.

具体流程

加固流程:

解释器执行流程:

加固前:

     public void onCreate(Bundle bundle) {          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);      }
/* access modifiers changed from: protected */ public void onStart() { super.onStart(); bindService(new Intent(this, TerminalService.class), this.mServiceConn, 1); }
/* access modifiers changed from: protected */ public void onStop() { super.onStop(); unbindService(this.mServiceConn); }
public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.activity, menu); return true; }
public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); menu.findItem(R.id.menu_close_tab).setEnabled(this.mTermAdapter.getCount() > 0); return true; }
public boolean onOptionsItemSelected(MenuItem menuItem) { switch (menuItem.getItemId()) { case R.id.menu_close_tab /*{ENCODED_INT: 2131165281}*/: this.mService.destroyTerminal(this.mService.getTerminals().keyAt(this.mPager.getCurrentItem())); this.mTermAdapter.notifyDataSetChanged(); invalidateOptionsMenu(); return true; case R.id.menu_new_tab /*{ENCODED_INT: 2131165282}*/: this.mService.createTerminal(); this.mTermAdapter.notifyDataSetChanged(); invalidateOptionsMenu(); this.mPager.setCurrentItem(this.mService.getTerminals().size() - 1, true); return true; default: return false; } } ```

加固后:

private final PagerAdapter mTermAdapter = new PagerAdapter() {        /* class com.android.terminal.TerminalActivity.AnonymousClass2 */        private SparseArray<SparseArray<Parcelable>> mSavedState = new SparseArray<>();
static { NativeUtil.classesInit0(629); }
@Override // androidx.viewpager.widget.PagerAdapter public native void destroyItem(ViewGroup viewGroup, int i, Object obj);
@Override // androidx.viewpager.widget.PagerAdapter public native int getCount();
@Override // androidx.viewpager.widget.PagerAdapter public native int getItemPosition(Object obj);
@Override // androidx.viewpager.widget.PagerAdapter public native CharSequence getPageTitle(int i);
@Override // androidx.viewpager.widget.PagerAdapter public native Object instantiateItem(ViewGroup viewGroup, int i);
@Override // androidx.viewpager.widget.PagerAdapter public native boolean isViewFromObject(View view, Object obj); }; private PagerTitleStrip mTitles;
static { NativeUtil.classesInit0(425); }
/* access modifiers changed from: protected */ public native void onCreate(Bundle bundle);
public native boolean onCreateOptionsMenu(Menu menu);
public native boolean onOptionsItemSelected(MenuItem menuItem);
public native boolean onPrepareOptionsMenu(Menu menu);
/* access modifiers changed from: protected */ public native void onStart();
/* access modifiers changed from: protected */ public native void onStop();

如代码所示,Java方法已经替换为native方法,当代码真正执行的时候会执行到native侧,此时会进行方法的指令获取,类型判断,指令解析以及真正的的逻辑执行.

至此,综合前几代加固方案,对静态代码,资源文件,内存,调试等几方面的保护,逆向攻击者已经无法轻松的破解我们的程序了.


3

总结与展望

反作弊是有没有终点的,当黑产付出的代价已经远超获得的利益时,我们就已经算是阶段性胜利了,在经过现阶段的相关安全技术升级,工作端作弊现象基本已经杜绝,能够很好的保证阿姨接单的公平性.

应用加固的必要性在现如今越来越重要,因此后续将会逐步尝试进行加固工具的自研,取代外部采购.


参考资料

  • https://www.guardsquare.com/manual/troubleshooting/troubleshooting 

  • https://github.com/shwenzhang/AndResGuard 

  • 安装包立减1M--微信Android资源混淆打包工具 

  • https://bbs.kanxue.com/thread-255672.htm 

  • https://bbs.kanxue.com/thread-225717.htm 

  • https://bbs.kanxue.com/thread-269627.htm  

  • https://mabin004.github.io/2018/07/31/Mac%E4%B8%8A%E7%BC%96%E8%AF%91Frida/ 

  • https://blog.csdn.net/itachi85/article/details/56669808 

  • https://blog.csdn.net/hzwailll/article/details/85339714 

  • https://bbs.kanxue.com/thread-271538.htm 

  • https://www.cnblogs.com/2014asm/p/6534897.html 

  • https://github.com/maoabc/nmmp 

  • https://blog.csdn.net/chuyouyinghe/article/details/125800941


作者介绍

刘思奇,LBG-终端技术部-工作端组高级研发工程师,主要负责58到家工作端日常开发维护工作.

本文分享自微信公众号 - 58技术(architects_58)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部