注册

花亿点时间,写个Android抓包库

0x1、引言


上周五版本刚提测,这周边改BUG边摸鱼,百无聊赖,想起前不久没业务需求时,随手写的Android抓包库。


就公司的APP集成了 抓包功能,目的是:方便非Android开发的同事在 接口联调和测试阶段 能够看到APP的请求日志,进行一些简单的问题定位(如接口字段错误返回,导致APP UI显示异常),不用动不动就来找Android崽~


手机摇一摇,就能查看 APP发起的请求列表具体的请求信息



能用,但存在一些问题,先是 代码层面



  • 耦合:抓包代码直接硬编码在项目中,线上包不需要抓包功能,也会把这部分代码打包到APK里
  • 复用性差:其它APP想添加抓包功能,需要CV大量代码...
  • 安全性:是否启用抓包功能,通过 BuildConfig.DEBUG 来判断,二次打包修改AndroidManifest.xml文件添加 android:debuggable="true" 或者 root手机后修改ro.debuggable为1 设置手机为可调试模式,生产环境的接口请求一览无余。

当然,上面的安全性有点 夸大 了,编译时,编译器会进一步优化代码,可能会删除未使用的变量或代码块。比如这样的代码:


if (BuildConfig.DEBUG) {
xxx.setBaseUrl(Config.DEBUG_BASE_URL);
} else {
xxx.setBaseUrl(Config.RELEASE_BASE_URL);
}

Release打包,BuildConfig.DEBUG永远为false,编译器会优化下代码,编译后的代码可能就剩这句:


xxx.setBaseUrl(Config.RELEASE_BASE_URL);

不信的读者可以反编译自己的APP试试康~


尽管编译后的Release包不包含 启用抓包的代码,但是把抓包代码打包到APK里,始终是不妥的。


毕竟,反编译apk,smail加个启用抓包的代码,并不是什么难事,最好的处理方式还是不要把抓包代码打包到Release APK中!


接着说说 实用性层面



  • 请求相关信息太少:只有URL、请求参数和响应参数这三个数据,状态吗码都没有,有时需要看下请求头或响应头参数。
  • 只能看不能复制:有时需要把请求参数发给后端。
  • 字段查找全靠肉眼扫:请求/响应Json很长的时候,看到眼花😵‍💫。
  • 不支持URL过滤: 执行一个操作,唰唰唰一堆请求,然后就是滑滑滑,肉👀筛URL。
  • 请求记录不会动态更新,要看新请求得关闭页面再打开。
  • 等等...

综上,还是有必要完善下这个库的,毕竟也是能 提高团队研发效率的一小环~


说得天花龙凤,其实没啥技术难点,库的本质就是:自定义一个okhttp拦截器获取请求相关信息然后进行一系列封装 而已。


库不支持HttpUrlConnection、Flutter、其它协议包的抓取!!!此抓包库的定位是:方便非Android崽,查看公司APP的请求日志


如果是 Android崽或者愿意折腾,想抓手机所有APP包 的朋友,可以参考下面两篇文章:



接着简单记录下库的开发流程~


0x2、库


① 拦截器 和 请求实体类


这一步就是了解API,把能抠的参数都抠出来,请求/响应头,请求体响应体,没啥太的难度,直接参考 lygttpod/AndroidMonitor 拦截器部分的代码:


class CaptureInterceptor : Interceptor {
private var maxContentLength = 5L * 1024 * 1024

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val networkLog = NetworkLog().apply {
method = request.method() // 请求方法
request.url().toString().takeIf(String::isNotEmpty)?.let(URI::create)?.let { uri ->
url = "$uri" // 请求地址
host = uri.host
path = uri.path + if (uri.query != null) "?${uri.query}" else ""
scheme = uri.scheme
requestTime = System.currentTimeMillis()
}
requestHeaders = request.headers().toJsonString() // 请求头
request.body()?.let { body -> body.contentType()?.let { requestContentType = "$it" } }
}
val startTime = System.nanoTime() // 记录请求发起时间(微秒级别)
val requestBody = request.body()
requestBody?.contentType()?.let { networkLog.requestContentType = "$it" }
when {
// 请求头为空、未知编码类、双工(可读可写)、请求体只能用一次
requestBody == null || bodyHasUnknownEncoding(request.headers()) || requestBody.isDuplex || requestBody.isOneShot -> {}
// 上传文件
requestBody is MultipartBody -> {
networkLog.requestBody = StringBuilder().apply {
requestBody.parts().forEach {
val key = it.headers()?.value(0)
append(
if (it.body().contentType()?.toString()?.contains("octet-stream") == true)
"${key}; value=文件流\n" else "${key}; value=${it.body().readString()}\n"
)
}
}.toString()
}
else -> {
val buffer = Buffer()
requestBody.writeTo(buffer)
val charset = requestBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (buffer.isProbablyUtf8()) networkLog.requestBody =
formatBody(buffer.readString(charset), networkLog.requestContentType)
}
}

val response: Response
try {
response = chain.proceed(request)
networkLog.apply {
responseHeaders = response.headers().toJsonString() // 响应头
responseTime = System.currentTimeMillis()
duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) // 当前时间减去请求发起时间得出响应时间
protocol = response.protocol().toString()
responseCode = response.code()
responseMessage = response.message()
}
val responseBody = response.body()
responseBody?.contentType()?.let { networkLog.responseContentType = "$it" }
val bodyHasUnknownEncoding = bodyHasUnknownEncoding(response.headers())
// 响应体不为空、支持获取响应体、知道编码类型
if (responseBody != null && response.promisesBody() && !bodyHasUnknownEncoding) {
val source = responseBody.source()
source.request(Long.MAX_VALUE) // 将响应体的内容都读取到缓冲区中
var buffer = source.buffer // 获取响应体源数据流
// 如果响应体经过Gzip压缩,先解压缩
if (bodyGzipped(response.headers())) {
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
// 获取不到字符集的话默认使用UTF-8 字符集
val charset = responseBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (responseBody.contentLength() != 0L && buffer.isProbablyUtf8()) {
val body = readFromBuffer(buffer.clone(), charset)
networkLog.responseBody = formatBody(body, networkLog.responseContentType)
}
networkLog.responseContentLength = buffer.size()
}
NetworkCapture.insertNetworkLog(networkLog)
Log.d("NetworkInterceptor", networkLog.toString())
return response
} catch (e: Exception) {
networkLog.errorMsg = "$e"
Log.e("NetworkInterceptor", networkLog.toString())
NetworkCapture.insertNetworkLog(networkLog)
throw e
}
}

// 检查头中的内容编码是否为除了 "identity" 和 "gzip" 外的其他未知编码类型
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
!contentEncoding.equals("gzip", ignoreCase = true)
}

// 判断头是否包含Gzip压缩
private fun bodyGzipped(headers: Headers): Boolean {
return "gzip".equals(headers["Content-Encoding"], ignoreCase = true)
}

// 从缓冲区读取字符串数据
private fun readFromBuffer(buffer: Buffer, charset: Charset?): String {
val bufferSize = buffer.size()
val maxBytes = min(bufferSize, maxContentLength)
return StringBuilder().apply {
try {
append(buffer.readString(maxBytes, charset!!))
} catch (e: EOFException) {
append("\n\n--- Unexpected end of content ---")
}
if (bufferSize > maxContentLength) append("\n\n--- Content truncated ---")
}.toString()
}

}

请求实体:


data class NetworkLog(
var id: Long? = null,
var method: String? = null,
var url: String? = null,
var scheme: String? = null,
var protocol: String? = null,
var host: String? = null,
var path: String? = null,
var duration: Long? = null,
var requestTime: Long? = null,
var requestHeaders: String? = null,
var requestBody: String? = null,
var requestContentType: String? = null,
var responseCode: Int? = null,
var responseTime: Long? = null,
var responseHeaders: String? = null,
var responseBody: String? = null,
var responseMessage: String? = null,
var responseContentType: String? = null,
var responseContentLength: Long? = null,
var errorMsg: String? = null,
var source: String? = null
) : Serializable {
fun getRequestTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(requestTime!!))

fun getResponseTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(responseTime!!))
}

② 数据库 和 Dao


直接用原生SQLite实现,就一张表和一些简单操作,就不另外引个第三方库了,自定义SQLiteOpenHelper:


class NetworkLogDB(context: Context) :
SQLiteOpenHelper(context, "cp_network_capture.db", null, DB_VERSION) {
companion object {
private const val DB_VERSION = 1
}

override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(NetworkLogDao.createTableSql())
}

override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {}
}

接着在Dao里编写建表,增删查表的方法:


class NetworkLogDao(private val db: NetworkLogDB) {
companion object {
const val TABLE_NAME = "network_log"

/**
* 建表SQL语句
* */

fun createTableSql() = StringBuilder("CREATE TABLE $TABLE_NAME(").apply {
append("id INTEGER PRIMARY KEY AUTOINCREMENT,")
append("method TEXT,")
append("url TEXT,")
append("scheme TEXT,")
append("protocol TEXT,")
append("host TEXT,")
append("path TEXT,")
append("duration INTEGER,")
append("requestTime INTEGER,")
append("requestHeaders TEXT,")
append("requestBody TEXT,")
append("requestContentType TEXT,")
append("responseCode INTEGER,")
append("responseTime INTEGER,")
append("responseHeaders TEXT,")
append("responseBody TEXT,")
append("responseMessage TEXT,")
append("responseContentType TEXT,")
append("responseContentLength INTEGER,")
append("errorMsg STRING,")
append("source STRING")
append(")")
}.toString()
}


/**
* 插入数据
* */

fun insert(data: NetworkLog) {
db.writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put("method", data.method)
put("url", data.url)
put("scheme", data.scheme)
put("protocol", data.protocol)
put("host", data.host)
put("path", data.path)
put("duration", data.duration)
put("requestTime", data.requestTime)
put("requestHeaders", data.requestHeaders)
put("requestBody", data.requestBody)
put("requestBody", data.requestBody)
put("requestContentType", data.requestContentType)
put("responseCode", data.responseCode)
put("responseTime", data.responseTime)
put("responseHeaders", data.responseHeaders)
put("responseBody", data.responseBody)
put("responseMessage", data.responseMessage)
put("responseContentType", data.responseContentType)
put("responseContentLength", data.responseContentLength)
put("errorMsg", data.errorMsg)
put("source", data.source)
})
NetworkCapture.context?.contentResolver?.notifyChange(NetworkCapture.networkLogTableUri, null)
}

/**
* 查询数据
* @param offset 第几页,从0开始
* @param limit 分页条数
* */

fun query(
offset: Int = 0,
limit: Int = 20,
selection: String? = null,
selectionArgs: Array<String>? = null
)
: ArrayList<NetworkLog> {
val logList = arrayListOf<NetworkLog>()
val cursor = db.readableDatabase.query(
TABLE_NAME, null, selection, selectionArgs, null, null, "id DESC", "${offset * limit},${limit}"
)
if (cursor.moveToFirst()) {
do {
logList.add(NetworkLog().apply {
id = cursor.getLong(0)
method = cursor.getString(1)
url = cursor.getString(2)
scheme = cursor.getString(3)
protocol = cursor.getString(4)
host = cursor.getString(5)
path = cursor.getString(6)
duration = cursor.getLong(7)
requestTime = cursor.getLong(8)
requestHeaders = cursor.getString(9)
requestBody = cursor.getString(10)
requestContentType = cursor.getString(11)
responseCode = cursor.getInt(12)
responseTime = cursor.getLong(13)
responseHeaders = cursor.getString(14)
responseBody = cursor.getString(15)
responseMessage = cursor.getString(16)
responseContentType = cursor.getString(17)
responseContentLength = cursor.getLong(18)
errorMsg = cursor.getString(19)
source = cursor.getString(20)

})
} while (cursor.moveToNext())
}
cursor.close()
return logList
}

/**
* 根据id删除数据
* @param id 记录id
* */

fun deleteById(id: Long) {
db.writableDatabase.delete(TABLE_NAME, "id = ?", arrayOf("$id"))
}

/**
* 清空数据
* */

fun clear() {
db.writableDatabase.delete(TABLE_NAME, null, null)
}
}

③ UI 与 交互


没带安卓机回家...待补充图片...


④ 集成方式


参考leakcanary的集成方式,利用 activity-alias 标签单独创建一个桌面图标,作为抓包页面入口:


<activity-alias
android:name=".NetworkCaptureActivity"
android:exported="true"
android:icon="@mipmap/cp_network_capture_logo"
android:label="抓包"
android:targetActivity="cn.coderpig.cp_network_capture.ui.activity.NetworkCaptureActivity"
android:taskAffinity="cn.coderpig.cp_dev_helper.${applicationId}">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

接着是Context传递的,自定义一个ContentProvider,在onCreate()处获得,顺带加上监听数据库变化:


class CpNetworkCaptureProvider : ContentProvider() {
override fun onCreate(): Boolean {
val context = context
if (context == null) {
Log.e(TAG, "CpNetworkCapture库初始化Context失败")
} else {
Log.e(TAG, context.packageName)
NetworkCapture.init(context)
}
return true
}

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
)
: Cursor? = null

override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
}

接着使用 debugImplementation 方式导入依赖,打debug包才会打包这部分代码,接着使用使用反射的方式添加抓包拦截器即可~


作者:coder_pig
来源:juejin.cn/post/7276750877250699320

0 个评论

要回复文章请先登录注册