CyberConnect功能逆向与EasyCyberConnect的实现

前言

在我学过的知识中,计算机知识是最开放最容易获取的。在计算机领域中,前端技术又是更开放的一个存在,因为浏览器的存在,你可以随意查看一些大项目的前端源代码而不需要谁给你任何权限,故在前端领域中逆向某个项目是最容易的了。逆向这个词,可以简单理解为根据结果推导过程,一般解密也就是对未开放源码的程序进行的,因为没有源码,我们只能对程序本身进行解密等操作,从而猜测其实现是什么。今天的主角CyberConnect作为一个社交图谱的web3应用,目前其对用户主要开放的就是cyberconnect.me这个站点。逆向部分就是cyberconnect的follow,查看follwers、followings,注册等功能。根据逆向的代码还实现了一个简易版本的cyberconnect前端,链接如下。

https://easycyberconnect.000webhostapp.com/

目前支持搜索某个地址所有的followers、followings,不只是前50个哦,而且支持follow那些未关注的followers。假设你是一个大v,有很多人关注(followers可能达到了10000+),现在你想回关一些followers,现在无论是通过cyberconnect.me还是galaxy,还是

http://47.102.205.94:2000/

目前都没办法很方便的去操作。我上面的easycyberconnect也就是解决了这个痛点。

除了解决自己的痛点外,自己做这个事情也是以学习为目的。自己作为一个年轻的程序员,对任何新技术都还抱有着热情,而且光有热情还是不够的,还要参与进来。那么通过一个个小的项目,从做中学,是一个很好的开始。希望自己可以坚持,我也会将自己的学习过程分享给大家。

EasyCyberConnect开源

https://github.com/PictorHugo/EasyCyberConnect

大部分朋友可能并不关注实现的细节,这里我把源代码开源出来,从而也让大家使用起来更放心。因为这个项目只是一个纯前端的实现,我将它架设在了000webhostapp上,这样也不需要掏任何服务器费用。想尝试丝滑般关注体验的朋友,可以尝试下下面的链接。

https://easycyberconnect.000webhostapp.com/

EasyCyberConnect源码逆向

这一部分记录了我对cyberconnect的研究,属于自己对学习过程的一个记录与整理。不感兴趣的朋友可以直接跳过。

在逆向前我要划定自己逆向的范围,因为cyberconnect确实是个很庞大的项目,像无头苍蝇一样到处看,必定是不行的。我逆向的目的如下:

  • 查看超过top 50外的followers和followings

  • 如何follow某个用户

确定了范围,接下来就要看具体方法了。从程序员的角度看,cyberconnect app也就是客户端就是一个前端程序,而且现在普遍的前端与后端交互的方式就是REST API。那么,我们只要获得,上面目的 中对应功能的REST API,并且了解REST API的各个参数是如何获得的,那么这个任务也就完成了。

Task 1: 查看超过top 50外的followers和followings

我们从galaxy的前端入手(原因是galaxy比较快一点),先了解下是如何获得top 50的followers的,那么也许可以猜测出如何查看超过top50的followers。这里就要利用chrome的一个强大的抓包功能(在galaxy.eco页面上单击鼠标右键 → 点击inspect或者检查 → 切换到Network或者网络),通过这个抓包功能我们能看到每个从前端发送出去的HTTP/HTTPs请求,那么只要耐心,我们一定可以找到一些线索。

下面就是我找到本次任务中API的过程

  • 打开浏览器抓包功能

  • 打开galaxy.eco/galaxyid这个页面

  • 查看每一个请求的响应,看看是否有包含followers的信息

galaxy用于显示top 50 followers/follwings的API
galaxy用于显示top 50 followers/follwings的API

通过以上方法,和一点耐心,终于找到了这个API,具体的API格式如下。这里可以看到比较重要的就是API BODY部分,如果理解了这一部分那么完成本task就不是问题了。API BODY又分为两个部分,一部分是query,这里使用的一种与graphql相关的语句,具体的作用就是查询后端服务器的哪些数据,因为我们没法看到后端的实现是什么,那么这一部分大概率先保持原状。初步理解这个graphql语句就当成一个函数,我们给它某些输入参数,这个函数就会返回一个JSON结构的数据,里面包含有followers和followings。另一部分是variables,目前看里面只有两个参数,分别是address和first,address就是当前的cyberconnect账户,first结合response结果也很好理解,也就是显示前50个数据。那么到这里我们也就有个想法,是不是修改first对应的值就可以了呢?比如改成100?我尝试后改成100还是不行,仍然只显示前50个数据。不过通过抓包发现cyberconnect的主站点上,在调用这个API的时候还多加了一个参数after,这个成为了本问题的突破口。

REST API请求:

HTTP METHOD: POST
API PATH: api.cybertino.io/connect
API BODY:
{
    "query": "query GetIdentity($address: String!, $first: Int, $after: String) {\n  identity(address: $address) {\n    address\n    ens\n     social {\n      twitter\n      __typename\n    }\n    followerCount(namespace: \"\")\n    followingCount(namespace: \"\")\n followers(first: $first, after: $after, namespace: \"\") {\n      pageInfo {\n        ...PageInfo\n        __typename\n      }\n      list {\n        ...Connect\n        __typename\n      }\n      __typename\n    }\n  __typename\n  }\n}\n\nfragment PageInfo on PageInfo {\n  startCursor\n  endCursor\n  hasNextPage\n  hasPreviousPage\n  __typename\n}\n\nfragment Connect on Connect {\n  address\n  ens\n  alias\n  namespace\n  __typename\n}\n",
    "variables": {
        "address": "0x037aa2a37b06699f9ea40c4830275620d367fefa",
        "first": 50
    }
}

REST API响应 (… 代表下面内容基本与上面一致,为了简洁考虑所以省略了):

{
    "data": {
        "identity": {
            "address": "0x037aa2a37b06699f9ea40c4830275620d367fefa",
            "ens": "pictorhugo.eth",
            "social": {
                "twitter": "PictorHugo",
                "__typename": "Social"
            },
            "followerCount": 196,
            "followingCount": 238,
            "followings": {
                "pageInfo": {
                    "startCursor": "0",
                    "endCursor": "49",
                    "hasNextPage": true,
                    "hasPreviousPage": false,
                    "__typename": "PageInfo"
                },
                "list": [
                    {
                        "address": "0xeefc6347cb77bbc878dd4ea0a616da69d7bc67f3",
                        "ens": "",
                        "alias": "",
                        "namespace": "Galaxy",
                        "__typename": "Connect"
                    },
                    ...
                ],
                "__typename": "ConnectionIdentityPage"
            },
            "followers": {
                "pageInfo": {
                    "startCursor": "0",
                    "endCursor": "49",
                    "hasNextPage": true,
                    "hasPreviousPage": false,
                    "__typename": "PageInfo"
                },
                "list": [
                    {
                        "address": "0xee857065cbe599febfa772e96c17996deacd7830",
                        "ens": "",
                        "alias": "",
                        "namespace": "CyberConnect",
                        "__typename": "Connect"
                    },
                    ...
                ],
                "__typename": "ConnectionIdentityPage"
            },
            "__typename": "UserIdentity"
        }
    }
}

这个就是从cyberconnect上查找到带after参数的接口,其实这个信息也可以通过graphql语句得到,但因为一开始不是很熟悉它的语法没有注意到。

带有after参数的请求
带有after参数的请求

经过一些尝试,这个after参数里填写的也是一个地址string,而且通过这个after就可以实现翻页功能。具体意思就是,当after为空的时候,查找前first个数据,假设分别是,follower1,follower2,… folower50。在下一次执行查询语句的时候,将上一次的follower50的address填入after,first仍然是50,那么就可以查找到top50后面的数据。

Task 2: 如何follow某个用户

想获取follow某个用户的API很容易,不用像task1里那样,从整个抓包数据中一点点过滤。因为我们只需要触发一下follow这个操作,那么接下来抓去到的请求一定与follow操作相关。抓去到的API如下。

REST API请求:

HTTP METHOD: POST
API PATH: api.cybertino.io/connect
API BODY:
{
    "operationName": "connect",
    "query": "mutation connect($input: UpdateConnectionInput!) {connect(input: $input) {result}}",
    "variables": {
        "input": {
            "fromAddr": "0x037aa2a37b06699f9ea40c4830275620d367fefa",
            "toAddr": "0xf601d1f8e82fc71514e88bc2b0a26d8a305f9985",
            "alias": "",
            "namespace": "Galaxy",
            "signature": "...",
            "signingKey": "...",
            "operation": "{\"name\":\"follow\",\"from\":\"0x037aa2a37b06699f9ea40c4830275620d367fefa\",\"to\":\"0xf601d1f8e82fc71514e88bc2b0a26d8a305f9985\",\"namespace\":\"Galaxy\",\"network\":\"ETH\",\"timestamp\":1653799783858,\"alias\":\"\"}",
            "network": "ETH",
            "type": "FOLLOW"
        }
    }
}

REST API响应:

{
    "data": {
        "connect": {
            "result": "SUCCESS"
        }
    }
}

这个API的获取难度非常低,但难点在于如何知道API请求中各个参数都应该怎样填写。下面让我们来按个分析一下。

operation,的值为connect,这应该代表follow操作的意思,因为我也同时抓去到了unfollow的请求,其中operation的值为disconnect。

query,其值依然是一个graphql语句,我们暂且不去理解细节。

下面分析variables中input里的各个参数。

fromAddr,其值是连接cyberconnect的钱包地址

toAddr,将要关注的钱包地址

alias,暂时不清楚作用

signature,将某些数据经过浏览器生成的私钥(为了方便,后面称其为私钥a)签名后的字符串,具体计算逻辑查看下面`signature与signingKey计算逻辑`那一节。

signingKey,私钥a对应的公钥经过hash后的值,具体可以查看`signature与signingKey计算逻辑`。

operation,包括follow动作的发起人,将要follow的人等信息,具体和上面介绍过的都差不多,只是没有signature和signingKey等参数。

network,目前应该可以填写ETH和Solana两种。

type,是操作的名字。

浏览完上面每个参数的介绍后,我们可以发现,大部分参数都是很好获取的,比如network,type等,都是固定填写就好。但signingKey与signature就不太好理解了,下面让我们重点关注这两个参数。

signature与signingKey计算逻辑

为了获得这两个参数的计算逻辑,我使用了两种方法,一种是归纳法,一种是查看源码法。

归纳法:

我尝试了多次的follow操作,发现signingKey是一直不变的,从而可以断定这是一个存储在本地的值,那么也就很有可能是连接本网站的钱包地址经过hash后得到的值(后面被证明是错误的,请继续看)。但是signature却次次不一样,一种可能的猜测是,每次计算签名的时候加入了时间戳信息。

查看源码法: 光靠归纳的方法去猜测肯定是不行了,还是得看源码,这也是前端代码的一个好处,那么接下来的问题就是:

  • 查看哪个具体文件的源码

  • 如何看源码

第一个问题,chrome的抓包功能帮我们解决了,下面截图中有一列叫做initiator,这里就代表了这个请求发起的时候所对应的代码。

查看请求对应文件
查看请求对应文件
源码文件
源码文件

但当我们打开这个源码的时候却发现是这样的,基本上不具有任何可读性,这个时候我们可以通过点击左下角的按钮从而格`{}`式化源码文件,但也只能是让格式变的好看点,各种变量名仍然是不具有可读性的。下面就只能靠耐性了,说实在的,接下来是有点枯燥的,因为只能一点点去钻研这类minified(前端的代码压缩)过的代码,我下面抽取一段,分享一下基本的思路。下面就是当用户出发follow按键的时候会触发的函数,其核心逻辑就在switch case语句中,如果熟悉js的async/await代码实现原理的朋友看下面的代码应该并不陌生,或者大家可以查看下面这篇文章。其是就是一种状态机的转换。我下面实际的说一下,首先当a.lable为0的时候执行相应的语句,其返回值为this.getAddress(),当a.label为1的时候执行相应的语句,并且通过a.sent可以获得a.label为0时候的返回值,接下来就以此类推。

http://codereform.com/blog/post/es6-generators-and-asyncawait-in-typescript/

e.prototype.connect = function(e, t, n) {
                var r, o, s, c;
                return void 0 === t && (t = ""),
                void 0 === n && (n = x.ConnectionType.FOLLOW),
                i(this, void 0, void 0, (function() {
                    var i, u, f, d, l, h, b;
                    return a(this, (function(a) {
                        switch (a.label) {
                        case 0:
                            return a.trys.push([0, 8, , 9]),
                            i = this,
                            [4, this.getAddress()];
                        case 1:
                            return i.address = a.sent(),
                            [4, this.authWithSigningKey()];
                        case 2:
                            return a.sent(),
                            u = {
                                name: n.toLowerCase(),
                                from: this.address,
                                to: e,
                                namespace: this.namespace,
                                network: this.chain,
                                timestamp: Date.now(),
                                alias: t
                            },
                            [4, (0,
                            E.signWithSigningKey)(JSON.stringify(u), this.address)];
                        case 3:
                            return f = a.sent(),
                            [4, (0,
                            E.getPublicKey)(this.address)];
                        case 4:
                            return d = a.sent(),
                            l = {
                                fromAddr: this.address,
                                toAddr: e,
                                alias: t,
                                namespace: this.namespace,
                                signature: f,
                                signingKey: d,
                                operation: JSON.stringify(u),
                                network: this.chain,
                                type: n
                            },
                            [4, (0,
                            v.follow)(l, this.endpoint.cyberConnectApi)];
                        case 5:
                            return h = a.sent(),
                            "INVALID_SIGNATURE" !== (null === (r = null === h || void 0 === h ? void 0 : h.data) || void 0 === r ? void 0 : r.connect.result) ? [3, 7] : [4, (0,
                            E.clearSigningKey)()];
                        case 6:
                            throw a.sent(),
                            new y.ConnectError(y.ErrorCode.GraphqlError,null === (o = null === h || void 0 === h ? void 0 : h.data) || void 0 === o ? void 0 : o.connect.result);
                        case 7:
                            if ("SUCCESS" !== (null === (s = null === h || void 0 === h ? void 0 : h.data) || void 0 === s ? void 0 : s.connect.result))
                                throw new y.ConnectError(y.ErrorCode.GraphqlError,null === (c = null === h || void 0 === h ? void 0 : h.data) || void 0 === c ? void 0 : c.connect.result);
                            return [3, 9];
                        case 8:
                            throw b = a.sent(),
                            new y.ConnectError(y.ErrorCode.GraphqlError,b.message || b);
                        case 9:
                            return M.DFLAG && this.ceramicConnect(e, t),
                            [2]
                        }
                    }
                    ))
                }
                ))
            }

通过上面的方式,我通读了这一部分的代码,总结了一个follow过程的流程图给大家。具体的代码大家可以查我已经开源的代码。

获取signature signingKey的流程
获取signature signingKey的流程

研究这一部分后,主要获得一个信息,就是对数据进行签名的私钥并不是连接钱包的账户的私钥,而是前端自己生成的一个公钥和私钥对。这样确实也能让follow操作更丝滑一点,因为每次都要用户自己签名一次就去follow一个人还是挺费劲的。

key的注册

在我以为理解了signature的计算后就能自己脱离cyberconnect前端去follow用户的时候,我发现cyberconnect还是返回了一个error,不能让我成功follow,仔细推测后,发现有个流程被我漏掉了,就是当前端无法从indexed db超找到keypair的时候其实是会发送一个registerKey的请求,因为我漏掉了这个请求所以follow会失败。下面就是register key的REST API。

REST API请求:

HTTP METHOD: POST
API PATH: api.cybertino.io/connect
API BODY:
{
    "operationName": "registerKey",
    "query": "mutation registerKey($input: RegisterKeyInput!) {\n      registerKey(input: $input) {\n        result\n      }\n    }",
    "variables": {
        "input": {
            "address": "0x037aa2a37b06699f9ea40c4830275620d367fefa",
            "message": "I authorize CyberConnect from this device using signing key:....",
            "signature": "....",
            "network": "ETH"
        }
    }
}

那么到此,我们也基本上理解了如何去通过REST API去follow一个用户。