效果演示:
老规矩,先上几张效果图:
哈哈,是不是很好玩,可以控制任意app执行弹框操作。
诞生背景
去年年中因一次偶然的机会发现了某款国产车的车机系统存在一个惊天大漏洞(不知道是不是厂商故意开放出来钓鱼的):它居然将一个sharedUserId为android.uid.system
的系统常驻进程(android:persistent="true"
)的android:debuggable
属性设置为"true"
了!!!
妈呀,这是什么概念?! 这就意味着,可以借助debug来间接获取到android.uid.system
(也就是俗称的系统级)权限,轻松实现很多普通app无法实现的功能!
不过,其局限性也很明显,就是不能实现自动化,因为每次要用到system权限时都需要依赖连接电脑才能获得。
大胆的想法: 由于此前有学习过 Shizuku 的源码,对其中的adb通讯协议有一定的了解,再加上之前想调试zygote进程的时候,也研究过一阵子的jdwp(最后没搞成功),有一天忽然想到:平时debug也是建立在adb的基础上,现在adb已经有人在android端实现了,那能不能把jdwp协议也搬过来呢?那样的话不就可以脱离对电脑的依赖,直接从app上发起debug,实现代码注入了?理论上是可行的,因为它就只是个协议,跟平台无关。
初步了解JDWP协议
在oracle官网上找到了jdwp协议文档: jdwp-spec。
文档上说,在双方(debugger和target vm)建立连接之后,需要先进行握手: 先由debugger端发出"JDWP-Handshake",然后target vm端会回复同样的消息。
握手成功之后,就可以正式通讯了。
通讯数据包有两种,分别是命令包(Command Packet)和应答包(Reply Packet),由消息头+数据两部分组成,命令包和应答包的消息头只有最后两字节表示的东西不一样:
命令包消息头:
数据包长度(4字节),数据包ID(4字节),flag(1字节),命令集(1字节),命令(1字节)。
应答包消息头:
数据包长度(4字节),数据包ID(4字节),flag(1字节),错误码(2字节)。
数据包长度:消息头+数据部分的总长度,如果没有数据部分,就是11(字节);
数据包ID:全局唯一,但命令包所对应的应答包id是相同的,而且一个命令包可能会有多个应答包;
flag:命令包的flag固定是0,应答包的flag值有两个:128代表正常应答,-128代表发生错误;
错误码:当应答包的flag为-128时,表示在处理命令时发生错误,错误码对照表在这里:Error Constants;
命令集和命令:在这里可以看到每一个命令集下有哪些命令,以及每一个命令所对应的功能,所需参数,返回格式等等:jdwp-protocol;
在Java / Android虚拟机中,byte类型占1个字节、short占2个字节、int类型占4个字节。结合上面的描述,对jdwp数据包读写的代码就可以写成这样:
// 解包
val bytes = // 从inputStream中读取到的字节数组
val replyPacket = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
val packetLength = replyPacket.getInt() // 取出前4个字节
val packetId = replyPacket.getInt() // 取出第4~8个字节
val flag = replyPacket.get() // 取出第9个字节
val errorCode = replyPacket.getShort() // 取出第10~11个字节
//封包
val commandPacket = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
commandPacket.putInt(11) // 因为没有data部分,所以长度为11 (4+4+1+1+1)
commandPacket.putInt(++uniqueID) // 全局唯一的id
commandPacket.put(0) // 命令包的flag固定是0
commandPacket.put(cmdSet.toByte()) // 命令集
commandPacket.put(cmd.toByte()) // 命令
好,现在我们已经大致了解jdwp的通讯流程和方式,是时候写个demo来验证一下了。
小试牛刀
我们先来完成跟目标进程的握手吧:
fun main() {
val localHost = "127.0.0.1"
val localPort = 9876
"开始连接".log()
val socket = Socket(localHost, localPort)
"连接成功".log()
val outputStream = socket.getOutputStream()
"发送握手消息".log()
outputStream.write("JDWP-Handshake".toByteArray())
val inputStream = DataInputStream(socket.getInputStream())
"校验握手消息".log()
// 读取inputStream中的前14个字节,校验握手消息
if (String(ByteArray(14).apply { inputStream.readFully(this) }) == "JDWP-Handshake") {
"握手成功".log()
} else {
throw RuntimeException("握手失败")
}
}
fun Any?.log() = println(this)
可以看到,Socket连接的是本地主机,端口9876;
inputStream
用DataInputStream包装了一下,因为我们需要借助它的readFully
方法来一次性读取出指定长度的数据;
当然了,现在还不能马上连接,在此之前还需要用adb forward
来搭建一个桥梁:
forward命令需要先知道目标应用的进程id,emmmm,就选系统设置(com.android.settings
)作为本次demo的目标进程吧:
adb shell
ps -A -o PID,NAME | grep -w com.android.settings
注意,对于release包需要设备开启全局调试(需安装magisk)
Android 14以下:
magisk resetprop ro.debuggable 1 && stop;start
Android 14:
magisk resetprop ro.build.type userdebug && magisk resetprop persist.debug.dalvik.vm.jdwp.enabled 1 && magisk resetprop ro.debuggable 1 && stop;start && setenforce 0
可以看到系统设置的进程id是10494,那么接下来,可以用以下命令来映射到pc端:
adb forward tcp:9876 jdwp:10494
执行这条命令的时候,adb工具会在电脑上创建一个端口为9876的ServerSocket,我们的demo连接到这个ServerSocket之后,它会把收到的数据原封不动地转发给后面的jdwp线程。
好,现在通讯桥梁已经搭建完成,可以运行demo看看效果了:
坑: 运行demo之前一定要先关闭Android Studio或者想办法断开测试设备跟Android Studio的连接,因为Android Studio会把jdwp的端口都占用了,导致握手消息转发不出去,也就收不到回应!
握手成功! 现在可以正式发送命令包了。
不知道大家有没有玩过jdb工具?
在attach成功之后输入classes
,会打印目标虚拟机当前所有已加载的class。在jdwp协议中,对应的是AllClasses命令:
int | classes | Number of reference types that follow. | |
Repeated classes times: | |||
---|---|---|---|
byte | refTypeTag | Kind of following reference type. 1: CLASS 2: INTERFACE 3 : ARRAY | |
referenceTypeID | typeID | Loaded reference type | |
string | signature | The JNI signature of the loaded reference type | |
int | status | The current class status. 1: VERIFIED 2: PREPARED 4 : INITIALIZED 8 : ERROR |
AllClasses命令位于虚拟机命令集(VirtualMachine Command Set)中,发送此命令,目标虚拟机会按以上图表的格式回复数据,转成更直观的json来表述就是这样:
{
"loadedClassCount": 123, // 已加载的class总数量
"classes": [
{
"refTypeTag": 1, // class类型(类,接口,数组)
"typeID": 1, // class唯一id,按加载顺序来排序
"signature": "Ljava/lang/Object;", // class签名
"status": 1 // class状态
},
{
"refTypeTag": 1,
"typeID": 2,
"signature": "Ljava/lang/String;",
"status": 1
},
......
]
}
注意,这里的typeID
并不是一个固定的类型,在图表中声明为referenceTypeID
,表示类型id的长度(在解析时应取多少字节),除了referenceTypeID
之外还有fieldIDSize
(变量id长度)、methodIDSize
(方法id长度)等等。
这些ID在不同规格的虚拟机上可能会返回不同的长度,有以下三种:
- 2(说明id是一个short值,应取2个字节);
- 4(说明id是一个int值,应取4个字节);
- 8(说明id是一个long值,应取8个字节);
这些ID值都集中在IDSizes命令里返回:
int | fieldIDSize | fieldID size in bytes |
int | methodIDSize | methodID size in bytes |
int | objectIDSize | objectID size in bytes |
int | referenceTypeIDSize | referenceTypeID size in bytes |
int | frameIDSize | frameID size in bytes |
所以在解析AllClasses数据包之前,需要先发送IDSizes命令来获取各个数据类型的占位大小:
var uniqueID = 0
var fieldIDSize = 0
var methodIDSize = 0
var objectIDSize = 0
var referenceTypeIDSize = 0
var frameIDSize = 0
private fun getIdSizes(outputStream: OutputStream, inputStream: DataInputStream) {
val commandPacket = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
commandPacket.putInt(11) // 因为没有data部分,所以长度为11 (4+4+1+1+1)
commandPacket.putInt(++uniqueID) // 全局唯一的id
commandPacket.put(0) // 命令包的flag固定是0
commandPacket.put(1) // VirtualMachine Command Set (1)
commandPacket.put(7) // IDSizes (7)
// 发送数据包
outputStream.write(commandPacket.array())
// 读取消息头
val headerBytes = ByteArray(11).apply { inputStream.readFully(this) }
val replyPacket = ByteBuffer.wrap(headerBytes).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
val packetLength = replyPacket.getInt() // 取出前4个字节
val packetId = replyPacket.getInt() // 取出第4~8个字节
val flag = replyPacket.get() // 取出第9个字节
val errorCode = replyPacket.getShort() // 取出第10~11个字节
if (flag.toInt() != -128) {
throw RuntimeException("接收到错误代码: $errorCode")
}
if (packetLength > 11) {
// 大于11表示有data部分,一次性读取出来
val dataBytes = ByteArray(packetLength - 11).apply { inputStream.readFully(this) }
val dataBuffer = ByteBuffer.wrap(dataBytes).order(ByteOrder.BIG_ENDIAN)
// 取出变量占位大小
fieldIDSize = dataBuffer.getInt()
// 方法id大小
methodIDSize = dataBuffer.getInt()
// 对象id大小
objectIDSize = dataBuffer.getInt()
// 类型id大小
referenceTypeIDSize = dataBuffer.getInt()
// 栈帧id大小
frameIDSize = dataBuffer.getInt()
}
}
好,获取到各个类型的id size之后,我们试着实现jdb工具中的classes命令——把所有已加载进目标虚拟机的class信息打印出来:
private fun getAllClasses(outputStream: OutputStream, inputStream: DataInputStream) {
val commandPacket = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
commandPacket.putInt(11) // 因为没有data部分,所以长度为11 (4+4+1+1+1)
commandPacket.putInt(++uniqueID) // 全局唯一的id
commandPacket.put(0) // 命令包的flag固定是0
commandPacket.put(1) // VirtualMachine Command Set (1)
commandPacket.put(3) // AllClasses (3)
// 发送数据包
outputStream.write(commandPacket.array())
// 读取消息头
val headerBytes = ByteArray(11).apply { inputStream.readFully(this) }
val replyPacket = ByteBuffer.wrap(headerBytes).order(ByteOrder.BIG_ENDIAN) // jdwp协议采用大端字节序
val packetLength = replyPacket.getInt() // 取出前4个字节
val packetId = replyPacket.getInt() // 取出第4~8个字节
val flag = replyPacket.get() // 取出第9个字节
val errorCode = replyPacket.getShort() // 取出第10~11个字节
if (flag.toInt() != -128) {
throw RuntimeException("接收到错误代码: $errorCode")
}
if (packetLength > 11) {
// 大于11表示有data部分,一次性读取出来
val dataBytes = ByteArray(packetLength - 11).apply { inputStream.readFully(this) }
val dataBuffer = ByteBuffer.wrap(dataBytes).order(ByteOrder.BIG_ENDIAN)
// class数量
val classCount = dataBuffer.getInt()
repeat(classCount) {
// 获取class类型
val classType = when (val classTypeValue = dataBuffer.get().toInt()) {
1 -> "class"
2 -> "interface"
3 -> "array"
else -> "未知的class类型: $classTypeValue"
}
// class类型id
val typeId = when (referenceTypeIDSize) {
8 -> dataBuffer.getLong()
4 -> dataBuffer.getInt().toLong()
2 -> dataBuffer.getShort().toLong()
else -> throw IllegalArgumentException("接收到未知的id size: $referenceTypeIDSize")
}
// class名称长度
val classNameLength = dataBuffer.getInt()
// class名称
val className = String(ByteArray(classNameLength).apply { dataBuffer.get(this) }, Charsets.UTF_8)
val classStatusValue = dataBuffer.getInt()
// class状态
val classStatus = when {
(classStatusValue and 8) != 0 -> "ERROR"
(classStatusValue and 4) != 0 -> "INITIALIZED"
(classStatusValue and 2) != 0 -> "PREPARED"
(classStatusValue and 1) != 0 -> "VERIFIED"
else -> "LOADED" // 保底为loaded状态
}
"type: $classType\ntype id: $typeId\nname: $className\nstatus: $classStatus\n------------------------------------------------".log()
}
"已加载class数量: $classCount".log()
}
}
最后在main
方法里,握手成功之后分别加上getIdSizes
和getAllClasses
方法的调用:
fun main() {
......
if (String(ByteArray(14).apply { inputStream.readFully(this) }) == "JDWP-Handshake") {
"握手成功".log()
getIdSizes(outputStream, inputStream)
getAllClasses(outputStream, inputStream)
}
......
}
运行看下效果:
哈哈,成功获取所有类信息!
可以看到当前一共加载了25403个类,而最后的2个type id(类ID)刚好分别是25402和25403,这就说明,这些类ID是按加载顺序来分配的。
实战准备
我们当然不满足于此,我们的最终目标是在Android端实现一个可以对debuggable app实现自动化代码注入的工具(超精简魔改版Debugger)。
试想一下,如果要把以下代码注入到目标进程里运行:
public class Injector {
public static void main() {
Log.i("Injector", "我运行在" + Process.myPid() + "里!");
}
}
使用Android Studio来完成的话,要经过哪些步骤呢:
- 在目标app的外部储存目录准备好一个dex或者apk文件,并且在目标app运行的必经之路打好断点;
- 点击Android Studio上方工具栏的Attach Debugger to Android Process按钮,然后选择要注入的进程attach;
- 等待断点命中;
- (断点命中后)通过debugger的 evaluate expression 功能,创建一个DexClassLoader对象加载外部dex/apk文件,并以SystemClassLoader作为parent;
- load出Injector class;
- 反射调用Injector的
main
方法; - 大功告成,断开debugger;
很简单,只有六七个步骤就能做到。
但是jdwp协议文档里并没有直接提供 evaluate expression 功能,不过像调用静态方法,实例方法,创建对象这些命令还是有的,看来只能自己手动实现了。
其实debugger的evaluate expression功能,是借助了ExpressionParser来解析表达式,然后将解析出来的每个步骤逐一执行,就比如
new java.lang.Object().toString();
,它最终会分解成以下5个步骤:
- 根据类名
java.lang.Object
找到对应的类ID(对应jdwp协议中的ClassesBySignature命令);- 根据Object的类ID找到它的无参构造方法ID
<init>()V
(对应Methods命令);- 调用Object的无参构造方法创建实例,并记录其实例ID(对应NewInstance命令);
- 根据Object的类ID找到
toString()Ljava/lang/String;
方法ID(对应Methods命令);- 根据Object的实例ID调用该
toString
方法,并记录方法的返回结果(对应InvokeMethod命令);
我们要在Debugger里实现以下操作:
断点命中之后,创建一个DexClassLoader对象,传入事先部署好的dex路径,然后调用loadClass方法得到入口类Injector,再调用入口类的静态方法main,完成代码注入。
从Debugger的角度来看,就是这样:
- (attach到目标进程后)找到要设置断点的类的ID;
- 找到要设置断点的(方法/成员变量)ID;
- 设置断点;
- 等待断点命中;
- (断点命中后)移除断点,避免重复命中;
- 找到ClassLoader类的ID;
- 找到ClassLoader类的
getSystemClassLoader
静态方法ID; - 调用ClassLoader类的静态方法
getSystemClassLoader
,并记下返回的ClassLoader对象ID; - 找到DexClassLoader类的ID;
- 找到DexClassLoader类的
<init>(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V
构造方法ID; - 调用DexClassLoader类的构造方法,并记下返回的DexClassLoader对象ID;
- 找到ClassLoader类的
loadClass
实例方法ID; - 调用DexClassLoader类的实例方法
loadClass
,并记下返回的入口类Injector的Class ID; - 找到Injector的
main
静态方法ID; - 找到调用Injector类的静态方法
main
; - 恢复目标进程的运行(断点命中后是suspended状态);
- 大功告成,断开与目标进程的连接;
看上去挺复杂,步骤有点多,但没办法,越底层的东西就越麻烦。
我们来梳理一下,看看以上步骤需要用到jdwp协议中的哪些命令:
- 位于VirtualMachine Command Set的IDSizes命令(这个不必多说);
- 位于VirtualMachine Command Set的ClassesBySignature命令(通过类签名找到类ID);
- 位于VirtualMachine Command Set的CreateString命令(在内存中创建String对象,比如在调用包含String参数的构造/静态/实例方法时,需要先发送此命令创建String对象);
- 位于VirtualMachine Command Set的Resume命令(恢复虚拟机的运行);
- 位于ReferenceType Command Set的Fields命令(列出类ID除父类以外的所有成员变量);
- 位于ReferenceType Command Set的Methods命令(列出类ID除父类以外的所有成员方法);
- 位于EventRequest Command Set的Set命令(用于设置各种类型的断点);
- 位于EventRequest Command Set的Clear命令(清除指定断点);
- 位于ClassType Command Set的InvokeMethod命令(调用类ID的静态方法);
- 位于ClassType Command Set的NewInstance命令(调用类ID的构造方法创建实例);
- 位于ObjectReference Command Set的InvokeMethod命令(调用类ID的实例方法);
这样看下来,也不算很多,一共只要11个命令,就可以满足我们的自动化注入需求。
实现jdwp协议
emmmm,我想在这里偷个懒。。。为什么呢?结合我之前写的贴了一大堆代码的文章,相信很多同学也不会从到看到尾,还不如直接在github上clone下来看完整的代码更加直观。
加上现在有协议文档可以对照,再基于前面我们了解的通讯原理和流程,继续把上面的11个命令实现,基本上就是搭积木,没啥难度了(每一个命令的实现都大同小异),把全部代码贴出来反而徒增篇幅。
所以现在就剩一个adb协议了。
了解adb协议
前面写的demo,都是在电脑端运行,依赖adb forward
命令进行搭桥,而我们最初的目标正是要脱离电脑,让Debugger可以单独在Android环境里工作。
说到adb,相信作为android开发者的各位对它一点都不陌生,但其背后的实现原理,估计有不少同学都没有详细了解过。那么现在先对它做个初步的了解吧:
协议角色:
在整套adb协议里,大致分为3个角色,分别是:
● adbd:
运行在android设备中的系统服务进程,由init进程启动。
在命令行里
adb shell
之后的输入的各种命令,最终都是由adbd临时fork出来的子进程来执行,但adbd并没有对这个子进程进行降权处理,所以当你进入shell环境之后会继承父进程adbd的当前身份,通常情况下,它的uid是2000,也就是shell身份(用shell身份启动的子进程同样也会是shell身份,利用这个特性可以做很多普通命令做不到的事情,比如直接调用InputManager相关api来分派全局的触摸事件,实现自动化触摸操作;还有向AMS注册一个ProcessObserver来监听各个app进程的前台状态等等)。
但是,前面说到,adbd进程是由init进程启动,说明它身份本该是root的,只是在启动时对自身进行了降级(daemon/main.cpp#118),不过android还是给我们留了一手 —— 只要符合一定条件,是可以避免降级的,这个条件就是:当系统属性ro.debuggable
和service.adb.root
都为1的时候(daemon/main.cpp#86),adbd就不会更改其gid和uid(也就是保持原来的root身份)。
大家都知道,ro开头的属性无法修改(除非有内鬼),但是service.adb.root
属性是可以用shell身份修改的,所以如果目标设备出厂时ro.debuggable=1
,那么恭喜你,你随时可以提升到root权限。这也就是adb root
功能的原理。
adbd提供3种方式来建立连接:
- usb串口方式(前提是开启了USB调试);
adbd进程启动后,会不断尝试打开
/dev/usb-ffs/adb
目录下的ep0
(control endpoint)文件,周期为1秒(usb.cpp#748),能成功打开的话说明可能连接了电脑,这时adbd会尝试向ep0
写入设备信息(USB descriptors) ,然后依次打开ep1
(bulk-out endpoint)和ep2
(bulk-in endpoint),分别作为串口的读取和写入节点(usb_ffs.cpp#276)。接下来会启动UsbFfs-monitor
线程,用来监听USB事件(usb.cpp#287),当收到FUNCTIONFS_ENABLE 事件时会启动一个UsbFfs-worker
线程(usb.cpp#361),读取从串口中发来的消息(usb.cpp#452),最后交给adb.cpp#handle_packet来处理。
- 通过tcp端口连接;
adbd在启动时,会检查
service.adb.listen_addrs
、service.adb.tcp.port
、persist.adb.tcp.port
这三个属性是否正确设置(daemon/main.cpp#250),如果有的话,会注册一个mdns服务(daemon/main.cpp#185),把对应的端口暴露出去供外部发现,然后根据这些属性值指定的地址和端口启动一个server socket
线程(transport_local.cpp#247),绑定并监听来自这些地址端口的连接(transport_local.cpp#268),当新的连接建立之后,会启动一个read线程(transport.cpp#290)不断将读取到的消息交给adb.cpp#handle_packet来处理(transport.cpp#806)。
大家熟知的adb tpcip <port>
命令,正是通过设置service.adb.tcp.port
属性来实现的(细心的同学会发现,每次运行完adb tpcip <port>
命令都会导致当前的adb连接断开,这是因为adbd进程只会在启动的时候去检查这个属性值,所以必须重启adbd来让它重新走一遍初始化流程)。
- (android11以上)Wireless debugging(无线调试);原理和流程都跟上面的差不多,同样是通过tcp连接,只是建立连接后的认证方式不一样,Wireless debugging需要在连接前先进行无线配对。
adbd进程启动后,会监听
persist.adb.tls_server.enable
系统属性变更(adb_wifi.cpp#197)来启动或停止socket和mdns服务,其连接方式和流程,以及对消息的处理,都跟上面的tcp端口方式一样。
● adb server:
跟adbd的性质差不多,adb server同样是作为一个后台服务存在,只不过它是运行在电脑端。
它主要做两件事:
- 管理与android设备之间的连接;
adb server在启动后,会启动一个
device poll
线程,每隔一秒遍历一次/dev/bus/usb
目录(usb_linux.cpp#612(我的是linux系统,因此我看的是这个文件)),对名字为纯数字文件夹做进一步的遍历(usb_linux.cpp#142),然后检测是否符合【开启了usb调试的android设备】的特征,检测通过后,回调到usb_linux.cpp#604,然后注册usb设备(transport.cpp#763),最后启动一个read线程(transport.cpp#290)不断将读取到的消息交给adb.cpp#handle_packet来处理(transport.cpp#806) ,这句话是不是觉得似曾相识?没错,从这里开始,adb server处理消息的逻辑,跟前面提到的 “通过tcp端口连接到adbd” 这种方式对消息的处理逻辑是一样的!adb组件重用了这部分代码!不过adb server还多做了一步,那就是在注册完设备后主动向adbd发送了连接请求(CNXN消息)(transport.cpp#819)!这个在后面会详细介绍。
- 处理来自5037(默认)端口的socket连接请求;
既然名字带有server字眼,那它当然还有一个server的身份了,当我们在命令行里输入
adb devices
或adb start-server
等命令时,adb会尝试连接到localhost:5037(adb_client.cpp#162),如果连接失败,说明adb server没有正确启动,这时候命令行会输出一个我们熟悉的提示:* daemon not running; starting now at tcp:5037
,然后尝试启动adb服务(adb_client.cpp#243),其实是启动了一个新进程,并在新进程里创建了一个socket服务(adb_listeners.cpp#206)。
● adb client:
嗯,有server自然就有对应的client,这里的client,同样是运行在电脑端,跟adb server通过socket进行本地通讯。
adb的可执行文件(windows系统是adb.exe),是同时包含了server和client的,它内部是通过是否带有
server
参数来区分(commandline.cpp#1486),如果带有server
参数,即表示要启动adb服务。
像我们常用的devices
、shell
、install <apk path>
等参数,对应的都是client,adb进程在启动时(每次在命令行里执行adb xxx都等于是启动了一个新的adb进程)检测到带有这些参数(devices
,shell
…),会先向server发送host:version
指令(adb_client.cpp#233),检查当前运行的server的版本是否跟自己的版本一致(adb_client.cpp#282),是的,它发送指令前,要先通过socket连接到adb server,如果连接失败的话,说明adb server没有正确启动(请看上面关于adb server的介绍)。
检测到版本一致,会继续创建一个socket连接到server,然后把解析好的参数发给server并等待adb server返回处理结果(adb_client.cpp#381)。
值得注意的是,我们常用的adb start-server
,它也是属于client命令,并不会直接启动server,它在发送完检查server版本的指令后就结束了(adb_client.cpp#336),聪明的你肯定已经想到了,这是因为client在检查版本时,如果连接不到server的话,就会启动server!所以这个命令并不需要做其他多余的动作。
是的,你又猜到了!既然平时使用的各种adb命令,其内部都是通过socket来通讯,那么我们完全可以自己写代码,使用socket来连接到adb server,从而实现一个简单的adb client!
现在就来试试吧:
fun main() {
// tport:xxx表示选择目标设备,any代表不指定设备,但是有多个已连接设备的时候就会出错。
// 如果要指定设备,则用 host:tport:serial:<DEVICE-SERIAL> <DEVICE-SERIAL>是设备的序列号,也就是你运行adb devices命令时,输出的左边的那一串东西
// 感兴趣的同学可以看下对应的源码,链接:<https://aosp.app/android-11.0.0_r1/xref/system/core/adb/client/adb_client.cpp#75>
val cmd = "host:tport:any"
// shell命令格式: shell[,arg1,arg2,...]:[command],比如: "shell,v2,TERM=xterm-256color,raw:ls"
// shell: 固定前缀;
// v2: 表示将使用shell协议来运行本次命令(发送"host:features"命令的回复包含"shell_v2"字眼,就表示支持shell协议);(可选)
// TERM=xterm-256color取自当前运行环境变量 "TERM";(可选)
// raw: 终端类型,分pty和raw,默认pty;(必填)
// 具体可参考源码:<https://aosp.app/android-11.0.0_r1/xref/system/core/adb/client/commandline.cpp#585>
// 所以下面的命令等同于"adb shell uname -a",即输出系统信息。
val cmd2 = "shell,raw:uname -a"
Socket("localhost", 5037).use { socket ->
"连接成功".p()
thread {
val content = ByteArray(128)
var count: Int
while (socket.inputStream.read(content).also { count = it } > -1) {
"收到: ${content.copyOfRange(0, count).contentToString()}:\n${java.lang.String(content, 0, count)}".p()
}
}
// adb client/server的消息格式是: 消息内容长度(4字节16进制数)+消息内容(字节数组)
socket.outputStream.write("${String.format("%04x", cmd.length)}${cmd}".toByteArray().also {
"发送: ${it.contentToString()}".p()
})
socket.outputStream.write("${String.format("%04x", cmd2.length)}${cmd2}".toByteArray().also {
"发送: ${it.contentToString()}".p()
})
// 预留1秒的时间等待子线程读取adb server回复的数据
Thread.sleep(1000)
}
}
fun Any?.p() = println(this)
代码很简单,使用socket直接连接到localhost:5037,然后分别发送了"host:tport:any"和"shell,raw:uname -a"命令,并在子线程里打印返回的数据。
运行看下效果:
哈哈哈哈,成功执行了uname -a
命令!
这时候有同学可能想到,这个adb server居然没有任何身份验证,谁都可以连接,这也太不安全了吧?!
emmmm,但是它默认只监听了localhost地址,也就是说,非本机进程是没办法直接连接的。
不过,倒是有办法让它监听所有地址,这样的话,局域网内的其他主机就能共享这台主机的adb设备了(其实共享的不止是已连接的adb设备,准确的说是adb的全部功能)。
前面说过,每次adb client进程启动的时候,都会先检查adb server是否正在运行,如果没运行,会通过以下命令来启动它:
adb -L tcp:5037 fork-server server --reply-fd <用来告知【启动它的client进程】server已启动 的文件描述符>
(adb.cpp#924)
这个命令肯定不能直接拿来用,因为我们没办法在命令行里生成一个用来通讯的fd。
来看下它处理启动参数的代码(commandline.cpp#1464):
int adb_commandline(int argc, const char** argv) {
bool no_daemon = false;
bool is_daemon = false;
bool is_server = false;
.......
while (argc > 0) {
if (!strcmp(argv[0], "server")) {
is_server = true;
} else if (!strcmp(argv[0], "nodaemon")) {
no_daemon = true;
} else if (!strcmp(argv[0], "fork-server")) {
/* this is a special flag used only when the ADB client launches the ADB Server */
is_daemon = true;
} else if (!strcmp(argv[0], "--reply-fd")) {
ack_reply_fd = strtol(reply_fd_str, nullptr, 10);
} else if (!strcmp(argv[0], "-a")) {
gListenAll = 1;
}
......
}
......
if (is_server) {
if (no_daemon || is_daemon) {
if (is_daemon && (ack_reply_fd == -1)) {
fprintf(stderr, "reply fd for adb server to client communication not specified.\n");
return 1;
}
r = adb_server_main(is_daemon, server_socket_str, ack_reply_fd);
} else {
r = launch_server(server_socket_str);
}
return r;
}
......
}
看到没有,如果启动参数带"server",is_server
就会标记为true,然后根据no_daemon
和is_daemon
来判断调用adb_server_main
还是launch_server
,最后return,不会继续往下执行这个函数后面的代码。
而刚刚的启动命令带有"fork-server" 和 “server”,很显然它最终会运行adb_server_main
而不是launch_server
。
对了,还有一个-a
参数,如果带了-a
参数的话,gListenAll
会标记成1。
这个参数有什么作用呢?
其实在adb usage里就有提到:
没错了!正是我们想要的东西,监听所有的地址!
不过,它只是告诉我们这个参数的效果,并没有说明要怎么把它应用到server上。
翻一下源码,可以看到它只在这里(socket_spec.cpp#322)有使用到:
int socket_spec_listen(std::string_view spec, std::string* error, int* resolved_port) {
......
int result;
if (hostname.empty() && gListenAll) {
result = network_inaddr_any_server(port, SOCK_STREAM, error);
} else if (tcp_host_is_local(hostname)) {
result = network_loopback_server(port, SOCK_STREAM, error, true);
} else if (hostname == "::1") {
result = network_loopback_server(port, SOCK_STREAM, error, false);
}
......
return result;
......
}
我们主要看gListenAll
为1的情况,从它里面调用的函数名(any_server
)对比其他两个分支的(loopback_server)
,就能大致猜出,一个是监听所有的地址,一个是只监听环回地址,这就跟上面usage中的描述对上了!
接着,我们顺瓜摸藤,可以摸到它有一条调用链是这样的:
socket_spec.cpp#322 <— adb_listeners.cpp#206 <— client/main.cpp#144
调用的源头居然是adb_server_main
!这说明什么呢?说明我们只要让adb server在启动时调用到adb_server_main
函数,就能实现我们想要的效果!
再看一眼上面处理启动参数的代码:
if (is_server) {
if (no_daemon || is_daemon) {
if (is_daemon && (ack_reply_fd == -1)) {
fprintf(stderr, "reply fd for adb server to client communication not specified.\n");
return 1;
}
r = adb_server_main(is_daemon, server_socket_str, ack_reply_fd);
} else {
r = launch_server(server_socket_str);
}
return r;
}
如果指定了is_daemon
,那就必须指定--reply-fd
,这个刚刚分析了做不到,但是不要紧,只要no_daemon
为true,也能进入到调用adb_server_main
的分支,所以最终的命令就是:
“adb server nodaemon -a
”
运行这个命令之后,必须一直挂着命令行,毕竟我们指定了"no daemon"嘛。
好了,现在,你可以通过局域网的ip+5037端口来连接到本机的adb server了!
有的同学可能想说:用得着这么麻烦吗,我直接用adb start-server -a
不行吗?
不好意思,还真的不行,因为start-server
除了发送host:version
命令外啥都不会做(adb_client.cpp#338),自然也不会追加-a
参数到启动server的命令里。
有同学可能想说:可这样有什么用?如果要多台电脑连同一台设备,我为什么不直接用adb tcpip
+ adb connect
呢?
emmmm,你说的这种情况的前提是对应的电脑必须也配置了adb环境,而用上面这种方法的话,是可以通过socket直接连接的,不需要任何额外的环境,甚至同网段内的手机也能直接使用这台电脑的adb!
好吧,扯远了,回归主线,我们来学习下adb协议的通讯流程。
协议通讯流程:
首先来看下它的数据包格式:
struct message {
uint32_t command; /* command identifier constant (A_CNXN, ...) */
uint32_t arg0; /* first argument */
uint32_t arg1; /* second argument */
uint32_t data_length; /* length of payload (0 is allowed) */
uint32_t data_crc32; /* crc32 of data payload */
uint32_t magic; /* command ^ 0xffffffff */
};
跟前面的jdwp协议差不多,adb消息也是分【消息头】和【数据】两部分组成:
-
command
:消息包指令,用来告诉对方此条消息的目的。当前协议提供了7种可用的指令(在下面详细介绍)。 -
arg0
,arg1
:消息包的附加参数; -
data_length
:消息包的【数据】部分的长度; -
data_crc32
:消息包的【数据】部分的checksum(累加校验);
有两点很坑,第一个,它协议文档里写着checksum是crc32,但实际上只是简单的累加校验(adb.cpp#84):
你看它结构体里面的字段名也改成data_check
而非文档中的data_crc32
了!
还有一个,如果使用0x01000001
以上的协议版本来建立链接的话,checksum直接不启用了(transport.cpp#554)!换句话说就是,如果链接使用0x01000001
以上的协议版本,就不需要验证数据包的checksum,因为这个字段会一直返回0!
magic
:消息包的魔数,同样用于校验消息包的有效性,通过command
^ 0xffffffff(转int后就是-0x01) 得出。
消息包指令:
-
A_CNXN(0x4e584e43) ,如果是【adb server】发给【adbd】,表示请求建立连接,如果是【adbd】回复给【adb server】的,表示连接已成功建立;
-
A_STLS(0x534C5453),【adbd】先发给【adb server】,表示本次连接将使用TLS来进行加密通讯;
-
A_AUTH(0x48545541),这个消息有三种不同的类型,通过
arg0
参数来区分,分别是ADB_AUTH_TOKEN(【adbd】发给【adb server】,要求对附带的随机字符串进行RSA签名)、ADB_AUTH_SIGNATURE(【adb server】发回给【adbd】,携带对前面的随机字符串签名后的数据)、ADB_AUTH_RSAPUBLICKEY(【adb server】发回给【adbd】,携带前面对随机字符串进行签名的私钥对应的公钥数据); -
A_OPEN(0x4e45504f),由【adb server】发给【adbd】,请求连接到adbd提供的本地服务,比如我们常用的
shell
,还有即将使用到的jdwp
; -
A_WRTE(0x45545257),写数据,基本上所有带交互性质的服务都需要通过此命令来写入数据;
-
A_OKAY(0x59414b4f),用于告诉对方自己已准备好接收下一条消息。比如在每次收到对方的WRITE消息之后,都要回复一个OKAY消息,不然的话,对方可能会认为你还在处理上一条消息,从而停止发送新的消息。
-
A_CLSE(0x45534c43),通知对方,连接已关闭。通常是因为某些不符合预期的状态导致要关闭本次连接,比如通过A_OPEN指令尝试连接到一个不存在的服务,或者服务已运行结束等等;
协议提供了2种建立通讯的方式,分别对应普通连接(usb, tcpip)和tls连接(Wireless debugging):
普通连接:
-
【adb server】通过usb或socket连接到【adbd】之后,【adb server】先发出一个A_CNXN消息(adb.cpp#232):
arg0
=0x01000001(当前【adb server】版本)
arg1
=1024*1024(能接受单条消息【数据】部分的最大长度,如果超过了此长度,必须分包发送,否则将忽略此条消息)
data
=connection string,格式为: <设备角色>::features=<支持的功能>,【adb server】的角色固定为"host",例如:
host::features=sendrecv_v2_brotli,remount_shell,sendrecv_v2,abb_exec,fixed_push_mkdir,fixed_push_symlink_timestamp,abb,shell_v2,cmd,ls_v2,apex,stat_v2
(transport.cpp#1172),别小看这个features后面跟着的一堆东西,它可是能决定你这次连接能够使用哪些功能的(transport.cpp#1209); -
【adbd】收到后,会进入认证阶段,此时【adbd】会回复一个A_AUTH消息,要求对附带的随机字符串进行RSA签名(daemon/auth.cpp#256):
arg0
=1(ADB_AUTH_TOKEN )
data
=长度为20的随机字符串(daemon/auth.cpp#192)
对于
ro.boot.verifiedbootstate=orange
或者开启了全局调试,并且ro.adb.secure=0
的系统会跳过认证阶段(daemon/main.cpp#215),直接回复连接成功(adb.cpp#330)。
- 【adb server】这边,使用RSA私钥对
data
进行签名(client/auth.cpp#470)后同样会回复A_AUTH消息(client/auth.cpp#477):
arg0
=2(ADB_AUTH_SIGNATURE )
data
=签名后的数据
这里说是签名,但实际上是用私钥对
data
进行加密(加密前还会在data
前面插入一段固定的字节数组)!并不是常规的签名操作。它内部是调用了openssl的RSA_sign函数。
-
【adbd】读取本地
/adb_keys
(系统自带)和/data/misc/adb/adb_keys
(用户授权)的公钥记录(adbd_auth.cpp#393),遍历里面的公钥,逐一对发过来的签名进行验证(daemon/auth.cpp#177),此时有两种情况:如果其中一个公钥验证通过,则说明此【adb server】已经被授权过(跳到第7点);一种是之前没有认证过的,这时候【adbd】会像第3点那样,再发一次A_AUTH(ADB_AUTH_SIGNATURE)消息(adb.cpp#390)。 -
【adb server】第二次收到了A_AUTH(ADB_AUTH_SIGNATURE)消息,说明刚刚用来签名的密钥还没有被授权,这时需要再次回复A_AUTH消息(client/auth.cpp#433),并把公钥/主机名/用户名带上:
arg0
=3(ADB_AUTH_RSAPUBLICKEY )
data
=按照指定格式编码后转成base64的公钥(刚刚用来 “签名” 的私钥所对应的公钥)、主机名、用户名(rsa_2048_key.cpp#49); -
【adbd】收到A_AUTH(ADB_AUTH_RSAPUBLICKEY)消息之后,会通知系统进行授权弹框(adbd_auth.cpp#261):“允许USB调试吗?此台计算机的RSA密钥指纹如下……”,当用户点击允许按钮,【adbd】会标记此公钥已获得用户授权(adbd_auth.cpp#216)。如果用户勾选了"一律允许xxx",还会把这个公钥追加到
/data/misc/adb/adb_keys
文件里(AdbDebuggingManager.java#1555)(那么下次就能通过这个文件读取出公钥来验证,而不是每次都弹授权框了)。
【adb server】的通讯对象是【adbd】,而【adbd】的身份只能是root或shell,那么它是如何弹出请求用户授权的对话框的呢?
原来,在【adbd】进程初始化的时候,会创建并监听localsocket “adbd” 的连接请求(adbd_auth.cpp#94),它的通讯对象是AdbService(注意不是【adb server】,AdbService跟ActivityManagerService、WindowManagerService它们一样,是在SystemServer里初始化,运行在system_process进程里的),但是最终操作弹框的也不是它,而是systemui进程,它们之间的关系就像这样:
adbd
<—localsocket—>adb service
<—binder—>systemui
【adbd】通过localsocket跟adb service通讯,然后adb service和systemui之间通过binder来通讯。
AdbService在启动后会监听USB调试和无线调试(Adnroid11)设置项的开关状态(AdbService.java#196),当检测到USB调试或无线调试已开启时,会连接到上面说的【adbd】进程下的unix域套接字"adbd"(AdbDebuggingManager.java#386),并接收它发过来的消息。
比如当【adbd】需要弹授权框时,会发送"PK"消息(adbd_auth.cpp#261),AdbService这边收到后,会启动systemui的一个activity来处理弹框(AdbDebuggingManager.java#1460),默认是com.android.systemui/com.android.systemui.usb.UsbDebuggingActivity
,用户确认授权后,回调AdbService的allowDebugging(注意此时是在systemui进程中,是通过binder进行跨进程调用)方法,AdbService这边收到后,向【adbd】回复"OK"(AdbDebuggingManager.java#865)消息。【adbd】收到此消息后,请看下面第7点。
-
当知道公钥已获得用户授权后,【adbd】会向【adb server】发送A_CNXN消息(adb.cpp#235):
arg0
=0x01000001(当前【adbd】版本)
arg1
=1024*1024(能接受单条消息【数据】部分的最大长度,如果超过了此长度,必须分包发送,否则将忽略此条消息)
data
=【adbd】方发送的connection string,比【adb server】多了ro.product.name
、ro.product.model
和ro.product.device
这三个属性信息(其实也没啥大作用,只是做个标识容易区分而已),格式为: <设备角色>::设备属性;features=<支持的功能>,【adbd】的角色为"device",例如:
device::ro.product.name=aosp_walleye;ro.product.model=AOSP on walleye;ro.product.device=walleye;features=sendrecv_v2_brotli,remount_shell,sendrecv_v2,abb_exec,fixed_push_mkdir,fixed_push_symlink_timestamp,abb,shell_v2,cmd,ls_v2,apex,stat_v2
(transport.cpp#1172);当然了,android端的设备角色除了常规的device,还有在recovery模式下的recovery
,还有rescue
、sideload
等等,在不同角色下所支持的功能和操作也有所不同。 -
【adb server】收到A_CNXN消息,说明已经成功与【adbd】建立连接,这时会把对应设备的状态改为在线(adb.cpp#108),然后可以进行正式的通讯了。
tls连接:
前面说到过,adbd进程启动后,会监听persist.adb.tls_server.enable
系统属性变更(adb_wifi.cpp#197)来启动或停止socket和mdns服务,这个persist.adb.tls_server.enable
属性,对应的就是无线调试的开关。
如果【adb server】是通过这个socket端口来连接到【adbd】,那么【adbd】会把use_tls标记为true(adb_wifi.cpp#146),并在收到【adb server】发来的A_CNXN消息后回复A_STLS消息(adb.cpp#329),表示需要进行加密通讯:
arg0
=0x01000000(STLS版本)
data
=无data;
【adb server】这边收到后回复同样的消息:
arg0
=0x01000000(STLS版本)
data
=无data;
然后双方使用TLSv1.3进行常规的SSL握手(transport.cpp#493),在交换证书时,【adb server】这边会从【adbd】以前认证过的公钥指纹(daemon/auth.cpp#106)来筛选出对应的私钥(其实在大多数情况下,只有一个私钥)(client/auth.cpp#511),然后生成证书。
【adbd】收到证书后,遍历本地/adb_keys
(系统自带)和/data/misc/adb/adb_keys
(用户授权)的公钥记录,逐一对比是否与证书的公钥一致(daemon/auth.cpp#338),如果能找到相同的公钥,证明此前已经得到过授权,标记为验证成功。 之后的流程(adb_wifi.cpp#223),请看上面普通连接的第7点。
如果没找到一致的公钥,说明此前既没有使用普通连接记住过授权,也没有进行过无线配对(当然,也有可能是用户手动清空了授权记录),这时有三种方法:
- 通过上面普通连接的方式让用户记住授权,这样的话,本地
/data/misc/adb/adb_keys
文件就会有了此【adb server】的公钥记录,在通过无线调试来连接时,就能直接通过验证; - 进行无线配对(下面讲);
- 这个方法比较野,如果目标设备有root权限,可以作为保留手段,那就是:在停掉adbd后直接修改
/data/misc/adb/adb_keys
文件的内容,在里面追加自己的公钥/主机名/用户名,然后重新启动adbd,达到手动授权的效果;
无线配对:
在android11以上的系统设置—>开发者选项—>无线调试界面里,会看到有一个【使用配对码配对设备】的选项(使用二维码配对,局限性太大,因此不作考虑):
点击了之后,会弹出一个配对码的对话框:
无线配对本质上就是【adb server】通过tls连接将公钥数据发送给【AdbService】进行持久化储存(跟前面提到的 “用户通过弹框授权” 之后的效果一样)。
在开启无线配对时,【AdbService】会生成一个长度为6的随机数字作为配对码(AdbDebuggingManager.java#1112),然后启动一个socket服务,监听随机端口(pairing_server.cpp#230),并注册mdns服务(AdbDebuggingManager.java#224)供外部发现。
【adb server】这边通过对应的端口连接后,同样是通过特定的数据包格式进行通讯:
struct PairingPacketHeader {
uint8_t version; // PairingPacket version
uint8_t type; // the type of packet (PairingPacket.Type)
uint32_t payload; // Size of the payload in bytes
}
version
的值目前固定是1(pairing_connection.cpp#41);
type
只有两种,分别是SPAKE2_MSG(0)和PEER_INFO(1)(pairing.proto#26);
payload
就是后面data的长度;
它的通讯流程是这样的:
【adb server】先通过某些渠道获取到配对码,然后双方(【adb server】和【AdbService】)在完成TLS握手之后(pairing_connection.cpp#185),导出一个label为"adb-label",长度为64的TLS密钥材料(tls_connection.cpp#183),然后追加到配对码后面(pairing_connection.cpp#197),作为SPAKE2消息的密码。
注意!这个TLS密钥材料,在握手成功之后双方获取到的都是一样的,也就是说,如果【adb server】这边获取到了正确的配对码,那么它最终得到的密码跟【AdbService】那边是相同的!
在生成SPAKE2消息之后(pairing_auth.cpp#123),双方各自向对方发送一条type
为SPAKE2_MSG的数据包,并携带这个SPAKE2消息(pairing_connection.cpp#304)。
双方收到SPAKE2消息后,会对它进行计算处理(pairing_auth.cpp#150),最终得到一个相同的密钥,然后通过HKDF(aes_128_gcm.cpp#41)在这个密钥的基础上扩展出128bit的密钥,在接下来的阶段中用作AES-128-GCM加解密。
上面反复提到的SPAKE2是什么?
它其实是一种密钥交换协议,可以在双方不直接传输密钥的情况下,通过一些相同的输入(比如TLS密钥材料(Conscrypt.java#476))来计算出相同的密钥,我这样举个例子你就明白了:
/
// 客户端
/
// 双方使用相同的初始密码,假设内容是123456
String password = "123456";
// 在客户端里,self是client,peer是server,服务端则相反
String self = "client";
String peer = "server";
// 创建spake2对象
Spake2 clientSpake2 = new Spake2(self, peer);
// 和服务端使用相同的密码生成spake2消息
byte[] clientMsg = clientSpake2.generateMessage(password);
// 将生成的消息发送给服务端
socket.write(clientMsg);
// 读取服务端发过来的spake2消息
byte[] serverMsg = socket.read();
/
// 处理消息,得到一个与服务端相同的密钥
/
byte[] secretKey = clientSpake2.processMessage(serverMsg);
/
// 服务端
/
// 双方使用相同的初始密码,假设内容是123456
String password = "123456";
// 在服务端里,self是server,peer是client
String self = "server";
String peer = "client";
// 创建spake2对象
Spake2 serverSpake2 = new Spake2(self, peer);
// 和客户端使用相同的密码生成spake2消息
byte[] serverMsg = serverSpake2.generateMessage(password);
// 将生成的消息发送给客户端
socket.write(serverMsg);
// 读取客户端发过来的的spake2消息
byte[] clientMsg = socket.read();
/
// 处理消息,得到一个与客户端相同的密钥
/
byte[] secretKey = serverSpake2.processMessage(clientMsg);
当然了,在这个过程中,如果客户端用于生成SPAKE2消息的密码跟服务端的不一样,那么最终计算出来的密钥也是不同的,应用到真实场景中,如果【adb server】使用了不正确的配对码,那么将无法成功解密对方发过来的加密数据。
嗯,回到原来的话题,现在双方已经得到了相同的密钥,开始第二阶段 —— 交换数据:
【adb server】这边会使用刚刚的密钥对公钥数据进行AES加密(pairing_connection.cpp#340),然后发送一条type
为PEER_INFO的数据包(pairing_connection.cpp#349),并携带这串加密数据。
【AdbService】这边也会做同样的事情,只不过它加密的数据不是公钥,而是刚刚用来注册mdns服务的名称字符串。
双方收到对方消息,且解密成功之后(pairing_connection.cpp#400):
【adb server】这边会记录这个无线服务的名称(adb_wifi.cpp#240),然后主动尝试连接该设备。
而【AdbService】这边则和普通连接中的 “用户通过弹框授权” 之后的操作一样:将公钥数据追加到本地文件,进行持久化储存(AdbDebuggingManager.java#1371)。
至此,配对流程结束。
正式通讯
前面说的一大堆,都只是属于建立正式通讯前的认证阶段,认证通过后才能进行正式通讯 —— 使用【adbd】提供的本地服务。
这些本地服务,需要通过发送A_OPEN命令(sockets.cpp#508)来打开,比如要打开一个shell服务:
command
=0x4e45504f;
arg0
=发送方唯一标识,使用任意正整数来标识,比如1
;
arg1
=接收方唯一标识,现在服务还没打开,所以现在是0
;
data
="shell:"
(目标服务名称);
注意,这时候的附加参数
arg0
和arg1
的作用就相当于是handle(句柄),分别代表发送方和接收方(相对自身来说)。 因为adb支持同时打开多个服务,但收发数据都是通过同一个socket的in/out stream,这样的话,就需要用唯一标识来区分每条消息的发送者和接收者。
如果shell服务打开成功,【adbd】会先回复一条OK消息,并附带对应shell服务的句柄,类似这样:
command
=0x59414b4f;
arg0
=100
(shell服务句柄(唯一标识));
arg1
=1
(接收方句柄(相对于【adbd】);
data
=null;
【adb server】这边收到后要记录这个句柄,以用于后续通讯的
arg1
(接收方)参数;
接着【adbd】还会发送一条WRITE消息:
command
=0x45545257;
arg0
=100
(表示消息依然来自刚刚新打开的shell服务);
arg1
=1
(接收方句柄);
data
="oriole:/ $"
(熟悉的命令提示符,由 ro.product.device、当前路径以及用户标识组成);
【adb server】这边收到后,需回复OK消息,表示已经接收到【adbd】的WRITE消息。
到这一步,shell服务的基本通讯已经建立好,那么接下来,就可以按照以下流程去进行shell命令交互了:
【adb server】 ————— WRITE
(1
, 100
, data
=shell命令) ————> 【adbd】
【adb server】 <—————— OK
(arg0
=100, arg1
=1) ——————— 【adbd】
【adb server】 <—— WRITE
(100
, 1
, data
=shell命令执行结果) ——— 【adbd】
【adb server】 ——————— OK
(arg0
=1, arg1
=100) ——————> 【adbd】
当然了,在此过程中,任意一方发送CLOSE消息都会导致连接关闭。
还有jdwp服务的打开和后续的通讯流程也大致相同,不一样的只是当【adbd】在接收到OPEN(jdwp:<pid>
) 消息时,只会回复一条OK消息,然后等待【adb server】发来握手消息。
好了,说了这么多,相信大家对adb已经有一个比较深刻的了解,那么接下来我们就开始动手写代码,实现一个能和【adbd】进行基本通讯的精简版【adb server】(Debugger),并结合jdwp协议来进行debug。
实现adb协议
先来看看数据包要怎么封装和拆解:
由上面贴出来的message结构体可以看到,里面的6个字段类型都是uint32_t
,32bit
=4byte
,6个4byte
=24byte
,也就是每个adb数据包的【消息头】部分固定占24个字节,如果有【数据】部分,那么整条消息的长度 = 24 +【数据】的长度。
跟前面的jdwp协议不同,adb协议使用的是小端字节序,那么组装数据包的代码就可以写成这样:
private fun generateMessage(cmd: Int, arg0: Int, arg1: Int, data: ByteArray = ByteArray(0)) =
// 数据包的长度=消息头长度(24) + 数据部分长度
ByteBuffer.allocate(24 + data.size).apply {
order(ByteOrder.LITTLE_ENDIAN) // 使用小端字节序
// 按协议规定的顺序放置字段:command, arg0, arg1, data_length。每个字段长度都是4字节,所以全部用putInt
putInt(cmd).putInt(arg0).putInt(arg1).putInt(data.size)
// 数据部分的字节数据checksum,如果没有数据部分,就是0
putInt(data.sumOf { if (it >= 0) it.toInt() else it + 256 })
// magic,由command ^ -0x01得出
putInt(cmd xor -0x01)
// 最后放置data字节数组
put(data)
}.array()
解包也是同样的逻辑:
private fun readMessage(): Triple<Int, Int, ByteArray> {
// 创建长度为24的消息头,使用小端字节序
val header = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN)
// 从inputStream中读取数据
inputStream.readFully(header.array())
// 依次读出各个字段值
val command = header.getInt()
val arg0 = header.getInt()
val arg1 = header.getInt()
val dataLength = header.getInt()
val checksum = header.getInt()
val magic = header.getInt()
var data = ByteArray(0)
// 如果dataLength>0,证明有【数据】部分
if (dataLength > 0) {
data = ByteArray(dataLength)
// 一次性读取出数据部分
inputStream.readFully(data)
}
// 检查消息是否有效
if (command != magic xor -0x1 || (checksum > 0 && dataLength > 0 && data.sumOf { if (it >= 0) it.toInt() else it + 256 } != checksum)) {
throw IllegalStateException("Invalid message!")
}
return Triple(command, arg0, data)
}
我们要实现的【adb server】(Debugger) 不需要考虑同时debug多个进程,也就是说,同一时间最多只会开启一个jdwp服务,所以对于【adbd】发来的消息中的arg1
(接收方)参数,就可以忽略掉了(因为就只有一个接收方,不用猜肯定是发给它的了),这样的话,最终对我们有用的字段就只有command
、arg0
和data
这3个,刚好可以用Triple来装载,而不用专门去定义一个实体类了。
好,现在创建一个Debugger类,尝试与【adbd】建立连接:
class Debugger(host: String, port: Int) {
companion object {
private const val CMD_CONNECTION = 0x4e584e43
private const val CMD_AUTHORIZATION = 0x48545541
private const val CMD_OKAY = 0x59414b4f
private const val CMD_CLOSE = 0x45534c43
private const val CMD_WRITE = 0x45545257
private const val CMD_OPEN = 0x4e45504f
}
private val socket: Socket
private val inputStream: DataInputStream
private val outputStream: OutputStream
private val publicKey: RSAPublicKey
private val privateKey: RSAPrivateKey
private var connected = false
init {
"尝试连接socket".log()
socket = Socket(host, port).also {
it.tcpNoDelay = true
outputStream = it.outputStream
// 使用DataInputStream来包装
inputStream = DataInputStream(it.inputStream)
}
"socket连接成功".log()
// 生成一个长度为2048bit的RSA密钥对
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA).apply { initialize(2048) }.genKeyPair().apply {
publicKey = public as RSAPublicKey
privateKey = private as RSAPrivateKey
}
connect()
}
private fun Any?.log() = Log.i("Debugger", toString())
}
代码很简单,在构造函数里就立即尝试连接到socket,连接成功后,创建一个2048bit的RSA密钥对,并调用connect
方法尝试建立adb协议层的连接,来看下connect
方法:
private fun connect() {
// 先发送CONNECTION消息
val adbVersion = 0x01000001
val maxPayload = 1024 * 1024
val connectionString = "host::features=sendrecv_v2_brotli,remount_shell,sendrecv_v2,abb_exec,fixed_push_mkdir,fixed_push_symlink_timestamp,abb,shell_v2,cmd,ls_v2,apex,stat_v2"
"发送connect消息".log()
writeMessage(generateMessage(CMD_CONNECTION, adbVersion, maxPayload, connectionString.toByteArray()))
// 读取adbd回复的消息:(cmd, arg0, data)
var response = readMessage()
when (response.first) {
CMD_CONNECTION -> {
"收到了connection消息".log()
// 直接收到了CONNECTION消息,说明目标设备没有开启验证,标记连接成功
connected = true
}
CMD_AUTHORIZATION -> {
"收到了authorization消息,回复签名数据".log()
// 收到了AUTHORIZATION消息,表示adbd要求对data进行签名
writeMessage(generateMessage(CMD_AUTHORIZATION, 2/*ADB_AUTH_SIGNATURE*/, data = signWithPrivateKey(response.third)))
// 继续读取消息
response = readMessage()
when (response.first) {
CMD_CONNECTION -> {
"收到了connection消息".log()
// 收到了CONNECTION消息,说明刚刚用来签名的私钥在之前已经得到过用户授权,标记连接成功
connected = true
}
CMD_AUTHORIZATION -> {
"第二次收到authorization消息,回复公钥数据".log()
// 第二次收到AUTHORIZATION消息,说明此私钥还没有被授权,现在将对应的公钥编码后发过去
writeMessage(generateMessage(CMD_AUTHORIZATION, 3/*ADB_AUTH_RSAPUBLICKEY*/, data = encodePublicKey()))
// 我们期望adbd下一条发来的是CONNECTION消息
if (readMessage().first == CMD_CONNECTION) {
"收到了connection消息".log()
// 果真发来了CONNECTION消息,说明已通过用户授权,标记连接成功
connected = true
} else {
// 没有盼来CONNECTION消息,当作连接失败处理,抛一个exception
throw ConnectException("failed to connect adb")
}
}
}
}
}
}
private fun writeMessage(data: ByteArray) = synchronized(outputStream) {
outputStream.write(data)
outputStream.flush()
}
里面的signWithPrivateKey
方法是这样的(参考:rsa.c#RSA_sign、rsa.c#RSA_add_pkcs1_prefix、rsa_impl.c#rsa_default_sign_raw、padding.c#RSA_padding_add_PKCS1_type_1、rsa_impl.c#rsa_default_private_transform):
private fun signWithPrivateKey(data: ByteArray) = Cipher.getInstance("RSA").run {
// 使用RSA加密
init(Cipher.ENCRYPT_MODE, privateKey)
// 在data前面添加ASN.1 DER编码的固定前缀 (https://aosp.app/android-11.0.0_r1/xref/external/boringssl/src/crypto/fipsmodule/rsa/rsa.c#405)
val prefix = byteArrayOf(0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, 0x04, 0x14)
val signedMsg = prefix + data
doFinal(ByteArray((privateKey.modulus.bitLength() + 7) / 8).apply {
this[1] = 1
// 填充0xff:从第3个元素开始,一直到signedMsg的前2个
Arrays.fill(this, 2, size - signedMsg.size - 1, -1/*0xff*/)
// 把signedMsg的内容填充到数组的最后
// 也就是相当于在signedMsg的前面追加[0, 1, 0xff......0xff, 0]
signedMsg.copyInto(this, size - signedMsg.size)
})
}
还有一个对公钥进行编码的encodePublicKey
方法(参考:android_pubkey_encode):
private fun encodePublicKey(): ByteArray {
val androidPublicKeyModulusSize = 2048 / 8
val androidPublicKeyModulusSizeWords = androidPublicKeyModulusSize / 4
val androidPublicKeyEncodeSize = 3 * 4 + 2 * androidPublicKeyModulusSize
val r32 = BigInteger.ZERO.setBit(32)
val n0inv = publicKey.modulus.mod(r32).modInverse(r32).negate().toInt()
val rr = BigInteger.ZERO.setBit(androidPublicKeyModulusSize * 8).run {
multiply(this).mod(publicKey.modulus)
}
val modulusArray = publicKey.modulus.bn2binPaddedAndReverse(androidPublicKeyModulusSize)
val rrArray = rr.bn2binPaddedAndReverse(androidPublicKeyModulusSize)
val exponent = publicKey.publicExponent.toInt()
val keyBuffer = ByteBuffer.allocate(androidPublicKeyEncodeSize).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(androidPublicKeyModulusSizeWords)
putInt(n0inv)
put(modulusArray)
put(rrArray)
putInt(exponent)
}.array()
// 格式: 用户名@主机名\0
val userInfo = " wuyr@test.com\u0000".toByteArray()
return Base64.encode(keyBuffer, Base64.NO_WRAP) + userInfo
}
private fun BigInteger.bn2binPaddedAndReverse(len: Int) = toByteArray().run {
val bytesLength = (bitLength() shr 3) + 1
// 如果 len < bytesLength 证明BigInteger是正数,有前导零,要裁剪掉
if (len < bytesLength) copyOfRange(1, bytesLength) else this
}.copyInto(ByteArray(len)).apply { reverse() }
好,现在来运行看看(记得要先开个无线端口:adb tcpip 5555
):
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 在子线程里运行
thread {
// 要连接的目标在本地,直接使用回环地址
Debugger("127.0.0.1", 5555)
}
}
}
成功弹出了授权框!
点击允许授权,看下日志输出:
连接成功了!
连接到jdwp
前面讲过,连接到jdwp服务和打开shell服务的流程几乎是一样的,不同的只是在发出OPEN jdwp:消息之后,【adbd】只会回复一个OK消息,然后等待我们这边发送握手消息。
来看下代码怎么写:
// 发送方句柄
private val localId = 1
// 接收方(jdwp服务)句柄
private var remoteId = 0
fun connect2jdwp(targetPid: Int): Boolean {
var jdwpConnected = false
"发送OPEN jdwp:$targetPid 消息".log()
writeMessage(generateMessage(CMD_OPEN, localId, remoteId, "jdwp:$targetPid".toByteArray()))
// 读取回复
var response = readMessage()
if (response.first != CMD_OKAY) {
throw RuntimeException("failed to connect target process: $targetPid")
}
// 收到OK消息标识打开成功,记录此条消息的arg0参数(jdwp服务句柄),作为remoteId
remoteId = response.second
"收到OK回复,remoteId=$remoteId".log()
"发送WRITE JDWP-Handshake 握手消息".log()
writeMessage(generateMessage(CMD_WRITE, localId, remoteId, "JDWP-Handshake".toByteArray()))
response = readMessage()
if (response.first != CMD_OKAY) {
throw RuntimeException("failed to handshake with target process: $targetPid")
}
"收到OK回复,读取握手回复".log()
response = readMessage()
// 将回复的data转字符串,校验内容是否为JDWP-Handshake
val dataString = String(response.third)
"收到握手回复,data=$dataString".log()
if (dataString == "JDWP-Handshake") {
"握手消息校验成功".log()
jdwpConnected = true
} else {
"握手消息校验失败".log()
}
"回复OK消息".log()
writeMessage(generateMessage(CMD_OKAY, localId, remoteId))
return jdwpConnected
}
其实这里应该要有一个超时中断机制,因为一个进程在同一时间内只能连接一个Debugger,如果目标进程已连接了其他Debugger,它就不会回复握手消息,会导致readMessage
一直阻塞。
好,现在修改MainActivity代码,随便找个debuggable的进程号来测试下:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 在子线程里运行
thread {
// 要连接的目标在本地,直接使用回环地址
Debugger("127.0.0.1", 5555).apply {
if (connect2jdwp(9345)){
// 成功连接到jdwp
}
}
}
}
}
握手成功!
那么现在就可以按照前面介绍过的【shell命令交互流程】正常收发jdwp数据包了,这里不再赘述。
没错,到了这一步,其实就相当于我们平时debug的时候,用Debugger attach上了目标进程,接下来通常会寻找合适的位置打断点。
问题来了:
断点应该设置在哪里呢? 我们最终要实现的是通过debugger来实现代码自动化注入,这个过程中肯定不能让用户去干预,这就诞生了一个要求:断点必须主动触发! 不然就不算自动化了,而且这个触发的时机必须尽量早,否则会影响整体效率。
选择断点位置
有同学可能会说:“把断点打在onTouchEvent
方法上,然后通过adb模拟一个屏幕触摸事件从而实现主动触发”。
emmmm,先不说方法断点对app运行效率的影响,这个方案有一个明显的弊端就是:因为你无法保证每次的触摸事件都能完美避开一些功能性的按钮,比如这个事件的坐标值刚好落在一个跳转界面的按钮上,那么在vm恢复运行的时候,会自动跳转界面,站在用户角度来看,就会觉得莫名其妙。就算是一个ACTION_UP事件,也可能当时用户正在拖拽一样东西,你一个ACTION_UP把人家拖拽的东西放下了,所以这个方案还是不太友好,无法保证无感触发。
更严重的是,要知道被debug的app不一定是运行在前台,现在模拟触摸事件都是通过InputManager.injectInputEvent()
来传入一个InputEvent,这个方法对应的是display,无法只针对某个应用进行分派,如果此时目标应用运行在后台的话,就接收不到事件了,进一步导致断点不能及时触发。
咦?那Handler怎么样?!
对噢,Handler!ActivityThread里的Handler.handleMessage
方法回调频率非常高,Activity的生命周期变化,都要经过这里:比如当app从后台切换到前台,AMS会调用IApplicationThread的scheduleTransaction
方法,把即将要发生的事件(ResumeActivityItem)通过binder告诉ActivityThread的mAppThread
,接着mAppThread
就会向Handler发一条消息。
目前看来,把断点打在Handler.handleMessage
方法是比较合适的。不过!把断点打在方法上,是会大大影响app的运行效率的,但又不能按行号来打断点,因为各个系统版本的行号都可能有变化。这样排除下来,就只有变量断点(Field Watchpoint)能用了。
想一下,Handler.handleMessage
一定会访问哪个类的哪个成员变量?
没错!就是MessageQueue里面的mMessages
! watch这个变量之后,只要Handler有消息要处理,就一定会触发。
但是,Handler总会有空闲的时候,如果在注入时Handler刚好处于空闲状态,断点就不能及时触发,这又回到了刚开始的问题了! 所以必须在设置好Watchpoint之后,让目标进程的Handler忙起来。
主动触发断点
刚刚提到,Activity每当生命周期发生变化时,都是由AMS跨进程通知ApplicationThread,然后ApplicationThread向Handler发一条消息。
那么,我们能不能用shell命令模拟键盘事件,比如发送HOME键之类的,间接使目标进程的Activity的生命周期发生变化,而从让ActivityThread的Handler收到消息,进一步触发断点呢?!
问题又来了,一个正在运行的进程,不一定会启动activity!人家可能只启动了一个service! 而且,通过这些命令强行改变了activity的生命周期,比刚开始的模拟触摸事件方案更不友好。
退一步来想,那还有没有其他的命令可以让AMS给ApplicationThread发通知呢?
翻了一下源码还真有:
- am crash <package>;
- am trace-ipc start;
- am trace-ipc stop --dump-file <output path>;
- am profile start <package> <output path>;
- am profile stop <package>;
- am dumpheap <package>;
- am attach-agent <package> <agent path>;
am crash
就太暴力了,Handler收到这个消息,会直接抛出一个RemoteServiceException来结束进程。
中间这几个: trace-ipc
、profile
、dumpheap
都是跟内存/性能分析有关,最后一个attach-agent
是什么鬼?跟踪一下调用链:
return runAttachAgent(pw); ——> mInternal.attachAgent(process, agent); ——> proc.thread.attachAgent(path); :
public void attachAgent(String agent) {
sendMessage(H.ATTACH_AGENT, agent);
}
可以看到,这个attach-agent
命令也是会给Handler发消息的。
看下处理消息的代码:
private static boolean attemptAttachAgent(String agent, ClassLoader classLoader) {
try {
VMDebug.attachAgent(agent, classLoader);
return true;
} catch (IOException e) {
Slog.e(TAG, "Attaching agent with " + classLoader + " failed: " + agent);
return false;
}
}
妈呀!这不就是加载JVMTI Agent的方法吗?!am居然还提供了从外部加载的入口!
注意看,这里对VMDebug.attachAgent
加了try catch块,也就是说,这个方法就算报错了,也不会影响程序的正常运行,只是输出了一个error级别的log而已。
那么我们完全可以利用这个attach-agent
命令,传入一个不合法的路径(不影响程序正常运行),让AppThread给Handler发消息,从而主动触发打在MessageQueue.mMessages
上的断点!
像这样:
am attach-agent com.android.systemui(进程名) /
好啦,最后的问题现已解决,那么本篇文章就到此结束,有错误的地方请指出,谢谢大家!
Github地址:https://github.com/wuyr/jdwp-injector-for-android 欢迎Star
下集预告
《Android系统内鬼之 ActivityManager篇》
没错,就是利用刚刚发现的attach-agent
命令实现代码注入
敬请期待……
注:本文内容仅供学习交流,不要用来干坏事噢~