FreshRSS

🔒
❌ About FreshRSS
There are new articles available, click to refresh the page.
Before yesterdayYour RSS feeds

美团App页面视图可测性改造实践

一次编写多处运行的动态化容器技术给研发效率带来了极大的提升,但对于依旧需要多端验证的测试流程来说,在效率层面却面临着极大的挑战。本文围绕动态化容器中的动态布局技术,阐述了如何通过可测性改造来帮助达成提升测试效率的目标。希望可以给同样需要测试动态化页面的同学们带来一些启发和帮助。

美团终端消息投递服务Pike的演进之路

Pike 2.0致力于为美团提供一套易接入、高可靠、高性能的双向消息投递服务。本文首先从系统架构升级、工作模式升级、长稳保活机制升级等方面介绍了Pike2.0的技术演进,然后介绍了Pike 2.0在直播、游戏等新业务场景下的特性支持。希望本文能给对消息投递服务感兴趣或者从事相关工作的读者一些帮助和启发。

Android系统推送Hook实战

July 18th 2021 at 08:23

背景

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

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

这次我们就主要尝试拦截下小米手机的系统通道的推送数据。当然,对于各个 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": "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bGxiYWNrLnBhcmFtAAAAMDE0M0pyQnQ0R2JKVzAwNjM3KjFFTU5QV2pjZHA1dW5zZXQ4MncwTVdoNlJlZG1pNgAAAA1fX3RhcmdldF9uYW1lAAAAQE5UTWpMRk1ZNnRQWkpONFcrdytvZndvTUZOOWxhZDNublFUOWJPUEEyeUNJSDlrSml2bWM5V1Jmb0R0ZWRXOWoAAAAFZmVfdHMAAAANMTYyNDkzMjg4MDk2MgAAAA1jYWxsYmFjay50eXBlAAAAAjE3AAAAF25vdGlmaWNhdGlvbl9zdHlsZV90eXBlAAAAATEAAAAIY2FsbGJhY2sAAAA+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通知

本地生活综合性需求图谱的构建及应用

本地生活综合性需求图谱(GENE: lifestyle GEneral NEeds net),是从用户需求视角出发,深入挖掘本地生活场景下用户多样化的需求,并将其与多行业、多类型的供给形成关联的知识图谱,旨在提升平台供需匹配效率,助力业务增长。本文介绍了本地生活综合性需求图谱的背景、体系设计和涉及的算法实践,并展示了在美团多个业务线的应用落地,希望给大家带来一些帮助或启发。

多业务建模在美团搜索排序中的实践

美团搜索排序是一个典型的多业务混合排序建模问题,这种多业务场景搜索存在很多挑战,本文聚焦于到店商家多业务场景,进行了多业务排序建模优化工作。希望能对从事相关工作的同学有所启发或者帮助。

Isaac64解密算法JNI的封装

July 5th 2021 at 13:47

前言

众所周知,理论上最安全的加密方式是使用一次一密(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指纹隐藏实战

June 30th 2021 at 16:27

前言

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

需要明确的是,“隐藏浏览器指纹”和“隐藏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

迁移到了Hexo

December 1st 2019 at 17:24

2020-08-29:
更换主题为

检测浏览器 DevTools 是否打开的几种方法

February 13th 2019 at 01:37

前言

今天关于看板娘的文章收到了一个

❌