Android CTF

2023-10-20

1. android逆向简介

总的来说,android逆向可以分为java层逆向native层逆向
java层逆向就是很简单的,把一个apk文件拖入Android分析工具(比如jeb、jadx)中,就可以看到java代码
native层逆向指的是把代码存入一个so文件(通常由c/c++编写),然后再把so文件打包进apk中,在apk中会调用native层的代码,然而一般的Android逆向工具无法看到其代码,需要将so文件拖入ida进行分析

2. 通过mainifest.xml文件查看入口

举个例子:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:versionCode="1" android:versionName="1.0" package="com.example.mobicrackndk">
    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="17" />
    <application android:theme="@style/AppTheme" android:label="@string/app_name" android:icon="@drawable/ic_launcher" android:allowBackup="true">
        <activity android:label="@string/app_name" android:name="com.example.mobicrackndk.CrackMe">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

其中的activity android:label的这个项,即是入口点

3. java层逆向

一般用jebjadx反编译出来的就是java层代码,需要你自己熟读代码

4. native层逆向

java层想要调用native层的函数,其实so文件中是有迹可循的,即被调用的函数必须被JNI注册
JNI注册方法分为静态注册和动态注册,静态注册的方法可以在IDA的函数窗口或者导出表中直接找到,比较简单。动态注册的方法需要分析JNI_OnLoad函数

4.1 静态注册

静态注册的方法可以在IDA的函数窗口或者导出表中直接找到,比较简单
静态注册的函数格式如下:

#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_com_frank_ffmpeg_FFmpegCmd_hello(JNIEnv *env, jclass thiz, jint num) {
}
#ifdef __cplusplus
}
#endif

JavaVM 是虚拟机在 JNI 层的代表,一个进程只有一个 JavaVM,所有的线程共用一个 JavaVMJavaVM 是一个全局变量,一个进程只有一个 JavaVM 对象。
JNIEnv 是一个线程相关的结构体,该结构体代表了 Java 在本线程的运行环境 。JNIEnv 是一个线程拥有一个,不同线程的 JNIEnv 彼此独立。

4.2 动态注册

动态注册的方法需要分析JNI_OnLoad函数
以下是一个简单的JNI_OnLoad函数

//第一步,实现JNI_OnLoad方法
JNIEXPORT jint JNI_OnLoad(JavaVM* jvm, void* reserved){
    //第二步,获取JNIEnv
    JNIEnv* env = NULL;
    if(jvm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK){
        return JNI_FALSE;
    }
    //第三步,获取注册方法所在Java类的引用
    jclass clazz = env->FindClass("com/curz0n/MainActivity");
    if (!clazz){
        return JNI_FALSE;
    }
    //第四步,动态注册native方法
    if(env->RegisterNatives(clazz, gMethods, sizeof(gMethods)/sizeof(gMethods[0]))){
        return JNI_FALSE;
    }
    return JNI_VERSION_1_6;
}

其中第四步gMethods变量是JNINativeMethod结构体,用于映射Java方法与C/C++函数的关系,其定义如下:

typedef struct {
    const char* name; //动态注册的Java方法名
    const char* signature; //描述方法参数和返回值
    void*       fnPtr; //指向实现Java方法的C/C++函数指针
} JNINativeMethod;

所以逆向中,动态注册的函数经常可以在Jni_onload里发现端倪
我们知道JNI_OnLoad函数的第一个参数是JavaVM指针类型,这里IDA工具不能自动识别,所以需要手动修复一下,选中int,右键选择Set lvar tyep(快捷键Y)重新设置变量类型,这有助于帮你理解代码

4.3 .init段分析

在链接so共享目标文件的时候,如果so中存在.init和.init_array段,则会先执行.init和.init_array段的函数,然后再执行JNI_OnLoad函数。通过静态分析可知,JNI_OnLoad函数中的v4指针指向的地址上的变量值是加密状态,在实际运行的过程中,v4指针指向的地址上的值应该是解密状态,所以解密的操作应该在JNI_OnLoad函数运行之前,.init或者.init_array段上的函数。 查看Segments视图(快捷键Ctrl+S),该目标文件只存在.init_array段: 有时候init段会进行一些加解密操作,需要注意

5. 代码混淆

android逆向常规步骤如上述,先看manifest.xml文件,找到入口点,然后看java层代码,再看native层代码。
然而,事情不总是那么顺利,有的人比较恶心啊,会给代码进行混淆,这样你看不懂到底是什么逻辑。

5.1 办法1: ida findcrypt3

https://github.com/polymorf/findcrypt-yara
android的反编译工具没有findcrypt3,怎么办呢?其实findcrypt的原理是,搜索一些加密算法中会使用到的常数来判断使用了什么加密算法
所以,如果遇到了混淆的代码,我们可以 抄出里面用到的常数,编译成x86架构的执行文件,再放入ida中使用findcrypt进行分析!

5.2 办法2: 反混淆工具

这里列两个目前用过比较顶的java反混淆工具:
https://seosniffer.com/javascript-deobfuscator
https://github.com/kuizuo/js-deobfuscator

5.3 armariris 混淆

有一些ctf的逆向题目,默认情况下代码是加密过的,在运行时会执行解密函数来完成解密
以armariris为例,实现在https://github.com/GoSSIP-SJTU/Armariris
本质上是对android中的so进行了加密混淆,影响你的阅读

so运行时解密,已经有了一站式的解决方案!unicorn yyds!

from elftools.elf.constants import P_FLAGS
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection
from unicorn import Uc, UC_ARCH_ARM, UC_MODE_LITTLE_ENDIAN, UC_PROT_WRITE, UC_PROT_READ, UC_PROT_EXEC
from unicorn.arm_const import *
from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB, CsInsn
from keystone import Ks, KS_MODE_THUMB, KS_ARCH_ARM, KS_MODE_ARM
import struct

filename = "./libcms.so"
fd = open(filename, 'r+b')
elf = ELFFile(fd)

# 遍历符号表,找到.datadiv_decode开头的函数
datadivs = []
dynsym = elf.get_section_by_name(".dynsym")
assert isinstance(dynsym, SymbolTableSection)
for symbol in dynsym.iter_symbols():
    if symbol.name.startswith('.datadiv_decode'):
        datadivs.append(symbol.entry.st_value)


# 加载 so 到内存中
def align(addr, size, align):
    fr_addr = addr // align * align
    to_addr = (addr + size + align - 1) // align * align
    return fr_addr, to_addr - fr_addr


def pflags2prot(p_flags):
    ret = 0
    if p_flags & P_FLAGS.PF_R != 0:
        ret |= UC_PROT_READ
    if p_flags & P_FLAGS.PF_W != 0:
        ret |= UC_PROT_WRITE
    if p_flags & P_FLAGS.PF_X != 0:
        ret |= UC_PROT_EXEC
    return ret


load_base = 0
emu = Uc(UC_ARCH_ARM, UC_MODE_LITTLE_ENDIAN)
load_segments = [x for x in elf.iter_segments() if x.header.p_type == 'PT_LOAD']
for segment in load_segments:
    fr_addr, size = align(load_base + segment.header.p_vaddr, segment.header.p_memsz, segment.header.p_align)
    emu.mem_map(fr_addr, size, pflags2prot(segment.header.p_flags))
    emu.mem_write(load_base + segment.header.p_vaddr, segment.data())

STACK_ADDR = 0x7F000000
STACK_SIZE = 1024 * 1024
start_addr = None
emu.mem_map(STACK_ADDR, STACK_SIZE, UC_PROT_READ | UC_PROT_WRITE)
emu.reg_write(UC_ARM_REG_SP, STACK_ADDR + STACK_SIZE)

# 调用datadiv
for datadiv in datadivs:
    print("Function invoke", hex(datadiv))
    emu.reg_write(UC_ARM_REG_LR, 0)
    emu.emu_start(datadiv, 0)
    print("Function return")

# Patch data
print("Patch data")
data_section_header = elf.get_section_by_name('.data').header
new_data = emu.mem_read(data_section_header.sh_addr, data_section_header.sh_size)
fd.seek(data_section_header.sh_offset)
fd.write(new_data)

# Patch datadiv 直接返回
print("Patch datadiv")
ks_thumb = Ks(KS_ARCH_ARM, KS_MODE_THUMB)
ks_arm = Ks(KS_ARCH_ARM, KS_MODE_ARM)
for datadiv in datadivs:
    fd.seek(datadiv & 0xFFFFFFFE)
    if datadiv & 0x1 == 0x1:
        a = ks_thumb.asm('bx lr')[0]
    else:
        a = ks_arm.asm('bx lr')[0]
    for _ in a:
        fd.write(struct.pack("B", _))

fd.close()
print("done!")

6. 安全加固

安全加固也是一种特有的方式,本质上是把实际执行代码加密,并替换android的启动入口点,在android程序启动后,对实际执行代码解密并进行装载
与混淆不同的是,混淆是为了影响你对源代码的阅读,而安全加固直接就是不让你读源代码
这里只能附上高手的blog,之后在慢慢分析
https://blog.niunaijun.top
https://cnblogs.com/bmjoker

脱壳工具:https://github.com/CodingGay/BlackDex
查壳工具:https://github.com/MagiCiAn1/APKProtectionSearch

7. 反调试机制

有的apk会有反调试机制,比如检测是否被fridaxposedcydia等hook框架hook,如果被hook了,就会直接退出
frida详情可参考https://wsxk.github.io/frida_hook/
这里列举一个文章,这篇文章教你如何绕过神秘的反调试:
https://bbs.kanxue.com/thread-277034.htm

references

https://curz0n.github.io/2021/05/10/android-so-reverse/#0x00-%E5%89%8D%E8%A8%80
https://ctf-wiki.org/android/basic_reverse/static/so-example/
https://blog.csdn.net/freeking101/article/details/106044591