lab1 android
lab1 安卓逆向
- step1 安卓开发基础
- step2 探究smali
- step3 逆向夺旗游戏
- step4 soot入门
lab1 step1 安卓开发基础
开发一个demo安卓app
task1 后台获取定位的app
目标
开发一个安卓app,该app创建一个服务,每3秒获取一次用户地址,并且通过toast显示出来。
项目结构
- MainActivity 程序入口,主界面,唤起服务
- SecretService 后台程序,获取地址并显示
- SecretBootReceiver 接受开机信号,自动启动服务
开发过程
- Android Studio没有代码提示
必须下载所有组件后才能正常开发,如果中途下载断了就进来,会发现项目结构都创建的不完整。需要重新启动ide,重新下载。对于网速问题,可以设置HTTP代理为国内镜像站,也可以在系统代理的分流规则中把dl.google.com和gradle.org强制代理。
- 运行按钮是灰色的
大概率是gradle没有下载完所有包导致的。在确认网络和配置无误的前提下:点击File > Sync Project with Gradle Files,如果按钮灰色,尝试File > Invalidate Caches / Restart > Invalidate and Restart重启ide。
- Gradle构建特别慢
即使正确设置了代理,Gradle启动也特别慢。实在无法依赖网络了,于是把Gradle改为离线模式。首先,在gradle.org下载gradle的binary版本。然后,在ide设置中把gradle模式从Wrapper改为Local,指定目录为对应地址。构建速度果然大幅提升。
- app可以启动但无法获取gps
这是由于在安卓8之后,很多敏感权限需要请用户授权。不能在MainActivity中直接startService,需要添加一个if-else来请求权限。
- 切换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", "开机启动服务成功");
}
}
}
效果

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();
});

task3 探究Java反射
目标
给定了一个jar包,包含两个类,类中有私有方法。用安卓app通过Java反射访问其中的私有方法。
思路1 静态引入jar包
-
jar包导入项目 在app目录下添加一个/libs目录,用它存储外部jar。
-
修改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"))))
}
- 在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);
}

思路2 jar2dex动态加载
静态加载虽然简单,但是不符合实际需求。希望在app运行时动态加载外部包,并且通过Java反射来访问包中的私有域和方法。在这个过程中,需要注意权限问题。
- jar转dex
brew install dex2jar
d2j-jar2dex ./jar
- 导入dex到安卓虚拟机 可以在android studio中GUI操作,打开View ->Tool Windows ->Device Explore,把dex拖入/sdcard就可以了。也可以用adb。
adb devices
adb -s emulator-5556 push ./d.dex /sdcard/d.dex
- 反射访问 相比静态访问,需要添加一段dexloader的代码,指明dex位置
String dexPath = "/sdcard/privateClass.dex";
File optimizedDir = getDir("dex_opt", MODE_PRIVATE);
DexClassLoader classLoader = new DexClassLoader(
dexPath,
optimizedDir.getAbsolutePath(),
null,
getClassLoader()
);
- 权限问题 这些还不够,logcat报错找不到文件,推测是权限问题导致的。不但需要静态提权还需要动态提权。
AndroidManifest.xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
修改MainActivity的onCreate为以下结构
- 静态变量REQUEST_CODE_STORAGE
- onRequestPermissionResult 向用户请求存储和定位权限
- requestStorageIfNeeded 检查是否获取了权限,调用反射相关代码
- loadDexAndReflect 反射相关代码

task4 生成签名apk
目标
把前面制作的项目打包成apk,并且签名。
用Android Studio打包
- Build ->Generate Signed App Bundle or APK
- 选择APK
- 选择keystore,输入密码
- 如果没有keystore,就create一个
- 指定一个位置,各个选项随便填
- 选择debug或release,打包,记住生成路径
默认生成在/app/release
验证
可以用Android Studio自带的SDK中的apksigner进行验证。
cd ~/Library/Android/sdk
./apksigner verify --verbose --print-certs app-release.apk
可以看到apk已经签名了。

lab1 step2 探究smali
task1 Checker
目标
给定三个smali文件,将其打包成dex后在安卓虚拟机中执行。分析smali代码得到其逻辑,输入正确的input使其返回true。
打包dex
看似十分简单但是很费了一番功夫,主要原因就是smali没有提供release,只能从源代码自己build,然而build还不能用jdk18,会报错,只能现场下载jdk11。
- 切换到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)
- 构建smali.jar 进入获得的smali源代码的目录,执行gradle构建。
cd ~/Downloads/smali-2.5.2
./gradlew build
构建完成后,得到的可执行jar包是./smali/build/libs/smali-2.5.2-dev-fat.jar
- 用smali.jar将smali打包成dex
java -jar /smali.jar assemble /smali_source_dir -o step2.dex
推送并执行
首先,通过
- 启动虚拟机 Android Studio安装的虚拟机可以通过命令行访问,可以在zshrc中添加一个alias,十分方便。
alias asemu="~/Library/Android/sdk/emulator/emulator"
列出设备
asemu -list-avds
启动设备
asemu -avd Small_Phone
- 通过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
效果

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 加盐的混淆加密方案,步骤如下
- 生成一个16位的 salt(只包含 ‘0’ 或 ‘1’)
- 对 str + salt 进行 MD5 哈希
- 将哈希结果(16字节)转换成32位的十六进制字符串
- 将 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)
- 返回得到的48位字符串
效果

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

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
这下终于获得了胜利。
效果

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年研制的吧哈哈

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!
