FreshRSS

🔒
❌ 关于 FreshRSS
发现新文章,点击刷新页面。
昨天以前Mythsman

Android系统推送Hook实战

2021年7月18日 08:23
作者 mythsman

背景

随着数据生产功能的逐渐稳定,工作重点开始从保证数据总量转移到了保证数据实效性上来了。传统的定时爬虫能比较轻松的把数据总量做起来,但是对于很多热点数据,却很难做到实时获取。

考虑到大部分产品、尤其是新闻资讯类的产品,都会对热点数据做推送拉活,如果能拦截到这些数据,那么我们就能应当将数据实效性提升一个档次。

这次我们就主要尝试拦截下小米手机的系统通道的推送数据。当然,对于各个 App 自身的通道,也可以用类似的方法做到拦截,不过那就需要对各个 App 再做好保活。

推送流程

以小米官方的文档为例,整体推送流程大致分四步:

  1. 应用客户端在启动时向 MiPush SDK 中注册当前设备,并获得对应的唯一标识 regId。
  2. 应用服务端告诉小米统一推送服务,他需要向某个指定账号、指定类型、或指定设备推送消息。
  3. 小米统一的服务端通过与手机上的 MiPush SDK 的长连接,向手机推送数据,并展示在通知栏中。
  4. 应用客户端实现一个自定义 PushMessageReceiver 做好数据解析的准备,等用户点击通知栏后就可以将数据传过去。

牵涉到的大概有五方的代码:

  1. 手机内部的MIUI框架部分代码。
  2. 应用客户端代码。
  3. 应用客户端引用的小米的 MiPush SDK代码。
  4. 小米 PushServer 的代码。
  5. 应用服务端的代码。

在这里当中,我们其实只需要关心 1、3 两部分的代码即可。

获取思路

简单翻阅了网络上已知的策略,获得通知栏的推送数据一般有如下思路:

  1. 通过 Appium 等自动化测试工具,直接获取通知栏的元素中的消息文本。
  2. 自己写一个 App ,实现 NotificationListenerService 方法,监听所有过来的消息。
  3. 通过 Frida 等 hook 工具,直接拦截 MiPush 推过来的原始数据,自行做解析和解密。

虽然我并没有做尝试,但是前两种方式似乎最多只能获得推送的标题和文本等基本信息,无法获得更详细的原始信息(尤其是跳转 scheme 、参数列表等)。因此我们主要尝试第三种思路。

动手实践

下面就以手头的 Redmi6 设备进行尝试。

定位应用

既然我们希望获取通知栏的数据,那我们首先应当定位到通知栏的相关代码。拉出通知栏,dumpsys一下:

$ adb shell dumpsys activity top|grep ACTIVITY
ACTIVITY com.android.systemui/.recents.RecentsActivity c2efb6 pid=30467
ACTIVITY com.ss.android.ugc.aweme/.splash.SplashActivity f9d8beb pid=7270
ACTIVITY com.miui.home/.launcher.Launcher 119c6 pid=1950

看起来 com.android.systemui 和 com.miui.home 这两个包比较可疑,我们再用 Appium check 一下。

这样就肯定是 com.android.systemui 没跑了。

获取代码

定位到包名,那就去手机上把包搞下来就行:

$ adb shell pm list packages -f |grep com.android.systemui
package:/vendor/overlay/SysuiDarkTheme/SysuiDarkThemeOverlay.apk=com.android.systemui.theme.dark
package:/system/priv-app/MiuiSystemUI/MiuiSystemUI.apk=com.android.systemui
 
$ adb pull /system/priv-app/MiuiSystemUI/MiuiSystemUI.apk
/system/priv-app/MiuiSystemUI/MiuiSystemUI.apk: 1 file pulled, 0 skipped. 10.5 MB/s (4901017 bytes in 0.446s)
 
$ ls -la MiuiSystemUI.apk
-rw-r--r--  1 myths  staff  4901017 Jun 29 15:42 MiuiSystemUI.apk

然后拿 jadx 打开看看:

发现不对劲,怎么一行代码也没有,都是些没用的资源文件。。。在尝试了诸如 jeb、androidkiller、apktool等反编译软件均失败后,我陷入了沉思。

沉思过后,决定换一部更高级的 Redmi9 手机试试,结果发现 Redmi9 手机的 MiuiSystemUI.apk 竟然是有代码的,估计是 Redmi6 用了什么古老的科技把代码隐藏掉或者下沉了啥的,但是代码一定是有的,否则哪来的 com.android.systemui/.recents.RecentsActivity 这个 activity。

于是我暴力 grep 了 /system 目录下的文件,果然给我找到了线索:

$ adb shell su -c "grep RecentsActivity -r /system"
grep: /system/framework/miuisdk.jar: No such file or directory
grep: /system/framework/miuisystem.jar: No such file or directory
Binary file /system/framework/oat/arm/services.vdex matches
Binary file /system/priv-app/MiuiSystemUI/oat/arm/MiuiSystemUI.vdex matches

发现了一个奇怪的 MiuiSystemUI.vdex 文件,简单查了下,应当是为了优化系统 OTA 时 dex2oat 过程的类似预编译的东西,本质还是代码片段。

于是用 vdexExtractor 转成 MiuiSystemUI.dex  :

$ ./vdexExtractor -i  ../../Jadx/MiuiSystemUI.vdex  -o output
[INFO] Processing 1 file(s) from ../../Jadx/MiuiSystemUI.vdex
[INFO] 1 out of 1 Vdex files have been processed
[INFO] 1 Dex files have been extracted in total
[INFO] Extracted Dex files are available in 'output'

再用 jadx 打开这个 MiuiSystemUI.dex :

这下就舒服了。

代码结构

对源码经过简单阅读,再辅以 objection 和 frida 的 Java.choose() 方法动态调试后发现存储通知栏消息的地方主要是在 com.android.systemui.statusbar.phone.NotificationGroupManager 。这个类在系统中似乎还是个单例。

找到这个类之后,四下观察就可以整理出大致如下的类图:

这里每个 NotificationData.Entry 就是每一条推送记录,不断深入看下去就会发现真正的数据还是指向了 StatusBarNotification 这个类。而这个类的源码只能去对应的 Android SDK 里去看了。

于是打开了对应版本(api 27)的SDK,继续整理了一下核心数据涉及到的类:

数据解析

跟踪上面涉及到的相关对象,根据所需要的数据做相关解析和拼接即可:

Java.perform(function () {

    let MurmurHash3 = {
        mul32: function (m, n) {
            let nlo = n & 0xffff;
            let nhi = n - nlo;
            return ((nhi * m | 0) + (nlo * m | 0)) | 0;
        },
        hashString: function (data, len, seed) {
            let c1 = 0xcc9e2d51, c2 = 0x1b873593;

            let h1 = seed;
            let k1 = '';
            let roundedEnd = len & ~0x1;

            for (let i = 0; i < roundedEnd; i += 2) {
                let k1 = data.charCodeAt(i) | (data.charCodeAt(i + 1) << 16);

                k1 = this.mul32(k1, c1);
                k1 = ((k1 & 0x1ffff) << 15) | (k1 >>> 17);
                k1 = this.mul32(k1, c2);

                h1 ^= k1;
                h1 = ((h1 & 0x7ffff) << 13) | (h1 >>> 19);
                h1 = (h1 * 5 + 0xe6546b64) | 0;
            }

            if ((len % 2) === 1) {
                k1 = data.charCodeAt(roundedEnd);
                k1 = this.mul32(k1, c1);
                k1 = ((k1 & 0x1ffff) << 15) | (k1 >>> 17);
                k1 = this.mul32(k1, c2);
                h1 ^= k1;
            }

            h1 ^= (len << 1);

            h1 ^= h1 >>> 16;
            h1 = this.mul32(h1, 0x85ebca6b);
            h1 ^= h1 >>> 13;
            h1 = this.mul32(h1, 0xc2b2ae35);
            h1 ^= h1 >>> 16;

            return h1;
        }
    };

    let NotificationGroup = Java.use('com.android.systemui.statusbar.phone.NotificationGroupManager$NotificationGroup')
    let Entry = Java.use('com.android.systemui.statusbar.NotificationData$Entry')
    let Base64 = Java.use('android.util.Base64');

    function processInfo(statusbar, folded) {
        let mmap = statusbar.notification.value.extras.value.mMap.value;

        if (statusbar.notification.value.contentIntent.value) {
            let intent = statusbar.notification.value.contentIntent.value.getIntent();
            if (intent.mComponent.value) {
                let txt = mmap.get("android.text").toString()
                let id = MurmurHash3.hashString(txt, txt.length, 0).toString().replace("-", "")
                let msg = {
                    'type': 'mi_push',
                    'data': {
                        "folded": folded,
                        "id": id,
                        "intent": intent.getDataString() === null ? "" : intent.getDataString(),
                        "title": mmap.get("android.title").toString(),
                        "text": mmap.get("android.text").toString(),
                        "package": intent.mComponent.value.mPackage.value,
                        "class": intent.mComponent.value.mClass.value,
                        "time": statusbar.postTime.value
                    },
                    'id': id
                };
                let bundle = intent.getExtras()
                if (bundle) {
                    let miPushByte = bundle.get("mipush_payload")
                    if (miPushByte) {
                        let arr = Java.array('byte', miPushByte)
                        msg['data']['mipush'] = Base64.encodeToString(arr, 0).replaceAll("\n", "");
                    }

                }
                send(JSON.stringify(msg));
            } else {
                console.log("contentIntent is null " + mmap)
            }
        } else {
            console.log("intent.mComponent.value not exist " + mmap)
        }
    }


    function processManager(manager) {
        try {
            let entrys = manager.mGroupMap.value.keySet()
            let it = entrys.iterator();
            while (it.hasNext()) {
                let key = it.next();
                let data = manager.mGroupMap.value.get(key)
                let nData = Java.cast(data, NotificationGroup)
                let unfoldIterator = nData.unfoldChildren.value.iterator()
                let foldIterator = nData.foldChildren.value.iterator()

                while (unfoldIterator.hasNext()) {
                    let entry = Java.cast(unfoldIterator.next(), Entry);
                    let row = entry.row.value
                    let statusbar = row.mExpandedNotification.value
                    processInfo(statusbar, false)

                }
                while (foldIterator.hasNext()) {
                    let entry = Java.cast(foldIterator.next(), Entry);
                    let row = entry.row.value
                    let statusbar = row.mExpandedNotification.value
                    processInfo(statusbar, true)
                }
            }
        } catch (e) {
            console.log(e.stack)
        }
    }
    Java.choose("com.android.systemui.statusbar.phone.NotificationGroupManager", {
        onMatch: function (instance) {
            processManager(instance)
        },
        onComplete: function () {
        }
    });
});

需要注意的是,这里的代码和 Android 版本、MIUI 版本都有关,不同种的设备之间大概率是不能兼容的。在我的手机上,这样解析出来数据如下:

{
    "package": "com.smile.gifmaker",
    "mipush": "CAABAAAABQIAAgECAAMACwAEAAAEsOp4pGZjhmJnTz3lGB/Ta7aS5iS5UIKGF23UT2AvpzdmIbLJu0h9JH799FoT7VEdFq9vzPWuiLybhqJ2tcBm5tHXJ5Ff/eSje9jFUm799PQKg7uQEE3ieH/Bu5nglvYwk0+ku7bWfp3MjB8ukRrr5XgIUqLSVEyi4wICeBkHSWwVzIcfk63TH6z8r9wemQq8fZFPqq817rR4zED6MUQu83frrtYY/4i6kSazw2zKkhoR+T0j7/7ed4r/si3RwUBCsO9d73Ehdmrjg7odKxHP9q6yUFam4JihZYN2DzG3KsajgwZg+CmXCOQFanmpyYgKhUc+psE1Kl5bikWY9KqLYo1JD/Boesq4d82Yfub61xGoWaeli/SJwG8JMtsH5FmC7mxDwluhutoDEuRlX3HodtU1GlEaz2SBabRIOcFLYv6SjOrBN1o2xE+lvpHv1o0Reh4GG0mlnmrV7a236XHwMmSOdY4Akr4WR3eQz+NTNaqLwUjvQIb1GwWXauNWMAM4nroK38KAfBORVDLAZSoMLambNM+JsBZKRcmIPreuupVaYlS2hnlEr2qaGsfpPZK8bJs7PgXU6zscMBmVMjy/nL6D5+uUwVEkYbzoPdMCbsHPxxKcFeiR/AYJe7G1QK5KQMTtVY/jnJVRNG8T3H5fC52CO4xOFKRrpdNv30C/NvtBOIokBCqXXXf2Z4gWroq17Tc385/WhxhvcvazXoI8rjaF/ZHh7KGUN24VuN07tnjKvs74h3Z1JJi82WRuzxkSsFDQJNwIX3/Qk6U5mgHGwZJTtdB8IijTUn5GZG6C/c8CNYN36iy7ToIDCiNbIWO40GwDrmIHiaK3R6a0de6jCr917C1AfT1JFlNr9GeztWq/4tLH3F88SGWIhj00XFWcBWVa2EB7Du3yjFD0S18/HoCIi5j5l/HsvvMo0BpdWd0hTRQ89NUK409QiblaVjJ8vCtz3PrNLAiCROJPzA5GcD5x6F7xUi05YsBE+xX59kqf7Wzrs6IXb9frgllwWkS2aKcHNmZYT6TTE6QNII4rYOHlLhIB6bIq6iktbMY63TRAQi8a/1sBPuoMJ3tndbBZmumsV+emgbY5p4xv7YnA9AYK001Q+o2CfyfUjCRlhWAGZ5ZOe0Z/o6jSQegq39ib3KbPyE6ZxjEtNYMt/pLdIKyh86ixiv0rRqI2meLilcekGrFKmI46JK8YbYuVzYr5VIJdDEnvawnxjmw5qYVo6AE/uppaX5p8t+sBAaofqbYmpW6dIFEBR5vmE/ahZZolCgD1dwxNFAkTZf8hoBfG9/cZI3zaqEOfTghxw9FkVyauJwY4Qe5E4VuQxZfHEy4HVKhhIi4zkRLDpPJBUiovGZFaLMR3s2EnHppq2xrnA12FVeBuUjDJQpRER+E8DFErvk8MfjT4CoL05djf5BWPHfCdCvnGFg49Up1LbidPpycnlnTr4f/Nu87Ed0FmL6/81knkj3rT2fPdINpweHWZok9o55lHojNHANHae64FBQrwC/057zoKI3Oo5GBU85qcnCxvGKpzDZbVVF9Y/HQIR4DGVdIC91i6EiYGYdD1VsQVDwRu6P77ixubXjV+eq56KQsABQAAABMyODgyMzAzNzYxNTE3MTMwNTM0CwAGAAAAEmNvbS5zbWlsZS5naWZtYWtlcgwABwoAAQAAAAAAAAAFCwACAAAACzcwMDg0ODc2NDA2CwADAAAACnhpYW9taS5jb20LAAQAAAAIYW5TUVJTRUgIAAYAAAAACwAIAAAAAkM0AAwACAsAAQAAABZzY201OTI1NDYyNDkzMjg4MDk2MmpTCgACAAABelWMEkILAAQAAAAG5b+r5omLCwAFAAAAq+S9oOeci+i/h+eahOWon+WnkO+8iOaJi+acuuaRhOW9se+8ieWPkeW4g+S6huaWsOS9nOWTge+8muWkj+WkqeeahOiaiuWtkOWlveWkmuWViu+9nuWkquiuqOWOjOS6hu+8jOaVtOS4quWkp+iaiummmeaKiuS7luS7rOeGj+aZleWQp++8gSAj5omL5py65pGE5b2xICPliJvkvZzngbXmhJ8gI+WIm+aEjwgABgAAAAAIAAgAAAAACAAJD5/Mww0ACgsLAAAACQAAAA5uX3N0YXRzX2V4cG9zZQAAAIB2SktrUzArUHo2QzVXRXN5MjhtWnlvSE4zZnFRMGorYXhLMVhWWFovK3h0NUFDQTcybmRjSmcxdWh4clhMdStSblI0ODFCc0hwZDNoeWZPVWQ4dHdqbVVJM0lBbDJ3V3J3d3owSEQ0b2xvNXlHQUM5NStZaDdYMU5WT29VbUtTeAAAABFub3RpZnlfZm9yZWdyb3VuZAAAAAExAAAADmNhbGxiYWNrLnBhcmFtAAAAMDE0M0pyQnQ0R2JKVzAwNjM3KjFFTU5QV2pjZHA1dW5zZXQ4MncwTVdoNlJlZG1pNgAAAA1fX3RhcmdldF9uYW1lAAAAQE5UTWpMRk1ZNnRQWkpONFcrdytvZndvTUZOOWxhZDNublFUOWJPUEEyeUNJSDlrSml2bWM5V1Jmb0R0ZWRXOWoAAAAFZmVfdHMAAAANMTYyNDkzMjg4MDk2MgAAAA1jYWxsYmFjay50eXBlAAAAAjE3AAAAF25vdGlmaWNhdGlvbl9zdHlsZV90eXBlAAAAATEAAAAIY2FsbGJhY2sAAAA+aHR0cHM6Ly9wdXNoLmtzYXBpc3J2LmNvbS9yZXN0L2luZnJhL3B1c2gvcHJvdmlkZXIvbXQvY2FsbGJhY2sAAAAGX19tX3RzAAAADTE2MjQ5MzI4ODEzNDQNAAsLCwAAAAEAAAAKc2NvcmVfaW5mbwAABIl7InNlcnZlcl9zY29yZSI6MSwiZ3JvdXBfaW50ZXJ2YWwiOjcyMDAwMDAsImV4dHJhX2luZm8iOiJbe1widlwiOjAsXCJiXCI6LTAuOTI4NjcyMzk3NDQxOTE1NSxcIndcIjpbMTQuMDQyMzAwNjYwODQ4MDQyLC0wLjEwNDgyMTA5MTUxOTE1MzA5LDAsMCwwXSxcImVsXCI6W1szMCwwXSxbMCwwXV0sXCJjbFwiOltbMCwwXV0sXCJjZ1wiOltbMCwwXV0sXCJwa2dcIjp7XCJjb20udGVuY2VudC5tbVwiOjksXCJjb20udGVuY2VudC5tb2JpbGVxcVwiOjksXCJjb20uYW5kcm9pZC5tbXNcIjo5LFwiY29tLmFuZHJvaWQuY29udGFjdHNcIjo5LFwiY29tLmFuZHJvaWQucHJvdmlkZXJzLmNvbnRhY3RzXCI6OSxcImNvbS5hbmRyb2lkLmNhbGVuZGFyXCI6OSxcImNvbS5hbmRyb2lkLnByb3ZpZGVycy5jYWxlbmRhclwiOjksXCJjb20uZ29vZ2xlLmFuZHJvaWQuY2FsZW5kYXJcIjo5LFwiY29tLndoYXRzYXBwXCI6OSxcImNvbS5mYWNlYm9vay5vcmNhXCI6OSxcImNvbS5nb29nbGUuYW5kcm9pZC5nbVwiOjksXCJjb20uYW5kcm9pZC5kZXNrY2xvY2tcIjo5LFwiY29tLmFuZHJvaWQucGhvbmVcIjo5LFwiY29tLmFuZHJvaWQuc3RrXCI6OSxcImNvbS5hbmRyb2lkLmNlbGxicm9hZGNhc3RyZWNlaXZlclwiOjksXCJjb20uYW5kcm9pZC5pbmNhbGx1aVwiOjksXCJjb20uYW5kcm9pZC5zZXJ2ZXIudGVsZWNvbVwiOjksXCJjb20uYW5kcm9pZC5lbWFpbFwiOjksXCJjb20ueGlhb21pLmNoYW5uZWxcIjo5LFwiY29tLmFuZHJvaWQudXBkYXRlclwiOjksXCJjb20uYW5kcm9pZC5zZXR0aW5nc1wiOjksXCJjb20ubWl1aS5wbGF5ZXJcIjo5LFwiY29tLm1pdWkuYnVncmVwb3J0XCI6OSxcImNvbS5lZy5hbmRyb2lkLkFsaXBheUdwaG9uZVwiOjksXCJjb20ueGlhb21pLm1hcmtldFwiOjksXCJjb20uYW5kcm9pZC5wcm92aWRlcnMuZG93bmxvYWRzXCI6OSxcImNvbS5hbGliYWJhLmFuZHJvaWQucmltZXQuYWxpZGluZ3RhbGtcIjo5LFwiY29tLmFsaWJhYmEuYW5kcm9pZC5yaW1ldFwiOjksXCJjb20udGVuY2VudC50aW1cIjo5LFwiY29tLm1pdWkuaG9tZVwiOjl9LFwidGhcIjowLjUsXCJuXCI6M31dIiwidGhyZXNob2xkIjowLjY3LCJyYXdfc2NvcmUiOjAuNjQ3Mzc2NTY0OTk5MDQ1NSwic29ydF9kZWxheSI6MTAwMDAsInNlcnZlcl9zdHJhdGVneSI6ImxyIn0CAAwAAAA=",
    "text": "你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意",
    "title": "快手",
    "folded": false,
    "id": "scm59254624932880962jS",
    "time": "1624932881487",
    "class": "com.xiaomi.mipush.sdk.PushMessageHandler"
}

可以发现,在 intent 的 bundle 里有一个 key 为 "mipush_payload" 的加密数据(我这里展示成了base64),这里存的就是加密后的推送数据详情,我们还需要继续做处理。

数据解密

入口

数据解密的逻辑应当是放在 MiPush SDK 中,我们就只能去小米开发者中心把源码下下来看看。小米这里的处理比较让人蛋疼,一定要注册小米开发者然后才能下载代码。求爹爹告奶奶终于整到了 SDK,下面就以 MiPush_SDK_Client_4_0_2.jar 这个版本来看。

很容易想到的入手点就是 "mipush_payload" 这个字符串,先简单搜索一番试试:

涉及到的代码不多,挺好。考虑到既然我们想要的是解密,那应当是更关心 getByteArrayExtra 方法。进去看一看:

这里对 action 的值有几个分支判断,我们显然是希望 MESSAGE_ARRIVED 这个事件,那解析代码应当就是这个没跑了。

坑点

入口都找到了,剩下就是无聊的人肉跟踪了。不过小米做了字节码级别的混淆,导致在同一个类中有很多类型不同但是名字相同的变量(不符合java语法、但是符合jvm规范)。这样就使 jadx 无法正常反编译出 java 代码,只能对变量名重命名展示。例如上面的 b.m36a(this.f43a) 这个方法,他的函数声明的地方有 jadx 的注释:

为了方便人眼看,这样的操作还是很有帮助的。但是由于在写代码的时候还是需要用真正的变量名,因此在搞清逻辑之后,我们还是只能用 IDEA 去看真正的代码。上面的代码在 IDEA 中就是这样:

不得不说这种代码混淆是真的恶心,在 IDEA 中我还看到了这种槽点十足、普通方法根本无法调用的代码:

上面的一系列操作导致的结果就是我们不得不一律采用反射来调用这些函数。

密钥

在经过一系列斗争后,发现这些数据其实是采用了 AES 对称加密,加解密用的 key 是从名为 mipush 的 sharedpreferences 中的 regSec 中获得的:

//com.xiaomi.push.i
private static Cipher a(byte[] bArr, int i) {
    SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(a);
    Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
    instance.init(i, secretKeySpec, ivParameterSpec);
    return instance;
}
 
//com.xiaomi.mipush.sdk.b
public static SharedPreferences a(Context context) {
    return context.getSharedPreferences("mipush", 0);
}
 
//com.xiaomi.mipush.sdk.b
public static a a(Context context, String str) {
    try {
        JSONObject jSONObject = new JSONObject(str);
        a aVar = new a(context);
        aVar.f62a = jSONObject.getString("appId");
        aVar.b = jSONObject.getString("appToken");
        aVar.c = jSONObject.getString("regId");
        aVar.d = jSONObject.getString("regSec");//这个!
        aVar.f = jSONObject.getString("devId");
        aVar.e = jSONObject.getString("vName");
        aVar.f63a = jSONObject.getBoolean("valid");
        aVar.f64b = jSONObject.getBoolean("paused");
        aVar.a = jSONObject.getInt("envType");
        aVar.g = jSONObject.getString("regResource");
        return aVar;
    } catch (Throwable th) {
        com.xiaomi.channel.commonutils.logger.b.a(th);
        return null;
    }
}

密钥的位置在对应 app 内部存储目录的 shared_prefs 文件夹下。以 gifmaker 为例:

$ adb shell su -c 'cat /data/data/com.smile.gifmaker/shared_prefs/mipush.xml'
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="devId">951ECBAB1F2EB405ABF912BD65FC703FF43969CE</string>
    <boolean name="valid" value="true" />
    <string name="regResource">oGZD6g</string>
    <string name="vName">8.2.31.17191</string>
    <string name="pref_msg_ids">,sdm580566248810824873c,sdm58782624881295100Zr</string>
    <int name="envType" value="1" />
    <string name="appToken">5431713053534</string>
    <string name="appId">2882303761517130534</string>
    <string name="regId">NTMjLFMY6tPZJN4W+w+ofwoMFN9lad3nnQT9bOPA2yCIH9kJivmc9WRfoDtedW9j</string>
    <string name="regSec">AMjTZmbZx2LS1X7dp0AAAA==</string>
    <string name="appRegion">China</string>
</map>

这里的密钥就是 AMjTZmbZx2LS1X7dp0AAAA== 。

需要注意的是,这个密钥是在 app 初次启动时生成的,不通设备之间不一样,相同设备的不同次安装也不一样。

实现

最后把相关代码拼接下,在服务端实现一个服务即可。代码参考如下:

public class PushMessage implements Serializable {
    private static final long serialVersionUID = 4745289224411613134L;
 
    private String id;
    private Long channelId;
    private String userId;
    private String server;
    private String resource;
    private String appId;
    private String packageName;
    private String payload;
    private Long createAt;
    private Long ttl;
    private Boolean isOnline;
    private Boolean needsAck;
    
    //some getter and setter
}
 
public PushMessage decrypt(String secKey, String payload) {
    try {
        byte[] data = Base64.getDecoder().decode(payload);
        byte[] key = Base64.getDecoder().decode(secKey);
 
        im im = new im();
 
        ja.a(im, data);
 
        byte[] buffer = null;
        for (Method declaredMethod : im.getClass().getDeclaredMethods()) {
            if (declaredMethod.getReturnType().equals(byte[].class)) {
                try {
                    buffer = (byte[]) declaredMethod.invoke(im);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        if (buffer == null) {
            throw new RuntimeException("buffer is null");
        }
 
 
        byte[] decrypted = i.a(key, buffer);
        it a3 = null;
        for (Method declaredMethod : com.xiaomi.mipush.sdk.ai.class.getDeclaredMethods()) {
            if (declaredMethod.getName().equals("a")
                    && declaredMethod.getReturnType().equals(jb.class)
                    && declaredMethod.getModifiers() == (Modifier.PRIVATE | Modifier.STATIC)) {
                declaredMethod.setAccessible(true);
                try {
                    a3 = (it) declaredMethod.invoke(com.xiaomi.mipush.sdk.ai.class, hq.a(5), true);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException("reflect invoke failed", e);
                }
            }
        }
 
        if (a3 == null) {
            throw new RuntimeException("a3 not found");
        }
        new jf(new com.xiaomi.push.js.a(true, true, data.length)).a(a3, decrypted);
 
        PushMessage pushMessage = convertToMessage(a3);
        logger.info("decrypt for {} \ndata: {}\nresult: {}", secKey, payload, JSONObject.toJSONString(pushMessage));
        return pushMessage;
    } catch (DecryptFailedException e) {
        logger.error("decrypt for {} failed\ndata: {}", secKey, payload, e);
        throw e;
    } catch (Throwable e) {
        RuntimeException ex = new RuntimeException("decode failed,", e);
        logger.error("decrypt for {} failed\ndata: {}", secKey, payload, ex);
        throw ex;
    }
}
 
private PushMessage convertToMessage(it result) throws IllegalAccessException, ClassNotFoundException {
    PushMessage pushMessage = new PushMessage();
 
    Field idField = findFieldByNameAndType(it.class, "b", String.class);
    pushMessage.setId((String) idField.get(result));
 
    Field needsAckField = findFieldByNameAndType(it.class, "a", boolean.class);
    pushMessage.setNeedsAck((boolean) needsAckField.get(result));
 
    Field appIdField = findFieldByNameAndType(it.class, "c", String.class);
    pushMessage.setAppId((String) appIdField.get(result));
 
    Field packageNameField = findFieldByNameAndType(it.class, "d", String.class);
    pushMessage.setPackageName((String) packageNameField.get(result));
 
    Class<?> ifClass = Class.forName("com.xiaomi.push.if");
    Field ifField = findFieldByNameAndType(it.class, "a", ifClass);
    Object target = ifClass.cast(ifField.get(result));
 
    Field targetField = findFieldByNameAndType(ifClass, "a", long.class);
    pushMessage.setChannelId((long) targetField.get(target));
 
    Field userIdField = findFieldByNameAndType(ifClass, "a", String.class);
    pushMessage.setUserId((String) userIdField.get(target));
 
    Field serverField = findFieldByNameAndType(ifClass, "b", String.class);
    pushMessage.setServer((String) serverField.get(target));
 
    Field resourceField = findFieldByNameAndType(ifClass, "c", String.class);
    pushMessage.setResource((String) resourceField.get(target));
 
    Field icField = findFieldByNameAndType(it.class, "a", ic.class);
    ic pmsg = (ic) icField.get(result);
 
    Field payloadField = findFieldByNameAndType(ic.class, "c", String.class);
    pushMessage.setPayload((String) payloadField.get(pmsg));
 
    Field createAtField = findFieldByNameAndType(ic.class, "a", long.class);
    pushMessage.setCreateAt((long) createAtField.get(pmsg));
 
    Field ttlField = findFieldByNameAndType(ic.class, "b", long.class);
    pushMessage.setTtl((long) ttlField.get(pmsg));
 
    Field onlineField = findFieldByNameAndType(ic.class, "a", boolean.class);
    pushMessage.setOnline((boolean) onlineField.get(pmsg));
 
    return pushMessage;
}
 
private Field findFieldByNameAndType(Class<?> clazz, String name, Class<?> returnType) {
    for (Field field : clazz.getDeclaredFields()) {
        if (field.getName().equals(name) && field.getType().equals(returnType)) {
            return field;
        }
    }
    throw new FieldNotFoundException(String.format("cannot find name->%s returnType->%s for class->%s", name, returnType, clazz));
}

mipush 的内部实现依赖了 android 相关的类,虽然在解密的过程中并没有用到,但是如果类不存在还是会报错的。因此还需要加两个mock类,空实现即可。

package android.content;
 
public class Context {
}
package android.text;
 
public class TextUtils {
}

由于 java 编译器的关系,在运行时需要加上 -noverify 的参数,否则会报 java.lang.VerifyError 的错。(参考 sf

运行

最后,我们把本次从 gifmaker 拿到的推送解析一把:

{
    "appId": "2882303761517130534",
    "channelId": 5,
    "createAt": 1624932880962,
    "id": "scm59254624932880962jS",
    "needsAck": true,
    "online": true,
    "packageName": "com.smile.gifmaker",
    "payload": "{\"click_payload\":\"true\",\"push_notification\":\"{}\",\"infra_tag\":\"cdp\",\"onlyInBar\":\"false\",\"dndModeIsOn\":\"false\",\"push_msg_id\":\"0000016249328800885970645000158\",\"id\":\"0000016249328800885970645000158\",\"push_back\":\"143JrBt4GbJW00637*1EMNPWjcdp5unset82w0MWh6Redmi6oANDROID_021f5a0bbeeb1eb1\",\"server_key\":\"{\\\"business\\\":\\\"RECO_PUSH\\\",\\\"object_type\\\":\\\"OBJECT_PHOTO\\\",\\\"item_id\\\":\\\"0\\\",\\\"ks_order_id\\\":\\\"empty\\\",\\\"object_id\\\":\\\"5205879746946008323\\\",\\\"sender_id\\\":\\\"0\\\",\\\"push_type\\\":\\\"PUSH_PHOTO\\\",\\\"infra_tag\\\":\\\"cdp\\\",\\\"event_type\\\":\\\"EVENT_CONSUME_AUTHOR_PHOTO_PUSH\\\",\\\"time_ms\\\":\\\"1624932880244\\\",\\\"business_type\\\":\\\"KUAISHOU\\\",\\\"badge\\\":0}\",\"title\":\"快手\",\"body\":\"你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意\",\"uri\":\"kwai:\\/\\/work\\/5205879746946008323?userId=2331994191&exp_tag=1_a\\/0_ps\"}",
    "resource": "anSQRSEH",
    "server": "xiaomi.com",
    "ttl": 86401,
    "userId": "70084876406"
}

其中的payload 再展开看下:

{
    "click_payload": "true",
    "push_notification": "{}",
    "infra_tag": "cdp",
    "onlyInBar": "false",
    "dndModeIsOn": "false",
    "push_msg_id": "0000016249328800885970645000158",
    "id": "0000016249328800885970645000158",
    "push_back": "143JrBt4GbJW00637*1EMNPWjcdp5unset82w0MWh6Redmi6oANDROID_021f5a0bbeeb1eb1",
    "server_key": "{\"business\":\"RECO_PUSH\",\"object_type\":\"OBJECT_PHOTO\",\"item_id\":\"0\",\"ks_order_id\":\"empty\",\"object_id\":\"5205879746946008323\",\"sender_id\":\"0\",\"push_type\":\"PUSH_PHOTO\",\"infra_tag\":\"cdp\",\"event_type\":\"EVENT_CONSUME_AUTHOR_PHOTO_PUSH\",\"time_ms\":\"1624932880244\",\"business_type\":\"KUAISHOU\",\"badge\":0}",
    "title": "快手",
    "body": "你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意",
    "uri": "kwai://work/5205879746946008323?userId=2331994191&exp_tag=1_a/0_ps"
}

信息非常全,还带了跳转用的scheme,通过adb shell am start -a android.intent.action.VIEW -d kwai://work/5205879746946008323?userId=2331994191&exp_tag=1_a/0_ps 就能直接跳转到对应页面了。

通知栏清理

上面的脚本仅仅涉及了获取数据,并没有涉及清理通知栏。显然,如果一直不清理,不仅要考虑数据去重,也会影响拉取效率。实际用起来发现,如果一直不清理,也容易 hook 不动。。。

要清理也很简单,在每次拉取消息结束后,调用一下命令即可:

$ adb shell su -c 'service call notification 1'

实时监听

不要忘了,我们需要的是实时监听,因此还需要监听下NotificationGroupManager 的触发器。

let NotificationGroupManager = Java.use('com.android.systemui.statusbar.phone.NotificationGroupManager')
let onEntryAdded = NotificationGroupManager.onEntryAdded;
onEntryAdded.implementation = function (entry) {
    let res = onEntryAdded.call(this, entry);
    processManager(this)
    return res;
}

不过,从实践中看,想通过直接 hook 一次这个 onEntryAdded 事件就能一劳永逸还是想太多了。Frida进程或者是桌面进程总会有各种理由挂掉,因此我们在生产中也并没有依赖这个,而是通过外部定时任务来触发。

定时拉活

由于小米厂家自身的通道(可能)比较贵,因此各大应用几乎都有很大一部分走的是自己的长链接。与小米自身的系统通道不同,这些长链接都是需要 App 在后台运行才能保证的。因此我们也需要定期把那些重要的 App 进行强制拉活,这样我们能收到的 push 才能更快、更多。不过好消息是,应用自身通道的推送数据是不用走 mipush 加密那一套东西,所以搞起来更简单~

结语

最后反手夸一夸腾讯,看起来各大厂家对热点事件的推送中,腾讯爸爸还是最及时的,运营同学们辛苦了。

参考资料

小米推送产品说明

Android 8.0 VDEX机制简介

逆向settings实现监控app通知

Isaac64解密算法JNI的封装

2021年7月5日 13:47
作者 mythsman

前言

众所周知,理论上最安全的加密方式是使用一次一密(OTP)。但是传递与明文长度相等的、完全随机的加密面板这件事情并不具有实践意义,因此就诞生了流密码(Stream Cipher)。流密码将一个密钥作为种子,按照某种伪随机数生成算法生成供OTP使用的加密面板。有了加密面板之后,就可以逐字使用传统的 Vernam 算法 或者 Vigenère 算法进行加密解密。

由于这样进行的加密解密操作没有复杂的计算、并且不需要对数据进行预取分块等复杂操作,因此执行效率很高,非常适合用作流媒体数据的加密。最常见的流加密算法就是大名鼎鼎的RC4。其实 RC4 本质就是一个伪随机数生成器,加密方式其实就是用某个密钥作为种子,通过该生成器生成一个与明文等长的二进制流,再用 Vernam 算法(逐字异或)对明文处理得到密文。

由于是采用 Vernam 算法进行实际的加密,因此判断这类流加密算法的一个很典型的特点,就是对于相同的密钥,将明文和密文进行异或得到的数据是完全相等的(就是那个一次一密的加密板)。

当然,由于 RC4 算法太常见了,业内在使用流密码时常常会选择一些较为小众的伪随机数生成器,比如 Bob Jenkins 提出的 isaac 。而以 isaac 作为伪随机数生成器再结合 Vernam 或者 Vigenère 的加密方法就是 isaac 流加密算法。

由于业务需求,本次我们需要实现 ISAAC64位 的算法。

业界实现

目前较为常见的实现有以下三个。

  1. ISAAC paper 中的伪随机数生成器实现。
  2. Apache Commons Math 中的加密算法实现。
  3. Rosetta Code 提供的加密算法实现。
  4. GNU CoreUtils 中的加密算法实现。

ISAAC paper 中的默认实现只是用C实现了其32位和64位的伪随机数生成器的功能,并没有结合实际的加密功能。

Apache Commons Math 的 org.apache.commons.math3.random.ISAACRandom  提供了 ISAAC 作为伪随机数生成器并结合 Vernam 的加密算法实现。但是只有32位的实现,并没有64位的实现。

Rosetta Code 非常人性化的提供了 C、C++、C#、Dephi、Go等近三十种语言的实现,并且同时支持了 Vernam 算法和 Vigenère 算法,可以说是很有心了。但也是只有32位的实现,没有64位的实现。

GNU CoreUtils 提供了 C 实现的 ISAAC 伪随机数算法,同时适配了32位和64位。不过也并没有结合加密功能。

JavaApi封装

引入

既然我们需要64位的算法,考虑再三后我们选择了 CoreUtils 中的实现。核心文件如下:

https://github.com/coreutils/coreutils/blob/master/gl/lib/rand-isaac.h

https://github.com/coreutils/coreutils/blob/master/gl/lib/rand-isaac.c

由于只依赖了C标准库,因此源文件可以直接拿过来用。

生成JNI

先准备一个有 native 方法的类,可以先把加载动态库的逻辑写进来。(虽然运行时肯定报错,毕竟这个动态连接库目前还没有生成)

package com.mythsman.api.manager;
 
public class IsaacManager {
    static {
        try {
            System.loadLibrary("isaac");
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
 
    /**
     * @param data      待解密的数据
     * @param decodeKey 解密key
     */
    public native void decrypt(byte[] data, int decodeKey);
 
}
 

然后在适当的位置将这个文件编译一把,再生成一个用于静态加载的头文件。

$ javac com/mythsman/api/manager/IsaacManager.java
$ javah com.mythsman.api.manager.IsaacManager 
$ ls -la com_mythsman_api_manager_IsaacManager.h 
-rw-r--r--  1 myths  staff  547 Jun 18 00:03 com_mythsman_api_manager_IsaacManager.h

生成的头文件大致如下

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_mythsman_api_manager_IsaacManager */
 
#ifndef _Included_com_mythsman_api_manager_IsaacManager
#define _Included_com_mythsman_api_manager_IsaacManager
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_myths_api_manager_IsaacManager
 * Method:    decrypt
 * Signature: ([BI)V
 */
JNIEXPORT void JNICALL Java_com_mythsman_api_manager_IsaacManager_decrypt
  (JNIEnv *, jobject, jbyteArray, jint);
 
#ifdef __cplusplus
}
#endif
#endif

根据这个头文件,我们就可以利用 rand-isaac 实现一波加密算法了:com_mythsman_api_manager_IsaacManager.c

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <stdio.h>
#include <string.h>
#include "rand-isaac.h"
#include "com_mythsman_api_manager_IsaacManager.h"
 
/*
 * Class:     com_mythsman_api_manager_IsaacManager
 * Method:    decrypt
 * Signature: ([BI)V
 */
JNIEXPORT void JNICALL Java_com_mythsman_api_manager_IsaacManager_decrypt
        (JNIEnv *jniEnv, jobject obj, jbyteArray data, jint key) {
 
    jbyte *olddata = (*jniEnv)->GetByteArrayElements(jniEnv, data, 0);
 
    struct isaac_state state;
    isaac_word result[ISAAC_WORDS];
    memset((void *) state.m, 0, ISAAC_WORDS * sizeof(isaac_word));
    memset((void *) result, 0, ISAAC_WORDS * sizeof(isaac_word));
    *(uint32_t *) state.m = key;
    isaac_seed(&state);
 
    int encrypted_len = (*jniEnv)->GetArrayLength( jniEnv, data);
 
    for (int i = 0; i < encrypted_len; i++) {
        int mod = i % (ISAAC_WORDS * sizeof(isaac_word));
        if (mod == 0) {
            memset((void *) result, 0, ISAAC_WORDS * sizeof(isaac_word));
            isaac_refill(&state, result);
        }
        int decrypt_tbl_idx = ISAAC_WORDS * sizeof(isaac_word) - 1 - mod;
        *(olddata + i) ^= *((uint8_t *) result + decrypt_tbl_idx);
    }
    (*jniEnv)->ReleaseByteArrayElements(jniEnv, data, olddata, 0);
}

这里的代码最好在 CLion 里写,涉及到 JNI 的相关用法还是要看看源码的,而且用起来也要小心,搞不好就容易内存泄漏或者 core 。。。

编译

都搞完了,整一个 Cmake 文件。主要需要引入一下 jni 所在的头文件,以及生成动态链接库即可。

project(issac)
 
cmake_minimum_required(VERSION 3.5.0)
 
include_directories(
        $ENV{JAVA_HOME}/include
        $ENV{JAVA_HOME}/include/darwin
        $ENV{JAVA_HOME}/include/linux
        ${PROJECT_SOURCE_DIR}
)
 
add_library(isaac SHARED rand-isaac.c com_mythsman_api_manager_IsaacManager.c)

为了编译不污染源文件,先生成一个空的 build 文件夹,现在当前的文件结构如下:

$ tree .
.
├── CMakeLists.txt
├── build
├── com_mythsman_api_manager_IsaacManager.c
├── com_mythsman_api_manager_IsaacManager.h
├── rand-isaac.c
└── rand-isaac.h

现在进入 build 文件夹,执行 cmake .. && make 即可:

$ cmake .. && make
-- The C compiler identification is AppleClang 12.0.5.12050022
-- The CXX compiler identification is AppleClang 12.0.5.12050022
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/myths/Downloads/isaac/build
Scanning dependencies of target isaac
[ 33%] Building C object CMakeFiles/isaac.dir/rand-isaac.c.o
[ 66%] Building C object CMakeFiles/isaac.dir/com_mythsman_api_manager_IsaacManager.c.o
[100%] Linking C shared library libisaac.dylib
[100%] Built target isaac
$ ls
CMakeCache.txt      CMakeFiles          Makefile            cmake_install.cmake libisaac.dylib

这样就生成了我们要用的 libisaac.dylib 动态链接库。

使用

启动时,需要带上JVM参数以指定加载动态链接库的路径:-Djava.library.path=/path/to/libisaac.xxx

如果用于单测,则需要在该模块的 gradle 配置文件中加上:

    test {
        systemProperty "java.library.path", "/path/to/libisaac.xxx"
    }

然后就可以直接用啦~

byte[] data = xxxx;
int decodeKe = xxxx;
 
IsaacManager isaacManager = new IsaacManager();
isaacManager.decrypt(data, decodeKey);

这样 data 中的数据就从密文变成了明文。

参考资料

ISAAC Home Page

ISAAC 多语言实现

ISAAC 在GNU中的实现

Canvas指纹隐藏实战

2021年6月30日 16:27
作者 mythsman

前言

前两天和隔壁做风控的同学聊天,据说他们经常使用浏览器指纹来识别和标记爬虫(当然具体的细节是不能透露的),联想到我们最近也经常遇到被风控的情况,于是就花了点时间研究下浏览器指纹相关的知识。

需要明确的是,“隐藏浏览器指纹”和“隐藏Webdriver/Selenium/Puppeteer”、“匿名浏览”都不是同一个问题。“隐藏Webdriver/Selenium/Puppeteer”的目的是告诉服务端自己不是自动化爬虫(这个似乎可以尝试用 stealth.min.js 来做);“匿名浏览”的目的是不让浏览记录在本地留下痕迹(这个可以用浏览器的匿名模式来实现);“隐藏浏览器指纹”是为了不让服务端追踪“现在的我和之前的我是同一个我”(这个是我们这次想解决的问题)。

浏览器指纹一般是通过 Javascript 能提取或计算得到的一些具有一定稳定性独特性的参数。风控的同学将这些参数做一定取舍和组合,就能得到一台浏览器的大致标识。在没有强制登陆的情况下,有了这个标识,风控的同学可以做反爬、广告的同学可以做推荐、数据的同学可以算uv等等,用处还是不小的。

常见的浏览器指纹会提取如下东西:

  1. UserAgent和平台信息
  2. 浏览器加载的字体信息
  3. 声卡指纹
  4. Canvas指纹
  5. 显示器分辨率
  6. 语言、地区时区信息
  7. CPU核数和可用内存信息
  8. 本地存储、Cookie等信息
  9. 浏览器安装的插件信息
  10. 科学计算的指纹信息
  11. IP和代理信息

可以看出来,这里有的指标稳定性比较好,比如UserAgent、分辨率、地区、语言、字体信息等,但是这些指标通常区分度不是很好。因此我们通常更喜欢用一些硬件指纹信息来进行区分。当然,为了衡量指标的这种“独特性”,Peter Eckersley 提出了用一个指标对设备唯一指纹引入的熵来衡量指标的这种特性,下面是我的设备在 coveryyourtracks 中跑出来的一些常见指标的效果:

USER AGENT
Bits of identifying information: 9.04
One in x browsers have this value: 524.71
 
HTTP_ACCEPT HEADERS
Bits of identifying information: 8.96
One in x browsers have this value: 496.75
 
BROWSER PLUGIN DETAILS
Bits of identifying information: 3.25
One in x browsers have this value: 9.54
 
TIME ZONE OFFSET
Bits of identifying information: 5.26
One in x browsers have this value: 38.38
 
TIME ZONE
Bits of identifying information: 6.83
One in x browsers have this value: 114.13
 
SCREEN SIZE AND COLOR DEPTH
Bits of identifying information: 6.28
One in x browsers have this value: 77.57
 
SYSTEM FONTS
Bits of identifying information: 3.92
One in x browsers have this value: 15.12
 
ARE COOKIES ENABLED?
Bits of identifying information: 0.13
One in x browsers have this value: 1.09
 
LIMITED SUPERCOOKIE TEST
Bits of identifying information: 1.41
One in x browsers have this value: 2.67
 
HASH OF CANVAS FINGERPRINT
Bits of identifying information: 9.73
One in x browsers have this value: 847.61
 
HASH OF WEBGL FINGERPRINT
Bits of identifying information: 9.75
One in x browsers have this value: 862.69
 
WEBGL VENDOR & RENDERER
Bits of identifying information: 8.5
One in x browsers have this value: 362.9
 
DNT HEADER ENABLED?
Bits of identifying information: 0.95
One in x browsers have this value: 1.93
 
LANGUAGE
Bits of identifying information: 7.47
One in x browsers have this value: 176.69
 
PLATFORM
Bits of identifying information: 3.11
One in x browsers have this value: 8.62
 
TOUCH SUPPORT
Bits of identifying information: 0.76
One in x browsers have this value: 1.7
 
AD BLOCKER USED
Bits of identifying information: 0.15
One in x browsers have this value: 1.11
 
AUDIOCONTEXT FINGERPRINT
Bits of identifying information: 5.92
One in x browsers have this value: 60.59
 
CPU CLASS
Bits of identifying information: 0.12
One in x browsers have this value: 1.09
 
HARDWARE CONCURRENCY
Bits of identifying information: 1.93
One in x browsers have this value: 3.82
 
DEVICE MEMORY (GB)
Bits of identifying information: 2.31
One in x browsers have this value: 4.96

其中,“Bits of identifying information” 表示这个指标对我的浏览器引入的唯一性的位数,这个位数越大,越表明这个指标更能区分我的浏览器和其他的浏览器;“One in x browsers have this value” 表示平均多少个浏览器的这个指标和我的浏览器的这个指标一样,这个数越大,越表明这个指标的区分度好。

显然,从这里看,区分度最高的指标就是 "UserAgent" , "WebGL指纹" 和 "Canvas 指纹" 。那这些指纹是怎么 work 的呢?例如Canvas指纹,其实就是由于Web 浏览器使用的图像处理引擎、图像导出选项、压缩级别、操作系统的字体,抗锯齿和亚像素渲染算法等的不同,导致画出来的图片在像素级别存在的微小但稳定的差距。这样我们就可以拿这个生成图片的校验和或算一个Hash作为他的指纹。

指纹检测

目前业内较为知名的浏览器指纹检测网站大概有下面这三个:

  1. https://fingerprintjs.github.io/fingerprintjs/
  2. http://f.vision/
  3. https://coveryourtracks.eff.org/

第一个是最常见的浏览器指纹生成项目,之前某手用过一段时间,后来好像不用了,可以作为入门项目来看。

第二个是一个进阶的在线检测指纹的网站,检测点更全,也有一定的反伪造能力。

第三个是 Peter Eckersley 实验用的检测指纹的网站,教育意义更明显,也有一定的反伪造能力。

有矛就有盾,作为(爬虫)普通用户,我们显然不想让服务端跟踪我们的操作路径,但又不想用 Tor 浏览器。因此我们就要想一些对付这些指纹检测的办法。下面就是我做的一些尝试。

Fingerpintjs指纹

指纹生成算法

首先我们先看下 fingerprintjs 检测 Canvas 指纹的核心代码,作为我们首先需要绕过的目标:

function makeTextImage(canvas, context) {
    // Resizing the canvas cleans it
    canvas.width = 240;
    canvas.height = 60;
    context.textBaseline = 'alphabetic';
    context.fillStyle = '#f60';
    context.fillRect(100, 1, 62, 20);
    context.fillStyle = '#069';
    // It's important to use explicit built-in fonts in order to exclude the affect of font preferences
    // (there is a separate entropy source for them).
    context.font = '11pt "Times New Roman"';
    // The choice of emojis has a gigantic impact on rendering performance (especially in FF).
    // Some newer emojis cause it to slow down 50-200 times.
    // There must be no text to the right of the emoji, see https://github.com/fingerprintjs/fingerprintjs/issues/574
    // A bare emoji shouldn't be used because the canvas will change depending on the script encoding:
    // https://github.com/fingerprintjs/fingerprintjs/issues/66
    // Escape sequence shouldn't be used too because Terser will turn it into a bare unicode.
    var printedText = "Cwm fjordbank gly " + String.fromCharCode(55357, 56835) /* 😃 */;
    context.fillText(printedText, 2, 15);
    context.fillStyle = 'rgba(102, 204, 0, 0.2)';
    context.font = '18pt Arial';
    context.fillText(printedText, 4, 45);
    return save(canvas);
}
function makeGeometryImage(canvas, context) {
    // Resizing the canvas cleans it
    canvas.width = 122;
    canvas.height = 110;
    // Canvas blending
    // https://web.archive.org/web/20170826194121/http://blogs.adobe.com/webplatform/2013/01/28/blending-features-in-canvas/
    // http://jsfiddle.net/NDYV8/16/
    context.globalCompositeOperation = 'multiply';
    for (var _i = 0, _a = [
        ['#f2f', 40, 40],
        ['#2ff', 80, 40],
        ['#ff2', 60, 80],
    ]; _i < _a.length; _i++) {
        var _b = _a[_i], color = _b[0], x = _b[1], y = _b[2];
        context.fillStyle = color;
        context.beginPath();
        context.arc(x, y, 40, 0, Math.PI * 2, true);
        context.closePath();
        context.fill();
    }
    // Canvas winding
    // http://blogs.adobe.com/webplatform/2013/01/30/winding-rules-in-canvas/
    // http://jsfiddle.net/NDYV8/19/
    context.fillStyle = '#f9c';
    context.arc(60, 60, 60, 0, Math.PI * 2, true);
    context.arc(60, 60, 20, 0, Math.PI * 2, true);
    context.fill('evenodd');
    return save(canvas);
}

这里主要生成了下面两个东西:

指定形状、字体、文字、颜色等,画了一个图。

指定了颜色、位置,画了一些奇奇怪怪的圆弧。

然后 fingerprintjs 会用这两个图,根据 murmurHash3 算一个指纹。

Canvas Fingerprint Defender

Chrome有一个声称能防止 canvas 指纹泄露的插件 Canvas Fingerprint Defender ,我们姑且拿他来检测绕过 fingerprintjs 看看。

安装后,找到 crx 安装目录,发现他的逻辑主要是在 data/content_script/inject.js 中,核心逻辑如下:

var inject = function () {
  const toBlob = HTMLCanvasElement.prototype.toBlob;
  const toDataURL = HTMLCanvasElement.prototype.toDataURL;
  const getImageData = CanvasRenderingContext2D.prototype.getImageData;
  //
  var noisify = function (canvas, context) {
    if (context) {
      const shift = {
        'r': Math.floor(Math.random() * 10) - 5,
        'g': Math.floor(Math.random() * 10) - 5,
        'b': Math.floor(Math.random() * 10) - 5,
        'a': Math.floor(Math.random() * 10) - 5
      };
      //
      const width = canvas.width;
      const height = canvas.height;
      if (width && height) {
        const imageData = getImageData.apply(context, [0, 0, width, height]);
        for (let i = 0; i < height; i++) {
          for (let j = 0; j < width; j++) {
            const n = ((i * (width * 4)) + (j * 4));
            imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
            imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
            imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
            imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
          }
        }
        //
        window.top.postMessage("canvas-fingerprint-defender-alert", '*');
        context.putImageData(imageData, 0, 0);
      }
    }
  };
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toBlob.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toDataURL.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
    "value": function () {
      noisify(this.canvas, this);
      return getImageData.apply(this, arguments);
    }
  });
  //
  document.documentElement.dataset.cbscriptallow = true;
};
inject();

代码基本不用解释,主要就做了一件事情:重写 Canvas 的 toBlob,toDataURL 方法和 Context 的 getImageData 方法,使他们在生成图片时加一些噪点。

通过下面的 selenium 代码注入上述脚本:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
 
options = Options()
 
driver = webdriver.Chrome(options=options, executable_path="/path/to/chromedriver")
 
f = open('./inject.js')
js = f.read()
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": js})
 
driver.get('https://fingerprintjs.github.io/fingerprintjs/')

连续跑两次,发现指纹的确并不相同:

f.vision指纹

但是上面的脚本如果应用在 f.vision 上(或者  canvas-fingerprint ),虽然也会生成不同的指纹,但是会发现有下面的结果:

有意思,果然是做了一些检测,让我们摘下面具看看他做了啥。一番搜索后发现了如下检测点。

检测点一

CanvasRenderingContext2D.prototype.getImageData.length !== 4 
|| !CanvasRenderingContext2D.prototype.getImageData.toString().match(/^\s*function getImageData\s*\(\)\s*\{\s*\[native code\]\s*\}\s*$/) 
|| (CanvasRenderingContext2D.prototype.getImageData.name !== "getImageData" && !ie)

原来他check了一下关键对象的 prototype 的属性,我们来看下当前实际的结果:

CanvasRenderingContext2D.prototype.getImageData.length
0
 
CanvasRenderingContext2D.prototype.getImageData.toString()
"function () {\n      noisify(this.canvas, this);\n      return getImageData.apply(this, arguments);\n    }"
 
CanvasRenderingContext2D.prototype.getImageData.name
"value"

这里果然和没有改过的不一样。

检测点二

function cKnownPixels(size) {
    "use strict";
 
    var canvas = document.createElement("canvas");
    canvas.height = size;
    canvas.width = size;
    var context = canvas.getContext("2d");
    if (!context)
        return false;
 
    context.fillStyle = "rgba(0, 127, 255, 1)";
    var pixelValues = [0, 127, 255, 255];
    context.fillRect(0, 0, canvas.width, canvas.height);
    var p = context.getImageData(0, 0, canvas.width, canvas.height).data;
    for (var i = 0; i < p.length; i += 1) {
        if (p[i] !== pixelValues[i % 4]) {
            return false;
        }
    }
    return true;
}

这个是个很容易想到的检测方法,输入一个已知的稳定像素图(由于不存在字体、复杂计算等,因此不同机器的渲染几乎不会有差异),如果输出的结果和已知输入不一样,那肯定是人为加了噪点。

检测点三

function cReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
    context.putImageData(imageData, 0, 0);
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
    var imageData2 = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

这也是个比较容易想到的检测方法,将同一份像素点连续输出两次,如果这两次的结果不一样,那肯定是加了某些随机的噪点。

检测点四

function cDoubleReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
 
    var canvas2 = document.createElement("canvas");
    var context2 = canvas2.getContext("2d");
    context2.putImageData(imageData1, 0, 0);
    var imageData2 = context2.getImageData(0, 0, canvas.width, canvas.height);
 
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

和上面的检测挺像,但是检测出来的结果比较trick,没太搞懂这是检测的啥,不过实践中发现如果 a 通道随机到的噪点偏移值不太好,很容易检测不通过。

各个击破

道高一尺,魔高一丈,知道他是怎么检测的,我们就可以有针对性的处理了。解决思路如下:

  1. 再次 mock 出对应prototype 的正确 length、toString、name 属性。
  2. 对于没有添加文字、没有复杂位图变换的图,不进行随机填充。
  3. 随机参数在启动时就生成且只生成一次,保证相同的图多次处理结果也一样。

具体操作可以参考下面的代码:

function random(list) {
    let min = 0;
    let max = list.length
    return list[Math.floor(Math.random() * (max - min)) + min];
}
 
let rsalt = random([...Array(7).keys()].map(a => a - 3))
let gsalt = random([...Array(7).keys()].map(a => a - 3))
let bsalt = random([...Array(7).keys()].map(a => a - 3))
let asalt = random([...Array(7).keys()].map(a => a - 3))
 
const rawGetImageData = CanvasRenderingContext2D.prototype.getImageData;
 
let noisify = function (canvas, context) {
    let ctxIdx = ctxArr.indexOf(context);
    let info = ctxInf[ctxIdx];
    const width = canvas.width, height = canvas.height;
    const imageData = rawGetImageData.apply(context, [0, 0, width, height]);
    if (info.useArc || info.useFillText) {
        for (let i = 0; i < height; i++) {
            for (let j = 0; j < width; j++) {
                const n = ((i * (width * 4)) + (j * 4));
                imageData.data[n + 0] = imageData.data[n + 0] + rsalt;
                imageData.data[n + 1] = imageData.data[n + 1] + gsalt;
                imageData.data[n + 2] = imageData.data[n + 2] + bsalt;
                imageData.data[n + 3] = imageData.data[n + 3] + asalt;
            }
        }
    }
    context.putImageData(imageData, 0, 0);
};
 
let ctxArr = [];
let ctxInf = [];
 
(function mockGetContext() {
    let rawGetContext = HTMLCanvasElement.prototype.getContext
 
    Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
        "value": function () {
            let result = rawGetContext.apply(this, arguments);
            if (arguments[0] === '2d') {
                ctxArr.push(result)
                ctxInf.push({})
            }
            return result;
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "toString", {
        "value": () => "function getContext() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.constructor, "name", {
        "value": "getContext"
    });
})();
 
(function mockArc() {
    let rawArc = CanvasRenderingContext2D.prototype.arc
    Object.defineProperty(CanvasRenderingContext2D.prototype, "arc", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useArc = true;
            return rawArc.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "length", {
        "value": 5
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "toString", {
        "value": () => "function arc() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "name", {
        "value": "arc"
    });
})();
 
(function mockFillText() {
    const rawFillText = CanvasRenderingContext2D.prototype.fillText;
    Object.defineProperty(CanvasRenderingContext2D.prototype, "fillText", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useFillText = true;
            return rawFillText.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "toString", {
        "value": () => "function fillText() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "name", {
        "value": "fillText"
    });
})();
 
 
(function mockToBlob() {
    const toBlob = HTMLCanvasElement.prototype.toBlob;
 
    Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toBlob.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "toString", {
        "value": () => "function toBlob() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "name", {
        "value": "toBlob"
    });
})();
 
(function mockToDataURL() {
    const toDataURL = HTMLCanvasElement.prototype.toDataURL;
    Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toDataURL.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "length", {
        "value": 0
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "toString", {
        "value": () => "function toDataURL() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "name", {
        "value": "toDataURL"
    });
})();
 
 
(function mockGetImageData() {
    Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
        "value": function () {
            noisify(this.canvas, this);
            return rawGetImageData.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "toString", {
        "value": () => "function getImageData() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "name", {
        "value": "getImageData"
    });
})();
 

再用 selenium 跑两下 f.vision,结果如下:

这下老实了。

实战-某音

有了上面的能力,理论上我们就可以找个小白鼠来实战试验一下。

下图是某音网页版的推荐页 https://douyin.com/recommend , 指纹计算的逻辑在他的 webmssdk.js 中,具体逻辑没仔细看,姑且当成黑盒测试一下。可以看到他给我计算了一个Canvas指纹(即 canvasHash 变量)。多次打开、清理cookie、使用不同显示器后指纹均能够保持稳定。

然后上一下我们的代码,可以发现指纹的确发生了变化,且每次打开均不同:

coveryourtracks指纹

本来以为上面的代码足够应对绝大多数场景了,但是回头跑了一下 https://coveryourtracks.eff.org/ 这个(检测时可能要翻墙),忽然发现他竟然还是能检测出来我做了伪造:

回想到他在检测时似乎有刷新页面的操作,因此猜测他也是通过刷新页面的方式,比较两次生成的指纹是否相同来检测伪造。于是我尝试将随机性从 js 脚本中提取到 python 代码里,保证相同会话无论刷新多少次都是用的同一套随机数。结果果然印证了我的猜想。

小结

虽然看起来上述操作能解决已知的 Canvas 检测问题,但像所有的攻守对抗一样,只要检测方愿意,他们可以十分轻松的想出很多种办法检测出我们检测出了他们的检测代码😅,问题只在于他们是否有闲情做这件事、以及这件事情是否必要。据我所知目前被搞的比较多的网站大都也都没有做这些检测,毕竟除了Canvas指纹之外,他们也有太多的工具可以使用。

参考资料

BrowserLeaks

Coveryourtracks.eff.org

How Unique Is Your Web Browser?

Device_fingerprint(Wikipeida)

What is Fingerprinting?

IT IS *NOT* POSSIBLE TO DETECT AND BLOCK CHROME HEADLESS

Do I have a canvas fingerprint spoofer installed in my browser

Gnirehtet生产环境实践

2021年6月26日 15:37
作者 mythsman

背景

目前业界的移动端爬虫在前端数据提取的部分大致有两套方案,一套是网络抓包、另一套是逆向Hook。无论是哪一种方案,都必须要解决一个问题,那就是网络要稳定。在一个较为狭小的空间内,如果是只有几十台设备,那一两台AP一般还能顶住。可如果有数百台设备,无脑堆AP的话就会出现各种问题(过载、负载不均、信道干扰、网段可用IP不足)等问题。

即使网络本身没有问题,在对抓包数据进行审计的时候,通常也需要维护一个设备号到IP的映射关系。随着设备的增多,映射关系的维护也是一个麻烦的事情,而且IP漂移的情况也不容忽视,管理起来也十分费劲。

既然无线网络不可靠,那么我们就考虑使用有线网络。传统手机一般都内置了 USB 网络共享功能,也就是手机通过USB和PC连接后,PC就可以使用手机的流量。但我们显然需要的应当是相反的功能,希望手机能够通过USB使用PC的流量出口。而这就是 Gnirehtet 项目解决的痛点。

Gnirehtet

GnirehtetRomain Vimont (rom1v)于2017年发起的开源项目,有 Rust 和 Java 的双重实现。这位法国老哥曾供职于 Genymobile 公司做设备监控和群控相关的工作,现在在 VideoLabs 做 VLC 相关的开发。除了 Gnirehtet 这个项目,他的 scrcpy 项目也非常棒( 50k+ star),目前我们也在内部系统中集成并魔改了这个项目用于设备投屏监控以及远程操作。

这个项目一个最大的坑就是他的读法,由于 rom1v 取 tethering 逆序之意,因此从音节上看可读性并不好。因此我私下一直都只叫他VPN项目 :)

架构图

Gnirehtet 项目主要分为三块:Apk(部署在手机端),RelayServer(部署在主机端),CommandLine。

  • Apk 端会实现 Android 系统的 VpnService 接口。用户授权后,系统的所有流量都会以IP报文的形式传给 Apk。
  • RelayServer 会与Apk建立一个长链接以获得IP报文。获得IP包后,根据 RFC-793RFC-768 标准分别解出 TCP 和 UDP 报文的目的IP和内容,然后自行与目的IP建立连接,再进行数据转发(其实就是实现了NAT)。
  • CommandLine 用于进行初始化、Apk和RelayServer的启停、和一些辅助操作。

流程分析

初始化

  1. CommandLine 启动 RelayServer,RelayServer 默认开启 31416 TCP端口,用于监听 Apk 的连接。
  2. CommandLine 通过实现 track-devices 协议,与 adb 的 5037 端口通信,实时获得设备上下线信息。
  3. CommandLine 通过 adb reverse ,将本地的 31416 TCP端口映射到手机的 localabstract:gnirehtet 的Unix域端口,方便 Apk 端访问。
  4. CommandLine 通过 adb 向 手机设备中安装 APK。
  5. CommandLine 通过 adb shell 的 am start 指令拉起 Apk 的 activity。
  6. Apk 拉起后,首次启动时需要获得用户的开启VPN的授权。
  7. Apk 服务的 VpnService 服务被拉起,通过 localabstract:gnirehtet 与 RelayServer 建立好长连接。

初始化完成后,手机端的通知栏会出现VPN选项。

同时,手机的 ifconfig 中也会看到多了一个 tun0 的通道:

$ adb shell
cereus:/ $ ifconfig
lo        Link encap:UNSPEC
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope: Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:125860 errors:0 dropped:0 overruns:0 frame:0
          TX packets:125860 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1
          RX bytes:2585242418 TX bytes:2585242418
 
tun0      Link encap:UNSPEC
          inet addr:10.0.0.2  P-t-P:10.0.0.2  Mask:255.255.255.255
          inet6 addr: fe80::76d3:d75a:4a1b:a574/64 Scope: Link
          UP POINTOPOINT RUNNING  MTU:16384  Metric:1
          RX packets:336 errors:0 dropped:0 overruns:0 frame:0
          TX packets:412 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:500
          RX bytes:130951 TX bytes:71517

运行时

  1. 系统将所有的流量发送给 Apk。
  2. Apk 将 IP 报文发送给 RelayServer。
  3. RelayServer 解析 IP报文头,区分出 TCP 和 UDP 请求,并将其余类型的报文丢弃(例如ICMP等)。
  4. RelayServer 根据从 IP 报文解析出的 IPv4 地址,以及从 TCP 或 UDP 中解析出的端口,与实际的目的地址进行数据收发。

坑点处理

由于 Gnihretet 这个项目应该没被系统性地使用过,因此该项目在大规模应用时会出现很多问题。截至 v2.5 版本,依然有很多坑或bug。以下问题的解决方案中有一部分已经提给 rom1v 进行修复,但是截至目前还没有发布新的 Release。

命令阻塞问题

在 CommandLine 向手机安装 Apk 的时候,有概率会出现命令卡在 `Checking gnirehtet client... 之后,这是因为在 com.genymobile.gnirehtet.Main 在用 dumpsys package com.genymobile.gnirehtet 检测是否安装 Apk 时,在 fork 子进程的时候,未及时将新子进程写在缓冲区的数据读出,从而主进程 wait 子进程结束、而子进程 wait 缓冲区 flush, 导致死锁。

解决方案就是主进程保证先读出子进程的写缓冲区,再 wait 子进程结束。

         List<String> command = createAdbCommand(serial, "shell", "dumpsys", "package", "com.genymobile.gnirehtet");
         Log.d(TAG, "Execute: " + command);
         Process process = new ProcessBuilder(command).start();
-        int exitCode = process.waitFor();
-        if (exitCode != 0) {
-            throw new CommandExecutionException(command, exitCode);
-        }
-        Scanner scanner = new Scanner(process.getInputStream());
-        // read the versionCode of the installed package
-        Pattern pattern = Pattern.compile("^    versionCode=(\\p{Digit}+).*");
-        while (scanner.hasNextLine()) {
-            Matcher matcher = pattern.matcher(scanner.nextLine());
-            if (matcher.matches()) {
-                String installedVersionCode = matcher.group(1);
-                return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+        try {
+            Scanner scanner = new Scanner(process.getInputStream());
+            // read the versionCode of the installed package
+            Pattern pattern = Pattern.compile("^    versionCode=(\\p{Digit}+).*");
+            while (scanner.hasNextLine()) {
+                Matcher matcher = pattern.matcher(scanner.nextLine());
+                if (matcher.matches()) {
+                    String installedVersionCode = matcher.group(1);
+                    return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+                }
+            }
+        } finally {
+            int exitCode = process.waitFor();
+            if (exitCode != 0) {
+                // Overwrite any pending exception, the command just failed
+                throw new CommandExecutionException(command, exitCode);
             }
         }

内存不足问题

由于 Gnirehtet 需要维护并模拟所有手机到外网的长链接,因此在 RelayServer 内部维护了很多 StreamBuffer 和 DatagramBuffer 等的缓冲区用于进行数据拷贝。这就导致当连接数和设备数比较大时,很容易出现 OOM 等问题。

解决方案如下:

  1. 减小 com.genymobile.gnirehtet.relay.TCPConnection 中 clientToNetwork 的缓冲区大小,默认是IP报文长度的8倍。
  2. 减小 com.genymobile.gnirehtet.relay.UDPConnection 中 clientToNetwork 的缓冲区大小,默认是IP报文长度的4倍。
  3. 减小 com.genymobile.gnirehtet.relay.Client 中 networkToClient 的缓冲区大小,默认是IP报文长度的16倍。
  4. 在启动 RelayServer 时把 JVM 堆大小适当增大。
  5. 后续可以考虑将缓冲区的逻辑进行改造,其实没有必要维护这么多空缓冲区,可以做一些共享缓冲区的逻辑。

连接过多问题

当设备连接时间变长后,如果有些连接回收的不及时,Router 维护的 Connection 表就会出现如下问题:

  1. Router 的 Connection 表是数组形式存储,查找和删除性能会随着连接数变长而变差。rom1v 认为这里连接数不多,List 的性能要高于 Map,但是生产用起来才发现由于回收不及时,这里的连接数经常是上千的量级。
  2. 很多老的 Connection 没法及时得到释放,导致他们占用的缓冲区也没法及时释放,从而加剧了内存不足的问题。

解决方案如下:

  1. 将 Router 的 Connection 表改造成 ConcurrentHashMap。
  2. 对 Connection 进行 LRU 淘汰,对每个 Client 限制最大的连接数。在一个新连接被建立时,如果当前连接数超过最大连接数,则close 掉最久没被使用过的一批连接。

无法监听设备上下线

前文提到,Gnirehtet 的 RelayServer 是通过 adb 的 track-devices 协议从 adb 获取设备的上下线信息的。相关逻辑在 com.genymobile.gnirehtet.AdbMonitor 中。

但是 rom1v 并没有很好处理好异常情况。当大量设备同时批量上下线时,AdbMonitor 维护的状态就会有问题,并且接受 track-devices 信息的地方无法从异常解析中恢复,这就导致后续设备上下线的信息都被丢弃了。

解决方案:

 static String readPacket(ByteBuffer input) {
     if (input.remaining() < LENGTH_FIELD_SIZE) {
         Log.i(TAG, "No field size found");
         return null;
     }
     // each packet contains 4 bytes representing the String length in hexa, followed by a list of device states, one per line;
     if (input.remaining() < length) {
         // not enough data
         input.rewind();
-        return null;
+        throw new RuntimeException("Not Enough data");
     }
     input.get(BUFFER, 0, length);
     return new String(BUFFER, 0, length, StandardCharsets.UTF_8);
 }

客户端宕机问题

由于安卓系统的特性,正在跑的 VPN 客户端总是会因为各种原因被Kill 。无论是系统内存不足、还是用户主动清理后台、还是因为手机的省电策略,客户端都有可能挂掉。

简单的解决方法是手动在 ”最近活动“ 中将 Gnirehtet 的客户端用小锁锁住。这样虽然也不能完全保证不被杀,但是生存的概率还是大了很多。

如果嫌手动搞麻烦,也可以用 adb 工具直接设置 settings:

$ adb shell settings get system locked_apps
[{"u":0,"pkgs":[]},{"u":-100,"pkgs":["com.jeejen.family.miui"]}]
$ adb shell settings put system locked_apps '"[{\"u\":0,\"pkgs\":[\"com.genymobile.gnirehtet\"]},{\"u\":-100,\"pkgs\":[\"com.jeejen.family.miui\"]}]"'
$ adb reboot

重启后,就会发现客户端一样加了锁。

业务优化

以下是我们在生产环境使用时,出于性能提升或审计需要实现的一些功能。虽然不是必须的功能,但也算是一种不错的实践。

桌面icon展示

默认的 gnirehtet.apk 在桌面时没有图标的,因此有时候比较难判断是否安装过,也不方便手动启动。通过修改 AndroidManifest.xml 文件,将android:icon=”@null" 修改为一个指定图片即可。

DNS缓存

Gnirehtet 的 Apk 中将 DNS 服务器指定为 8.8.8.8 ,当然 Gnirehtet 也提供了指定 DNS server 的参数,但是在流量较大的情况下,如果能使用到本地的 DNS 缓存服务就更好了。

做法如下:

  1. 在 Linux 主机上开启 DNS 缓存服务(一般默认都是开启的),服务开启在了 127.0.0.53#53 。
  2. 修改 RelayServer,在 com.genymobile.gnirehtet.relay.AbstractConnection  中,仿照 主机 127.0.0.1 映射 10.0.2.2 的逻辑,将 主机的 127.0.0.53 映射到 10.0.2.53 。
  3. 在启动 Gnirehtet 的 RelayServer 时,带上 -d 10.0.2.53  参数即可。

最后看一下本地缓存命中率:

$ systemd-resolve --statistics
DNSSEC supported by current servers: no
 
Transactions
Current Transactions: 0
  Total Transactions: 40246262
 
Cache
  Current Cache Size: 823
          Cache Hits: 31190533
        Cache Misses: 9319409
 
DNSSEC Verdicts
              Secure: 0
            Insecure: 0
               Bogus: 0
       Indeterminate: 0

在我们的量级下、默认缓存配置一般都能缓存 3/4 左右的请求。虽然实际使用体感上看不出啥变化,但从理论上应该还是有点用的 :)

网络检测

由于各种原因,Gnirehtet 有跪的可能,因此需要在主机上检测手机网络到底与主机通不通。但是由于 Gnirehtet 会丢弃 ICMP 协议,因此 ping 是 ping 不到东西的。

因此我们在所有设备的 /data/local/tmp/ 下安装了 busybox ,使用 nc 命令与尝试与主机的某个端口通信。如果 socket 能连接上,则说明 vpn 工作正常。

$ adb shell /data/local/tmp/busybox nc -vzw1 10.0.2.2 5037
10.0.2.2 (10.0.2.2:5037) open

-v 参数保证有回显, -z 参数表示只检测连通性不处理IO,-w1 表示最多等待 1s 超时。

10.0.2.2 是在 Gnirehtet 的 RelayServer 中硬编码的、在手机中代指主机的IP地址。

5037 是 adb 的端口,这个这个可以改成主机上任意一个可用的端口。只是借来探测连通信,不会发送实际数据。

设备号透传

出于审计(抓包统计)需要,我们总是想知道,某一个流量是来自哪一个手机设备。在 WIFI 网络方案中,我们(虽然愚蠢但是)可以手动维护设备ID与网络IP的关系。但是在 Gnirehtet 方案中,连接在同一主机的设备是公用同一出口IP的,无法进行区分。

为了解决这个问题,我们借鉴并扩展了 HAProxy 的 proxy-protocol 协议,仿照 py-proxy-protocol 的实现,在 TCP 建立连接后发送一小段数据包用于透传设备序列号。具体分为以下步骤:

  1. Apk 需要申请 android.permission.READ_PHONE_STATE 权限,读取设备序列号。(申请方式随 Android 版本而不同)。
  2. Apk 在与 RelayServer 建立连接后,需要将序列号透传给 RelayServer。
  3. RelayServer 在与外部主机(这里是我们自己的代理主机)建立连接时,透传一段 proxy-protocol 协议,并带上序列号。
  4. 代理主机解析扩展的 proxy-protocol 协议,获取序列号,这样就将每一个连接和具体的设备号一一对应上了。

异常恢复

无论如何,总会有一些未知的 bug 或者无法恢复的情况,因此我们也做了VPN相关的兜底策略:

  1. 根据上面的网络检测功能,如果发现设备网络不通,则自动尝试重启手机上的 VPN 客户端。
  2. 如果多次重启VPN客户端后,网络依然不通,则重启手机。

这样的步骤下来,基本上能解决 99% 的问题了 :)

参考资料

https://github.com/Genymobile/gnirehtet/blob/master/DEVELOP.md

https://developer.android.com/guide/topics/connectivity/vpn

https://android.googlesource.com/platform/system/adb/+/refs/heads/master/SERVICES.TXT

Frida爬虫分析流程——以微信视频号下载为例

2021年6月10日 06:15
作者 mythsman

前言

微信的通信协议没有使用传统的https,而是采用 mmtls 和 quic 协议结合的方案(可能),导致常用的抓包方案完全无效。因此我们考虑使用逆向 hook 的方式,对微信视频号的数据进行获取。

Frida 是目前几乎最好跨平台hook工具,深受广大牢友的喜爱。因此我们考虑用这个工具来进行 hook 。

准备

  1. 准备一个解锁了 bootloader 、刷了 TWRP 并安装了 Magisk 的手机。(当然,也有无需root权限的方法,但是用起来会不方便,还是建议 root )
  2. 准备好 adb 环境。
  3. 参考 FRIDA 安装 frida 用于 hook。并最好把官网的 Tutorials 看下。
  4. 准备 Pycharm 作为开发环境。
  5. 准备好 wechat.apk 。
  6. 参考 JADX 安装好 jadx 用于代码静态分析。

思路

Frida 爬虫的思路如下:

  1. 利用 adb 的 dumpsys 工具定位到我们关心的 Activity 页。
  2. 利用 jadx 的静态分析工具在代码中定位到解密后的后的数据对象。
  3. 利用 frida 的 hook 能力重写数据对象的构造、拷贝等关键方法,提取出入参出参等。
  4. 将提取到的数据序列化成json,并持久化。

流程

启动 frida-server

首先需要在 Magisk 商店中找到 MagiskFrida 插件。这个插件会在手机启动时以高权限启动 frida-server 服务器用于后续注入 hook 代码。

如果一不小心 frida-server 跪了,只需要重启手机即可。

定位Activity

首先我们需要大概了解我们关注的页面的一些信息,方便我们后续定位代码。因此我们先打开感兴趣的页面(我这里是微信视频号的 tab),并执行 dumpsys 命令。

这样我们知道了,这个页面对应的是 FinderHomeUI 这个 activity。

定位数据对象

打开 jadx-gui ,定位到 FinderHomeUI 这个类。(可能loading一段时间,如果报OOM,则需要 通过 $ mdfind jadx-gui 找到启动脚本,并修改 JVM 参数)。

简单的四下观望,就可以找一个叫 FeedData 的类,也找到类这个类的一个类似 Builder 模式的静态内部类。

看起来这个 i 方法大概就是构造 FeedData 这个类的方法了,因此我们可以考虑下 hook 这个 i 方法。

提取重要参数

找到了上面的 com.tencent.mm.plugin.finder.storage.FeedData$a  对象,我们就可以简单编写一个hook脚本看看。

# -*- coding: UTF-8 -*-
import frida
import sys


def on_message(message, data):
    print(message)


jscode = """
Java.perform(function () {

  var a = Java.use('com.tencent.mm.plugin.finder.storage.FeedData$a');

  var i = a.i;
  i.implementation = function (finderItem) {

    var res = i.call(this, finderItem);

    try{
        send(JSON.stringify(res))
    }catch (e){
        console.log('Error: ' + e);//自己的逻辑要做好catch防止脚本有问题导致app崩溃。
    }
    return res;//注意保证函数输出不变。
  };

});
"""

process = frida.get_usb_device().attach('com.tencent.mm')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] Running Hook')
script.load()
sys.stdin.read()

执行这个脚本,并滑动一下可以看到如下输入:

显然,这里的 payload 并没有正确的被序列化,因此我们需要再做一个用序列化工具。

数据序列化输出

由于安卓自带的 org.json.JSONObject 不支持json序列化,而 Javascript 的方法也无法序列化 java 对象。因此我们需要自己引入一个java包用于序列化,这里我选用无脑的fastjson。

  1. 首先需要下载fastjson的jar包,我在本地的maven仓库中找到了: /Users/myths/.gradle/caches/modules-2/files-2.1/com.alibaba/fastjson/1.2.69/6cb063f1d527ff65bdbb9ea74888a5ffc3f92197/fastjson-1.2.69.jar
  2. 然后利用 adb 的 build-tools 中的 dx 工具将 jar 包重新打包成 dex 包:$ /Users/myths/Library/Android/sdk/build-tools/26.0.2/dx --dex --output=fastjson.dex fastjson.jar
  3. 将上述生成的 dex 包 push 到手机中,例如 /data/local/tmp/fastjson.dex  下。
  4. 更改下脚本,再滑动下页面:
# -*- coding: UTF-8 -*-
import frida
import sys


def on_message(message, data):
    print(message['payload'])


jscode = """
Java.perform(function () {

  var a = Java.use('com.tencent.mm.plugin.finder.storage.FeedData$a');
  Java.openClassFile('/data/local/tmp/fastjson.dex').load();
  var JSONObject = Java.use('com.alibaba.fastjson.JSONObject')
  
  var i = a.i;
  i.implementation = function (finderItem) {

    var res = i.call(this, finderItem);

    try{
        send(JSONObject.toJSONString(res));
    }catch (e){
        console.log('Error: ' + e);
    }
    return res;
  };

});
"""

process = frida.get_usb_device().attach('com.tencent.mm')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] Running Hook')
script.load()
sys.stdin.read()

得到的 json 如下:

{
    "commentCount": 62,
    "description": "人心换人心,谁都有底线!更多情感内容点击关注@疗伤情感 #晏子情感",
    "expectId": -4877419130498574272,
    "feedId": -4877419130498574272,
    "hasBgmInfo": false,
    "id": -4877419130498574272,
    "likeCount": 2951,
    "liveId": 0,
    "liveStatus": 0,
    "localId": 0,
    "longVideo": false,
    "mediaList": [
        {
            "NMD": false,
            "NMq": 0,
            "NMt": false,
            "NMv": 28000,
            "NMw": "http://wxapp.tc.qq.com/251/20350/stodownload?encfilekey=RBfjicXSHKCOONJnTbRmmlD8cOQPXE48ibNoPibrzxbICjN0mdDNRj71nM2TJfVYGhIxX0WCMf74biaftFqLW2zGtdmXTJ8wibeesB7n8Ntyu5uOic8136mUibM44fnge8amjDu6BLmlSE59icicdjIrI7KtpP8FxXibG6LwzicQMlmaVaAicD0&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SH&idx=1&m=5190cada35800678ed0b1f917a2a32b5",
            "NMx": "&token=x5Y29zUxcibDjE9JYkmdS0shbZ7djRmsC99U0UibtNT1u9hlAxSyM01icQZXHkptzicv",
            "bitrate": 0,
            "coverUrl": "http://wxapp.tc.qq.com/251/20304/stodownload?filekey=30350201010421301f020200fb040253480410d8869ba74f63ba490ac4069f994280f202030180d8040d00000004627466730000000131&storeid=323032313034303531303235343030303064316634353761356264386134653730353566363430303030303066623030303034663530&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SH&m=d8869ba74f63ba490ac4069f994280f2",
            "decodeKey": "2065249527",
            "fileSize": 18284035,
            "full_bitrate": 0,
            "full_file_size": 0,
            "full_height": 0,
            "full_width": 0,
            "height": 1264,
            "hot_flag": 0,
            "includeUnKnownField": false,
            "md5sum": "f9f9eceb-6982-4e4d-bfa6-8db01fa9b522",
            "mediaId": "a6b5fac7d510c532a4376934a31015a4",
            "mediaType": 4,
            "spec": [
                {
                    "MZJ": 3141665,
                    "Nbp": 637,
                    "efu": "xV0",
                    "includeUnKnownField": true,
                    "vKg": "h264"
                },
                {
                    "MZJ": 1646534,
                    "Nbp": 345,
                    "efu": "xV2",
                    "includeUnKnownField": true,
                    "vKg": "h265"
                },
                {
                    "MZJ": 1037647,
                    "Nbp": 226,
                    "efu": "xV4",
                    "includeUnKnownField": true,
                    "vKg": "h265"
                },
                {
                    "MZJ": 472808,
                    "Nbp": 109,
                    "efu": "xV8",
                    "includeUnKnownField": true,
                    "vKg": "h265"
                },
                {
                    "MZJ": 418280,
                    "Nbp": 97,
                    "efu": "xV9",
                    "includeUnKnownField": true,
                    "vKg": "h264"
                },
                {
                    "MZJ": 295747,
                    "Nbp": 66,
                    "efu": "xV10",
                    "includeUnKnownField": true,
                    "vKg": "h265"
                }
            ],
            "thumbUrl": "http://wxapp.tc.qq.com/251/20304/stodownload?encfilekey=RBfjicXSHKCOONJnTbRmmlD8cOQPXE48ib0TrgC9GMRrlchGCNdXCyD2Pu6YbIWudBh6BngIDXS3M8Y18doicwuaXmAiblJJG5s2ib1XR3KMredUlbax6ZQvhQ77Ntoekw0O4VfpbFuHrhjIyEDd78AKe5GGybePkVA1jP75HyKHEyNc&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SH&idx=1&m=d8869ba74f63ba490ac4069f994280f2",
            "thumb_url_token": "&token=x5Y29zUxcibCadRELU5qibEtIicbNZqQqzxGicvUUexAbqribwZDAdDicOa5koiawnrKUtV",
            "url": "http://wxapp.tc.qq.com/251/20302/stodownload?encfilekey=G83YYE2iciaib491UK8yGibLXOdhNpDoPpG748uNIa5DNuyuonSofEYDt1yf8eDoibNty4U8UXvSG2micv4HaEUcErdibfTOiaKKalN8FrUibibrfVqnPOh8sZFWl5oDALZajdFJsTg7Sqd4bPIyWib5CehDW4NbxzzdLUpoDvYBVjDkfp9C7Q&adaptivelytrans=0&bizid=1023&dotrans=906&hy=SH&idx=1&m=6b3f33e50bfc1c5c5f6f6c14e06b7d03&scene=0",
            "url_token": "&token=AxricY7RBHdWhPYjkduXw4angAXxhu8UMIxGebhCliaYtTT7dCtwIxRibXyGodLxZxcPJYzq9CN5dU",
            "videoDuration": 28,
            "width": 1080
        }
    ],
    "mediaType": 4,
    "nickName": "疗伤情感",
    "onlineNum": 0,
    "rvFeedList": [],
    "sessionBuffer": "eyJzZXNzaW9uX2lkIjoic2lkXzIzNjY4ODU5NDVfMTYxNzc4MDIwOTk3NzYzNl8xNDk3MjAwODg4IiwicmVjb21tZW5kX3R5cGUiOjMsInJlY29tbWVuZF9zeXN0ZW0iOjIsInJlY29tbWVuZF93b3JkaW5nIjoiIiwiY3VyX2xpa2VfY291bnQiOjI5NTEsImN1cl9jb21tZW50X2NvdW50Ijo2MiwicmVjYWxsX3R5cGVzIjpbMTAxMTM1XSwiZGVsaXZlcnlfc2NlbmUiOjEzLCJkZWxpdmVyeV90aW1lIjoxNjE3NzgwMjEwLCJzZXRfY29uZGl0aW9uX2ZsYWciOjIsInRvdGFsX2ZyaWVuZF9saWtlX2NvdW50IjowLCJuZXdfZnJpZW5kX2xpa2VfY291bnQiOjAsInJlY2FsbF9pbmRleCI6WzBdLCJ0YWdfaWQiOiIwOyIsInJlcXVlc3RfaWQiOjE2MTc3ODAyMDg4MTc5MTMsIm1lZGlhX3R5cGUiOjQsInZpZF9sZW4iOjI4LCJjcmVhdGVfdGltZSI6MTYxNzU4OTU4NiwidGFiX3R5cGUiOjQsInJlY2FsbF9pbmZvIjpbeyJyZWNhbGxfdHlwZSI6MTAxMTM1LCJyZWNhbGxfc2NvcmUiOjAuNzI2MjMyOTQ1OTE5LCJyZWNhbGxfaW5kZXgiOjAsInJlcG9ydF9pbmZvIjoiNDA2XzEwMzVfMF8wXzEifV0sInJhbmtfc2NvcmUiOjEwNC40NzExNDU2Mywic2VjcmV0ZV9kYXRhIjoiQmdBQUFmMmliZTJFMXIrRFRHc2gwbzB6RGJVbnpvTkUwZDZncjliOVdKdyttakM2NkhnM3VXY0phOTg9IiwidGFiX3Nlc3Npb25faWQiOjE2MTc3ODAyMDg5MjE3ODQsImZyaWVuZF9saWtlZF9saXN0IjoiIiwiZGV2aWNlX3R5cGVfaWQiOjIsImRldmljZV9wbGF0Zm9ybSI6IlJlZG1pIDYiLCJkb3dubG9hZF9zcGVlZF9rYnBzIjoxMzE1OTUsIm5ldF90eXBlIjoxLCJ2aWRlb19pZCI6MCwiaXNfY2hpbGQiOnRydWUsInBhcmVudF9tZWRpYV90eXBlIjowLCJwYXJlbnRfaWQiOjAsImZlZWRfcG9zIjoyLCJwdWxsX3R5cGUiOjEsInBhZ2VfbnVtIjowLCJjbGllbnRfcmVwb3J0X2J1ZmYiOiJ7XCJzZXNzaW9uSWRcIjpcIjE0M18xNjE3NzEwNzYyNTY4IyQyXzE2MTc3MTA3NjEyNjgjXCJ9IiwiaXNfbGl2ZV9mZWVkIjowLCJpc19saXZlX2ZpbmRlcnVzZXIiOjAsImV4dF9mbGFnIjowLCJjb21tZW50X3NjZW5lIjoyMCwib2JqZWN0X2lkIjoxMzU2OTMyNDk0MzIxMDk3NzM0NCwiZmluZGVyX3VpbiI6MTMxMDQ4MDgwNjQ2MDA0ODYsInBvaW5hbWUiOiIiLCJjaXR5IjoiIiwiZ2VvaGFzaCI6MzM3NzY5OTcyMDUyNzg3Mn0=",
    "timestamps": 1617780210295,
    "urlValidDuration": 172800,
    "userName": "v2_060000231003b20faec8cae38f1dc5d5ce00e432b077b11d4f3ce9c011535e0fff9bba95dcc6@finder"
}

这里要小心,过长的 long 在转 json 的时候可能会丢失精度,如果发现这种情况要特殊处理下,把 long 转成 string 。

数据处理

视频问题

检查了下数据,发现通过 url+url_token 拼接的视频流虽然能下载,但是下载下来是加密后的,无法直接播放。

后来发现 url+thumb_url_token 是可以播放的,高兴了一段时间。但是4月26号左右微信好像做了什么操作,导致这个渠道不能播放了。经过简单分析后发现视频解码的流程是放在native方法中,一时半会难以破解,那就只能想办法下载缓存了。

(当然,这些加密算法在安全组的同学面前都不算问题,三下五除二就发现原来是某个比较小众的流式加密算法🤭)

文件追踪

利用 frida-trace 可以追踪app的系统调用,因此我们可以尝试看下 app 写文件的 open 方法。

$ frida-trace -U -i open com.tencent.mm

滑动下页面,我们发现了下面的日志:

看起来非常像是视频的缓存文件。打开一看,果然是。。。

那么,照着这个格式搜索下代码,稍微拼接下就能搞定这个缓存路径。

其他

调试过程中还发现一个查看当前调用堆栈的方法,可以辅助分析:

console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new()))

总结

头一次使用 frida 还是很有新鲜感的,用 Python 向 Android 中插入调用 Java api 的 Javascript 代码。。。

参考资料

基于Frida的全平台逆向分析

APP逆向神器之Frida

基于TLS1.3的微信安全通信协议mmtls介绍

Python依赖管理的一些思考

2021年5月31日 07:12
作者 mythsman

前言

之前一直比较抵触用 Python ,很大一部分原因是觉得 Python 项目的环境管理比较混乱。Node.js 有 Npm 包管理工具,通过 package.json 配置项目依赖,最多再通过 nvm 来进行环境切换;Java 有 Maven Gradle 来进行包管理和项目依赖配置,并体现在 pom.xml 和 build.gradle 等中。而 Python 相比编程语言有时更体现了脚本语言的特性,系统化和标准化程度都不太高。很多 Python 项目上来就是怼代码,没有声明依赖、配置环境的文件。这样的好处是简单项目堆砌起来非常快,但是一旦代码量上了规模,依赖管理、环境配置、项目启动等就到处都是坑。

可是稍微了解了一下后发现其实 Python 不止能当脚本语言来用。基于一定的工具链,Python 也能写出漂亮标准的项目代码、将环境和依赖理的明明白白。

基于PIP

最基础的依赖管理应当能解决如下问题:

  1. 能快速配置好项目依赖,搭建好开发环境。
  2. 明确知道当前项目依赖了哪些第三方的包,以及他们的依赖树。
  3. 能快速添加和移除给定的依赖,进行依赖调解。

这些功能使用 Pip 工具链其实是能很方便做到的。

快速配置环境(pip)

想简单预览当前环境下的依赖包可以直接用 pip list 命令:

$ pip list
Package    Version
---------- -------------------
certifi    2020.6.20
pip        19.3.1
setuptools 44.0.0.post20200106
wheel      0.36.2

对于一个空的 Python 环境,基础一般只会有这四个包。我们这样就知道了当前环境中有哪些包,以及他们的版本。

为了方便说明,我们先多引一些依赖 pip install flask

$ pip list
Package      Version
------------ -------------------
certifi      2020.6.20
click        7.1.2
Flask        1.1.2
itsdangerous 1.1.0
Jinja2       2.11.3
MarkupSafe   1.1.1
pip          19.3.1
setuptools   44.0.0.post20200106
Werkzeug     1.0.1
wheel        0.36.2

安装了 Flask 之后,我们发现除了 Flask 他还多引入了好多个间接依赖。

如果想要将这个信息记录下来,我们可以用 pip freeze 命令,记在 requirements.txt 中(一个约定俗成的名字)。

$ pip freeze > requirements.txt
$ cat requirements.txt
certifi==2020.6.20
click==7.1.2
Flask==1.1.2
itsdangerous==1.1.0
Jinja2==2.11.3
MarkupSafe==1.1.1
Werkzeug==1.0.1

好了,记下这个文件,以后我们如果需要在一个新的 Python 环境中引入当前的依赖,只需要使用 pip install -r requirements.txt  即可。

明确项目依赖(pipdeptree)

pip listpip freeze 打印出来的依赖有一个问题,就是并没有明确依赖关系。这样的坏处是,当我们想清理依赖的时候,就不知道到底哪些依赖是能被直接删除的、哪些依赖又是被间接依赖而不能轻易删除的。

例如我们可能在项目中用了 Flask ,但是我们可能不知道 Flask 也引用了 Jinja2 。这是我们如果擅自删除了 Jinja2 ,项目就可能跑不起来。。。

这时就可以使用 pipdeptree 工具来管理依赖树:

$ pip install pipdeptree
...
$ pipdeptree
certifi==2020.6.20
Flask==1.1.2
  - click [required: >=5.1, installed: 7.1.2]
  - itsdangerous [required: >=0.24, installed: 1.1.0]
  - Jinja2 [required: >=2.10.1, installed: 2.11.3]
    - MarkupSafe [required: >=0.23, installed: 1.1.1]
  - Werkzeug [required: >=0.15, installed: 1.0.1]
pipdeptree==2.0.0
  - pip [required: >=6.0.0, installed: 19.3.1]
setuptools==44.0.0.post20200106
wheel==0.36.2

现在我们就知道了,原来 Jinja2 是被 Flask 依赖的,这样我们就不会随便删除了。。。

项目依赖治理(pip-autoremove)

那么问题来了,如果我忽然不想依赖 Flask 了,我们需要怎么做呢?

无脑的做法是 pip uninstall flask -y 。不那么显然的是,这其实不够优雅:

$ pip uninstall flask -y
...
$ pipdeptree
certifi==2020.6.20
click==7.1.2
itsdangerous==1.1.0
Jinja2==2.11.3
  - MarkupSafe [required: >=0.23, installed: 1.1.1]
pipdeptree==2.0.0
  - pip [required: >=6.0.0, installed: 19.3.1]
setuptools==44.0.0.post20200106
Werkzeug==1.0.1
wheel==0.36.2

发现没,Flask 虽然被卸载了,但是他的依赖包并没有卸载干净。你可能需要重新一个一个判断你是否需要剩下的包,然后再递归删除。。。

幸运的是,我们就可以用 pip-autoremove 工具来做这件事。我们重新安装Flask,再用这个工具删除试试:

$ pip install flask
$ pip install pip-autoremove
$ pip-autoremove flask -y
$ pipdeptree
certifi==2020.6.20
pip-autoremove==0.9.1
pipdeptree==2.0.0
  - pip [required: >=6.0.0, installed: 19.3.1]
setuptools==44.0.0.post20200106
wheel==0.36.2

这下干净了😊。

基于Conda

pip 能基本解决单一项目的环境处理问题。但是由于 Python 是全局环境,如果有多个项目,我们就无法区分项目维度的依赖。解决这个问题一般有两个思路,一个是像 Node.js 一样用 package.json 配置文件支持项目维度的环境隔离,另一个就是走 rvm、nvm的思路用虚拟环境隔离。目前看 Python 只能支持后者,也就是用基于 Conda 的虚拟环境。

值得一提的是,conda 虽然为Python 而生,但他其实是一个通用的虚拟环境工具。他的官网写的很清楚:

Package, dependency and environment management for any language---Python, R, Ruby, Lua, Scala, Java, JavaScript, C/ C++, FORTRAN
Conda is an open-source package management system and environment management system that runs on Windows, macOS, and Linux. Conda quickly installs, runs, and updates packages and their dependencies. Conda easily creates, saves, loads, and switches between environments on your local computer. It was created for Python programs but it can package and distribute software for any language.

很强大,有多强大,可以将不同语言的依赖环境整合在一起的强大。

安装

Conda 官网给了两个发行版本,一个是 Anaconda ,一个是 Miniconda。Anaconda 相比 Miniconda 主要是多预装了很多科学计算的库,而我更喜欢按需使用不喜欢全家桶,所以我选 Miniconda。

官网下载miniconda3,并执行安装脚本。

安装后会发现 .bashrc 下多了几行:

# >>> conda initialize >>>
# !! Contents within this block are managed by 'conda init' !!
__conda_setup="$('/home/zhenping/miniconda3/bin/conda' 'shell.bash' 'hook' 2> /dev/null)"
if [ $? -eq 0 ]; then
    eval "$__conda_setup"
else
    if [ -f "/home/zhenping/miniconda3/etc/profile.d/conda.sh" ]; then
        . "/home/zhenping/miniconda3/etc/profile.d/conda.sh"
    else
        export PATH="/home/zhenping/miniconda3/bin:$PATH"
    fi
fi
unset __conda_setup
# <<< conda initialize <<<

重新登录,或着手动执行 source ~/.bashrc ,以加载conda命令。

现在就会发现提示符前多了默认环境 (base),表示当前启用了默认环境 base 。

如果不想在会话启动时就开启conda环境,就执行 conda config --set auto_activate_base false

环境操作

创建一个纯净的 Python2.7 环境,名字姑且叫 frida ,并激活该环境。

$ conda create -n frida python=2.7 -y
...
$ conda activate frida

需要注意的是,创建环境之后,一定要 activate 该环境,否则后续的 install 操作还是在 base 环境。。。

查看已有环境列表:

(frida) $ conda env list
# conda environments:
#
base                     /home/myths/miniconda3
frida                 *  /home/myths/miniconda3/envs/frida

查看当前环境下的依赖:

(frida) $ conda list
# packages in environment at /home/myths/miniconda3/envs/frida:
#
# Name                    Version                   Build  Channel
_libgcc_mutex             0.1                        main
ca-certificates           2021.4.13            h06a4308_1
certifi                   2020.6.20          pyhd3eb1b0_3
libffi                    3.3                  he6710b0_2
libgcc-ng                 9.1.0                hdf63c60_0
libstdcxx-ng              9.1.0                hdf63c60_0
ncurses                   6.2                  he6710b0_1
pip                       19.3.1                   py27_0
python                    2.7.18               h15b4118_1
readline                  8.1                  h27cfd23_0
setuptools                44.0.0                   py27_0
sqlite                    3.35.4               hdfb4753_0
tk                        8.6.10               hbc83047_0
wheel                     0.36.2             pyhd3eb1b0_0
zlib                      1.2.11               h7b6447c_3

我们发现,与 pip list 只展示 Python 包不同,conda list 还展示了对其他语言项目代码的依赖。

退出环境:

(frida) $ conda deactivate

这里需要注意,conda 的环境是可以默认嵌套两层的,因此 deactivate 的时候要看清楚了,可能要 deactivate 两次才能真正退出 Conda 。

依赖管理

Conda 也有和 pip freeze 类似的依赖管理方式:

为当前环境创建配置文件:

(frida) $ conda env export > environment.yaml
(frida) $ cat environment.yaml
name: frida
channels:
  - defaults
dependencies:
  - _libgcc_mutex=0.1=main
  - ca-certificates=2021.4.13=h06a4308_1
  - certifi=2020.6.20=pyhd3eb1b0_3
  - libffi=3.3=he6710b0_2
  - libgcc-ng=9.1.0=hdf63c60_0
  - libstdcxx-ng=9.1.0=hdf63c60_0
  - ncurses=6.2=he6710b0_1
  - pip=19.3.1=py27_0
  - python=2.7.18=h15b4118_1
  - readline=8.1=h27cfd23_0
  - setuptools=44.0.0=py27_0
  - sqlite=3.35.4=hdfb4753_0
  - tk=8.6.10=hbc83047_0
  - wheel=0.36.2=pyhd3eb1b0_0
  - zlib=1.2.11=h7b6447c_3
prefix: /home/myths/miniconda3/envs/frida

根据配置文件复现当前环境:

$ conda env create -f environment.yaml

IDE集成

使用 conda 还有个很大的好处就是和 IDE 可以非常方便的集成。

一些思考

用Conda做其他语言的虚拟环境方便么?

现在看起来非常方便,几乎所有需要区分全局环境的地方都可以用。比如 Java 环境:

$ conda create -n java8
$ conda activate java8
$ conda install openjdk=8.0.152 -y
$ conda list
# packages in environment at /home/myths/miniconda3/envs/java8:
#
# Name                    Version                   Build  Channel
openjdk                   8.0.152              h7b6447c_3

同时,我们也可以在这个环境中集成 Node 环境,Python 环境,Ruby环境,甚至集成一些 curl、wget 等常用命令,非常方便。这对于一些跨语言、跨环境项目的环境搭建可是太有帮助了。。。

如何找conda支持的包呢?

可以直接用 conda search xxx 来搜索。不过这样可能不太全,我们也可以在 https://anaconda.org/search?q=openjdk 这里根据关键字搜索,当然也可以向这里贡献。

安装 Python 包是用 conda 好还是用 pip 好?

如果明确是纯粹的 python 包,还是建议用 pip install 安装,方便用 pip 统一管理。对于跨语言的、或者是本身就整合了各种依赖的环境(比如 tenserflow),再考虑用 conda install。

参考

anaconda-vs-miniconda

conda官方文档

Pip-deptree

Pip-autoremove

Magisk模块常用功能编写

2021年5月24日 12:13
作者 mythsman

Magisk简述

概述

Magisk 是目前最流行的安卓手机 root 解决方案。

虽然像小米等手机厂商也提供了所谓支持 root 的开发版 Rom,但在较新的版本中,他们无法直接写入像 /system/ 之类的被保护的路径。这就导致了很多事情仍然做不了。最经典的就是连系统证书都修改不了。。。

而 Magisk 能避免写入被保护的路径,将自己的文件系统 “Mask” 在原生的文件系统上。这样既不需要直接修改的原始的数据,也能骗过程序使用 Magisk 提供的文件系统。

令人感叹的是 Magisk 的作者 topJohnWu 在发布项目的时候只是一名大二的学生,目前就职于 Apple 做机器翻译。

模块仓库

Magisk之所以火,很大程度上还是由于大家可以贡献自己的创造力分享自己写的Magisk模块。但是在使用别人的模块时还是要小心再小心。

官方的仓库其实只有 https://github.com/Magisk-Modules-Repo 这个由 JohnWu 自己维护的。这里的模块基本都可以放心使用。当然市面上也有各种其他的 Magisk 仓库,但是用这些的时候还是要先认真读一下代码吧。。。

Magisk安装

刷 TWRP

TWRP相关的信息可以在官网和一个非官方社区中搜索对应机型,并按照说明进行刷机。

当然,很大概率,这两个国外网站的信息可能不太适合社会主义国情,如果刷机失败了也不要灰心。国内的 @wzsx150 所在的 L.R.Team 在官方的基础上做了改进和懒人包教程并支持了Android10,也提供了国内大多数机型的刷机工具和教程,你值得拥有。

目前他们提供的相关内容在这里:

最后需要注意的是,如果在刷入TWRP完成后手机重启前,没有通过 TWRP 安装一些如第三方rom(如Magisk或SuperSU),那么手机重启后,TWRP就会被原生recovery重新覆盖。。。

刷入 Magisk

在刷入 TWRP 进入新的 recovery后,就可以刷入 Magisk了。安装 Magisk 的步骤可以参考官方文档

关于Root权限

通过 Magisk 可以获得有 root 权限的 shell,但是这个 root 权限依然是受限的。

获取 Root 权限

和原生的开发版不同,刷入 Magisk 的设备无法使用 adb root 这样的命令:

$ adb rootadbd cannot run as root in production builds

但是可以在进入 shell 后登入root 用户:

$ adb shell
cereus:/ $ su
cereus:/ #

当然,这需要在 Magisk 弹出的窗口中点击授权:

如果不想用交互式指令,则可以用 -c 参数:

$ adb shell su -c 'ls /data/data/'
android
android.ext.services
android.ext.shared
...

或者用管道实现:

$ echo 'ls /data/data/'|adb shell su
android
android.aosp.overlay
android.ext.services
android.ext.shared
...

不足之处

看起来我们可以在shell中直接获得root权限,但是当你修改了 /system 等目录下的文件后,你的所有变更在下次重启之后都会丢失。

因此这个 root 权限其实更像是一个 readonly 的root,并不是一个通用的 root。这个当然也并不是一无是处,在查看一些系统文件的时候还是很有帮助的。

持久化的 Root

要想获得真正的、能持久化的写文件的 Root 权限,就必须要通过自己编写 Magisk 模块,在启动的时候准备好需要覆盖的文件,像 Mask 一样加载进系统。

Magisk模块编写

基础知识

编写标准的 Magisk 模块建议直接参考 官方文档,这里不再赘述。需要注意的是 Magisk 模块的结构有过一次调整,因此存在新老两种模块的文件结构。当然新版的Magisk对这两种写法都做了兼容,但还是建议用新的写法。

如果需要学习自定义模块的写法,建议参考下面两个模块:

  • v2ray 模块,用的是新模块写法,用于启动一个系统维度的 v2ray 服务(用于科学上网)。
  • movecert 模块,用的是老模块的写法,用于将用户证书移动到系统证书的路径下,使系统默认信任用户证书。

另外还有几个注意点(新版写法):

  1. customize.sh 中主要用于编写安装时执行的脚本,这里的脚本能够执行adb shell 中的指令。需要注意的是这个脚本只在安装过程中执行,重启后不会再次执行。
  2. 启动后台服务的地方放在 post-fs-data.sh 或者  service.sh 中;区别在于系统会阻塞等待 post-fs-data.sh 执行完,而 service.sh 则会与系统并发执行(常用于启动一些服务)。比较坑的是,这两个脚本使用的上下文是 magisk 自带的 busybox,因此在执行 adb shell 中的一些指令时,一定要使用绝对路径
  3. post-fs-data.sh 和  service.sh 在每次系统重启后都会执行。
  4. 修改 ro 开头的配置放在system.prop 中,其他配置可以直接用setprop 命令。
  5. 注意给文件正确的权限。

红米6的实践

开启adb安全模式

小米开启adb安全模式默认需要登录小米账号,我们可以在 customize.sh 中修改配置绕过。

remote_provider_preferences=/data/data/com.miui.securitycenter/shared_prefs/remote_provider_preferences.xml
 
grep -q 'security_adb_install_enable' $remote_provider_preferences
 
if [ $? -ne 0 ] ;then
  sed -i 's/<\/map>/<boolean name="security_adb_install_enable" value="true" \/>\n<\/map>/g' $remote_provider_preferences
  ui_print "- Insert security_adb_install_enable to true"
else
  sed -i '/security_adb_install_enable/ s|\([vV]alue="\)[^"]*\("\)|\1true\2|g' $remote_provider_preferences
  ui_print "- Change security_adb_install_enable to true"
fi
 

取消USB安装的确认框

小米在开启MIUI优化后,通过USB安装app会有个确认框,我们可以在 customize.sh 中修改配置绕过。

remote_provider_preferences=/data/data/com.miui.securitycenter/shared_prefs/remote_provider_preferences.xml
 
grep -q 'permcenter_install_intercept_enabled' $remote_provider_preferences
if [ $? -ne 0 ] ;then
  sed -i 's/<\/map>/<boolean name="permcenter_install_intercept_enabled" value="false" \/>\n<\/map>/g' $remote_provider_preferences
  ui_print "- Insert permcenter_install_intercept_enabled to false"
else
  sed -i '/permcenter_install_intercept_enabled/ s|\([vV]alue="\)[^"]*\("\)|\1false\2|g' $remote_provider_preferences
  ui_print "- Change permcenter_install_intercept_enabled to false"
fi

一些页面优化

# 取消相关动画
settings put global window_animation_scale 0.0
ui_print "- Change window_animation_scale to 0"
 
settings put global transition_animation_scale 0.0
ui_print "- Change transition_animation_scale to 0"
 
settings put global animator_duration_scale 0.0
ui_print "- Change animator_duration_scale to 0"
 
# 保持开机状态
settings put global stay_on_while_plugged_in 7
ui_print "- Change stay_on_while_plugged_in to 7"
 
# 屏幕方向锁定
settings put system accelerometer_rotation 0
ui_print "- Change accelerometer_rotation to 0"
 
settings put system user_rotation 0
ui_print "- Change user_rotation to 0"
 
# ADB 配置
setprop persist.security.adbinput 1
ui_print "- Change persist.security.adbinput to 1"
 
setprop persist.security.adbinstall 1
ui_print "- Change persist.security.adbinstall to 1"
 

如果有一些其他配置需要统一修改,建议将手机的语言调成英文,然后在安卓源码或者 getprop settings 命令中搜索相关的关键字。

关闭错误弹窗

由于未知原因,红米6 在刷入 Magisk 后必然会出现一个 "您的设备内部出现了问题。请联系您的设备制造商了解详情。" 的错误弹窗:

因此我们可以在 service.sh 中写一个自动关闭脚本:

dump_path=/data/local/tmp/dump.xml
for i in $(seq 1 10)
do
  rm -f $dump_path
  uiautomator dump $dump_path
  grep -q '设备内部' $dump_path
  if [ $? -eq 0 ];then
    sleep 1
    input tap 540 1270
    rm -f $dump_path
    sleep 1
    uiautomator dump $dump_path
    grep -q '设备内部' $dump_path
    if [ $? -ne 0 ];then
      echo "process done in $i times" >> $dump_path
      break
    fi
  fi
  sleep 10
done

自动设置PTP

又由于一些未知原因,手机在重启后总是默认充电模式,导致无法自动连接 adb 。经实践发现把连接模式强制设置为 PTP 模式可以解决,因此我们也可以在 service.sh 中加入如下自动设置脚本:

adb_log_path=/data/local/tmp/adb.log
for i in $(seq 1 10)
do
  rm -f $adb_log_path
  /system/bin/svc usb setFunction ptp true && /system/bin/svc usb getFunction &> $adb_log_path
  sleep 5
done

考虑到启动加载顺序未知等原因,为了保险起见这里需要多循环执行几次,否则还是会有概率修改失败。。。

刷砖急救

常在河边走哪能不湿鞋。理论上只要一直搞机下去,迟早有一天会把手机刷成砖。刷成砖不可怕,可怕的是刷成的砖连 recovery、fastboot 都进不去、连 adb 都连不上。如果是这样,那基本就是真砖了。

目前我遇到了两种刷假砖后急救的case。

刷TWRP后无限重启

对于 红米9 等机型,直接刷入 recovery 后可能会一直无限重启。这时候只需要下载 vbmet.img ( tar -xvf vbmeta.tar ) 并在 fastboot 模式下把这个也刷进去即可:

$ fastboot --disable-verity --disable-verification flash vbmeta vbmeta.img

装了第三方Magisk模块后无限重启

乱装 Magisk 模块改配置是有概率导致手机无法正常开机的。但是只要还能进 recovery ,那八成还是有救的。Magisk 的模块默认是安装在 /data/adb/modules/ 下的,只要在 recovery 下找到有问题的模块文件夹并删除重启,多半都是能救回来的。具体可以参考 How to Uninstall Magisk Modules Using TWRP Recovery

参考资料

Developer Guides | Magisk

每个 Android 玩家都不可错过的神器

How to Uninstall Magisk Modules Using TWRP Recovery

Install User Certificate Via ADB

Enable "Install via USB" without signing in to Xiaomi account

脑水肿治疗笔记

2021年4月23日 06:23
作者 mythsman
脑水肿治疗笔记

陪护笔记:

  1. 药水要一直滴,滴完就喊护士。晚点也没关系,空气进不去的。
  2. 打针的手不能别着,要不然会滴不进去。
  3. 要经常翻身,防止皮肤受损形成褥疮。由于是左额叶受伤,建议左卧或平卧。
  4. 要哄她一直吸氧。
  5. 尿袋分两块,小筒用于记录尿量,记录完了倒到袋子统一扔掉。小筒满了喊护士,如果护士不在要记录一下尿量再倒到袋子。
  6. 要一直吃乳果糖(利动),直到排便。
  7. 好久不拉粑粑了,要用开塞露(轻泻剂)。

用药笔记:

主要以降低颅内压为主,防止脑疝。

  1. 人血白蛋白。用于降低颅压,缓解脑水肿。
  2. 甘露醇。治疗颅压增高的首选药物(Mannitol),用作利尿剂和脱水剂。原理是能直接透过肾小球过滤造成血管高压以吸收体液、增加排尿。因此能快速降低颅压,但是也容易反弹,一般只能维持3~4个小时。
  3. 兰索拉唑。抑制胃酸分泌,可能是为了养胃。
  4. 吡拉西坦。促进脑内ATP,促进脑内代谢,修复脑损伤。对缺氧所致的逆行性健忘有改进作用。可以增强记忆,提高学习能力。
  5. 单唾液酸四己糖神经节苷酸钠。自猪脑中提取制得的对神经细胞功能损伤具有作用的物质。促进由于各种原因引起的中枢神经系统损伤的功能恢复。也常用于治疗帕金森症。
  6. 曲克芦丁。抑制血小板聚集,用于抗凝血,防血栓。
  7. 注射用脂溶性维生素(Ⅱ)。用于补充一些脂溶性维生素如维生素A、维生素D2、维生素E、维生素K1等,保证肠外营养。
  8. 氯化钾。保持体内电解质平衡,抵消利尿剂的不利影响。
  9. 七叶皂苷钠。用于增加静脉回流,缓解软组织肿胀。
  10. 头孢西丁钠。抗菌药,防感染。
  11. 酮咯酸氨丁三醇。止疼药,缓解症状。
  12. 甘油果糖。另一种脱水剂。脱水效果不如甘露醇效果快而且强,但是不容易造成水盐混乱,和白蛋白一样是一种持续时间更长的温和脱水剂。
  13. 注射用帕瑞昔布钠。止疼药,缓解头疼。
  14. 胞磷胆碱钠。通过降低脑血管阻力,增加脑血流来促进脑物质代谢,改善脑循环。

学习视频:

贺银成,外科学(5-8章)

小萌加油啊(05)

2021年4月22日 14:50
作者 mythsman

今天是4月22日,事故已经过去了6天。昨天从ICU里搬到了普通病房,单人间,环境挺不错的。不用跟隔壁病房一样闻臭脚和忍受病友外放刷抖音。除了楼下的打桩机一直还是嘟嘟嘟的不停,想必明天应该能消停了。

昨天小萌还不肯吃东西,不肯吸氧,一直闹着要起床,一直想挖耳屎;今天也不怎么耍脾气了,略微通了点人性。总感觉是有点倚病三分醉,有人过来看她的时候表现的一副认真休养讲道理懂人情的感觉,人一走就又原形毕露。

过来看她的师弟师妹们现在基本都能叫得上名字了。家里的远房亲戚过来叶基本都认识了。一时能想的起来的朋友名字也都记得。昨天问他记不记得“高可X“,她说就是那个“小小的“,今天也已经能记得是大学同学了。

感觉她还是有很努力在回忆,不想让别人失望。每次有人来拜访之后都会一直喊累累。

拿来她的手机,问她这个是谁:

她说是她老婆。

忽然想起来四天没碰过游戏了,正好赶上了yys的up,用她的号怒抽了一波。不好意思,一百抽只抽了一个缘结神。

今天的日子好过多了,盯一盯药水,玩一玩游戏,哄一哄妹子,接待接待亲戚。晚上出去散散步,吹吹牛。过两天应该就可以回去工作了(我攒了快三年的年假就这么都没了),可还真有点舍不得医院。

现在最大的挑战也就剩下哄她尿尿拉粑粑了。现在的我深刻的理解到了:

尿管和开塞露真是个好东西。

小萌加油啊(04)

2021年4月20日 10:54
作者 mythsman

4月20日,第四天,今天没有做CT。

医生说CT做的多了对身体也有影响,况且从症状上看小萌的表现也挺好,反正水肿没那么快消,就暂时先停一天。

下午ICU里的医生出来说要我们自己买一些白蛋白。好奇查了下,白蛋白是比甘露醇更柔和的脱水剂,经常配合甘露醇和甘油果糖一起使用。但是白蛋白比较贵,医保有规定的用量,多开了会给医院罚款。

昨晚和今天早上小萌的爸爸趁送吃的的机会偷偷溜进去看了一会:

现在是20日凌晨四点,事发己过去82个小时,又是一个茫茫一天的开始,昨天约十点,护士将女儿不吃的水果送出。沟通得知还是不肯吃,在我就恳求护士,让我进去喂一个,女儿是很听我话的。护士请示医生后让我悄悄进去,买点粥。悄悄进去后女儿正在闭目仰卧,好象熟睡了。看着女儿手婉上圆形密密的取血孔,我心如刀绞。我喊了声朦朦,女儿闭眼翻了个身,背对我。我又喊了声,女儿说:我不认识你嗳,我说你都没看我,怎么知道不认识我。女儿随后翻身平躺,眼睛看向我,噎!爸爸!我答应,女儿咬着上嘴唇,有熟悉的欲哭的表情,我说朦朦不哭。我说饿吗?女儿说不饿,不想吃东西,我说你不吃我会很伤心的。女儿说好吧。我兴奋不以,立马要喂,女儿要求将床摇高点,吃了2半勺,女儿说不想吃了,我就边跟她说以前的事,边哄她吃,问她以前的事,人,她都说不记得了,我说你会记起来的。女儿吃了七八勺就不肯吃了,说我冷,医生立马过来加了被子。爸爸,我累了,我想睡了,你出去吧,beybey!我退到一边,爸爸马上走,女儿又翻了个身,看着我说,爸爸,我累了,我想睡了,你出去吧,beybey!闭上眼。我说你要听医生的话,我马上走。好的,爸爸,我累了,我想睡了,你出去吧,beybey!我退了出去,医生同意让我六点半左右,七点前悄悄进去再喂点女儿喜欢吃的,说实在的,除了红烧肉外,我还真不知女儿喜欢吃什么。此次过程十几分钟,被女儿赶了出来。女儿!你会好起来的!加油!

看起来比昨天也好了不少,早上叔叔的心情好了很多。

今天轮到我探视。

探视需要核酸证明,早上趁早做了个核酸,说是第二天九点才能出结果,但是下午两点我已经能取到报告了。虽然觉得这有点形式主义,但效率高倒也还好。

ICU里的护士都面无表情,看起来压力不小。护工阿姨倒是热情挺多,跟小萌妈妈一样一直在叨叨叨。

小萌在睡觉,护士姐姐把她喊醒。

“萌萌呐“

——“爸爸“

一看就是没睡醒,戳醒她。

——“丁丁呀“

“奥,你还认得我啊“

——“我怎么会不认得丁丁啊“

萌萌说热热,我帮她呼呼。

萌萌说靠背高了,我帮她调低一点。

萌萌说抱枕碍事,我帮他拿到一边。

萌萌说想我抓她小手。

萌萌撅起小嘴说想和我亲亲。

——“想和你一起睡觉觉“

“你现在满脑子都是啥。。。“

——“我想出去玩“

“等你好了就带你出去玩啊“

“你还记得丁丁在哪里上班啊“

——“上海的。。不记得了“

“我问问你啊,你还记得潘X么?“

——“哎!记得哎“

“李XX呢“

——“嗯“

“窦XX呢?

——“记得,是我高中同学“(其实是大学室友)

"高可X呢?“

——“不太记得了“

“慢慢你就都记得了。“

——“戒指不见了!“

“戒指帮你收起来了哈“

“吃点草莓么?“

——“好!“(高兴)

护士姐姐说最多吃三个,怕拉肚子。

我喂了四个。

昨天护士说她什么都吃不进去,非要爸爸来喂才肯吃一点。今天看起来胃口好多了。

——“我困了“

“那我走了,本来还有7分钟时间的“

——“不要“

掏出她的手机。

——“这是我的手机哎“

“你会玩么?“

——“我好像会玩了,但是现在不能玩,等我出去再玩“

——“我困了“

临走。

“你明天想让你爸爸来还是你妈妈来啊?“

——“我想让你来。“

一天一天在变好。

小萌加油啊(03)

2021年4月20日 09:16
作者 mythsman

4月19日,今天是第三天,开始进入脑水肿的高峰期,CT上看水肿略微有所扩大,不过还在控制之中。医生说个别指标已经达到了手术指征,但是考虑到恢复到情况和病人的年龄,还是继续保守治疗。

医生说接下来的日子非常关键,如果挺过了这段时间,手术的可能性就不大了。毕竟手术无论如何都会造成不小的后遗症,不到万不得已一般不建议做。

今天小萌的眼神好看了很多,送CT的路上下意识的抓了我的手,讲话也清楚了不少。说她不想做这个(CT),说她被子热,说她头疼。做完CT之后快到病房门口了,知道主动跟我们挥挥手说拜拜。

啥都不记得了还这么懂礼貌,真是笨蛋。

小萌加油啊(02)

2021年4月18日 16:48
作者 mythsman

4月17日,可能是周末吧,今天早上CT做的晚了。小萌被推了出来,正好CT在排队。

小萌的爸爸问:“你认得我么“

——“爸爸“

小萌的妈妈问:“你认得我么“

——“你是我的医生“

我问:“你记得我么?“

——“不知道。“

忘了她是600度近视,我摘下了口罩。

——“丁丁。“

摸了摸她的手,指头蹭破了皮,红红的还没好,指甲间还有黑色的淤血。

她看了看自己的手,若有所问。

——“不见了,不见了“

“什么不见了“

——“不知道“

戒指项链在她进ICU的时候已经都摘下来了:

“想想是什么“

——“不知道。“

“你什么时候来上海找我玩啊“

——“等一下就好了“

“我考考你啊“

——点点头。

“3+5等于几“(我心里想了7,后来仔细一想应该是8)

——“不知道“。

——“累了累了“

下午3点多,说可以一个人进去探视。小萌的妈妈进去了。

出来说她在里面不听话,劲还大,情绪还是是好是坏,还是不怎么记得人。吐了好几次,还喜欢拔针头和氧气。

带进去前几天她新买的IPhone12给她看,她说她不会玩,等好了再玩。

脑挫裂伤中的血肿一般在前几天会扩大,随着医生逐渐加强给脱水剂。在3~7天内应当会慢慢被吸收,期间病情表征会有所反复。后续只要水肿能被正常吸收,应当会慢慢恢复。

肿块还要吸收。不急,慢慢来。

对了,今天还是小萌妈妈的生日。

小萌加油啊(01)

2021年4月18日 15:02
作者 mythsman

4月16日周五早上,小萌日常给我发早安。今天换了好看的衣服:

下午5点51分,忽然收到了小萌打来的电话,师妹借她的手机说她被电动车撞了,人还有意识,在送120的路上,请我联系一下她的父母。我心想可能是哪里皮肤蹭掉了啥的,估计没啥问题。通知完父母之后我也立刻踏上了去南京的旅程。

周五,上海到南京的高铁票尤其难抢,好不容易也只买到了凌晨才能到的车。等车的时候,小萌的父母和我发消息说她们在东大旁边的同仁医院,头部受到撞击,可能要转院做手术,开颅手术。

慌忙联系了她在省人医工作(实习?)的闺蜜,应该能帮上忙。她很冷静的联系了他们,并帮忙安排了转院的事情。靠谱,专业。

忽然想起来我似乎都把钱套在了股市里,立马慌忙的打开银行卡。还好,415刚发了月薪和年终奖,应该能撑的到周一开盘。平时果然还是要留有一些应急的钱,活期储蓄并不是一无是处。

D706次列车,卧铺,头一次在国内坐卧铺车,竟然还有惊喜,有点可笑。

到了医院,接近1点。叔叔阿姨都没睡,小萌躺在这里:

以前只知道ICU,现在知道了EICU。

叔叔阿姨说,是她在食堂的路上被其电瓶车的食堂员工撞到了被撞飞了九米(后来才知道是车子九米左右才停下,不是人被撞了九米)。还好是在学校里,立马被送到了医院,前后不过几分钟。

哦,电瓶车,我一直是为是电动车,这也能撞的这么厉害,活久见了。听说东南大学前几天也出了几次学生被车撞的事故,看来是老问题了,正常。

费用现在是学院和食堂单位方面在垫付。挺好,可以安心治疗。不过回头又想了想,我似乎只带了钱过来,我还能帮上什么忙呢。

一晚上在医院的椅子上打盹,被冻醒,走了走,天亮了。一整晚,医生没有找家属,挺好。

早上七点半,医生把小萌推出来做CT。眼睛睁的大大的,没有高光。

小萌不认得小丁了。

CT结果出来了:

第二排第四张可以明显看见右上角的左额有高亮的液块(CT图是左右颠倒的),靠近左耳的颞叶也有亮块。左额叶颞叶挫裂伤,蛛网膜下腔出血。

医生说可以暂时保守治疗,不用开颅手术。不开颅说明病情没有那么严重。挺好。

知乎刷了下南京同仁医院,有点不堪。不过好在是学校的附属医院,学院领导会帮忙盯着,感觉问题应当不大。但是为了保险起见,下午我们还是带着CT去了脑科医院看了专家门诊。医生的说法一样,保守治疗就可以,不用建议转院。

困了,躺在椅子上休息了一会,发现医院的光线挺亮的,口罩还可以当眼罩用呢。

Ngrok内网穿透简单上手

2019年10月15日 14:58
作者 mythsman

背景

这两天想跟异地的妹子一起玩《泰拉瑞亚》。直接用steam的联机非常卡,而自己的电脑又没有公网IP,于是最简单朴素的想法就是搞一个内网穿透,将自己本地的IP映射到一个公网IP上。

natapp

一开始是打算用natapp,但是这个东西的免费版会经常强制换域名换端口,非常难受。而收费版虽然不算贵,但总觉得挺浪费,可能也不怎么用却一直要续费。

后来想到natapp其实本质上是对ngrok的一个封装,于是就想自己干脆自己搭一个ngrok服务器就好了。

ngrok

当我打开ngrok官网的时候才发现事情却没那么简单。

原以为ngrok是一个开源项目,可是没想到为了恰饭,这个项目只有1.x版本是开源的,到了2.x以及3.x的阶段就直接闭源了。当然,对于一个普通用户来说,我并不关心他开源还是闭源,但是蛋疼的是他闭源之后,很多功能(比如自定义域名端口等)就从免费变成收费了。而且除了Github上还保留着的1.x的项目代码以及文档之外,他的官网上已经不提供对1.x版本的所有支持(包括文档、客户端下载链接等)。

不提供老版本的客户端下载链接就导致了我们只能自己编译并构建客户端。这显然很麻烦,根据多年开源项目的使用经验,编译别人的项目通常都会有一大堆的坑要踩。况且除了服务端需要编译、客户端也需要编译。我当然不希望这么麻烦,就想着要不试一试官网推荐的2.x,3.x版本?于是看了下官网的功能和收费:

真香,比natapp贵这么多,而且还是用的国外服务器,速度估计还比natapp要慢。是有多想不开才会用这个。

搭建

稳妥的做法当然是下载开源版本的源码自己编译,但是显然太慢了,我们希望能最快的构建服务。

服务端安装

我们知道,对于Unix系统来说,最方便的安装软件的方法就是直接在他自带的软件中心找。
我的云服务器是 Ubuntu 16.04LTS(xenial) 系统,于是我就去 ubuntu 的软件包搜索页面里搜了一把ngrok,结果如下:

虽然我们遗憾的看到,在 18.04(bionic) 和 18.10(cosmic) 中都被移除了,但是还好,ngrok-clientngrok-server 软件包在 16.04 的版本里还是有的。

于是我们只需要一条命令即可安装ngrok的开源版:

$ sudo apt install ngrok-client ngrok-server 

安装好后可以确认一下版本:

$ ngrok version
1.6

是1.x版本,可以安心使用了。

服务端SSL配置

ngrok服务端在使用自定义域名时需要配置TLS证书,最简单的方法当然是使用 letencryptcertbot工具啦。

$ sudo apt install certbot

具体操作方法可以另找教程,需要的结果就是在letsencrypt的相关目录下找到自定义域名的证书文件(*.pem):

$ ls /etc/letsencrypt/live/ngrok.mythsman.com
cert.pem  chain.pem  fullchain.pem  privkey.pem  README

服务端启动服务

先查看 ngrokd 服务的说明:

$ ngrokd -h
Usage of ngrokd:
  -domain="ngrok.com": Domain where the tunnels are hosted
  -httpAddr=":80": Public address for HTTP connections, empty string to disable
  -httpsAddr=":443": Public address listening for HTTPS connections, emptry string to disable
  -log="stdout": Write log messages to this file. 'stdout' and 'none' have special meanings
  -tlsCrt="": Path to a TLS certificate file
  -tlsKey="": Path to a TLS key file
  -tunnelAddr=":4443": Public address listening for ngrok client

依样画葫芦,比如可以设置成这样:

$ ngrokd -domain="ngrok.mythsman.com" -httpAddr="" -tunnelAddr=":443" -httpsAddr="" -tlsCrt="/etc/letsencrypt/live/ngrok.mythsman.com/cert.pem" -tlsKey="/etc/letsencrypt/live/ngrok.mythsman.com/privkey.pem"

由于这样默认是会将阻塞当前会话,因此需要用nohup配合将程序放到后台运行,并将输出重定向:

$ nohup ngrokd -domain="ngrok.mythsman.com" -httpAddr="" -tunnelAddr=":4443" -httpsAddr="" -tlsCrt="/etc/letsencrypt/live/ngrok.mythsman.com/cert.pem" -tlsKey="/etc/letsencrypt/live/ngrok.mythsman.com/privkey.pem" &>output.log &

这样一来,服务器就开启好了4443端口等待ngrok客户端的连接了。

客户端安装

如果是客户端是Ubuntu,那么其实已将安装好了。。。可是我这边的客户端是Windows,就比较难受了,没有一个官方的软件包下载中心。

还好,找了半天终于找到了一个好人将之前下载好的1.x版本的Windows客户端分享了下来(而且还不要积分):CSDN下载链接

这个是免安装的,下载下来打开命令行就可以直接用了。

客户端启动

客户端启动分两步即可:

1.编写ngrok.cfg配置文件如下:

server_addr: "ngrok.mythsman.com:4443"
trust_host_root_certs: true

这里的server_addr填写的就是ngrok服务端的域名以及当时指定的 -tunnelAddr 参数。

2. 启动

> ngrok.exe -subdomain="terraria" -config="ngrok.cfg" -proto="tcp" 7777

这里的-subdomain可以随便填一个、表示你需要在服务端域名的基础上生成的新的子域名。当然,由于我的服务用的并不是web协议,而只是一个普通tcp协议,因此这个配置实际没有用。

这里的-proto表示你内网需要映射的网络协议。如果不填,默认是会把你的端口当成是http或https。由于我这边需要映射泰拉瑞亚的游戏端口的并不是web协议而是一个底层的tcp协议,因此这里需要指定成tcp协议。

最后的端口是你需要映射的本地端口(对于泰拉瑞亚来说默认就是7777啦)

输入完之后就会跳出一个新的页面:

ngrok                                                                                                   (Ctrl+C to quit)

Tunnel Status                 online
Version                       1.7/1.6
Forwarding                    tcp://ngrok.mythsman.com:35146 -> 127.0.0.1:20533
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

那么,这里的 ngrok.mythsman.com:35146 就是在公网映射后的本地7777端口的服务了。

说明

最后有一个不容回避的问题,那就是ngrok只支持tcp协议的穿透,对于使用udp协议的服务是无法处理的。比如像《饥荒》这样的使用udp进行传输的游戏是不好用ngrok搞的。如果想要做的话,只能用工具将udp转为tcp或者用frp那种支持udp穿透的工具。

一个非典型Spring循环依赖的问题分析

2019年9月20日 09:49
作者 mythsman

前言

这两天工作遇到了一个挺有意思的Spring循环依赖的问题,但是这个和以往遇到的循环依赖问题都不太一样,隐藏的相当隐蔽,网络上也很少看到有其他人遇到类似的问题。这里权且称他非典型Spring循环依赖问题。但是我相信我肯定不是第一个踩这个坑的,也一定不是最后一个,可能只是因为踩过的人比较少、鲜有记录罢了。因此这里权且记录一下这个坑,方便后人查看。

正如鲁迅(我)说过,“这个世上本没有坑,踩的人多了,也便成了坑”。

循环依赖

典型场景

经常听很多人在Review别人代码的时候有如下的评论:“你在设计的时候这些类之间怎么能有循环依赖呢?你这样会报错的!”。

其实这句话前半句当然没有错,出现循环依赖的确是设计上的问题,理论上应当将循环依赖进行分层,抽取公共部分,然后由各个功能类再去依赖公共部分。

但是在复杂代码中,各个manager类互相调用太多,总会一不小心出现一些类之间的循环依赖的问题。可有时候我们又发现在用Spring进行依赖注入时,虽然Bean之间有循环依赖,但是代码本身却大概率能很正常的work,似乎也没有任何bug。

很多敏感的同学心里肯定有些犯嘀咕,循环依赖这种触犯因果律的事情怎么能发生呢?没错,这一切其实都并不是那么理所当然。

什么是依赖

其实,不分场景地、笼统地说A依赖B其实是不够准确、至少是不够细致的。我们可以简单定义一下什么是依赖

所谓A依赖B,可以理解为A中某些功能的实现是需要调用B中的其他功能配合实现的。这里也可以拆分为两层含义:

  1. A强依赖B。创建A的实例这件事情本身需要B来参加。对照在现实生活就像妈妈生你一样。
  2. A弱依赖B。创建A的实例这件事情不需要B来参加,但是A实现功能是需要调用B的方法。对照在现实生活就像男耕女织一样。

那么,所谓循环依赖,其实也有两层含义:

  1. 强依赖之间的循环依赖。
  2. 弱依赖之间的循环依赖。

讲到这一层,我想大家应该知道我想说什么了。

什么是依赖调解

对于强依赖而言,A和B不能互相作为存在的前提,否则宇宙就爆炸了。因此这类依赖目前是无法调解的。

对于弱依赖而言,A和B的存在并没有前提关系,A和B只是互相合作。因此正常情况下是不会出现违反因果律的问题的。

那什么是循环依赖的调解呢?我的理解是:

将 原本是弱依赖关系的两者误当做是强依赖关系的做法 重新改回弱依赖关系的过程。

基于上面的分析,我们基本上也就知道Spring是怎么进行循环依赖调解的了(仅指弱依赖,强依赖的循环依赖只有上帝能自动调解)。

Spring的循环依赖调解

为什么要依赖注入

网上经常看到很多手撸IOC容器的入门科普文,大部分人只是将IOC容器实现成一个“存储Bean的map”,将DI实现成“通过注解+反射将bean赋给类中的field”。实际上很多人都忽视了DI的依赖调解的功能。而帮助我们进行依赖调解本身就是我们使用IOC+DI的一个重要原因。

在没有依赖注入的年代里,很多人都会将类之间的依赖通过构造函数传递(实际上是构成了强依赖)。当项目越来越庞大时,非常容易出现无法调解的循环依赖。这时候开发人员就被迫必须进行重新抽象,非常麻烦。而事实上,我们之所以将原本的弱依赖弄成了强依赖,完全是因为我们将类的构造类的配置类的初始化逻辑三个功能耦合在构造函数之中。

而DI就是帮我们将构造函数的功能进行了解耦。

那么Spring是怎么进行解耦的呢?

Spring的依赖注入模型

这一部分网上有很多相关内容,我的理解大概是上面提到的三步:

  1. 类的构造,调用构造函数、解析强依赖(一般是无参构造),并创建类实例。
  2. 类的配置,根据Field/GetterSetter中的依赖注入相关注解、解析弱依赖,并填充所有需要注入的类。
  3. 类的初始化逻辑,调用生命周期中的初始化方法(例如@PostConstruct注解或InitializingBeanafterPropertiesSet方法),执行实际的初始化业务逻辑。

这样,构造函数的功能就由原来的三个弱化为了一个,只负责类的构造。并将类的配置交由DI,将类的初始化逻辑交给生命周期。

想到这一层,忽然解决了我堵在心头已久的问题。在刚开始学Spring的时候,我一直想不通:

  • 为什么Spring除了构造函数之外还要在Bean生命周期里有一个额外的初始化方法?
  • 这个初始化方法和构造函数到底有什么区别?
  • 为什么Spring建议将初始化的逻辑写在生命周期里的初始化方法里?

现在,把依赖调解结合起来看,解释就十分清楚了:

  1. 为了进行依赖调解,Spring在调用构造函数时是没有将依赖注入进来的。也就是说构造函数中是无法使用通过DI注入进来的bean(或许可以,但是Spring并不保证这一点)。
  2. 如果不在构造函数中使用依赖注入的bean而仅仅使用构造函数中的参数,虽然没有问题,但是这就导致了这个bean强依赖于他的入参bean。当后续出现循环依赖时无法进行调解。

非典型问题

结论?

根据上面的分析我们应该得到了以下共识:

  1. 通过构造函数传递依赖的做法是有可能造成无法自动调解的循环依赖的。
  2. 纯粹通过Field/GetterSetter进行依赖注入造成的循环依赖是完全可以被自动调解的。

因此这样我就得到了一个我认为正确的结论。这个结论屡试不爽,直到我发现了这次遇到的场景:

在Spring中对Bean进行依赖注入时,在纯粹只考虑循环依赖的情况下,只要不使用构造函数注入就永远不会产生无法调解的循环依赖。

当然,我没有任何“不建议使用构造器注入”的意思。相反,我认为能够“优雅地、不引入循环依赖地使用构造器注入”是一个要求更高的、更优雅的做法。贯彻这一做法需要有更高的抽象能力,并且会自然而然的使得各个功能解耦合。

打脸?

问题

将实际遇到的问题简化后大概是下面的样子(下面的类在同一个包中):

@SpringBootApplication
@Import({ServiceA.class, ConfigurationA.class, BeanB.class})
public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}
public class ServiceA {

    @Autowired
    private BeanA beanA;

    @Autowired
    private BeanB beanB;
}
public class ConfigurationA {

    @Autowired
    public BeanB beanB;

    @Bean
    public BeanA beanA() {
        return new BeanA();
    }
}
public class BeanA {
}
public class BeanB {

    @Autowired
    public BeanA beanA;
}

首先声明一点,我没有用@Component@Configuration之类的注解,而是采用@Import手动扫描Bean是为了方便指定Bean的初始化顺序。Spring会按照我@Import的顺序依次加载Bean。同时,在加载每个Bean的时候,如果这个Bean有需要注入的依赖,则会试图加载他依赖的Bean。

简单梳理一下,整个依赖链大概是这样:

我们可以发现,BeanA,BeanB,ConfigurationA之间有一个循环依赖,不过莫慌,所有的依赖都是通过非构造函数注入的方式实现的,理论上似乎可以自动调解的。

但是实际上,这段代码会报下面的错:

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'beanA': Requested bean is currently in creation: Is there an unresolvable circular reference?

这显然是出现了Spring无法调解的循环依赖了。

这已经有点奇怪了。但是,如果你尝试将ServiceA类中声明的BeanA,BeanB调换一下位置,你就会发现这段代码突然就跑的通了!!!

显然,调换这两个Bean的依赖的顺序本质是调整了Spring加载Bean的顺序(众所周知,Spring创建Bean是单线程的)。

解释

相信你已经发现问题了,没错,问题的症结就在于ConfigurationA这个配置类。

配置类和普通的Bean有一个区别,就在于除了同样作为Bean被管理之外,配置类也可以在内部声明其他的Bean。

这样就存在一个问题,配置类中声明的其他Bean的构造过程其实是属于配置类的业务逻辑的一部分的。也就是说我们只有先将配置类的依赖全部满足之后才可以创建他自己声明的其他的Bean。(如果不加这个限制,那么在创建自己声明的其他Bean的时候,如果用到了自己的依赖,则有空指针的风险。)

这样一来,BeanA对ConfigurationA就不再是弱依赖,而是实打实的强依赖了(也就是说ConfigurationA的初始化不仅影响了BeanA的依赖填充,也影响了BeanA的实例构造)。

有了这样的认识,我们再来分别分析两种初始化的路径。

先加载BeanA

  1. 当Spring在试图加载ServiceA时,先构造了ServiceA,然后发现他依赖BeanA,于是就试图去加载BeanA;
  2. Spring想构造BeanA,但是发现BeanA在ConfigurationA内部,于是又试图加载ConfigurationA(此时BeanA仍未构造);
  3. Spring构造了ConfigurationA的实例,然后发现他依赖BeanB,于是就试图去加载BeanB。
  4. Spring构造了BeanB的实例,然后发现他依赖BeanA,于是就试图去加载BeanA。
  5. Spring发现BeanA还没有实例化,此时Spring发现自己回到了步骤2。。。GG。。。

先加载BeanB

  1. 当Spring在试图加载ServiceA时,先构造了ServiceA,然后发现他依赖BeanB,于是就试图去加载BeanB;
  2. Spring构造了BeanB的实例,然后发现他依赖BeanA,于是就试图去加载BeanA。
  3. Spring发现BeanA在ConfigurationA内部,于是试图加载ConfigurationA(此时BeanA仍未构造);
  4. Spring构造了ConfigurationA的实例,然后发现他依赖BeanB,并且BeanB的实例已经有了,于是将这个依赖填充进ConfigurationA中。
  5. Spring发现ConfigurationA已经完成了构造、填充了依赖,于是想起来构造了BeanA。
  6. Spring发现BeanA已经有了实例,于是将他给了BeanB,BeanB填充的依赖完成。
  7. Spring回到了为ServiceA填充依赖的过程,发现还依赖BeanA,于是将BeanA填充给了ServiceA。
  8. Spring成功完成了初始化操作。

结论

总结一下这个问题,结论就是:

除了构造注入会导致强依赖以外,一个Bean也会强依赖于暴露他的配置类。

代码坏味道

写到这,我已经觉得有点恶心了。谁在写代码的时候没事做还要这么分析依赖,太容易出锅了吧!那到底有没有什么方法能避免分析这种恶心的问题呢?

方法其实是有的,那就是遵守下面的代码规范————不要对有@Configuration注解的配置类进行Field级的依赖注入

没错,对配置类进行依赖注入,几乎等价于对配置类中的所有Bean增加了一个强依赖,极大的提高了出现无法调解的循环依赖的风险。我们应当将依赖尽可能的缩小,所有依赖只能由真正需要的Bean直接依赖才行。

参考资料

Circular Dependencies in Spring

Spring-bean的循环依赖以及解决方式

Factory method injection should be used in "@Configuration" classes

❌