背景

现在我所维护的项目是 58 到家作业端,定位是一款 ToB 的东西型运用,意图是帮助家政从业人员更便利的进行上户作业,随着事务的逐步迭代,发现部分用户在日常的运用中存在做弊的现象,此现象的存在会导致未做弊阿姨可能接到的订单量减少,甚至在活动期间薅羊毛,影响派单的公平性以及增大公司的活动资金投入,因而需求咱们对运用的安全性进行一定的进步以确保全体体系的安全性以及公平性。

现阶段接入了梆梆加固,在接入进程中需求确定相关加固战略,因而需求对运用加固有体系的了解,本文主要是对此次安全晋级的总结及以及在 58 到家作业端中的落地实践。

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

1. 防护的根本战略

1.1 混杂

1.1.1 代码混杂

在 Android 渠道,源代码终究都会被编译成渠道所需求的字节码,其间包含了许多源代码信息,如类名、办法名、变量名等,因为其具有语义信息,因而在逆向进程中很容易就被反编译成源代码,为了避免这种现象,咱们能够运用混杂器来对代码进行混杂,意图是程序进行从头组织,运用等价的联系将类名、办法名、变量名等替换为简略的无意义的字符串,如 a、b、c 等,使得即便运用被反编译后也不会很容易的了解,增大阅读的难度。

敞开代码混杂
// 主工程 build.gradle
android {
    buildTypes {
        release {
            // 配置release包的签名
            signingConfig signingConfigs.key
            // 混杂是否敞开 [true 敞开 、 false 不敞开]
            minifyEnabled true
            // 配置混杂规矩文件
            proguardFiles getDefaultProguardFile(\'proguard-android-optimize.txt\'), \'proguard-rules.pro\'
        }
    }
}

注: 详细代码混杂规矩不进行讲述,详见文章末尾参考资料。

  • 混杂前后比照:

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

  • 混杂前后包巨细比照:

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

    经过混杂前后比照后显着能够看出,本来能够见名知意的办法名或变量名现已无法直观的看出其实在的意义。

1.1.2 资源混杂

与代码混杂相似,将本来见名知意的资源称号等价替换为无意义的字符串,添加破解后查找资源的难度. 详细接入办法及原理文中不进行阐述,见参考资料:资源混杂计划、资源混杂原理

  • 混杂前后比照:
    由工作端反作弊而引发的对应用安全的思考
  • 混杂前后包巨细比照:
    由工作端反作弊而引发的对应用安全的思考
混杂小结
  • 敞开代码混杂及资源混杂后,代码及资源变的没有规矩,无法见名知意,即便运用被破解,攻击者也无法很快的找到想要的内容,添加了阅读的难度.
  • 同时,混杂进程中会查看删除没有运用的类、办法、属性等,而且优化字节码,移除无用的指令,以及将较长的名字替换为较短的名字对减小运用包体积有很显着的帮助。

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 模拟器检测体系整理、检测 Android 虚拟机的办法和代码完结。

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.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 DEX-VMP 计划

代码指令虚拟化计划,原理是将代码编译为虚拟机指令,经过自界说虚拟机解说履行,其针对方针也是函数,通俗的讲便是自界说一套字节码指令,将函数替换为等价的自界说指令,然后运用一个解说器解说并运转字节码。

自界说字节码

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

自界说处理器

typedef struct processor_t
{
  int r0; // 虚拟寄存器r0~r15
  int r1;
  ....
  int FP;
  int IP;
  char* SP;
  int LR;
  unsigned char* PC; // 虚拟机寄存器PC,指向正在解说的字节码地址
  int cpsr; // 虚拟标志寄存器flag,作用相似于eflags
  vm_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 侧,此刻会进行办法的指令获取,类型判别,指令解析以及真实的的逻辑履行。

至此,归纳前几代加固计划,对静态代码,资源文件,内存,调试等几方面的维护,逆向攻击者现已无法轻松的破解咱们的程序了。

总结与展望

反做弊是有没有结尾的,当黑产付出的价值现已远超取得的利益时,咱们就现已算是阶段性成功了,在经过现阶段的相关安全技能晋级,作业端做弊现象根本现已根绝,能够很好的确保阿姨接单的公平性。 运用加固的必要性在现如今越来越重要,因而后续将会逐步尝试进行加固东西的自研,替代外部采购。

参考资料

  • 代码混杂规矩
  • 资源混杂计划
  • 资源混杂原理
  • Android 模拟器检测体系整理
  • 检测 Android 虚拟机的办法和代码完结
  • Xposed 剖析
  • Frida 源码剖析
  • Launcher 发动进程与体系发动流程
  • ActivityThread 的了解和 APP 的发动进程
  • 深入了解类加载器和动态加载
  • ARM 渠道指令虚拟化初探
  • 自行完结一套 DEX-VMP
  • 签名机制

作者介绍

刘思奇,LBG-终端技能部-作业端组高级研发工程师,主要担任 58 到家作业端日常开发维护作业。