模拟 Bilibili Android 客户端登录

作者:czp 分类: Java 发布于:2017-10-23 17:38 ė376次浏览 64条评论

本文撰写时的Bilibili Android客户端版本:  5.15.0.515000


截取数据包

    首先,我们要知道 B 站 APP 本身是如何调用B站的 RESTful API 的。为此,我们需要对 B 站 APP 进行截包。我使用 Anbox 直接在 Gnu/Linux 运行 APK,然后用 Wireshark 截取数据包,过程比较复杂就不描述了。Android 平台有一个比较小巧的截包 APP 叫做 Packet Capture ,效果也非常好,可以一用 https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture


分析数据包

    我们主要查看 http 类型的数据包,这里随便举例一个,他是这样的


GET /AppRoom/index?_device=android&_hwid=JxdyESFAJkcjEicQbBBsCTlbal5uX2Y&access_key=cb93fb8cc20b2d3245f9ea824130ac21&appkey=1d8b6e7d45233436&build=515000&buld=515000&jumpFrom=24000&mobi_app=android&platform=android&room_id=3151254&scale=xxhdpi&src=google&trace_id=20171012145800040&ts=1507791520&version=5.15.0.515000&sign=0ad8bd04c480714075b57e04aff2e8d3 HTTP/1.1
Display-ID: 20293030-1507791479
Buvid: JxdyESFAJkcjEicQbBBsCTlbal5uX2Yinfoc
User-Agent: Mozilla/5.0 BiliDroid/5.15.0 (bbcallen@gmail.com)
Device-ID: JxdyESFAJkcjEicQbBBsCTlbal5uX2Y
Host: api.live.bilibili.com
Connection: Keep-Alive
Accept-Encoding: gzip
Cookie: sid=lqyxyyr2


有三个 Params 我们是不清楚的,分别是 access_key, appkey, sign

继续查看其他请求,我们发现这个 appkey(1d8b6e7d45233436) 每次都是一样的,access_key 每次也都是一样的。

我们退出客户端的登录,我们发现请求中没有了 access_key ,我们再次登录,此时 access_key 与上一次登录不一样了。这说明,这个 access_key 就是 token。

那么也就是说,通过访问 B站 的登录接口,成功登录之后,就会返回一个 access_key, 用这个 token 我们就可以访问其他所有需要登录才能访问的接口。

而这个 sign 势必是一种校验算法,用于防止截取别人的数据包后修改几个数值再次发送从而非法操作他人账户。

通过搜索引擎,我们查到了 sign 的生成算法 http://www.jianshu.com/p/5087346d8e93


sign 生成算法

    具体的生成算法见上面的链接在大体上,他的算法是这样的:

    首先按 Name 排序 Params 的键值对,然后进行 URLEncode ,将参数变为这样的一个字符串 


    key1=value1&key2=value2&key3=value3


    之后对这个字符串进行字符串拼接,拼接上 Android APP 内置的 appSecret(只拼接值)(ea85624dfcf12d7cc7b2b3a94fac1f2c) ,变为 


    key1=value1&key2=value2&key3=value3ea85624dfcf12d7cc7b2b3a94fac1f2c


    再进行 md5 加密,就得到了 sign(如果参数真的如上所示,那么sign为以下这个字符串)


    302d7fd77cd91c5ac530f6bad109a3dd


    之后将这个 sign 加入到 params,最终的 params 就会是这样


    key1=value1&key2=value2&key3=value3&sign=302d7fd77cd91c5ac530f6bad109a3dd


    以下给出一段 Java 实现代码


public static String calculateSign(List<NameValuePair> params) {
        String urlEncodedParams = params.stream()
                .sorted(Comparator.comparing(NameValuePair::getName))
                .map(nameValuePair -> {
                    try {
                        return String.format("%s=%s", nameValuePair.getName(), URLEncoder.encode(nameValuePair.getValue(), StandardCharsets.UTF_8.toString()));
                    } catch (UnsupportedEncodingException e) {
                        e.printStackTrace();
                        return null;
                    }
                })
                .collect(Collectors.joining("&"));
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("MD5");
            messageDigest.update((urlEncodedParams + APP_SECRET).getBytes());
            return new BigInteger(1, messageDigest.digest()).toString(16);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
其中 NameValuePair 是 Apache HttpClient 中的一种类型,用于存储键值对。


通过这样一个算法,我们现在可以生成 sign 了。


常规参数

    所谓常规参数,即每次请求都会有的参数,我们反编译了 B站 的 Android APP,发现对于 rest 访问,客户端使用了 Retrofit 。Retrofit2 底层使用 OKHttpClient 来实现 http 访问,而这些常规参数就是用 OKHttpClient 过滤器来加到每一个访问上的。这些常规参数主要是这么一条


_device=android&_hwid=JxdyESFAJkcjEicQbBBsCTlbal5uX2Y&build=515000&mobi_app=android&platform=android&scale=xxhdpi&src=google&trace_id=20171012145800040&ts=1507791520&version=5.15.0.515000


_device 固定值 android

_hwid 硬件编码,不知道怎么生成的,每台手机固定值

build version编号最后的一节

mobi_app 固定值 android

platform 固定值 android

scale 手机屏幕参数,每台手机固定值,我的是 xxhdpi

src 固定值 google

trace_id 表示时间的字符串,纯数字,生成算法(注意秒前有三个零): String.format("%d%d%d%d%d000%d",年,月,日,时,分,秒)

ts 当前的Unix Timestamp

version 每个版本的客户端固定值


常规参数并不是每个api的必须参数,但是由于客户端使用一样的 ProcessChain 处理 Http 访问,所以都会有这么一些参数。这些参数主要用于客户端类型统计,大多数情况并不影响 API 本身的工作。

但是要注意的是,一些 API 可能会读取一个或几个常规参数,例如 http://live.bilibili.com/AppRoom/index

所以最好每个访问都加上这些参数,以免产生错误。


验证参数

    一些需要用户登录才能使用的 API,必须带有 access_key(一些API中叫做 access_token), appkey, sign (下面把这三个参数合称 验证参数)

    由于客户端是统一处理的,所以访问所有 API 都会带有这三个参数,就跟常规参数一样。

    因此实际上客户端访问一个 API 的参数有三个部分: 常规参数,API 参数,验证参数

    现在我们已经知道了常规参数,也知道了 appkey 和 sign 生成算法。接下去我们必须要获得 access_key 才能访问需要登录的 API。


acess_token:

    登录接口在这里 https://passport.bilibili.com/api/oauth2/login

    参数为:

    appkey

    username

    password

    sign

我们之前已经知道了 appkey,所以直接填入,然后 username 就是 B站 登录用的用户名。

接下去关键的来了,就是这个 password 。

这个 password 是加密的,但是跟 Web 版的密码加密算法是一样的,而非常不巧的是,这个算法我之前已经研究过了,通过查看 web 前端的 js。

在 web 版中,js会首先访问 https://passport.bilibili.com/login?act=getkey 来获得一个 hash 值和 B站 的 RSA公钥。

但是 Android 版中,访问 https://passport.bilibili.com/api/oauth2/getKey 来获得 hash值和 RSA公钥,他们有什么用待会会说。

这个 API 有两个参数,appkey 和 sign 。这两个参数我们现在都可以明确他们。访问结果如图所示

2017-10-23 20-03-44屏幕截图.png

密码加密算法是这样的:

将 hash值 与 明文密码做字符串拼接。将得到的结果字符串(先BASE64解密得到Byte[])用 RSA公钥 加密(再BASE64加密得到人类可读字符串)。得到的最终结果就是密码密文。

Java 实现如下


public static String cipherPassword(String password) throws Exception {
        HttpPost httpPost = new HttpPost("https://passport.bilibili.com/api/oauth2/getKey");
        List<NameValuePair> params = Collections.singletonList(new BasicNameValuePair("appkey", APP_KEY));
        params = signToParams(params);

        httpPost.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));

        String hash;
        String key;
        //获取 hash 和 key
        try (CloseableHttpClient closeableHttpClient = HttpClients.createMinimal()) {
            JSONObject jsonObject = JSON.parseObject(
                    EntityUtils.toString(
                            closeableHttpClient.execute(httpPost).getEntity()
                    )
            ).getJSONObject("data");
            hash = jsonObject.getString("hash");
            key = jsonObject.getString("key");
        }

        //计算密码密文
        RSAPublicKey rsaPublicKey = new RSAPublicKeyImpl(
                Base64.getDecoder().decode(
                        key.replace("-----BEGIN PUBLIC KEY-----", "")
                                .replace("-----END PUBLIC KEY-----", "")
                                .replace("\n", "")
                                .getBytes()
                )
        );
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, rsaPublicKey);
        return new String(
                Base64.getEncoder().encode(
                        cipher.doFinal((hash + password).getBytes())
                )
        );
    }
得到了所有的 API 参数,我们再计算得到 sign ,所有参数就齐了。


我们提交所有参数,但是服务器却返回了一个错误提示

2017-10-23 20-09-57屏幕截图.png

现在我们要确定问题所在,我们首先认为我们的 sign 算法其实是不对的,我们去掉 sign 参数或者修改为其他值再次提交

2017-10-23 20-11-09屏幕截图.png

我们可以看到,如果 sign 不对的话,服务器的 Filter (B站服务端是 Java 写的,所以可以用常规 Java Web 开发经验去考虑它)直接返回一个统一错误,code 为 -3 ,并没有进入 API 的控制器就结束了请求。所以我们的 sign 算法肯定是对的,不然 filter 都无法通过。

我们修改密码密文的第一位,使这个密文错误,也即是说,这个密文解码过程就会抛出异常。

2017-10-23 20-20-31屏幕截图.png

我们修改用户名最后一位,使用一个不存在的用户名登录

2017-10-23 20-21-35屏幕截图.png

我们使用一个错误的密码明文来得到一个密码密文(解密过程不会出错,但是密码明文和用户名是不匹配的)

2017-10-23 20-23-25屏幕截图.png

分析:

在通常的 Java web 开发中,我们会首先有一些过滤器,B站的 sign 验证就是一个我们已经明确的过滤器,他会直接阻止 sign 不正确的访问。

再然后,我们会从数据库查询得到 UserEntity ,例如 findByUsername(String username),如果返回值是 null,意味着没有这个用户,那么我们就可以直接返回 “账号或密码错误”,就像上面一张图里看到的那样。

接着,我们要验证密码,我们需要对密码密文进行解密。而通过测试,无论是正确的密码或者密码明文是错的还是密文密码解密会抛Exception,都会导致返回 “can't decrypt rsa password~”。

所以,我们的问题一定出现在密文密码解密后。

解密后,B站服务端会得到两个东西,一个是 hash值,一个是密码密文(hash值长度是固定的,所以可以切分字符串)。

我们使用正确的密码,也会返回这个错误,所以肯定不与密码密文有关,那么,返回这个错误的原因一定是 hash值 有问题。

那么 hash值 有什么问题呢。我们回去看一下 hash值 的获取。

2017-10-23 20-23-25屏幕截图.png

我们不停地点击 Send,我们看到,每次访问得到的 hash 都是不一样的,就像是随机生成的一样。

既然是随机生成的,那么服务端为什么认为 hash 有问题呢。

所以,这个 hash 一定不是随机生成的,他通过某种算法进行逆运算,一定能得到某种东西。

而如果这种东西是固定的,那么 hash值 就会有规律才对。

非常显然的,我们想到了一个每秒钟都不一样,并且永远不会重复的东西,那就是,时间戳。

没错,这个 hash 的生成算法里,用到了时间戳,并且这个算法是可逆的,服务端可以逆推得到 hash 的生成时间。

根据 hash 的生成时间,如果距离登录请求的时间超过一定值(我估算是五秒,也可能是一秒),就认为 hash 值错误。

这可以有效防止手动推测 sign 算法从而破解登录过程。

所以,我们必须在 hash 作废前,完成 密码加密 和 登录 这两个过程。我们用代码完成他们。

2017-10-23 20-36-46屏幕截图.png

很好,我们现在完成了登录,服务器返回了 access_token, refresh_token, mid, expires_in 等多个参数,其他参数等会讲他们的作用。其中的 access_token 就是我们访问各种需要登录才能访问的 API 所需的参数。

referesh_token 用于 token 到期时的刷新(会返回新的 access_token 和 refresh_token)(到期时间为一个月)

mid 其实是 用户ID ,一些 API 中需要用它,一些 API中可能包含别人的 mid (例如查看直播间信息时返回数据会包含主播的 mid)

expires_in 是时间戳,长度为一个月,意思是到期时间


访问API:

    激动人心的一刻来了,现在我们得到了 access_token 我们可以访问所有 API 啦!我们来访问试试看!

2017-10-23 20-44-01屏幕截图.png

很好,我们使用 API 完成了直播签到。我们访问 Web 端来验证签到是否成功。

2017-10-23 20-45-35屏幕截图.png

非常好,我们确实使用 API 完成了自动签到!小伙伴们欢呼雀跃!

一些有 API 参数的 API ,需要先加上 API 特有的参数再计算 sign,如下图所示

2017-10-23 21-16-20屏幕截图.png

获取用户基本信息 和 刷新 token 以及 登出

    懒得讲了,直接看图

2017-10-23 21-13-12屏幕截图.png

2017-10-23 20-56-45屏幕截图.png

2017-10-23 20-57-07屏幕截图.png


API 文档:

    B站 API 有很多很多,通过对客户端进行抓包可以看到它们。我决定制作一个 B站 API 调用库,目前还在开发状态,文档可能不完全 https://github.com/czp3009/bilibili-api


本文出自 czp的装逼站,转载时请注明出处及相应链接。

0

评论

  1. 大侠 Google Chrome 52.0.2743.116 Windows 10 2017-10-23 21:59 回复

    说这么多,就为了自动签到。。。。

    1. czp Google Chrome 61.0.3163.98 Android 2017-10-23 22:01 回复

      @大侠:谁跟你说只能签到

  2. eree Google Chrome 61.0.3163.100 Mac OSX 10_13_0 2017-10-23 21:27 回复

    好了好了,这就是你大半夜不吃鸡折腾的结果

    1. czp Google Chrome 61.0.3163.98 Android 2017-10-23 21:31 回复

      @eree:好了好了,吃鸡不如学习有意思

发表评论

电子邮件地址不会被公开。必填项已用*标注


Ɣ回顶部