PicaComic 接口分析手记

好久没有更新技术类文章了,不过其实我也有慢慢在写几篇文章,然而它们依旧躺在草稿箱。刚好群里讨论写个 Pica 客户端,于是我就来分析下 Pica 的接口吧。

拆开来一看,竟然没有混淆……build.gradle 改改也没多大成本吧,虽然给我省事就是了。

抓包得知,Pica 的接口使用 signature 头以校验。所以首先要找出 signature 的计算方法。由于,Pica 采用了 okhttp3,所以 signature 的计算逻辑八成会写在 interceptor 里。搜索 addInterceptor,果然只有一处,出现在 com.picacomic.fregata.networks.RestClient 的构造器里。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// 局部变量名称与部分逻辑有所调整
Request request = chain.request();
String nonce = UUID.randomUUID().toString().replace("-", "");
String path = request.url().toString().replace("https://picaapi.picacomic.com/", "");
String timeStamp = System.currentTimeMillis() / 1000L + PreferenceHelper.getTimeDifference(var1) + "";
String signature = MyApplication.getInstance().getStringCon(new String[]{"https://picaapi.picacomic.com/", path, timeStamp, nonce, request.method(), "C69BAF41DA5ABD1FFEDC6D2FEA56B", RestClient.version, RestClient.buildVersion});
// 局部变量名称与部分逻辑有所调整 Request request = chain.request(); String nonce = UUID.randomUUID().toString().replace("-", ""); String path = request.url().toString().replace("https://picaapi.picacomic.com/", ""); String timeStamp = System.currentTimeMillis() / 1000L + PreferenceHelper.getTimeDifference(var1) + ""; String signature = MyApplication.getInstance().getStringCon(new String[]{"https://picaapi.picacomic.com/", path, timeStamp, nonce, request.method(), "C69BAF41DA5ABD1FFEDC6D2FEA56B", RestClient.version, RestClient.buildVersion});
// 局部变量名称与部分逻辑有所调整
Request request = chain.request();
String nonce = UUID.randomUUID().toString().replace("-", "");
String path = request.url().toString().replace("https://picaapi.picacomic.com/", "");
String timeStamp = System.currentTimeMillis() / 1000L + PreferenceHelper.getTimeDifference(var1) + "";
String signature = MyApplication.getInstance().getStringCon(new String[]{"https://picaapi.picacomic.com/", path, timeStamp, nonce, request.method(), "C69BAF41DA5ABD1FFEDC6D2FEA56B", RestClient.version, RestClient.buildVersion});

于是顺藤摸瓜,看 com.picacomic.fregata.MyApplicationgetStringCon 方法。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public String getStringCon(String[] strs) {
if (this.generateSignature == null) {
this.generateSignature = new GenerateSignature();
}
String rawParams = "";
for (String str : strs) {
rawParams += str + ", ";
}
PrintLog.PrintErrorLog(TAG, "RAW parameters = " + rawParams);
String concatParam = this.getStringConFromNative(strs);
PrintLog.PrintErrorLog(TAG, "CONCAT parameters = " + concatParam);
String concatKey = this.getStringSigFromNative();
PrintLog.PrintErrorLog(TAG, "CONCAT KEY = " + concatKey);
return this.generateSignature.getSignature(concatParam, concatKey);
}
public String getStringCon(String[] strs) { if (this.generateSignature == null) { this.generateSignature = new GenerateSignature(); } String rawParams = ""; for (String str : strs) { rawParams += str + ", "; } PrintLog.PrintErrorLog(TAG, "RAW parameters = " + rawParams); String concatParam = this.getStringConFromNative(strs); PrintLog.PrintErrorLog(TAG, "CONCAT parameters = " + concatParam); String concatKey = this.getStringSigFromNative(); PrintLog.PrintErrorLog(TAG, "CONCAT KEY = " + concatKey); return this.generateSignature.getSignature(concatParam, concatKey); }
public String getStringCon(String[] strs) {
    if (this.generateSignature == null) {
        this.generateSignature = new GenerateSignature();
    }

    String rawParams = "";
    for (String str : strs) {
        rawParams += str + ", ";
    }

    PrintLog.PrintErrorLog(TAG, "RAW parameters = " + rawParams);
    String concatParam = this.getStringConFromNative(strs);
    PrintLog.PrintErrorLog(TAG, "CONCAT parameters = " + concatParam);
    String concatKey = this.getStringSigFromNative();
    PrintLog.PrintErrorLog(TAG, "CONCAT KEY = " + concatKey);
    return this.generateSignature.getSignature(concatParam, concatKey);
}

getStringConFromNativegetStringSigFromNative 两个方法都是 native 的,那暂且放一遍,先把 Java 层的 getSignature 看完。不过逻辑其实也不难猜到,就是转小写后 HmacSHA256。所以重点还应该是在 native 层。不过有点挺有趣的,getStringSigFromNative 很好理解,但 getStringConFromNative 怎么看都像是拼接字符串。在这上面做文章还是没怎么见过,有意思。

lib 名是 libJniTest,很优秀。丢进 IDA,很顺利。找到函数,很顺利,毕竟静态注册。F5,依旧很顺利。嘛,看到 lib 名也应该能想到的。首先分析 Java_com_picacomic_fregata_MyApplication_getStringConFromNative,依旧是当初分析 B 站 App 的套路,改类型、重命名,然后分析逻辑。其实也什么好分析,首先从数组中取出对应下标的字符串然后 GetStringUTFChars,然后就是主要的拼接逻辑。

拼接很直接,就是这个用于判断的 repack_chk_and_genKey10(原名 genKey10)需要分析一哈。通过分析,发现这个函数是用来校验 apk 签名的。

然而校验失败返回 false 后,getStringConFromNative 依旧可以拼接,而且会变更拼接的方式。至于为什么这么做,笔者暂且被蒙在吉他里。

接下来分析 Java_com_picacomic_fregata_MyApplication_getStringSigFromNative

……

心凉半截。看看汇编。

没办法,按照 esp 的偏移慢慢算咯。当然傻傻的一个个字符改也确实没效率,所以简单处理一下数据,写个 py jio 本。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
char_dic = {0x9: 110, 0x2D: 107, 0x37: 97, 0x3B: 107, 0x40: 114, 0x46: 114, 0xC: 83, 0x14: 85, 0x19: 76, 0x1B: 82, 0x27: 67,
0x28: 69, 0x2C: 75, 0x33: 90, 0x45: 67, 0xD: 57, 0x16: 56, 0x1F: 57, 0x21: 52, 0x23: 51, 0x2F: 55, 0x38: 53, 0x42: 49}
int_dic = {0x17: "zf", 0x29: "sl", 0x39: "zk", 0x1D: "PM", 0x31: "BY",
0x35: "BA", 0x0F: "lG", 0x11: "ts", 0x3C: "RB", 0x3E: "L7"}
if __name__ == "__main__":
offset = 0x8
org_str = "~*}$#,$-\").=$)\",,#/-.'%(;$[,|@/&(#\"~%*!-?*\"-:*!!*,$\"%.&'*|%/*,*"
ls = list(org_str)
for pos, ch in char_dic.items():
ls[pos - offset] = chr(ch)
for pos, ch in int_dic.items():
ls[pos - offset] = ch[0]
ls[pos - offset + 1] = ch[1]
print("".join(ls))
char_dic = {0x9: 110, 0x2D: 107, 0x37: 97, 0x3B: 107, 0x40: 114, 0x46: 114, 0xC: 83, 0x14: 85, 0x19: 76, 0x1B: 82, 0x27: 67, 0x28: 69, 0x2C: 75, 0x33: 90, 0x45: 67, 0xD: 57, 0x16: 56, 0x1F: 57, 0x21: 52, 0x23: 51, 0x2F: 55, 0x38: 53, 0x42: 49} int_dic = {0x17: "zf", 0x29: "sl", 0x39: "zk", 0x1D: "PM", 0x31: "BY", 0x35: "BA", 0x0F: "lG", 0x11: "ts", 0x3C: "RB", 0x3E: "L7"} if __name__ == "__main__": offset = 0x8 org_str = "~*}$#,$-\").=$)\",,#/-.'%(;$[,|@/&(#\"~%*!-?*\"-:*!!*,$\"%.&'*|%/*,*" ls = list(org_str) for pos, ch in char_dic.items(): ls[pos - offset] = chr(ch) for pos, ch in int_dic.items(): ls[pos - offset] = ch[0] ls[pos - offset + 1] = ch[1] print("".join(ls))
char_dic = {0x9: 110, 0x2D: 107, 0x37: 97, 0x3B: 107, 0x40: 114, 0x46: 114, 0xC: 83, 0x14: 85, 0x19: 76, 0x1B: 82, 0x27: 67,
            0x28: 69, 0x2C: 75, 0x33: 90, 0x45: 67, 0xD: 57, 0x16: 56, 0x1F: 57, 0x21: 52, 0x23: 51, 0x2F: 55, 0x38: 53, 0x42: 49}
int_dic = {0x17: "zf", 0x29: "sl", 0x39: "zk", 0x1D: "PM", 0x31: "BY",
           0x35: "BA", 0x0F: "lG", 0x11: "ts", 0x3C: "RB", 0x3E: "L7"}

if __name__ == "__main__":
    offset = 0x8
    org_str = "~*}$#,$-\").=$)\",,#/-.'%(;$[,|@/&(#\"~%*!-?*\"-:*!!*,$\"%.&'*|%/*,*"
    ls = list(org_str)
    for pos, ch in char_dic.items():
        ls[pos - offset] = chr(ch)
    for pos, ch in int_dic.items():
        ls[pos - offset] = ch[0]
        ls[pos - offset + 1] = ch[1]
    print("".join(ls))

这放飞自我的编码风格…… 嘛,总之算是跑出了结果。另外,校验失败同样会返回另一个 key。

简单汇总一下 signature 的计算方法:

  1. 拼接请求路径(不包含/)+当前时间戳+nonce+请求方法+"C69BAF41DA5ABD1FFEDC6D2FEA56B"
  2. 转小写
  3. 计算其 HmacSHA256,密钥为 "~n}$S9$lGts=U)8zfL/R.PM9;4[3|@/CEsl~Kk!7?BYZ:BAa5zkkRBL7r|1/*Cr"

其中 nonce 生成的 java 实现为 UUID.randomUUID().toString().replace("-", "");,常见的随机串生成方式。实现时生成任意 32 长随机字符串即可。

总体感觉就是有安全意识但是做的很不够。不过讲字符串拼接的逻辑放在 native 层很有趣,而且 native 层的处理较 B 站早期的实现也更完善。改天测试下另一种 signature 有啥不同。

哦,强制更新啊。

新密钥:"~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn"

分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

  1. 没有图片
  2. 没有图片
  3. 没有图片
  4. 没有图片
  5. 没有图片

评论

  1. Mixesson 2019.02.13 11:18 下午

    Signature 后面加的 C69BAF… 这一串也在请求头里面啊。(顺便你站 SEO 不行啊,技术文上写的已经被 Google 索引了)

    • KAAAsS 2019.02.14 7:48 下午

      是指 C69BAF41DA5ABD1FFEDC6D2FEA56B 嘛?是的,这是 Appkey 所以要加入请求头的。
      另外本站 SEO 其实约等于没做啦,尤其是 Google,基本处于弃疗状态 w

  2. 前途未达 2019.08.03 7:05 下午

    秘钥 9 后面多了一个 \

    • KAAAsS 2019.08.03 8:55 下午

      并没有,那个是转义字符。

  3. kenny 2019.09.12 12:28 上午

    ~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn

    密钥是这个吗?

    • KAAAsS 2019.09.12 1:10 上午

      是的,注意 \\ 是转义字符。

      • kenny 2019.09.12 7:31 下午

        getStringConFromNative 函数主要是做了什么,我没看懂。方便为明文加密做个示例吗?

        URL: https://picaapi.picacomic.com/init?platform=android h2
        time: 1568095535
        nonce: f52361e4e0ec456dbc35c1bd0827b780
        Method: GET
        app-version: 2.2.1.0.1.2
        app-build-version: 43

        如果请求参数若是如上,用于 HmacSHA256 加密的明文是?请先生教我。

        • KAAAsS 2019.09.12 7:52 下午

          这个文章中已经说明了 “拼接请求路径(不包含 /)+ 当前时间戳 + nonce + 请求方法 +‘C69BAF41DA5ABD1FFEDC6D2FEA56B’”。
          拼接结果是:init?platform=android1568095535f52361e4e0ec456dbc35c1bd0827b780GETC69BAF41DA5ABD1FFEDC6D2FEA56B

  4. Ace 2019.09.29 4:51 下午

    能否讲解下计算密钥时是如何从汇编写成 python 脚本里的数组的?为什么 26234 对应 zf?或者有对应的资料能推荐下吗?

    • KAAAsS 2019.09.29 5:12 下午

      其实这里都是没必要的工作…… 只是 IDA 的 F5 抽风了而已,可以通过改首地址变量的类型为数组解决。
      26234 是__int16 2 字节,当时用的高低两字节转字符转写的,其实根本是没必要,更改类型 IDA 都可以帮你做。
      相关资料可以看比如《IDA Pro 权威指南》。

  5. 一只会水的咸鱼 2019.10.18 12:43 上午

    请问下最新的密钥还是这个么 请问是怎么破解的呢 我这里 使用旧版本 api https://picaapi.picacomic.com/ 都会提示我 更新 然而新版本的 https://uatapi.wakamoment.ga/ 我用的 https://uatapi.wakamoment.ga/keywords 这个网址 出来的结果一直都是 {
    "code": 200,
    "message": "success"
    }

    • KAAAsS 2019.10.20 10:55 上午

      最近没空拆…… 有空看看吧
      另外更新的话,需要在请求头传入正确的版本号。

      • 一只会水的咸鱼 2019.10.20 10:58 上午

        我是抓包的 ios 的 发现请求的网站不一样希望大佬有时间研究下

        • KAAAsS 2019.10.20 11:12 上午

          有机会看下,不过好像原接口是可以用的

          • 一只会水的咸鱼 2019.10.20 11:19 上午

            好的请问可以告诉我怎么反编译出来的加密密钥么 我按照你的算法写的 把网址提交的换成了 ios 的地址却发现一只返回 200success 并无其他数据 初步判断 ios 算法和 android 不一样

          • 一只会水的咸鱼 2019.10.21 10:40 下午

            大佬 我研究了下发现 ios 的 随机字符串是大写 我尝试了 算法修改为大写但是无用 所以估计密钥也是不对的 如果需要 安装包 我可以提供给您

          • KAAAsS 2019.10.21 11:00 下午

            ios 逆向我也不会啦……
            不能使用安卓接口么?

          • 一只会水的咸鱼 2019.10.23 11:07 下午

            他好像有强制更新了

  6. 一只会水的咸鱼 2019.10.21 11:04 下午

    其实我想学习一下如何逆向 安卓的跟着你的步骤来学我看到心凉半截哪里我就看不懂了吗在我眼里就是字节和 word 我想知道如何算出来的密码希望大佬能教给我下我下个版本可以自己弄嘿嘿

  7. 一只会水的咸鱼 2019.10.23 11:35 下午

    今天 安卓的更新了 密钥不变其他参数变了 app-channel 由 1 变为 2 app-version 由 2.2.1.0.1.2 变为 2.2.1.2.3.3 app-build-version 由 43 变为 44

    • KAAAsS 2019.10.26 10:46 下午

      感谢提醒。最近相当忙,实在是没有办法看……

      • boxsnake 2019.12.13 2:32 上午

        刚才解了一下 2.2.1.2.3.4 版本的,密钥没有更新,就改了 app-version 和 app-build-version

    • boxsnake 2019.12.14 10:48 下午

      app-channel 和你登录时候选的那个有关,不是签名时候必须的东西。签名时候传入的参数依次是:’https://picaapi.picacomic.com/’,域名后面的地址(没有开始的斜杠),时间戳,随机数 nonce,
      ,请求方法(GET/POST),api-key(C69BAF41DA5ABD1FFEDC6D2FEA56B),app-version,app-build-version

      • KAAAsS 2019.12.14 11:54 下午

        那看起来接口校验完全没有变化

  8. LLL 2020.03.18 3:11 上午

    我用 python 登录的时候 post 出去,结果返回 {‘code’: 500, ‘error’: "Cannot read property ‘toLowerCase’ of undefined", ‘message’: ‘–‘, ‘detail’: ‘:(‘}
    而且我手机登录抓包,验证写的是对的,但就是不知道为什么返回这个东西,就算 signature 是空的也会返回 success,只是没有 token,大佬能告诉一下原因吗?

    • KAAAsS 2020.03.18 12:23 下午

      从你的返回结果推测,应该是你传入数据缺失了。具体的还是要看你的 payload,可以脱敏后和我邮件交流下,目前来看只能猜是数据缺了。

  9. JKL 2020.06.21 10:59 下午

    请问你用的是什么工具,我用的 AndroidKiller v1.3.1,只有汇编代码,没有 java 代码。方便的话,能给我一个你的工具链接吗?另外又更新了,"app-version": "2.2.1.3.3.4","app-build-version": "45"。

    • KAAAsS 2020.06.21 11:14 下午

      可以先转 jar(dex2jar)再使用 jar 的逆向工具(Fern Flower, CFR, etc),也可以直接使用 Jadx。
      此外,由于我最近没什么精力看,似乎已经落下很多个版本了。而且在签名方式大改前应该也不会更新这篇博客了,感谢提供参数。

在此评论中不能使用 HTML 标签。