
sunilwang
2022/12/20阅读:26主题:绿意
由工作端反作弊而引发的对应用安全的思考
背景
目前我所维护的项目是 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.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.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 分析[6])。 frida 与 xposed 原理类似,同样是动态插桩工具,frida 最简单的检测方式就是检查运行的服务中是否有
frida-server
。 具体方案请参照Frida 源码分析[7]
2. 应用加固原理
在实际场景中,即使使用了大量的基本防护策略,但对于专业逆向人员来说,这些防护策略还是能够进行绕过的,只是需要花费一些时间而已,由此在不断的博弈中,应用加固这个顺势而生,简单来说就是对原有应用进行改造,提高攻击者的破解难度,让攻击者从中获取的利益与所花费的时间和经历不成正比,以达到保护应用的目的。
2.1 常规加壳原理及实践
Dex 加壳可以理解为对原 APK 进行加密后并再其外部套上一层外壳。
需要掌握的基本知识点:
-
Launcher 启动过程与系统启动流程[8] -
ActivityThread 的理解和 APP 的启动过程[9] -
深入理解类加载器和动态加载[10]
完整加固流程: 注: 打包过程中需要进行
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 侧,此时会进行方法的指令获取,类型判断,指令解析以及真正的的逻辑执行。
至此,综合前几代加固方案,对静态代码,资源文件,内存,调试等几方面的保护,逆向攻击者已经无法轻松的破解我们的程序了。
总结与展望
反作弊是有没有终点的,当黑产付出的代价已经远超获得的利益时,我们就已经算是阶段性胜利了,在经过现阶段的相关安全技术升级,工作端作弊现象基本已经杜绝,能够很好的保证阿姨接单的公平性。 应用加固的必要性在现如今越来越重要,因此后续将会逐步尝试进行加固工具的自研,取代外部采购。
参考资料
-
代码混淆规则[11] -
资源混淆方案[12] -
资源混淆原理 -
Android 模拟器检测体系梳理[13] -
检测 Android 虚拟机的方法和代码实现[14] -
Xposed 分析[15] -
Frida 源码分析[16] -
Launcher 启动过程与系统启动流程[17] -
ActivityThread 的理解和 APP 的启动过程[18] -
深入理解类加载器和动态加载[19] -
ARM 平台指令虚拟化初探[20] -
自行实现一套 DEX-VMP[21] -
签名机制[22]
作者介绍
刘思奇,LBG-终端技术部-工作端组高级研发工程师,主要负责 58 到家工作端日常开发维护工作。
参考资料
代码混淆规则: #混淆规则
[2]资源混淆方案: #资源混淆原理
[3]资源混淆原理: #资源混淆原理
[4]Android 模拟器检测体系梳理: #Android模拟器检测体系梳理
[5]检测 Android 虚拟机的方法和代码实现: #检测Android虚拟机的方法和代码实现
[6]Xposed 分析: #Xposed分析
[7]Frida 源码分析: #Frida源码分析
[8]Launcher 启动过程与系统启动流程: #Launcher启动过程与系统启动流程
[9]ActivityThread 的理解和 APP 的启动过程: #ActivityThread的理解和APP的启动过程
[10]深入理解类加载器和动态加载: #深入理解类加载器和动态加载
[11]代码混淆规则: https://www.guardsquare.com/manual/troubleshooting/troubleshooting
[12]资源混淆方案: https://github.com/shwenzhang/AndResGuard
[13]Android 模拟器检测体系梳理: https://bbs.pediy.com/thread-255672.htm
[14]检测 Android 虚拟机的方法和代码实现: https://bbs.pediy.com/thread-225717.htm
[15]Xposed 分析: https://bbs.pediy.com/thread-269627.htm
[16]Frida 源码分析: https://mabin004.github.io/2018/07/31/Mac%E4%B8%8A%E7%BC%96%E8%AF%91Frida/
[17]Launcher 启动过程与系统启动流程: https://blog.csdn.net/itachi85/article/details/56669808
[18]ActivityThread 的理解和 APP 的启动过程: https://blog.csdn.net/hzwailll/article/details/85339714
[19]深入理解类加载器和动态加载: https://bbs.pediy.com/thread-271538.htm
[20]ARM 平台指令虚拟化初探: https://www.cnblogs.com/2014asm/p/6534897.html
[21]自行实现一套 DEX-VMP: https://github.com/maoabc/nmmp
[22]签名机制: https://blog.csdn.net/chuyouyinghe/article/details/125800941
作者介绍
