阿里云盘签名算法探究及可用 PoC

查看 206|回复 12
作者:t00t00   
前言
先放文件 https://github.com/kazutoiris/ali_ecc。
通过 Python 实现的模拟阿里云盘签名算法。可以通过云盘`x-device-id` 和 `x-signature` 校验,妈妈再也不用担心  `invalid X-Device-Id` 了。
欢迎 Star、Issue、Pull Requests!!!
(施工完毕,请放心阅读)

背景
自从阿里云盘2023年2月13日一波大更新,直接把原来调网页版接口的第三方网盘客户端给干爆了。像小白羊、Clouddrive、AList都出现了`invalid X-Device-Id`。其中,Clouddrive通过官方申请API招安的方式勉强能用,其他俩都不能下载文件了。
这使得复习高数的我直接傻眼了,第三方客户端只能上传不能下载,而网页版的在线视频播放功能一坨,只好全部缓存下来再看。。。
这波操作属实有点逆天,晚饭吃撑了研究一下。

分析
STEP1
通过报错可以发现,阿里云盘请求时候主要使用 `x-device-id` 和 `x-signature` 来进行校验。像列目录之类的可以不用校验,但是取下载链接一定要校验。
(P.S. 这里发现一个小小的漏洞,在列文件目录的时候会携带下载链接,这似乎是油猴脚本至今可用的原因)


sshot-14.png (128.08 KB, 下载次数: 0)
下载附件
2023-2-14 00:27 上传

如果不带的话就只能收到 `invalid X-Device-Id` 的错误提示了。
STEP2
貌似在不同浏览器上登录,这个 `x-device-id` 会不同。暂时不清楚是怎样随机生成的(准确来说是懒得调了,又不是不能用)。
综上所述,唯一要生成的就是 `x-signature`。

开导逆
STEP1
首先就是要确定 `x-signature` 啥时候生成的。因为在前几条请求中都没有涉及到 `x-signature`,只有在请求目录后,所有的请求都带上了 `x-signature`。
(这里发现一个小小的细节:用开发者工具的”编辑并重新发送“,可以发现,即使是删掉了 `x-signature`,发送时也会自动带上。)
STEP2


sshot-1.png (220.44 KB, 下载次数: 1)
下载附件
2023-2-14 00:37 上传

随机挑一个请求,查看发起的程序。这个一目了然,发送逻辑必定在 `bundle.js` 中!


sshot-2.png (241.54 KB, 下载次数: 1)
下载附件
2023-2-14 00:39 上传

一个搜索,一个回车,直达要害!这证明了之前的猜想没错。
STEP3
看了一下请求记录,没有 WASM,那估计不是 WASM,是纯 JS 加密算法。
悲,纯 JS 没得下硬件访问断点,WASM 调试起来可方便多了。。
不管了,直接一个断,一个刷新,欸,直接就断下了。


sshot-3.png (417.93 KB, 下载次数: 1)
下载附件
2023-2-14 00:47 上传

这不比调 WASM 爽,环境不用补,连反调试都没有。
仔细观察,createSession 中 `x-signature` 是通过外部调用传参进来的,并不是这个函数内部产生的。
所以,这就需要根据调用堆栈来找是谁生成了这个值,是哪个函数传递进来的。
但是看了一圈,似乎在调用前就有了签名值。这就意味着必须要换一种方法了。
STEP4
直接开搜 `createSession`,很快啊,就找到了引用位置。


sshot-4.png (225.97 KB, 下载次数: 0)
下载附件
2023-2-14 09:12 上传

看了一下,整体代码似乎是状态机结构,总的来说还是通俗易懂的,比起恶心的 ollvm 确实正常了不少。
整体代码是自上而下运行的,是一种流水线式的运行方式。打个比方,A切肉,B把切完的肉拿去腌,C把腌完的肉拿去烤,D把烤完的拿去出餐。
这个函数先生成签名,然后生成会话。生成会话过程的签名就是上一级产生的。
当然,这里有很多其他函数,用以处理不同流水线段之间的数据传递,感兴趣的可以研究下,不感兴趣的倒也没啥关系,毕竟不影响抽象实现。
所以这个函数两个部分既可以分成多段运行,也可以直接合并起来。
而这种流水线的方式会导致前面找堆栈的时候,没有办法找到原始签名字符串是啥时候传进来的,就像是写在了全局变量一样(这也是没办法下硬件写入断点的槽点,不然早找到了)。
STEP5
找准了函数,下一步就简单了。创建签名是在 `genSignature` 中,直接一个 Ctrl+F 直达。


sshot-5.png (168.41 KB, 下载次数: 0)
下载附件
2023-2-14 09:23 上传

整体就很清晰了:
  • 先生成字符串 appId:deviceId:userId:nonce,然后 sha256 编码一下。
  • 交付给下一段流水线,用 privateKey 去签名。
  • 签名值后面加上 01。(这里的 1 是代码中 concat(u),因为 u 来自 recovered 的值,也就是图上蓝色的下一行。这个 1 的来源后续慢慢讲)

    STEP6
    签名是找到了,但是是拿啥算法签名的呢?


    sshot-6.png (223.4 KB, 下载次数: 1)
    下载附件
    2023-2-14 09:29 上传

    可以看到,阿里云盘有请求一个 `get_public_key` 的地址,而且返回了一个 RSA 密钥。前面代码又有公私钥加密的,这不就是 RSA 签名嘛!
    哎哎哎,可别高兴太早,RSA 签名有个显著的特点,就是 e 基本都是 65537。但是调了这么久,似乎从来也没看到过 65537?
    而且,根据上面的代码,这个私钥可以直接转换成公钥?RSA 双向转换都是不可以的。就算是调用私钥,最起码也得带上俩个数吧。
    [JavaScript] 纯文本查看 复制代码this.secp.utils.bytesToHex(this.secp.getPublicKey(this.dbMemData.privateKey))
    这波属实是半场开香槟——给爷整笑了。
    STEP7
    兄弟们,干就完事了。
    现在有两种可能,一是魔改的 RSA 算法,存在一个常数,这样就只需要提供另一个数就行了;二是,这压根不是 RSA 算法。
    不过这两种路子殊途同归,直接一个断点,打到 `getPublicKey` 里面。如果是魔改,必定有常数,如果不是,那也有提示。


    sshot-8.png (128.08 KB, 下载次数: 0)
    下载附件
    2023-2-14 09:46 上传



    sshot-7.png (75.4 KB, 下载次数: 1)
    下载附件
    2023-2-14 09:45 上传

    可以看到这是一个乘法运算。RSA 中我怎么记得是没有乘法的呢?
    放狗搜一下常数,哦哦哦哦哦哦.jpg


    sshot-9.png (553 KB, 下载次数: 0)
    下载附件
    2023-2-14 09:49 上传

    来了兄弟们,ecc-secp256k1 算法,也是非对称算法。这是用椭圆曲线做的,难怪怎么只需要一个常数就可以了。
    一把梭,直接开搞。
    [Python] 纯文本查看 复制代码private_key = [175, 87, 171, 214, 222, 196, 127, 36, 25, 50, 237, 179, 71, 81, 49, 196,
                   250, 103, 115, 203, 138, 179, 192, 182, 43, 175, 233, 72, 200, 14, 64, 254]
    private_key = int.from_bytes(private_key, byteorder='big')
    ecc_pri = ecdsa.SigningKey.from_secret_exponent(
        private_key, curve=ecdsa.SECP256k1)
    ecc_pub = ecc_pri.get_verifying_key()
    print(ecc_pub.to_string().hex())
    STEP8
    别高兴太早。
    出来的是 3e2a3bbd2dfe8675bc40b0b0af6a558421b827b5ad022c8d305eb861b9bfdcc48aea1243df77a6298dfd5cf692a407852b3fd996ea5a13ae213c4380bb7b8d0d,
    而发送的却是
    [color=]04
    3e2a3bbd2dfe8675bc40b0b0af6a558421b827b5ad022c8d305eb861b9bfdcc48aea1243df77a6298dfd5cf692a407852b3fd996ea5a13ae213c4380bb7b8d0d
    奇怪,算法搞错了?
    别急,这不还没调完嘛。。。


    sshot-10.png (62.61 KB, 下载次数: 0)
    下载附件
    2023-2-14 10:00 上传

    [JavaScript] 纯文本查看 复制代码t ? `${this.y & o ? "03" : "02"}${e}` : `04${e}${A(this.y)}`
    是吧,04 是后续转 HEX 的时候手动加上的。这证明算法已经掌握了。
    (( 经坛友提醒,04 指的是未压缩 ECC 公钥,细节可以看这篇文章 https://asecuritysite.com/ecc/js_ethereum2 ))
    STEP9
    算法搞完了,回过头看看公私钥对是咋生成的,这玩意不会有啥稀奇古怪的限制吧。
    现在有两点已经掌握:
  • 只需要提供私钥,就可以求解得到公钥。
  • 私钥每次登陆时都会变。

    大致可以分析出来,只需要生成私钥就可以了,而且是在初始化的时候。


    sshot-11.png (82.44 KB, 下载次数: 0)
    下载附件
    2023-2-14 10:05 上传

    没有限制,随机生成,完美收官。
    STEP10
    搞完了生成、算法,但是签名呢?哎,没想到吧,前面已经提到了哦!
  • 先生成字符串 appId:deviceId:userId:nonce,然后 sha256 编码一下。
  • 交付给下一段流水线,用 privateKey 去签名。
  • 签名值后面加上 01。(这里的 1 是代码中 concat(u),因为 u 来自 recovered 的值,也就是图上蓝色的下一行。这个 1 的来源后续慢慢讲)

    那你肯定要问了,楼主楼主,nonce 一开始是 0,后面会不会变捏?
    简单,友谊手套,Ctrl + F 一搜 nonce 赋值,很快奥,就定位到了。


    sshot-13.png (188.85 KB, 下载次数: 0)
    下载附件
    2023-2-14 10:13 上传

    目测是超时后生成新的 nonce。


    sshot-12.png (93.71 KB, 下载次数: 0)
    下载附件
    2023-2-14 10:13 上传

    更新时间随机的。


    sshot-15.png (45.16 KB, 下载次数: 0)
    下载附件
    2023-2-14 10:13 上传

    这里 nonce 只起限位作用,每次更新 +1,初始值为零,更新时间随机。
    那你肯定又要问了,
    [color=]楼主楼主,appId、deviceId、userId 捏?
    自己去 GitHub 项目看一下捏,这个抓包都有的,这里就不多做解释了,写文章太累了。。。
    开积写
    把前面导完的东西积一下,首先生成密钥对,发送公钥至阿里云盘,后续则使用这个密钥对进行签名。
    STEP1 生成密钥对
    [Python] 纯文本查看 复制代码private_key = random.randint(1, 2**256-1)
    ecc_pri = ecdsa.SigningKey.from_secret_exponent(
        private_key, curve=ecdsa.SECP256k1)
    STEP2 生成并处理公钥
    [Python] 纯文本查看 复制代码ecc_pub = ecc_pri.get_verifying_key()
    public_key = "04"+ecc_pub.to_string().hex()
    STEP3 签名
    [Python] 纯文本查看 复制代码def sign(appId, deviceId, userId, nonce) -> str:
        sign_dat = ecc_pri.sign(r(appId, deviceId, userId, nonce).encode('utf-8'), entropy=None,
                                hashfunc=hashlib.sha256)
        return sign_dat.hex()+"01"
    至于发送这种小事,完整代码直接见仓库吧,贴完文章太长没眼看了。
    这里有个坑点,非对称签名每次签同样的内容产生的签名是不一样的,但是似乎阿里云盘直接简单处理了。
    create_session 时候用的 `x-signature` 必须原封不动的传递到其他 API 的调用上,否则就算是密钥对一致、内容一致,也只会报无效。

    后记
  • 似乎油猴脚本下载不受影响。
  • AList通过分享创建的也不受影响。
  • 盲猜一波断更小白羊估计这次应该是彻底淘汰了,作者不管,第三方作者也不太好做,不知道有没有接盘侠接个盘。
  • 有啥问题建议直接 GitHub 项目下提交 Issue,秒级回复。论坛估计是得按天起步了。。。
  • Star、Follow、CB 任一大于 1145,胱速整个有意思小活。

    下载次数, 下载附件

  • t00t00
    OP
      

    施工完毕,请放心阅读。
    有问题请去 GitHub 下面提交 Issue,秒级回复,论坛回复有点慢。。。
    Star、Follow、CB 任一大于 1145,胱速整个有意思小活
    有需要的网页截图、仓库 Fork 保存一下哦,年后实习还要投阿里,万一删帖了懂得都懂。。。
    周小雨   

    我昨天调试网页的时候,调试工具其卡无比,在控制台敲个回车都要等十几秒钟,而且始终占满一个CPU内核,一关掉立马恢复正常。不知道是什么情况,我以为这就是他们的反调试手段了
    另,刚刚一直在试着用golang实现,但总觉得写出来的不太对,然后突然想到可以让chatgpt转一下,虽然有少量错误(golang没有request包,是httpclient,生成公钥需要用 ecdsa.GenerateKey(elliptic.P256() ),但改改应该就能用了
    (更新一下,发现调不对的根本原因是golang的库里没有 secp256k1 算法,只有 secp256R1 ,这下可有得忙了)
    [Golang] 纯文本查看 复制代码package main
    import (
        "crypto/ecdsa"
        "crypto/rand"
        "crypto/sha256"
        "encoding/hex"
        "fmt"
        "math/big"
    )
    func r(appId, deviceId, userId string, nonce int) string {
        return fmt.Sprintf("%s:%s:%s:%d", appId, deviceId, userId, nonce)
    }
    func sign(appId, deviceId, userId string, nonce int, privateKey *ecdsa.PrivateKey) string {
        dat := r(appId, deviceId, userId, nonce)
        hash := sha256.Sum256([]byte(dat))
        signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
        if err != nil {
            panic(err)
        }
        sig := hex.EncodeToString(signature)
        return sig + "01"
    }
    func main() {
        appId := "5dde4e1bdf9e4966b387ba58f4b3fdc3"
        deviceId := "e5173011-XXXX-XXXX-XXXX-XXXXf04f3c62"
        userId := "XXXXf36e37XXXX79bdXXXXdeb6203f67"
        nonce := 0
        privateKey, _ := ecdsa.GenerateKey(ecdsa.SECP256k1(), rand.Reader)
        publicKey := hex.EncodeToString(append([]byte{4}, privateKey.PublicKey.X.Bytes()...))
        publicKey = hex.EncodeToString(append([]byte(publicKey), privateKey.PublicKey.Y.Bytes()...))
        signature := sign(appId, deviceId, userId, nonce, privateKey)
        headers := map[string]string{
            "authorization": "Bearer BEARERBEARER",
            "origin":        "https://www.aliyundrive.com",
            "referer":       "https://www.aliyundrive.com/",
            "user-agent":    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.41",
            "x-canary":      "client=web,app=adrive,version=v3.17.0",
            "x-device-id":   deviceId,
            "x-signature":   signature,
        }
        reqBody := map[string]interface{}{
            "deviceName": "Edge浏览器",
            "modelName":  "Windows网页版",
            "pubKey":     publicKey,
        }
        req := requests.post("https://api.aliyundrive.com/users/v1/users/device/create_session", reqBody, headers)
        fmt.Println(req.Text())
        reqBody = map[string]interface{}{
            "expire_sec": 14400,
            "drive_id":   "341789",
            "file_id":    "63dd352b22e34327c0f84277b389eb381XXXXXXX",
        }
        req = requests.post("https://api.aliyundrive.com/v2/file/get_download_url", reqBody, headers)
        fmt.Println(req.Text())
    }
    kiseyzed   


    t00t00 发表于 2023-2-14 15:37
    好像有一位志愿者用 Go 改写了一份,你可以参考一下。
    https://github.com/kazutoiris/ali_ecc/pull/2

    确实对上了一部分,但长度比你的要短,我已经把私钥写死成你给的字节序列了
    [C] 纯文本查看 复制代码        // 0403 3e2a3bbd2dfe8675bc40b0b0af6a558421b827b5ad022c8d305eb861b9bfdcc 4
            // 04   3e2a3bbd2dfe8675bc40b0b0af6a558421b827b5ad022c8d305eb861b9bfdcc 48aea1243df77a6298dfd5cf692a407852b3fd996ea5a13ae213c4380bb7b8d0d


    image.png (102.49 KB, 下载次数: 0)
    下载附件
    2023-2-14 15:49 上传

    jzx765   

    看issue小白羊作者送外卖去了好像
    zw0476sky   

    好歹弄完再发出来吧……
    sqemail   

    前排🐎一下
    zzk19358590281   

    期待更新后的结果,目前已知的第三方全都不能播放视频了,看不鸟,是真失落。
    mosou   

    先mark下,等待楼主的续篇
    t00t00
    OP
      

    期待更新
    您需要登录后才可以回帖 登录 | 立即注册