#lab

lab1 android

developer

lab1 安卓逆向

  • step1 安卓开发基础
  • step2 探究smali
  • step3 逆向夺旗游戏
  • step4 soot入门

lab1 step1 安卓开发基础

开发一个demo安卓app

task1 后台获取定位的app

目标

开发一个安卓app,该app创建一个服务,每3秒获取一次用户地址,并且通过toast显示出来。

项目结构

  • MainActivity 程序入口,主界面,唤起服务
  • SecretService 后台程序,获取地址并显示
  • SecretBootReceiver 接受开机信号,自动启动服务

开发过程

  1. Android Studio没有代码提示

必须下载所有组件后才能正常开发,如果中途下载断了就进来,会发现项目结构都创建的不完整。需要重新启动ide,重新下载。对于网速问题,可以设置HTTP代理为国内镜像站,也可以在系统代理的分流规则中把dl.google.com和gradle.org强制代理。

  1. 运行按钮是灰色的

大概率是gradle没有下载完所有包导致的。在确认网络和配置无误的前提下:点击File > Sync Project with Gradle Files,如果按钮灰色,尝试File > Invalidate Caches / Restart > Invalidate and Restart重启ide。

  1. Gradle构建特别慢

即使正确设置了代理,Gradle启动也特别慢。实在无法依赖网络了,于是把Gradle改为离线模式。首先,在gradle.org下载gradle的binary版本。然后,在ide设置中把gradle模式从Wrapper改为Local,指定目录为对应地址。构建速度果然大幅提升。

  1. app可以启动但无法获取gps

这是由于在安卓8之后,很多敏感权限需要请用户授权。不能在MainActivity中直接startService,需要添加一个if-else来请求权限。

  1. 切换app到后台导致服务中断

app在前台时,可以正确获取地址和弹出toast,一切到后台后5s,服务就中断了。观察logcat,发现服务被终止,报错ANR: Reason: Context.startForegroundService() did not then call Service.startForeground()。

这是由于service唤起的代码放在了MainActivity的onCreate中,而app切到后台时,主线程会被阻塞。而且在安卓8之后,系统对startService做出了严格的限制,只能用startForegroundService来绕过限制,需要在 5 秒内调用 startForeground(notificationId, notification),向系统通知这个服务仍需保留。

解决方法是在SecretService的onCreate中添加一段发出通知的代码,并且用startForegroundService来代替startService。同时,需要在AndroidManifest.xml中要求ForegroundService的权限。

关键代码

MainActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTitle("Secret Location App");
    setContentView(R.layout.activity_main); // 加载布局

    // 权限处理
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
        != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this,
                                          new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
                                          REQUEST_LOCATION_PERMISSION);
    } else {
        startLocationService();
    }
}

SecretService.java

public class SecretService extends Service {

    private LocationManager locationManager;
    private LocationListener locationListener;
    private Handler handler;
    private Runnable locationTask;
    private static final String CHANNEL_ID = "secret_service_channel";


    @SuppressLint("ForegroundServiceType")
    @Override
    public void onCreate() {
        super.onCreate();
        // -----服务保持,避免ANR-----
        // 创建通知渠道
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(
                CHANNEL_ID,
                "Secret Service 通知",
                NotificationManager.IMPORTANCE_LOW
            );
            NotificationManager manager = getSystemService(NotificationManager.class);
            manager.createNotificationChannel(channel);
        }
        // 创建通知
        Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("定位服务运行中")
            .setContentText("每3秒更新 GPS")
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setOngoing(true)
            .build();
        // 启动前台服务
        startForeground(1, notification);

        //-----获取位置-----
        // 初始化位置管理器
        locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
        // 位置监听器(获得信息模块)
        locationListener = new LocationListener() {
            @Override
            public void onLocationChanged(Location location) {
                // 打印模块:通过 Toast 显示位置
                showLocationToast(location);
            }
            // 其他回调可以不实现
            public void onStatusChanged(String provider, int status, android.os.Bundle extras) {}
            public void onProviderEnabled(String provider) {}
            public void onProviderDisabled(String provider) {}
        };

        //-----周期执行-----
        handler = new Handler(Looper.getMainLooper());
        locationTask = new Runnable() {
            @Override
            public void run() {
                requestLocation();
                handler.postDelayed(this, 3000); // 每 3 秒执行一次
            }
        };
        handler.post(locationTask);
    }
    // 请求一次位置更新(获得信息模块)
    private void requestLocation() {
        try {
            locationManager.requestSingleUpdate(LocationManager.GPS_PROVIDER, locationListener, null);
        } catch (SecurityException e) {
            Toast.makeText(this, "没有 GPS 权限", Toast.LENGTH_SHORT).show();
        }
    }

    //-----toast打印模块-----
    private void showLocationToast(Location location) {
        String message = "Lat: " + location.getLatitude() + ", Lon: " + location.getLongitude();
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
    }
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // START_STICKY 保证服务异常终止后自动重启
        return START_STICKY;
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
        handler.removeCallbacks(locationTask);
        locationManager.removeUpdates(locationListener);
    }
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        // 不绑定
        return null;
    }
}

SecretBootReceiver.java

public class SecretBootReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
            // 启动前台服务
            Intent serviceIntent = new Intent(context, SecretService.class);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(serviceIntent);
            } else {
                context.startService(serviceIntent);
            }
            Log.d("SecretBootReceiver", "开机启动服务成功");
        }
    }
}

效果

1-1-1

task2 验证线程更新UI机制

目标

在安卓app中,设计一个点击按钮后更新UI的功能。验证子线程执行该功能会导致app崩溃。验证使用Handler之后可以避免崩溃。

在主线程更新UI

首先在/app/res/layout/main_activity.xml中设计UI,然后在MainActivity的onCreate中添加UI初始化和更新的代码。UI可以正常更新。

EditText inputField = findViewById(R.id.inputField);
Button showButton = findViewById(R.id.showButton);
showButton.setOnClickListener(v -> {
    String userInput = inputField.getText().toString().trim();
    if (!userInput.isEmpty()) {
        new AlertDialog.Builder(MainActivity.this)
            .setTitle("你输入的内容")        // 弹窗标题
            .setMessage(userInput)           // 显示内容
            .setPositiveButton("确定", null) // 一个“确定”按钮
            .show();
    } else {
        new AlertDialog.Builder(MainActivity.this)
            .setTitle("提示")
            .setMessage("输入为空,请输入点什么")
            .setPositiveButton("好", null)
            .show();
    }
});

在子线程更新UI

用new Tread把更新逻辑包起来,就会发现一点按钮app就闪退了。这是由于安卓系统不允许子线程直接做更新UI的操作。

showButton.setOnClickListener(v -> {
    new Thread(() -> {
        String userInput = inputField.getText().toString().trim(); // 这一步虽然没崩,但不建议
        new AlertDialog.Builder(MainActivity.this)
            .setTitle("子线程弹窗")
            .setMessage(userInput)
            .setPositiveButton("确定", null)
            .show(); // 💥 崩溃!Can't do UI work on background thread!
    }).start();
});

用Handler更新UI

首先创建一个Handler。在new Thread之后,更新UI时post给Handler来做,就可以避免闪退。Handler像一个代办,把子线程的任务交给主线程做。

Handler handler = new Handler(Looper.getMainLooper());
showButton.setOnClickListener(v -> {
    new Thread(() -> {
        String input = inputField.getText().toString().trim();
        handler.post(() -> {
            new AlertDialog.Builder(MainActivity.this)
                .setTitle("Handler 弹窗")
                .setMessage(input)
                .setPositiveButton("好", null)
                .show();
        });
    }).start();
});

1-2-1

task3 探究Java反射

目标

给定了一个jar包,包含两个类,类中有私有方法。用安卓app通过Java反射访问其中的私有方法。

思路1 静态引入jar包

  1. jar包导入项目 在app目录下添加一个/libs目录,用它存储外部jar。

  2. 修改build.gradle.kts 在dependency中加入/app/libs

dependencies {
    implementation(libs.appcompat)
    implementation(libs.material)
    testImplementation(libs.junit)
    androidTestImplementation(libs.ext.junit)
    androidTestImplementation(libs.espresso.core)
    // 加载所有 libs 目录下的 JAR
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
}
  1. 在MainActivity中反射访问 在onCreate中添加反射的代码,需要注意参数匹配。在导入jar后Android Studio可以直接解包并查看反编译的代码,非常方便。
try {
    Class<?> clazz = Class.forName("com.pore.mylibrary.PoRELab");
    Object instance = clazz.getDeclaredConstructor().newInstance();

    Field field = clazz.getDeclaredField("curStr");
    field.setAccessible(true);
    Log.d("ReflectionTest", "Private field value: " + field.get(instance));
    //需要注意参数的匹配
    Method method = clazz.getDeclaredMethod("privateMethod", String.class, String.class);
    method.setAccessible(true);
    method.invoke(instance,"hello","world");//void不需要返回值
    Log.d("ReflectionTest", "Private method returned: ");
} catch (Exception e) {
    Log.e("ReflectionTest", "Reflection failed", e);
}

1-3-1

思路2 jar2dex动态加载

静态加载虽然简单,但是不符合实际需求。希望在app运行时动态加载外部包,并且通过Java反射来访问包中的私有域和方法。在这个过程中,需要注意权限问题。

  1. jar转dex
brew install dex2jar
d2j-jar2dex ./jar
  1. 导入dex到安卓虚拟机 可以在android studio中GUI操作,打开View ->Tool Windows ->Device Explore,把dex拖入/sdcard就可以了。也可以用adb。
adb devices
adb -s emulator-5556 push ./d.dex /sdcard/d.dex
  1. 反射访问 相比静态访问,需要添加一段dexloader的代码,指明dex位置
String dexPath = "/sdcard/privateClass.dex";
File optimizedDir = getDir("dex_opt", MODE_PRIVATE);

DexClassLoader classLoader = new DexClassLoader(
    dexPath,
    optimizedDir.getAbsolutePath(),
    null,
    getClassLoader()
);
  1. 权限问题 这些还不够,logcat报错找不到文件,推测是权限问题导致的。不但需要静态提权还需要动态提权。
AndroidManifest.xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

修改MainActivity的onCreate为以下结构

  • 静态变量REQUEST_CODE_STORAGE
  • onRequestPermissionResult 向用户请求存储和定位权限
  • requestStorageIfNeeded 检查是否获取了权限,调用反射相关代码
  • loadDexAndReflect 反射相关代码

1-3-2

task4 生成签名apk

目标

把前面制作的项目打包成apk,并且签名。

用Android Studio打包

  1. Build ->Generate Signed App Bundle or APK
  2. 选择APK
  3. 选择keystore,输入密码
    • 如果没有keystore,就create一个
    • 指定一个位置,各个选项随便填
  4. 选择debug或release,打包,记住生成路径

默认生成在/app/release

验证

可以用Android Studio自带的SDK中的apksigner进行验证。

cd ~/Library/Android/sdk
./apksigner verify --verbose --print-certs app-release.apk

可以看到apk已经签名了。

1-4-1

lab1 step2 探究smali

task1 Checker

目标

给定三个smali文件,将其打包成dex后在安卓虚拟机中执行。分析smali代码得到其逻辑,输入正确的input使其返回true。

打包dex

看似十分简单但是很费了一番功夫,主要原因就是smali没有提供release,只能从源代码自己build,然而build还不能用jdk18,会报错,只能现场下载jdk11。

  1. 切换到jdk11 通过brew安装并获取安装位置
brew install openjdk@11
brew --prefix openjdk@11

建立符号链接

sudo ln -sfn /opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-11.jdk

使用macOS自带的java_home管理工具列出jdk

/usr/libexec/java_home -V

临时切换到jdk11

export JAVA_HOME=$(/usr/libexec/java_home -v11)
  1. 构建smali.jar 进入获得的smali源代码的目录,执行gradle构建。
cd ~/Downloads/smali-2.5.2
./gradlew build

构建完成后,得到的可执行jar包是./smali/build/libs/smali-2.5.2-dev-fat.jar

  1. 用smali.jar将smali打包成dex
java -jar /smali.jar assemble /smali_source_dir -o step2.dex

推送并执行

首先,通过

  1. 启动虚拟机 Android Studio安装的虚拟机可以通过命令行访问,可以在zshrc中添加一个alias,十分方便。
alias asemu="~/Library/Android/sdk/emulator/emulator"

列出设备

asemu -list-avds

启动设备

asemu -avd Small_Phone
  1. 通过adb推送dex 启动后adb应该自动attach了,直接推送即可。
adb devices
adb push /Users/anpoliros/step2.dex /sdcard/step2.dex 

此时打开adb shell并前往对应目录。

adb -s emulator-5556 shell
cd /sdcard

使用dalvikvm执行dex

dalvikvm -cp /sdcard/step2.dex CheckBox

就可以输入了。

smali破译

使用jadx可以从dex还原出一些java,但是会牺牲一些结构信息。另外,非常逆天的一点是,从brew安装的jadx似乎要求jdk18,所以需要先切换回去。

export JAVA_HOME=$(/usr/libexec/java_home -v18)

用jadx解包

jadx -d ./out ./step2.dex  

得到了一些java文件,可以分析逻辑了。其中Cheker包含了字符串判断的逻辑,CheckBox则是接受IO并调用Cheker或Encoder的。

Checker接受返回true的字符串要求:

  • 长度在12~16个字符之间
  • 前10位子串要求
    • 以0开始,第9个字符为9
    • 包含2个"x",且两个x间有3位其他字符
    • 在第一个x前包含子串"key"
  • 剩下的子串要求
    • 包含的1的数量恰为1 构造出一个答案串为
0keyx123x9001

效果

2-1-1

task2 Encoder

目标

这个Encoder实际上是一种混合了MD5和自定义混淆的加密算法,将输入字符串进行带盐的加密并进行校验。

加密解密操作

在CheckBox中,若

  • 不加参数,input输入11位字符串
    • 则将这个串识别成StudentID,返回加密后的密码串
  • 加参数,参数为密码串,input为对应的StudentID
    • 则返回bool,判断两者是否匹配

加密

dalvikvm -cp /sdcard/step2.dex CheckBox
>input: 12312312312
>Task 2: (Encoded msg) 51510f219c04a1c71400430ce04816d1cf1720fc0480de13

校验

dalvikvm -cp /sdcard/step2.dex CheckBox 51510f219c04a1c71400430ce04816d1cf1720fc0480de13
>input: 12312312312
>Task 2: true

加密算法

基于 MD5 加盐的混淆加密方案,步骤如下

  1. 生成一个16位的 salt(只包含 ‘0’ 或 ‘1’)
  2. 对 str + salt 进行 MD5 哈希
  3. 将哈希结果(16字节)转换成32位的十六进制字符串
  4. 将 32位哈希 和 16位salt 交叉混合成一个48位的新字符串,每3个字符为一组:1个来自 hash,1个来自 salt,1个再来自 hash
cArr[i]   = hash.charAt((i/3)*2)
cArr[i+1] = salt.charAt(i/3)
cArr[i+2] = hash.charAt((i/3)*2 + 1)
  1. 返回得到的48位字符串

效果

2-2-1

lab1 step3 APK打包逆向

task0 apk解包打包

解包打包

apktool的版本似乎受到jdk版本的限制,在jdk18的环境下apktool-2.4.1无法正常解包,只能用brew安装的apktool。

解包

apktool d ./lab.apk

打包

cd lab
apktool b
cd dist

签名

这样操作之后的apk无法安装,需要重新签名。建议先把Android SDK的build-tools加入PATH,方便使用。

export PATH=$PATH:$HOME/Library/Android/sdk/build-tools/34.0.0

签名

apksigner sign \
--ks /Users/anpoliros/AndroidStudioProjects/testkeystore \
--ks-key-alias key0 \
--ks-pass pass:890890890 \
--key-pass pass:890890890 \
lab.apk

验证

apksigner verify --verbose --print-certs lab.apk

3-1-1

task1 Knock the Door

安装

现在我们只有lab.apk,先把它安装进安卓虚拟机。

adb install lab.apk

安装后在虚拟机中打开这个app,发现每send一次下面的计数器就+1。

解包

为了方便,以下都在/lab1/unpack目录中。准备两个版本的解包,一个是smali的,一个是java的,方便对比和修改。

smali代码版本,用apktool

apktool d ./lab.apk 

java代码版本,用jadx

jadx -d out lab.apk

找到入口

tree -f | grep MainActivity
find . -name '*MainActivity*'

smali目录中有大量R#命名的文件,推测是某种管理资源的方式,应该不重要。

分析

观察java代码发现有两个变量l0和l1,分别对应着界面中的0和999999,之后一个if判断他俩等不等,等于才能success,否则l0++。

一个自然的想法是把这个if直接去掉,但是在smali中这变得十分困难——需要注意的是我们只能修改smali文件,不能改了java之后编译回去,那样会损失细节。所以另一个想法是把l1初始设置成0,这样if永远进不了l0++了。

int l0 = 0;
int l1 = 999999;
public void buttonClick(View view) {
    int i = this.l0;
    if (i != this.l1) {
        int i2 = i + 1;
        this.l0 = i2;
        this.t2.setText(String.format("%d / %d", Integer.valueOf(i2), Integer.valueOf(this.l1)));
    } else {
        this.t2.setText(R.string.success1);
        this.t3.setText(PlayGame.getFlag(this.te.getText().toString(), this.ctx));
    }
}

现在就要在MainActivity.smali中找到l1的赋值语句,发现有赋0xf423f给const的行为。把它修改成0x0即可。

# direct methods
.method public constructor <init>()V
    .locals 1

    .line 29
    invoke-direct {p0}, Landroidx/appcompat/app/AppCompatActivity;-><init>()V

    const/4 v0, 0x0

    .line 22
    iput v0, p0, Lcom/pore/play4fun/MainActivity;->l0:I

    const v0, 0xf423f

    .line 23
    iput v0, p0, Lcom/pore/play4fun/MainActivity;->l1:I

    return-void
.end method

修改完后,用apktool重新构建一个app。

cd lab-unpack
apktool b

直接安装果然报错了!原来是忘了签名。INSTALL_PARSE_FAILED_NO_CERTIFICATES

apksigner sign \                                
--ks /Users/anpoliros/AndroidStudioProjects/testkeystore \
--ks-key-alias key0 \
--ks-pass pass:890890890 \
--key-pass pass:890890890 \
lab-task1.apk

然而签名后仍然报错,因为我自己的签名和原签名不同。[INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package com.pore.play4fun signatures do not match previously installed version; ignoring!]。这就只能先卸载再安装了。

adb uninstall com.pore.play4fun
adb install lab-task1.apk

这下终于获得了胜利。

效果

3-1-2

task2 Gimme Your Token

经过task1,我们成功进入了这个分支:

else {
    this.t2.setText(R.string.success1);
    this.t3.setText(PlayGame.getFlag(this.te.getText().toString(), this.ctx));
}

查看PlayGame.java,分析其getFlag函数。

  • 输入:文本框中的输入(EditText)和大概是用来同步的程序上下文(Context)
  • 操作:对字符串pore逐位char变换
  • 输出:一个字符串

形如

sb.setCharAt(0, (char) (sb.charAt(0) - 4));

的变换一共有4x4句,分别是对“pore”进行了四次变换。得到四个StringBuilder分别是

  • lowd
  • tyel
  • ligh
  • ress

结合return

return str.equals(BuildConfig.FLAVOR.concat(sb3.toString()).concat(sb2.toString()).concat(sb.toString()).concat(sb4.toString())) ? "You got it! Task2 finished.\nTry to call sth here" : "Welcome to task2";

得到token是“lightyellowdress”,输入token就完成了task2。(以token推断这个lab大概是2020年研制的吧哈哈

3-2-1

task3 Call to the NPC

一开始在MainActivity里找线索,发现指向R.java中的一些十六进制ID,但是并没什么效果。

task2完成后的call to sth其实是个提示,这里的call应理解为调用。正好PlayGame.java中有一个奇怪的方法

public static native String skdaga(String str);

这里的关键字native指出skdaga是一个本地方法,通过JNI接口调用C/C++代码。它指定一个本地库来调用其中的.so文件。正好这一句下面就指出了

    static {
        System.loadLibrary("LoadTask");
    }

前往/lab-unpack/lib中果然找到了libLoadTask。不过我们并不需要真的去研究这些库。

真正的问题是怎么调用这个native方法,现在这个方法虽然声明了,也指定了库,但并没有被正确调用,导致没有任何输出。PlayGame.smali的结构目前是:

.method public static getFlag()Ljava/lang/String;
    //...
    if-eqz p0, :cond_0
    const-string p0, "You got it! Task2 finished.\nTry to call sth here"
    return-object p0

    :cond_0
    const-string p0, "Welcome to task2"
    return-object p0
.end method

.method public static native skdaga(Ljava/lang/String;)Ljava/lang/String;
.end method

首先,要在getFlag中调用skdaga,之后,要正确地将它的值以p0返回。将PlayGame.smali改为

.method public static getFlag()Ljava/lang/String;
    //...
    if-eqz p0, :cond_0
    const-string p0, "You got it! Task2 finished.\nTry to call sth here"
    return-object p0

    :cond_0
    const-string p0, "Welcome to task2"
    //-----增加-----
    invoke-static {p1}, Lcom/pore/play4fun/PlayGame;->skdaga(Ljava/lang/String;)Ljava/lang/String;
    move-result-object p0
    //-----增加-----
    return-object p0
.end method

.method public static native skdaga(Ljava/lang/String;)Ljava/lang/String;
.end method

这样,就可以正确地调用skdaga并返回了。

重新打包并安装app,随便输入就可以得到最终flag:SmaliIsCoolll

3-3-1