查看原文
其他

安卓逆向基础知识之动态调试及安卓逆向常规手段

黎明与黄昏 看雪学苑 2024-04-20



动态调试


ADB简介


在讲动态调试之前,我们需要知道一些简单的Android 调试桥 (ADB)命令,以及对ADB有一点了解:


ADB可以让你的电脑和你的模拟器或者手机设备进行通信,adb 命令可用于执行各种设备操作,例如:安装和调试应用程序。


它是一种客户端-服务器程序,包括以下三个组件:

客户端:用于发送命令。客户端在开发机器上运行。你可以通过发出adb命令从命令行终端调用客户端。
守护程序 (adbd):用于在设备上运行命令。守护程序在每个设备上作为后台进程运行。
服务器:用于管理客户端与守护程序之间的通信。服务器在开发机器上作为后台进程运行。


当你启动adb客户端的时候,该客户端会先检查是否有 adb 服务器进程已在运行。如果没有,它会启动服务器进程。服务器在启动后会与本地 TCP 端口 5037 绑定,并监听 adb客户端发出的命令。注意所有 adb客户端均使用端口 5037 与 adb服务器通信。


服务器成功启动后会与所有正在运行的设备建立连接。它通过扫描 5555 到 5585 之间(该范围供前 16 个模拟器使用)的奇数号端口查找模拟器。服务器一旦发现adb守护程序 (adbd),便会与相应的端口建立连接。


每个模拟器都使用一对按顺序排列的端口:一个用于控制台连接的偶数号端口,另一个用于 adb连接的奇数号端口。例如:

模拟器 1,控制台:5554
模拟器 1,adb:5555
模拟器 2,控制台:5556
模拟器 2,adb:5557
依此类推。


如上所示,在端口 5555 处与 adb 连接的模拟器与控制台监听端口为 5554 的模拟器是同一个。


服务器和客户端成功建立连接后,就可以使用adb命令进行操作了。


查询设备命令:adb devices -l


执行查询设备命令后,adb会针对每个设备输出以下状态信息:

序列号adb会创建一个字符串,用于通过端口号唯一标识设备。下面是一个序列号示例:emulator-5554
◆状态

:设备的连接状态可以是以下几项之一:

  • offline:设备未连接到 adb 或没有响应。

  • device:设备已连接到 adb 服务器。请注意,此状态并不表示 Android 系统已完全启动并可正常运行,因为在设备连接到 adb 时系统仍在启动。系统完成启动后,设备通常处于此运行状态。

  • no device:未连接任何设备。

说明:如果您加入-l选项,devices命令会告知您设备是什么。当您连接了多个设备时,此信息会很有用,方便您区分这些设备。


但是查询设备时有可能会查询不到,以下是导致这种情况发生的条件:


1.adb 服务器未运行:在运行 adb devices 命令之前,请确保 adb 服务器正在计算机上运行。你可以在终端中运行 adb start-server 命令启动 adb 服务器。


2.模拟器端口选择:使用 emulator 命令启动模拟器时,如果你将 -port 或 -ports 选项的端口值设为 5554 到 5584 之间的奇数,并且这些端口处于空闲状态,模拟器将无法与 adb 建立连接。这是因为 adb 服务器默认使用偶数端口与模拟器通信。要避免此问题,您可以让模拟器自行选择端口,不要手动指定奇数端口。


3.模拟器启动顺序:如果您在启动 adb 服务器之前启动了模拟器,它可能无法正确显示在 adb devices 输出中。在启动模拟器之前,请确保 adb 服务器已经运行。


4.端口忙碌状态:如果指定的奇数端口处于忙碌状态,模拟器可能会自动切换到另一个符合要求的端口。在这种情况下,adb devices 输出中可能不会显示模拟器。您可以尝试选择其他空闲的端口或重新启动模拟器和 adb 服务器来解决此问题。


避免出现这种情况的一种方法是让模拟器自行选择端口,并且每次运行的模拟器数量不要超过 16 个。另一种方法是始终先启动adb服务器,然后再使用emulator命令,下面举例几个情况怎么解决:


情况一:adb devices命令启动了adb服务器,但是设备列表未显示。


解决方法:

第一步:先使用adb kill-server命令停止 adb 服务器,再切换目录到android_sdk/tools 目录下,因为emulator 命令位于 android_sdk/tools 目录下。


第二步:停止 adb 服务器后,输入命令emulator -list-avds获取 AVD 名称列表,再执行命令emulator -avd AVD名称 -port 奇数端口号,最后执行adb devices -l查询设备即可。


情况二:在下面的命令序列中,adb devices 显示了设备列表,因为先启动了 adb 服务器。


解决方法:

第一步:先停止adb服务器,再切换目录到android_sdk/tools 目录下使用命令emulator -avd AVD名称 -port 奇数端口号


第二步:在使用adb devices -l命令查询设备之前,使用adb start-server命令重新启动adb服务器。


如果有多个设备在运行,需要将命令发送至特定设备,可以按以下步骤操作:


1.使用devices命令获取目标设备的序列号。


2.获得目标设备的序列号后,您可以使用-s选项与 adb 命令一起使用,来指定目标设备。例如,要在特定设备上安装应用程序,您可以使用以下命令:

adb -s <serial_number> install <path_to_apk>

3.如果有多个可用设备,但只有一个是模拟器,请使用-e选项将命令发送至该模拟器。如果有多个设备,但只连接了一个硬件设备,请使用-d选项将命令发送至该硬件设备。注意:如果您在多个设备可用时发出命令但未指定目标设备,adb会显示错误。


设置端口转发:


设置任意端口转发的命令为forward,该命令可以将特定主机端口上的请求转发到设备上的其他端口。如下所示:


1.设置主机端口 6100 到设备端口 7100 的转发:

adb forward tcp:6100 tcp:7100

这将在主机上创建一个监听主机端口 6100 的转发,并将请求转发到设备上的端口 7100。


2.设置主机端口 6100 到local:logd的转发:

adb forward tcp:6100 local:logd

这将在主机上创建一个监听主机端口 6100 的转发,并将请求转发到设备上的 logd 进程。这样,你可以通过连接到主机端口 6100 来查看设备的日志。


这些命令对于确定发送到设备上指定端口的内容非常有用。通过将请求转发到本地进程或特定端口,就可以捕获并查看设备上的数据。虽然官方文档所给出的端口转发命令是adb forward tcp:主机端口 local:设备上的其他端口;但是动态调试时还有一种可以设置端口转发的命令,那就是adb connect命令。


发出adb命令:

可以使用-d-e-s <serial_number>选项来指定应向其发送 adb 命令的目标设备。这些选项的含义如下:


-d:将命令发送到与开发机器通过 USB 连接的设备。如果只有一个设备连接到开发机器,则可以使用此选项。


-e:将命令发送到模拟器实例。如果只有一个模拟器正在运行,则可以使用此选项。


-s <serial_number>:将命令发送到具有指定序列号的设备。您可以使用adb devices命令查看设备的序列号。


以下是使用这些选项发送 adb 命令的示例:

1.发送命令到通过 USB 连接的设备:

adb -d <command>

将 <command> 替换为要执行的 adb 命令。


2.发送命令到模拟器实例:

adb -e <command>

将 <command> 替换为要执行的 adb 命令。


3.发送命令到具有指定序列号的设备:

adb -s <serial_number> <command>

将 <serial_number> 替换为目标设备的序列号,将 <command> 替换为要执行的 adb 命令。


发出shell命令:


发出shell命令可以使用 shell 命令通过 adb 发出设备命令,也可以使用该命令启动交互式 shell。如需发出单个命令,请使用如下所示的 shell 命令:

adb [-d |-e | -s serial_number] shell shell_command


要在设备上启动交互式 shell,请使用如下所示的shell命令:

adb [-d | -e | -s serial_number] shell


如需退出交互式 shell,请按 Control+D 或输入 exit。


调用activity管理器:

在 adb shell 中,你可以使用 activity 管理器 (am) 工具发出命令以执行各种系统操作,如启动 activity、强制停止进程、广播 intent、修改设备屏幕属性等。


在 shell 中,相应的 am 语法为:

am <command>


您可以直接在 adb shell 中使用am命令,也可以通过 adb 发出am命令,无需进入远程 shell。例如:

adb shell am start -a android.intent.action.VIEW


这将在设备上启动一个具有android.intent.action.VIEW动作的 activity。

请注意,am命令的具体用法和参数取决于您要执行的操作。您可以参考 Android 官方文档或使用am命令的帮助信息来了解更多详细信息。


JEB动态调试


了解完了adb调试桥,接下来就是重头戏——动态调试,我们直接进入实战,以下是这次要解决的软件:



这次要做的是找到注册码或者输入任意注册码都可以通过,一般情况下,逆向软件第一步都需要查壳,这里使用ApkScan-PKID进行查壳:



概确认该软件没有壳之后,我们下一步就去随意输入一个注册码,看看会弹出什么字符串:



它显示Error:卡号不存在或已被删除。得到该字符串后,我们可以去res/values/strings.xml文件中去查找该字符串,如果找到了该字符串,就可以从res/values/strings.xml文件中获取到对应的name字段,再通过name字段去public.xml中找到该字符串的资源ID,最后再在代码中这么一搜,即可大概定位关键方法位置。如果是硬编码,我们也可以在整个项目中查找有没有代码使用该字符串:



前往res/values/strings.xml文件中查找并未找到该字符串,再在整个项目中搜索一下该字符串有没有代码使用它:



发现也是搜索无结果,现在感觉是不是有点奇了怪了,明明软件中使用了该字符串,怎么搜索不到呢?或许字符串是以Unicode编码格式写在应用程序里面的,我们不能忽视这一种可能,需要去试试看:



还是没有搜索到,这是为什么呢?这个字符串肯定是要显示的,既然不在Java层,那字符串就有可能在so层,也有可能被加密了,或者字符串有可能是服务器返回的。但现在与其纠结字符串为什么搜不到,因为即使知道字符串为什么搜不到以现在我们所学的技术那也只能干瞪眼罢了,那我们不如找找其他办法。我们可以看看判断注册码操作以及显示提示字符串是在哪个activity中操作的,这或许可以找到突破口。直接用MT管理器的activity记录功能来观察:



发现判断注册码操作以及显示提示字符串是在com.stardust.autojs.inrt.SplashActivity中进行的,我的想法是搜索提示字符串的消息框所使用的Toast.makeText方法,再通过log插桩观察log日志,看看哪个API通过com.stardust.autojs.inrt.SplashActivity进行了调用。


我这么试着一搜,这个APP中调用Toast.makeText方法的地方不多,这样大大的缩小了我们寻找的范围:



这个APP只有是个十个地方调用了Toast.makeText方法来显示消息框,我稍微点进去看了一下,你猜怎么着,还真有惊喜:


以下是Lcom/jingtong/test3/Xl$4;类中的部分smali代码:


:goto_29
const-string v7, ""

invoke-virtual {v0, v7}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

move-result v7

if-eqz v7, :cond_44

invoke-static {}, Lcom/jingtong/test3/Xl;->access$100()Landroid/content/Context;

move-result-object v7

const-string v8, "激活码不能为空"

invoke-static {v7, v8, v9}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;

move-result-object v7

invoke-virtual {v7}, Landroid/widget/Toast;->show()V

:cond_3e
:goto_3e
return-void


为了大家能看的省事,我把Lcom/jingtong/test3/Xl$4;类中的smali代码转换成了Java代码:


//
// Decompiled by Jadx - 871ms
//
package com.jingtong.test3;

import android.content.Context;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.widget.EditText;
import android.widget.Toast;
import java.lang.reflect.Field;
import java.util.HashMap;

class Xl$4 implements DialogInterface.OnClickListener {
final EditText val$edit;

Xl$4(EditText editText) {
this.val$edit = editText;
}

@Override
public void onClick(DialogInterface dialogInterface, int i) {
String trim = this.val$edit.getText().toString().trim();
try {
Field declaredField = dialogInterface.getClass().getSuperclass().getDeclaredField("mShowing");
declaredField.setAccessible(true);
declaredField.set(dialogInterface, false);
} catch (Exception e) {
e.printStackTrace();
}
if (trim.equals("")) {
Toast.makeText(Xl.access$100(), "激活码不能为空", 0).show();
return;
}
HashMap hashMap = new HashMap();
hashMap.put("name", "zdy_login");
hashMap.put("c1", "10002");
hashMap.put("c2", trim);
hashMap.put("c4", "1.0");
hashMap.put("c5", Xl.getDev());
hashMap.put("c7", "11");
String post = Xl.post(Xl.access$000(), hashMap);
if (trim == null || trim.equals("")) {
Xl.toast("激活码不能为空");
} else if (post != null) {
if (post.indexOf("<|>1") == -1) {
Xl.toast(post);
return;
}
Context access$100 = Xl.access$100();
Xl.access$100();
SharedPreferences.Editor edit = access$100.getSharedPreferences("config", 0).edit();
edit.putString("card", trim);
edit.commit();
try {
Field declaredField2 = dialogInterface.getClass().getSuperclass().getDeclaredField("mShowing");
declaredField2.setAccessible(true);
declaredField2.set(dialogInterface, true);
} catch (Exception e2) {
e2.printStackTrace();
}
dialogInterface.dismiss();
Xl.toast("激活成功");
}
}

}


而这是APP未输入注册码时弹出的消息框:



哈哈!这下总算不是无头苍蝇乱撞了,现在终于轮到我们表演了!我们既然找到了它,那我们就准备准备动态调试了。


第一步:添加可调试权限


我这里选择了简单粗暴的方法,直接在AndroidManifest.xml文件中添加可调试权限的代码:

android:debuggable="true"


添加可调式权限代码位置:


添加好了可调试权限后保存并更新apk文件,但这时我们还不能准备安装,因为签名状态那显示“校验不通过‘,我们在安装前一定要进行重新签名,这里直接让MT管理器重新签名便可。


先点击要进行签名的apk文件,再点击左下角的功能后,会弹出很多供选择的选项,这里选择apk签名即可,最后选一下你想要的签名方案后点确定,就重新签名成功了:



重新签名完成后安装,在动态调试前还有一件事,要记得开启开发者模式并启动USB调试!


完成以上操作后,我们先win+R输入cmd后回车,弹出命令提示符后输入adb start-server命令启动adb:



然后进行端口转发,但是因为雷电模拟器自带端口转发就不用进行这个操作了,以下为大部分模拟器端口转发命令:

网易MuMu模拟器:adb connect 127.0.0.1:7555

夜神安卓模拟器:adb connect 127.0.0.1:62001

逍遥安卓模拟器:adb connect 127.0.0.1:21503

蓝叠安卓模拟器:adb connect 127.0.0.1:5555

雷电安卓模拟器:adb connect 127.0.0.1:5555

天天安卓模拟器:adb connect 127.0.0.1:5037

安卓模拟器大师:adb connect 127.0.0.1:54001

腾讯手游助手:adb connect 127.0.0.1:5555


接下来我们输入查询设备命令adb devices,得到以下内容(每个人的有可能不同):



可以看到设备名为emulator-5554,那我们接下来输入启动调试的命令:

adb -s 上一步查出来的设备名 shell am start -D -n 包名/第一个启动的activity的类名


根据以上命令结构得到以下命令:

adb -s emulator-5554 shell am start -D -n com.yddzs/com.stardust.autojs.inrt.SplashActivity


将命令输入后,会出现以下情况:


命令提示符:


模拟器:


在进行动态调试之前还要记得用jeb下断点,从显示消息框的位置往上看,发现只有一个判断激活码是否为空的判断,我在此处下了一个断点,按CTRL+B快捷键可以快速下断点。再往上面看发现Edit.getText方法,该方法用于获取编辑控件中的文本,所以我打算在onClick方法开始位置再下一个断点:



下好断点后我们在jeb中点击开始图标进行动态调试:



点击后我们得到以下信息:



我们可以看下模拟器之前弹窗的信息:



可以看到进程的包名是一致的,确认好后我们点击附上,就可以看到右边弹出一个窗口,显示以下信息:



我们只需要关注进程中的局部变量就好了,接下来返回模拟器随便输入一些东西后点击确定,发现它确实断下来了:



程序断在第一个断点后我们一行行代码跳过(单步步过),可以有效观察该程序的执行流程:



我通过一次次单步步过看明白了这里的执行流程后,找到了至关重要的关键跳转:



你肯定很好奇,为什么这里是关键跳转呢?


还记得这个类的Java代码吗?Java代码中有一个条件判断语句:


if (post.indexOf("<|>1") == -1) {
Xl.toast(post);
return;
}
Context access$100 = Xl.access$100();
Xl.access$100();
SharedPreferences.Editor edit = access$100.getSharedPreferences("config", 0).edit();
edit.putString("card", trim);
edit.commit();
try {
Field declaredField2 = dialogInterface.getClass().getSuperclass().getDeclaredField("mShowing");
declaredField2.setAccessible(true);
declaredField2.set(dialogInterface, true);
} catch (Exception e2) {
e2.printStackTrace();
}
dialogInterface.dismiss();
Xl.toast("激活成功");


可以看到只要post.indexOf("<|>1")不等于-1,那就会执行代码Xl.toast("激活成功");


所以我们只需要将这个关键判断从if-eq改成if-ne就可以让程序去执行激活成功的代码,改完保存后,在安装前记得用MT管理器或者NP管理器进行重新签名。我们来看看改后的效果,我在输入框中还是输入123456看能不能激活成功:



可以看到激活成功,起码那个让我们输入注册码的界面是没有了,也能正常使用,算是成功了吧!


但在动态调试的时候有可能会遇到一些奇奇怪怪的小问题,比如结束动态调试后模拟器中*“等待调试器*”对话框不会消失的情况,这种情况解决起来也十分简单,只需要在启动adb后执行以下命令即可解决:

adb shell am clear-debug-app


这样做应该大概会清除ADB对你的应用程序所造成的不良影响,起码比什么都不做或直接关闭模拟器设备并且杀死adb服务会好点。


我们上面介绍的是调试模式的jeb动态调试,但是还有一种普通模式动态调试,它的操作更加简单,但是像程序入口界面和入口点里的函数这种执行时机比较早的,使用普通模式是难以进行动态调试的。在嫌麻烦又不需要获取执行时机比较早的数据,就可以使用普通模式,它的操作也十分简单,第一步在jeb直接下断点,下好后打开对应的APP,随后点击附加调试找到对应的进程附上即可,省去了启动adb和端口转发以及输入启动调试的命令这些步骤!这里我就不演示了,感兴趣的可以自行去了解。






安卓逆向的常规手段


现在可能会有人说,这也没体现出动态调试和静态调试的区别呀!这样看确实区别不大,动态调试比起静态调试有一点好处在于可以实时获取到寄存器的值,下面这个实例我们就不详细讲怎么进行调试模式的动态调试了,而是讲动态调试一个软件的思路以及新的知识点——抓包和Log插桩。


我们进行测试的软件叫做嘟嘟牛在线:



以上是嘟嘟牛在线的登录界面,我们使用的抓包工具是Fiddler,可能有的人会问:为什么不用小黄鸟,我只能说用习惯了,懒得换。


我们输入账号和密码后,显示账号或密码错误:



因为这个是带数据去向服务器请求,然后服务器返回响应,以下是fiddler抓到得请求数据:

POST http://api.dodovip.com/api/user/login HTTP/1.1
If-Modified-Since: Sun, 26 Nov 2023 08:51:38 GMT
Content-Type: application/json; charset=utf-8
User-Agent: Dalvik/2.1.0 (Linux; U; Android 9; V1824A Build/PQ3A.190605.10171107)
Host: api.dodovip.com
Connection: Keep-Alive
Accept-Encoding: gzip
Content-Length: 263

{"Encrypt":"NIszaqFPos1vd0pFqKlB42Np5itPxaNH\/\/FDsRnlBfgL4lcVxjXii0Ena92vPqHATd2ON1f1IPP7\nFqd7fMXP\/z\/Kryc+bdv635r0KCv61qXAOS3K9VBiC\/iAzHJDXvLyOMp6wt9b8XtgjUvrqiF+LV84\nL0YqYp03z2hVDQvVHfFv0BiNKQu6f4gWVazYYwZVk3vYMwf0HLvU2jSuw48JyZttHawvD+1vNHPJ\nD+yQutU=\n"}


服务器返回的响应数据:

HTTP/1.1 200 OK
Date: Sun, 26 Nov 2023 08:52:15 GMT
Content-Type: application/json;charset=utf-8
Connection: keep-alive
Server: Nginx
Content-Length: 76

2v+DC2gq7RuAC8PE5GZz5wH3/y9ZVcWhFwhDY9L19g9iEd075+Q7xwewvfIN0g0ec/NaaF43/S0=


可以从以上数据发现,POST请求携带的是一组JSON数据,而JSON数据中"Encrypt"的值是一串我们看不懂的密文,很显然这是被加密了,而且返回值也是一串被加密的密文,那么我们该从什么点入手解决这个问题呢?


有两种比较常用的方法,一种是搜索请求数据中键值对中的键名称,比如这里POST请求携带的是一组JSON数据,它要将加密后的数据封装成JSON数据,自然在将数据封装成JSON的代码周围会有"Encrypt"字符串,如果我们搜索这个字符串是不是就能定位到将数据封装成JSON的代码位置。我尝试着一搜索,发现有而且有不少。



可以发现有足足23个地方包含有"Encrypt"字符串,这么多我们只能把这些当中有可能的全部下断点,然后动态调试了。但是不急,因为还有第二种方法搜索部分网址链接,因为要向服务器进行请求,那么就必须指定某个网址进行请求,但是直接搜索整个网址是难以搜索到的,因为一般开发者不会直接像"http://api.dodovip.com/api/user/login"这样写在代码中,一般POST请求都是像"http://api.dodovip.com" + "/api/user/login"这样在代码中进行拼接的,因为这种拼接方式可以使代码更加灵活和易于维护。我们去搜索"/api/user/login"发现并未搜索到,遇到这种情况我们可以进行删减,比如搜索"/user/login"或者"user/login",这么进行搜索后还真帮助我们缩减了范围:



我们进入第一个看看代码,以下是它的Java代码:


private void requestNetwork(String cmd, Map map0, Type type) {
this.showProgress();
this.request = new JsonRequest(this, "http://api.dodovip.com/api/" + cmd, "", new Listener() {
public void onResponse(RequestResult requestResult) {
if(!requestResult.code.equals("1")) {
LoginActivity.this.showToast(requestResult.message);
}
else if(cmd.equals("user/login")) {
DodonewOnlineApplication.loginUser = (User)requestResult.data;
DodonewOnlineApplication.loginLabel = "mobile";
Utils.saveJson(LoginActivity.this, DodonewOnlineApplication.loginLabel, "LOGINLABEL");
LoginActivity.this.intentMainActivity();
}

LoginActivity.this.dissProgress();
}
}, this, type);
this.request.addRequestMap(map0, 0);
DodonewOnlineApplication.addRequest(this.request, this);
}


观察以上代码可以发现,在requestNetwork方法中,首先调用showProgress方法显示进度条,然后创建了一个JsonRequest对象,该对象用于发送网络请求。在JsonRequest的构造函数中,拼接了请求的URL,然后传入了一个Listener对象用于处理请求的响应。在Listener的onResponse方法中,首先判断了请求结果的code,如果不等于"1",则显示请求结果的message。接着判断了cmd是否为"user/login",如果是,则将请求结果的data转换为User对象,并赋值给DodonewOnlineApplication中的loginUser变量,然后设置loginLabel为"mobile",并保存到本地。最后调用了dissProgress方法隐藏进度条。


用一句话来形容以上代码就是一个网络请求的方法,用于向指定的URL发送请求,并处理请求的响应结果。那么像这种用于向指定的URL发送请求,并处理请求的响应结果的方法,一般都会有地方调用它,那我们就右键点击方法名requestNetwork——》点击"交叉引用",就会弹出有哪些地方调用了这个方法:



我们可以看到一共就一处调用了requestNetwork方法,那我们跳过去看看它的Java代码:


// login方法所属类中定义的变量
private Type DEFAULT_TYPE;
private EditText etMobile;
private EditText etPwd;
private long firstime;
private Map para;
private JsonRequest request;

private void login(String userName, String pwd) {
this.DEFAULT_TYPE = new TypeToken() {
}.getType();
this.para.clear();
this.para.put("username", userName);
this.para.put("userPwd", pwd);
if(TextUtils.isEmpty(DodonewOnlineApplication.devId)) {
DodonewOnlineApplication.devId = Utils.getDevId(DodonewOnlineApplication.getAppContext());
}

this.para.put("equtype", "ANDROID");
this.para.put("loginImei", "Android" + DodonewOnlineApplication.devId);
this.requestNetwork("user/login", this.para, this.DEFAULT_TYPE);
}


以上代码是一个Android应用中的登录功能的实现。首先,有一个login方法,该方法接收用户名和密码作为参数。在login方法中,首先设置了DEFAULT_TYPE为一个TypeToken的类型。然后清空了para(参数)集合,并向其中添加了用户名和密码。接着判断了设备ID是否为空,如果为空,则获取设备ID并添加到参数中。然后设置了equtype为"ANDROID",loginImei为"Android"加上设备ID。最后调用了requestNetwork方法来发起登录请求。


从login方法的两个参数名可以看出传入到login方法中的参数是用户输入的账号和密码,实在不确认我们可以再往上一层查看:


@Override // android.view.View$OnClickListener
public void onClick(View v) {
switch(v.getId()) {
case 0x7F0D00C0: { // id:btn_forget_password
this.startActivity(new Intent(this, FindPasswordActivity.class));
return;
}
case 0x7F0D00C1: { // id:btn_login
String s = this.etMobile.getText() + "";
String s1 = this.etPwd.getText() + "";
Utils.hideSoftInput(this, this.etPwd);
if(this.checkInput(s, s1)) {
this.login(s, s1);
return;
}

return;
}
case 0x7F0D00C3: { // id:btn_register_now
this.startActivity(new Intent(this, RegisterActivity.class));
return;
}
default: {
return;
}
}
}


可以很直白的看出以上代码是一个在Android应用中实现的View.OnClickListener的onClick方法。该方法根据被点击的视图的ID执行不同的操作。


◆当被点击的视图的ID为0x7F0D00C0(btn_forget_password)时,会启动FindPasswordActivity。


◆当被点击的视图的ID为0x7F0D00C1(btn_login)时,会获取etMobile和etPwd中的文本,隐藏软键盘,检查输入,然后调用login方法。


◆当被点击的视图的ID为0x7F0D00C3(btn_register_now)时,会启动RegisterActivity。


◆对于其他视图,方法则不执行任何操作。


总的来说,这个onClick方法处理了特定视图的点击事件,并根据视图的ID执行不同的操作,而要传入到login方法中的参数,在传入前会获取etMobile和etPwd中的文本,再结合login方法中的代码,可以断定传入到login方法中的参数八九不离十是账号和密码了,如果还不确认可以动态调试,将断点下在调用login方法的位置,一看寄存器的值便知这个猜测对不对咯!


现在我们再回到login方法中的代码,前面讲过清空了para集合,并向其中添加了用户名和密码,然后又压入了"ANDROID"和设备ID,最后将para集合作为参数传入到了requestNetwork方法中进行处理,我们去requestNetwork方法中去看看para集合对于最后的加密数据做出了什么贡献。


我们可以从前面的requestNetwork方法代码中看出,para集合被传入到了addRequestMap方法中,我们需要进入addRequestMap方法中看看进行了怎样的处理。


public void addRequestMap(Map map0, int a) {
String s = System.currentTimeMillis() + "";
if(map0 == null) {
map0 = new HashMap();
}

((HashMap)map0).put("timeStamp", s);
String s1 = RequestUtil.encodeDesMap(RequestUtil.paraMap(((HashMap)map0), "sdlkjsdljf0j2fsjk", "sign"), this.desKey, this.desIV);
JSONObject obj = new JSONObject();
try {
obj.put("Encrypt", s1);
this.mRequestBody = obj + "";
}
catch(JSONException e) {
e.printStackTrace();
}
}


以上这段代码是一个方法,名为addRequestMap,它接受一个Map对象map0和一个整数a作为参数,而map0就是之前的para集合。


在这个方法中:

1.首先,获取当前时间的毫秒数,并将其转换为字符串s。

2.然后检查map0是否为null,如果是,则将map0实例化为一个新的HashMap。

3.接下来,将键值对"("timeStamp", s)"添加到map0中。

4.使用RequestUtil工具类的encodeDesMap方法对map0进行加密处理,然后将其赋值给字符串s1。

5.创建一个新的JSONObject对象obj,并将加密后的字符串s1放入obj中的"Encrypt"字段。

6.最后,将obj转换为字符串并赋值给类成员变量mRequestBody。


经过分析最后发送给服务器的字符串就是在这进行处理的,即使不看整体代码,就看obj.put("Encrypt", s1);这一句代码就明白s1是最后那JSON数据中的加密值,而s1是经过以下代码进行处理得到的:


String s1 = RequestUtil.encodeDesMap(RequestUtil.paraMap(((HashMap)map0), "sdlkjsdljf0j2fsjk", "sign"), this.desKey, this.desIV);


我们还可以从这句代码中的this.desKey, this.desIV两个参数看出encodeDesMap方法极大可能是DES对称加密,你们可能会疑惑this.desKey, this.desIV是什么玩意?这个是addRequestMap方法所属类初始化后的变量,而desKey应该是DES对称加密所使用的密钥,而desIV是CBC加密模式所要用到的初始向量,这些会在加解密的时候详解,这里我们就继续看代码了。这两个变量的值我们看一段addRequestMap方法所属类的一段代码就清楚了:


public class JsonRequest extends JsonBaseRequest {
private static final Type DEFAULT_TYPE;
private Context context;
private String desIV;
private String desKey;
private Gson mGson;
private Handler mHandler;
private String mRequestBody;
private Type typeOfT;
private boolean useDes;

static {
JsonRequest.DEFAULT_TYPE = new TypeToken() {
}.getType();
}

public JsonRequest(Context context, int method, String url, String requestBody, Listener response$Listener0, ErrorListener errorListener, Type typeOfT) {
super(method, url, requestBody, response$Listener0, errorListener);
this.useDes = true;
this.mGson = new GsonBuilder().serializeNulls().create();
this.desKey = "65102933";
this.desIV = "32028092";
this.mHandler = new Handler() {
@Override // android.os.Handler
public void handleMessage(Message msg) {
if(msg.what == 0) {
JsonRequest.this.intentLoginActivity();
}
}
};
this.context = context;
this.typeOfT = typeOfT;
this.mRequestBody = requestBody;
}


JsonRequest类我只截取了一小段,可以看到this.desKey, this.desIV两个参数在构造函数中进行了初始化,我们现在知道了这两个值,前面我们讲到encodeDesMap方法极大可能是DES对称加密,那么RequestUtil.paraMap(((HashMap)map0)极有可能是需要进行DES加密的数据,而paraMap自然是对map0这个集合进行处理,经过这一路的追寻我们知道map0这个集合中被压入了哪些数据,那么我们进入paraMap方法中看看DES最后要加密的值是怎么处理的,以及DES要加密的值的明文是怎样的。


public static String paraMap(Map map0, String append, String sign) {
try {
Set set0 = map0.keySet();
StringBuilder builder = new StringBuilder();
ArrayList list = new ArrayList();
for(Object object0: set0) {
String keyName = (String)object0;
list.add(keyName + "=" + ((String)map0.get(keyName)));
}

Collections.sort(list);
int i;
for(i = 0; i < list.size(); ++i) {
builder.append(((String)list.get(i)));
builder.append("&");
}

builder.append("key=" + append);
map0.put("sign", Utils.md5(builder.toString()).toUpperCase());
String s3 = new Gson().toJson(RequestUtil.sortMapByKey(map0));
Log.w("yang", s3 + " result");
return s3;
}
catch(Exception e) {
e.printStackTrace();
return "";
}
}


仔细看可以发现以上代码做了这些事情:


1.获取Map中的所有key值,存入Set集合中。

2.遍历Set集合,将每个key值及其对应的value值拼接成“key=value”的形式,存入ArrayList集合中。

3.对ArrayList集合进行排序。

4.遍历排序后的ArrayList集合,将每个“key=value”字符串使用"&"拼接成一个完整的URL参数字符串,并在最后添加上“key=append”,append变量为addRequestMap方法中传入的字符串"sdlkjsdljf0j2fsjk"。

5.对拼接后的URL参数字符串进行MD5加密,并将加密结果存入Map中的“sign”键值对中。

6.对Map中的键值对按照键名进行排序,并将排序后的Map转换成JSON格式的字符串。

7.返回JSON格式的字符串。


从以上流程中可以发现在字符串md5消息摘要算法处理之前是需要进行排序的,这是因为像消息摘要算法是不可逆的,同样的数据不同的顺序得到的结果甚至会完全不一样,而消息摘要算法就是通过结果是否相等来判断数据是否被修改过,所以在进行消息摘要算法之前都是需要进行排序的。


如果我们想要获取明文数据,那么我们可以在tostring()之后数据处理之前进行下断,然后动态调试就可以获取到明文数据,但在这之前,我们先去认识一下常规的MD5消息摘要算法的代码大概长什么样,这一期我们简单了解一下加解密与算法,下一期我们再详细讲讲加解密与算法。


public static String md5(String string) {
byte[] arr_b;
try {
MessageDigest messageDigest0 = MessageDigest.getInstance("MD5");
messageDigest0.update(string.getBytes());
arr_b = messageDigest0.digest();
}
catch(NoSuchAlgorithmException e) {
throw new RuntimeException("Huh, MD5 should be supported?", e);
}

StringBuilder hex = new StringBuilder(arr_b.length * 2);
int v;
for(v = 0; v < arr_b.length; ++v) {
byte b = arr_b[v];
if((b & 0xFF) < 16) {
hex.append("0");
}

hex.append(Integer.toHexString(b & 0xFF));
}

return hex.toString();
}


可以看到以上代码是一个用于计算字符串的MD5哈希值的方法,以下是该代码所做的事情:


1.创建一个MessageDigest对象,使用MD5算法进行初始化。

2.调用MessageDigest的update方法,将字符串转换为字节数组后进行摘要处理。

3.获取摘要后的字节数组。

4.创建一个StringBuilder对象,用于存储最终的MD5哈希值。

5.遍历摘要后的字节数组,将每个字节转换为16进制,并添加到StringBuilder中。

6.返回最终的MD5哈希值。


现在简单了解一下就好,我们还是先干正事把,使用动态调试获取到为进行消息摘要的明文以及最后返回给DES进行加密的值。我们要找到进行消息摘要之前的明文,那要从md5方法往前找,右键md5方法后点击解析,我们可以到smali代码中对应的方法附近,我们来看一下对应的smali代码:


000000C2 new-instance v9, StringBuilder
000000C6 invoke-direct StringBuilder-><init>()V, v9
000000CC const-string v10, "key="
000000D0 invoke-virtual StringBuilder->append(String)StringBuilder, v9, v10
000000D6 move-result-object v9
000000D8 invoke-virtual StringBuilder->append(String)StringBuilder, v9, p1
000000DE move-result-object v9
000000E0 invoke-virtual StringBuilder->toString()String, v9
000000E6 move-result-object v9
000000E8 invoke-virtual StringBuilder->append(String)StringBuilder, v0, v9
000000EE invoke-virtual StringBuilder->toString()String, v0
000000F4 move-result-object v9
000000F6 invoke-static Utils->md5(String)String, v9
000000FC move-result-object v9


我们可以从上面的smali代码看出一个有意思的事情,原本Java代码简单的一句builder.append("key=" + append);,而在smali代码中可以看到先创建了一个StringBuilder对象实例,随后调用StringBuilder对象的构造方法,初始化该对象,再往下看你会发现它先把字符串"key="追加到StringBuilder后并将返回值重新赋值给V9寄存器,接下来再追加上append变量,append变量为addRequestMap方法中传入的字符串"sdlkjsdljf0j2fsjk",追加append变量后会将返回值重新赋值给V9寄存器,然后会对追加后的StringBuilder对象进行toString,并且将返回值重新赋值给V9寄存器,现在离md5方法只有两个方法调用的距离,而这个v0我们不确定,那么我们试着动态调试去看看v0是什么吧!



可以从上图看到v0寄存器是StringBuilder对象,那v9是什么呢?我们可以看到在局部变量中没有v9寄存器,那么我们该怎么办呢?不急,还有一种方法,那就是Log插桩!我们该怎么Log插桩呢?没事我们一步步教你怎么写一个Log插桩。


Log插桩是为了做什么?当然是为了通过Log日志输出我们想要获取的参数或者返回值啦!我们这次自己来实现一次简单的Log插桩,想要完成一次简单的Log插桩需要知道要怎样才能实现,其实思路很简单,就是在我们要在想要获取值的寄存器下面通过Log方法来进行日志输出。


首先我们要自己编写一段用于Log日志输出的smali代码,我写的代码如下:


const-string 自己添加的寄存器, "自定义日志标签值"

invoke-static {自己添加的寄存器, 要Log日志输出值的寄存器}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I

move-result 自己添加的寄存器


可以从上面看出存放标签值的是自己添加的寄存器,因为如果使用原本存在于方法中的寄存器会比较麻烦,所以使用自己添加的寄存器会省事一点。一般情况下在方法开始的时候会定义这个方法的寄存器的个数,比如:


.locals 1 // 定义本地寄存器数为1


.registers 16 // 指定了方法中寄存器的总数为16,这个数量是参数寄存器和本地寄存器的总和。


那么我们要自己添加寄存器那就需要修改这个定义寄存器数量的值,这样才可以添加前面写的smali代码。废话不多说我们直接开始进行Log插桩。


要添加代码的第一步当然是要找到需要添加代码的地方,直接复制方法名到MT管理器中搜索,然后就发现了好几个名字一样的方法:



我们通过jeb提供的参数以及返回值分辨出第四个才是我们需要找的方法,点进去之后就在方法开始位置发现了不少信息:


.method public static paraMap(Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
.registers 15
.param p1, "append" # Ljava/lang/String;
.param p2, "sign" # Ljava/lang/String;
.annotation system Ldalvik/annotation/Signature;
value = {
"(",
"Ljava/util/Map",
"<",
"Ljava/lang/String;",
"Ljava/lang/String;",
">;",
"Ljava/lang/String;",
"Ljava/lang/String;",
")",
"Ljava/lang/String;"
}
.end annotation


前面讲过.registers 15就说明该方法参数寄存器和本地寄存器的总和为15,再通过其他代码可以得知以上这段代码是一个smali方法的定义,该方法名为paraMap,是一个静态方法,接受三个参数,返回一个String类型的结果。

而注解又能告诉我们如下信息:


◆.annotation system Ldalvik/annotation/Signature;:这是一个Dalvik虚拟机的Signature注解,用于描述方法的签名。

◆value = {...}:注解的值,即方法的签名;

◆"(":方法参数列表的开始符号;

◆"Ljava/util/Map":参数类型的描述符,表示参数类型为Map;

◆"<":泛型类型参数列表的开始符号;

◆"Ljava/lang/String;":泛型类型参数列表中的第一个参数类型,表示Map的key类型为String;

◆"Ljava/lang/String;":泛型类型参数列表中的第二个参数类型,表示Map的value类型为String;

◆">":泛型类型参数列表的结束符号;

◆";":参数类型描述符的结束符号;

◆"Ljava/lang/String;":参数类型的描述符,表示参数类型为String;

◆"Ljava/lang/String;":参数类型的描述符,表示参数类型为String;

◆")":方法参数列表的结束符号;

◆"Ljava/lang/String;":方法返回值的描述符,表示返回值类型为String。


因此,这个注解描述了paraMap方法接受一个Map<String, String>类型的参数,以及两个String类型的参数,并返回一个String类型的结果。


那这样我们就可以确定我们自己要添加的寄存器是V多少了,首先因为该方法是一个静态方法,所以p0参数不会为this,又因为该方法接受三个参数,所以p0、p1、p2参数寄存器用来接受三个参数,其余寄存器为v开头的寄存器。现在15-3还剩下12个,这就可以知道v11是数字最后的寄存器,那么我们自己要添加的寄存器就明了了,那就是v12寄存器!


我们在前往要打印Log日志信息的地方之前需要将数字15改为16,定义的寄存器数量小于实际的寄存器数量那可会出问题的。


我们找到要打印Log日志信息的地方便添加打印Log日志信息的代码:



我们想要打印在进行md5消息摘要算法处理之前的明文,首先参数肯定会在调用方法之前处理好再传入方法之中,其次要获取Log输出的信息自然要在信息被toString之后才能更好的获取完整的信息。


添加好代码后,保存并更改然后退出加签名最后重新安装一条龙服务收尾,到此我们就可以通过工具获取到Log日志信息就可以知道在消息摘要之前的明文具体是什么模样了。


原本打算使用算法助手的,但是不知道为什么,总是在日志中见不到嘟嘟牛在线,所以我就使用AS自带的monitor(DDMS)来获取日志信息:



可以通过日志信息看到明文为以下信息:



现在我们获取到了v9寄存器的值,前面的疑问也就解开了,进行md5消息摘要之前的明文也获知了,那我们就需要得知最后返回给DES进行加密的值了,而怎么获取值这里就不讲了,大家可以自己去试试手,这里就简单明了点讲了。


md5消息摘要算法处理之后并将字符串小写字符转换为大写后的密文:



日志输出结果:


该方法最后返回给DES进行加密的值:



日志输出结果:



我所写的这篇文档有一定的时间跨度,时间戳的值不同很正常,望大家不要在意这点小细节,那我们继续追加解密算法,我们现在得知传入encodeDesMap方法中的参数除了paraMap方法最后返回给DES进行加密的值之外,还有desKey这个DES对称加密所使用的密钥和desIV这个CBC加密模式所要用到的初始向量被传入到了encodeDesMap方法中,那我们追到该方法里面去看看这到底是如何进行DES加密的,如果要解密又该如何解密。


encodeDesMap方法调用处:


String s1 = RequestUtil.encodeDesMap(RequestUtil.paraMap(((HashMap)map0), "sdlkjsdljf0j2fsjk", "sign"), this.desKey, this.desIV);


encodeDesMap方法内部代码:


public static String encodeDesMap(String data, String desKey, String desIV) {
try {
return new DesSecurity(desKey, desIV).encrypt64(data.getBytes("UTF-8"));
}
catch(Exception e) {
e.printStackTrace();
return "";
}
}


可以看到该方法内部是一个异常处理,在try块中,它使用UTF-8编码将数据转换为字节数组,并将其传递给DesSecurity对象的encrypt64方法进行加密。如果在这个过程中出现异常,它会捕获异常并打印堆栈跟踪,然后返回一个空字符串。


我们想要知道DesSecurity对象的encrypt64方法是如何执行的那就需要进到里面去看看,这个类的整体结构并不复杂:


package com.dodonew.online.util;

import android.util.Base64;
import java.security.MessageDigest;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import javax.crypto.spec.IvParameterSpec;

public class DesSecurity {
Cipher deCipher;
Cipher enCipher;

public DesSecurity(String key, String iv) throws Exception {
if(key == null) {
throw new NullPointerException("Parameter is null!");
}

this.InitCipher(key.getBytes(), iv.getBytes());
}

private void InitCipher(byte[] secKey, byte[] secIv) throws Exception {
MessageDigest messageDigest0 = MessageDigest.getInstance("MD5");
messageDigest0.update(secKey);
DESKeySpec dsk = new DESKeySpec(messageDigest0.digest());
SecretKey secretKey0 = SecretKeyFactory.getInstance("DES").generateSecret(dsk);
IvParameterSpec paramSpec = new IvParameterSpec(secIv);
this.enCipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
this.deCipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
this.enCipher.init(1, secretKey0, paramSpec);
this.deCipher.init(2, secretKey0, paramSpec);
}

public byte[] decrypt64(String data) throws Exception {
return this.deCipher.doFinal(Base64.decode(data, 0));
}

public String encrypt64(byte[] data) throws Exception {
return Base64.encodeToString(this.enCipher.doFinal(data), 0);
}

}


我们可以看到这段代码用于进行DES加密算法的加密和解密操作,首先DesSecurity类包含了两个Cipher对象deCipherenCipher,用于解密和加密操作。DesSecurity类有一个构造函数,接受keyiv两个参数,用于初始化DesSecurity对象。在构造函数中,会调用InitCipher方法进行初始化,前面encodeDesMap方法在new这个类的对象时就会调用这个类的构造函数进行初始化,InitCipher方法接受secKeysecIv两个参数,分别代表DES算法的密钥和初始化向量。在该方法中,首先使用MD5算法对密钥进行摘要处理,然后将MD5算法处理过后得到的DES密钥材料通过DESKeySpec类进行实例化,实例化DES密钥材料后使用SecretKeyFactory.getInstance("DES")代码实例化密钥工厂,随后就使用密钥工厂通过generateSecret方法对最终的DES密钥进行生成。


随后创建了一个IvParameterSpec对象paramSpec,用于表示初始化向量(Initialization Vector,IV)。前面讲过初始化向量是在使用CBC(Cipher Block Chaining)模式进行加密时需要用到的参数,它与密钥一起影响着加密算法的输出结果。


接着使用Cipher类的getInstance方法创建了一个加解密操作的Cipher对象enCipherdeCipher,并指定了使用DES算法和CBC模式,同时使用PKCS5Padding填充方式进行填充。


InitCipher方法的最后调用了enCipher对象的init方法,用于初始化加密操作。参数1表示加密模式,secretKey0是之前生成的DES密钥,paramSpec是之前创建的初始化向量对象。类似地调用了deCipher对象的init方法,用于初始化解密操作。参数2表示解密模式,secretKey0是之前生成的DES密钥,paramSpec是之前创建的初始化向量对象。


调用这个类的构造函数进行初始化完成后,因为encodeDesMap类是调用了DesSecurity对象的encrypt64方法,所以在进行初始化完成后会执行encrypt64方法,而encrypt64方法将之前得到的需要进行DES加密的值作为参数,最后使用enCipher对象下的doFinal方法进行加密操作,加密后将其结果进行Base64编码,并返回编码后的字符串。


而这个进行编码后的加密数据经过两次return后,回到了addRequestMap方法中,前面看过该方法的代码,返回回来的编码后的加密数据存放在变量s1中,随后进行了以下代码的处理:


JSONObject obj = new JSONObject();
try {
obj.put("Encrypt", s1);
this.mRequestBody = obj + "";
}
catch(JSONException e) {
e.printStackTrace();
}


可以看到这段代码创建了一个JSONObject对象obj,并向其中放入了一个名为"Encrypt"的键和对应的s1变量中的值,然后将obj转换为字符串并赋值给mRequestBody,这样一来数据就以JSON格式的字符串形式存在了。如果这段代码中的catch块捕获了JSONException类型的异常,就会在捕获到异常时调用e.printStackTrace()方法打印异常堆栈信息。


到此为止,这个软件的加密流程就被我们给搞清楚了。


不知道各位听懂了没?如果各位想要复现算法,模拟请求过程,可以自己去试试,这里就不演示了,这篇文章也临近尾声,但主要是以动态调试为基础,而动态调试除了jeb之外,还有一个常用的软件,那就是Android Studio。


那下面讲一下Android Studio怎么进行配置和使用吧!Android Studio虽然说用起来会比jeb麻烦一点,但是功能还是很强大的。至于怎么配置我这里就不多赘述了,如果没有配置好可以看看这篇文章:

android studio动态调试apk最详细教程(https://blog.csdn.net/kenbo_257/article/details/122726128)


唯一美中不足的就是这篇文章配置的并非是debug模式,而且有的朋友可能没有安装AndroidKiller,在给AndroidManifest.xml文件中添加可调试权限的代码时和上面一样用MT管理器就行。


下面我对这篇文章做一点补充。


首先当你知道你要动态调试的APP的包名,你又不想特意去找APP的包名复制粘贴,那就可以使用以下命令查看PID:

adb shell ps


这个命令用于列出当前设备上运行的进程信息。它会显示以下这些信息:

◆USER:运行该进程的用户

◆PID:进程ID

◆PPID:父进程ID

◆VSIZE(VSZ):进程占用的虚拟内存大小

◆RSS:进程占用的物理内存大小

◆WCHAN:进程正在等待的事件

◆ADDR:进程所在内存的地址

◆S:进程的状态(R:运行,S:睡眠,Z:僵尸等)

◆NAME:进程的名称


例如这样的:


其中的NAME列就是该进程的包名,我们需要观察包名找到对应的PID,执行这个命令前一定要记得把要动态调试的APP在模拟器/真机上打开。我们通过观察APP的包名很快就找到了这个APP的PID:


这两种方法各有优劣,需要根据实际情况来判断哪个省事然后用哪个。


下一步就需要进行端口转发,文章作者使用的是官方文档给出的端口转发命令,也就是这种格式的命令:

adb forward tcp:主机端口 tcp:设备端口


我们执行完端口转发后就执行启动调试的命令:

adb -s 通过命令adb devices查出来的设备名 shell am start -D -n 包名/第一个启动的activity的类名


执行这个命令时一定要记得将要动态调试的APP给关闭,不然会报警告。执行完这个命令后,正常情况下模拟器还是会出现这种情况:


接下来我们只需要去Android Studio里面进行附上就好了:


点击红线划出来的按钮就和之前一样可以附上了,进行附上以后,只要出现Connected to the tarfet VM…说明已经附上成功了!


可能有的人想问,debug模式不就是多执行一行命令嘛!需要写的这么麻烦吗?我是想在这个操作的前后讲一讲,起码看完那篇文章再来通过debug模式来启动动态调试不会看的那么迷糊。


那么文章的最后,我们来对安卓逆向常规的手段进行总结,首当其冲的自然是静态分析了,而静态分析就是搜索呗!可以根据APP反馈情况进行搜索字符串或者安卓自带的方法来进行快速定位,而在抓包的情况下可以搜索链接、搜索参数、搜索算法的关键字。


如果想要获取某个方法中的参数或者返回值,那么我们有这么两种常规方法,分别是动态调试和Log插桩,关于Log插桩之前我们讲了如何在对应的smali文件里,添加相应的smali代码来将我们要获取的信息通过log日志的形式进行输出。但是我们是使用了在smali文件中仅插入smali代码来实现Log插桩的。


除此之外,还有一种我们经常见到的Log插桩方式,那就是插入smali文件,我们可以写一段代码,我们需要将这段写好的代码单独编译成Smali后,也就是编译成dex文件后,直接通过MT管理器等工具将编译好的dex文件放入到原本的APK文件中,因为dex是有统一的命名规则的,所以需要将其命名为classes(n+1).dex,n为apk文件中classses的数量,例如某apk文件的classes文件的数量为2,那么我们添加的dex文件就应该命名为classes3.dex;添加好后再在需要插入的地方进行调用即可。


插入的smali代码在编译前可以这么写:

package com.example.myapplication;

import android.util.Log;

public class GenericLogger {
// 泛型方法,可以接受任何类型的参数,并将其转换为字符串打印
public static <T> void log(T message) {
Log.d("CustomTag", String.valueOf(message));
}

// 记录当前线程的调用堆栈信息到Android的日志中
public static void logStackTrace() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
for (StackTraceElement stackTraceElement : stackTrace) {
Log.d("StackTraceLogger", "at " + stackTraceElement.toString());
}
}
}


以上这段代码定义了一个名为GenericLogger的类,它包含两个静态方法:log()logStackTrace()


log()方法是一个泛型方法,它可以接受任何类型的参数,并将其转换为字符串打印。该方法使用Android的Log.d()方法将日志记录到自定义标签CustomTag中。


logStackTrace()方法记录当前线程的调用堆栈信息到Android的日志中。该方法使用Thread.currentThread().getStackTrace()方法获取当前线程的堆栈跟踪信息,并使用Android的Log.d()方法将其记录到自定义标签StackTraceLogger中。


这些方法都是静态方法,可以直接通过类名调用,不需要创建GenericLogger类的实例。这使得它们可以在整个应用程序中被方便地重复使用。


我们写好需要编译成dex文件的安卓Java代码后,下一步需要通过工具将文件转换成Smali指令的文件,然后直接通过MT管理器等工具将编译好的dex文件放入到原本的APK文件中并修改包名,可以进行全局搜索将全部包名替换成需要进行Log插桩的apk文件的包名,最后调用我们编译好的smali代码就完成Log插桩了。


后面的编译成dex文件并插入的步骤大家可以自行试试,我使用Android studio试了编译前的Java代码是可以使用的。


其次还有之前讲过的APP资源ID,比如通过工具获取资源ID去资源文件夹寻找相关资源调用,也可以通过搜索字符串获取字符串对应的name字段,再通过name字段去public.xml中找到该字符串的资源ID,最后通过资源ID来寻找相关资源调用。


还有什么通过一些消息框或者按钮这些样式的调用代码来找到关键位置、使用DDMS的方法刨析来找突破口等方法。


到此为止我们过了一遍安卓逆向常规的手段,但安卓逆向的手段远不止如此,还有很多手段还需大家去探索,正所谓:“路漫漫其修远兮,吾将上下而求索。”愿大家在自己的路上越走越远。





看雪ID:黎明与黄昏

https://bbs.kanxue.com/user-home-926486.htm

*本文为看雪论坛优秀文章,由 黎明与黄昏 原创,转载请注明来自看雪社区



# 往期推荐

1、iOS越狱检测app及frida过检测

2、Large Bin Attack学习(_int_malloc源码细读 )

3、CVE-2022-2588 Dirty Cred漏洞分析与复现

4、开发常识 | 彻底理清 CreateFile 读写权限与共享模式的关系

5、XAntiDenbug的检测逻辑与基本反调试

6、Frida-Hook-Java层操作大全



球分享

球点赞

球在看



点击阅读原文查看更多

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存