介绍

RPC(Remote Procedure Call)中文名「远程过程调用」,又是一个很蹩脚的翻译。我们拆开理解下,「过程」也叫方法或函数,「远程」就是说方法不在当前进程里,而是在其他进程或机器上面,合起来 RPC 就是调用其他进程或机器上面的函数。
——聊聊 Node.js RPC(一)— 协议

JS RPC是指在浏览器开启一个ws和go服务连接,以调用http接口的形式来通信,浏览器端收到调用通信执行原先设置好的js代码。可以用于js逆向调用加密函数直接返回结果,也可以用来直接获取数据。
——Python网络爬虫之js逆向之远程调用(rpc)免去抠代码补环境简介

上次实战,其实就算是实现了一次JS RPC实战, 对于JS RPC,和爬虫一样,可以手搓,也可以选择成熟的第三方框架,这里推荐使用 Sekrio-官方文档

Sekiro


SEKIRO 是一个android下的API服务暴露框架,可以用在app逆向、app数据抓取、android群控等场景。同时Sekiro也是目前公开方案唯一稳定的JSRPC框架。
——来自官方文档

下载:https://oss.iinti.cn/sekiro/sekiro-demo

使用

首先需要确保已准备好 Java 环境,需要下载并配置JDK,随后运行bin/sekiro.bat(或bin/sekiro.sh)即可开启本地服务端

而客户端,请参考官方文档 浏览器入门

下载或者注入JsClient

然后使用以下代码暴露RPC服务:

function guid() {
    function S4() {
        return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
    }

    return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4());
}

var client = new SekiroClient("wss://sekiro.virjar.com/business/register?group=ws-group-2&clientId=" + guid());

client.registerAction("clientTime", function (request, resolve, reject) {
    resolve("SekiroTest:" + new Date());
});

client.registerAction("executeJs", function (request, resolve, reject) {
    var code = request['code'];
    if (!code) {
        reject("need param:{code}");
        return;
    }

    code = "return " + code;

    console.log("executeJs: " + code);

    try {
        var result = new Function(code)();
        resolve(result);
    } catch (e) {
        reject("error: " + e);
    }

});

但是在开始之前,我们先了解下一些概念

名词解释
group
业务类型(接口组),每个业务一个group。group下面可以注册多个终端(SekiroClient),同时Group可以挂载多个Action。 Group指代一个确定的业务,group下注册的client代码需要保持协议的完全一致。如微信机器人为一个group、xxx爬虫为另一个group。sekiro系统中Group全局唯一,同时Sekiro根据Group进行调用转发路由,并认为统一个group下面的所有client能够提供一致性的服务能力.也就是说,对sekiro来说,转发请求的时候,sekiro认为统一个group下的任意一个设备(手机、浏览器、客户端代码等)都是等价的,转发到任意一个终端都是相同的语义

SekiroClient
服务提供者客户端,主要场景为手机/浏览器等。最终的sekiro调用会转发到SekiroClient。每个client需要有一个惟一的clientId, ClientId最好用户自己生成,并且保证ClientId具备特定含义

action
接口,同一个Group下面可以有多个接口,分别做不同的功能。如视频app,区分用户搜索接口、用户视频接口、直播接口、用户详情接口等。action是为了方便用户开发的时候隔离多个业务进行的抽象,在sekiro层面可以帮你进行一次路由。 这是因为在实际开发过程中可以发现同一个设备,可能可以提供多个接口

clientId
ClientId指代设备,多个设备使用多个机器提供API服务,提供群控能力和负载均衡能力。如在手机上,同一个接口组下面可以挂载多台设备, ClientId大家需要保证每个客户端的ClientId的唯一性。建议使用mac地址、IMEI、ip地址+进程号等真实的物理ID作为ClientId。

使用ClientId可以作为调度策略控制的ID,完成对特定设备的调用管理。

转自官网

同时如果你已经打开了本地服务,Sekiro提供了以下API:

  • 查看分组列表:http://127.0.0.1:5620/business-demo/groupList

  • 查看分组下队列状态:http://127.0.0.1:5620/business-demo/clientQueue?group=test

    • group参数:需要查看的指定分组名
  • 调用转发:http://127.0.0.1:5620/business-demo/invoke?group=test&action=test&param=testparm

    • group参数:同上
    • action参数:需要调用的action名
    • param参数:需要传递的参数,注意这里param是自定义的参数名!也就是说在action参数后可以接比如sign,Sekiro客户端可以拿到此参数

现在来测试一下,以下代码是已经写好的DEMO

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sekiro TEST</title>
</head>
<body>
<h1>Sekiro TEST</h1>
<!--加载JsClient-->
<script src="https://sekiro.virjar.com/sekiro-doc/assets/sekiro_web_client.js"></script>
<script>
        function guid() {
            function S4() {
                  return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
            }
            return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
        }
        
        //注册Group,名为rpc-test
        var client = new SekiroClient("ws://127.0.0.1:5620/business-demo/register?group=rpc-test&clientId=" + guid());
        
        //注册Action,名为clientTime
        client.registerAction("clientTime",function(request, resolve,reject ){
            resolve(""+new Date());
        })

        //注册Action,名为getPwd
        client.registerAction("getPwd",function(request, resolve,reject ){
            const pwd = request['pwd']
            //调用假设的加密方法直接返回对应的结果
            resolve(pwdEncypt(pwd))
        })

</script>
</body>
</html>

浏览器打开此文件,确保本地服务开启,可以在浏览器开发人员工具中看到WS连接,访问http://127.0.0.1:5620/business-demo/groupList,响应结果:

{"data":["rpc-test"],"ok":true,"status":0}

访问http://127.0.0.1:5620/business-demo/clientQueue?group=rpc-test,响应结果:

{"data":["eddc8434-4c23-f7bb-31a6-21c4523b00d2"],"ok":true,"status":0}

现在,来调用以下刚才注册的Action。访问http://127.0.0.1:5620/business-demo/invoke?group=rpc-test&action=clientTime,响应结果:

{"__sekiro_seq__":2,"clientId":"eddc8434-4c23-f7bb-31a6-21c4523b00d2","data":"Wed Jan 11 2023 21:27:39 GMT+0800 (香港标准时间)","status":0}

可以看到,其将JS代码new Date()的结果转发了出来。

传递参数
现在需要加密一个数据,明文为123456,得到其加密后的结果,DEMO中注册了一个Action名为getPwd,它需要一个pwd参数,并调用返回加密结果,因此需要调用:
http://127.0.0.1:5620/business-demo/invoke?group=rpc-test&action=getPwd&pwd=123456
响应结果:

{"__sekiro_seq__":3,"clientId":"38617c08-b07d-40c0-d256-0ec1670cf20d","data":"123456abc123","status":0}

其中data便是期待的加密值(模拟)

实战-控制台注入方式

地址:http://q.10jqka.com.cn/

目标是个股行情,每次翻页都带有Cookie且不一致,为Cookie反爬

控制台中执行document.cookie,可以拿到对应的Cookie值,并猜测有JS代码在生成其值,可以在控制台执行以下代码,以在每次document.cookie值发生改变时,自动断住,方便在堆栈中找到可能的生成代码:

(function () {
  Object.defineProperty(document, 'cookie', {
    set: function (val) {
      if (val.indexOf('v') != -1) {
        debugger;
      }
      console.log('Hook捕获到cookie设置->', val);
      return val;
    }
  });
})();

执行后,翻页一下,在堆栈中搜索,可以轻松找到相关代码:

按往常是可以开始扣代码了,但是现在使用RPC方式,要做的就是将对应加密函数导出到全局然后供RPC客户端调用,在控制台输入并执行:

encypt = O

然后选择右上角的停用断点,取消调试状态并放行,这样就可以注入对应的RPC代码了

同时记得确保JS上下文为TOP,这样才能在控制台执行刚刚导出的encypt,并且后面的操作都要在此种执行

在控制台,输入JsClient所有源码然后执行,以及暴露RPC服务的代码:

function guid() {
    function S4() {
          return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
    }
    return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
}

var client = new SekiroClient("ws://127.0.0.1:5620/business-demo/register?group=ths&clientId=" + guid());

client.registerAction("getCookie",function(request, resolve,reject ){
    resolve(encypt());
})

此时打开http://127.0.0.1:5620/business-demo/groupList,响应结果:

{"data":["ths"],"ok":true,"status":0}

表明一切正常

此时再访问http://127.0.0.1:5620/business-demo/invoke?group=ths&action=getCookie,响应结果:

{"__sekiro_seq__":2,"clientId":"8db06d70-f1d1-defa-e975-13d3b9a0573a","data":"AzUv6rCtukrtP9729bCNgsH8RLrqsuEjs3XNPbdb8wT7mlskfwL5lEO23YdE","status":0}

可以看到Cookie值已成功被导出

现在就可以用Python脚本愉快地爬取:

import requests

def get_cookie():
    api = 'http://127.0.0.1:5620/business-demo/invoke?group=ths&action=getCookie'
    res = requests.get(api)

    return res.json()['data']

def get_info(page):
    
    api = f'http://q.10jqka.com.cn/index/index/board/all/field/zdf/order/desc/page/{page}/ajax/1/'
    headers = {
        "Accept": "application/json",
        "Accept-Encoding": "gzip, deflate, br",
        "Accept-Language": "zh-CN,zh;q=0.9",
        "Cache-Control": "no-cache",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.54",
        "Cookie":f"v={get_cookie()}"
    }

    res = requests.get(api,headers=headers)

    print(res.text)

if __name__ == '__main__':
    get_info(1)

实战-油猴注入方式

Tampermonkey (油猴) 是拥有 超过 1000 万用户 的最流行的浏览器扩展之一。 它适用于 Chrome、Microsoft Edge、Safari、Opera Next 和 Firefox。

它允许用户自定义并增强您最喜爱的网页的功能。用户脚本是小型 JavaScript 程序,可用于向网页添加新功能或修改现有功能。使用 Tampermonkey,您可以轻松在任何网站上创建、管理和运行这些用户脚本。

——转自官网

油猴可以让用户自行在添加脚本,并在开启对应页面时应用,可以把它认为是一个给自己注入脚本的一个工具。

可以在拓展上点击添加脚本来添加一个新脚本

选项 含义
@name 脚本的名称
@namespace 命名空间,用来区分相同名称的脚本,一般写作者名字或者网址就可以
@version 脚本版本,油猴脚本的更新会读取这个版本号
@description 描述这个脚本是干什么用的
@author 编写这个脚本的作者的名字
@match 从字符串的起始位置匹配正则表达式,只有匹配的网址才会执行对应的脚本,例如 * 匹配所有,https://www.baidu.com/* 匹配百度等,可以参考 Python re 模块里面的 re.match() 方法,允许多个实例
@include 和 @match 类似,只有匹配的网址才会执行对应的脚本,但是 @include 不会从字符串起始位置匹配,例如 *://*baidu.com/* 匹配百度,具体区别可以参考 TamperMonkey 官方文档
@icon 脚本的 icon 图标
@grant 指定脚本运行所需权限,如果脚本拥有相应的权限,就可以调用油猴扩展提供的 API 与浏览器进行交互。如果设置为 none 的话,则不使用沙箱环境,脚本会直接运行在网页的环境中,这时候无法使用大部分油猴扩展的 API。如果不指定的话,油猴会默认添加几个最常用的 API
@require 如果脚本依赖其他 JS 库的话,可以使用 require 指令导入,在运行脚本之前先加载其它库
@run-at 脚本注入时机,该选项是能不能 hook 到的关键,有五个值可选:document-start:网页开始时;document-body:body出现时;document-end:载入时或者之后执行;document-idle:载入完成后执行,默认选项;context-menu:在浏览器上下文菜单中单击该脚本时,一般将其设置为 document-start

接下来将通过油猴脚本方式注入RPC来逆向一个网站

目标地址:https://www.toutiao.com/

获取推荐栏榜单数据时,有一个关键的_signature参数

任意方式搜寻可能的生成代码(推荐搜索_signature),发现以下代码:

但是这并不意味着逆向结束,直接转发n,因为n的值是生成后的结果,要导出的目标应该是一个方法,因此进入I方法一探究竟

注意观察最后一段代码:

var o = {
                url: r + e
            };
return t.data && (o.body = t.data), (null === (n = window.byted_acrawler) || void 0 === n || null === (a = n.sign) || void 0 === a ? void 0 : a.call(n, o)) || ""

重点放在后面:

 (null === (n = window.byted_acrawler) || void 0 === n || null === (a = n.sign) || void 0 === a ? void 0 : a.call(n, o)) || ""

此处解析可参考Python 爬虫进阶必备 | 某新闻资讯站点参数 _signature 逻辑分析 (无代码)

所以这段代码执行时其实干了以下事:

n = window.byted_acrawler
a = n.sign
a.call(n,o)

总结一下就是window.byted_acrawler.sign(window.byted_acrawler,o),其中window.byted_acrawler是可以忽略的,参考Function.prototype.call(),也就是说可以进一步简化为window.byted_acrawler.sign(o)

可以在控制台调用下:

成功拿到结果

现在可以准备RPC代码了,同时对于o,可以试着自行构建一个一并返回,因此油猴代码如下:

// ==UserScript==
// @name         今日头条-RPC
// @namespace    替换成你的命名空间
// @version      0.1
// @description  今日头条-RPC
// @author       注意替换很你的名字
// @match        https://www.toutiao.com/*
// @icon         替换成你的图标或者干脆删除这一行
// @grant        none
// @require      https://sekiro.virjar.com/sekiro-doc/assets/sekiro_web_client.js
// ==/UserScript==

(function() {
    'use strict';
    function guid() {
        function S4() {
            return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
        }
    
        return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4());
    }
    
    var client = new SekiroClient("ws://127.0.0.1:5620/business-demo/register?group=jrtt&clientId=" + guid());

    client.registerAction("getUrl", function (request, resolve, reject) {
        var channelId = request.channel;
        if (!channelId){
            channelId = 0;
        }
        var urlParams = {
            url:'https://www.toutiao.com/api/pc/list/feed?min_behot_time='+Math.round(new Date().getTime()/1000)+'&refresh_count=5&category=pc_profile_recommend&aid=24&app_name=toutiao_web&channel_id='+channelId
        }


        resolve(""+urlParams.url+"&_signature="+window.byted_acrawler.sign(urlParams));
    });
    
})();

并且因为window.byted_acrawler是全局的,因此并不需要导出。保存油猴脚本并启动,回到今日头条并刷新,访问Sekiro的分组情况:

{"data":["jrtt"],"ok":true,"status":0}

可以看到成功连接

现在来调用一下:
http://127.0.0.1:5620/business-demo/invoke?group=jrtt&action=getUrl,响应结果:

{"__sekiro_seq__":2,"clientId":"72c7dff4-3e96-565e-449a-b5b5d397eb1b","data":"https://www.toutiao.com/api/pc/list/feed?min_behot_time=1673458709&refresh_count=5&category=pc_profile_recommend&aid=24&app_name=toutiao_web&channel_id=0&_signature=_02B4Z6wo00d01Ws5WIQAAIDAe2e9vmHbDGlrHVwAADl0A6cZQQXdRzJsXzV.yrJchiysvjx.ROE4yE2PVl450x0oeAmttZk.TZlJl6Zl1arOspUvyovWNV5x7vUHvHs3SXK2nZQubYjww-1965","status":0}

可以额外添加一个channel参数,用来调用不同的栏目,默认channel0

尝试使用Python来爬一下,发现200却一直乱码,在源码里翻找了一番也没看到有解密的意思,直接拿生成的url试试浏览器里编辑重放却没问题,编码也设置成了utf-8,找了半天才终于发现,requests响应头里有个'Transfer-Encoding': 'chunked',我猜测问题就出在这了,requests并没有把完整的文件下载完,然后尝试添加stream=True参数也没有用。

后来发现,请求头只保留一个user-agent就行,我TM@#!~&*,折腾两小时才发现这样就行了,淦
Python 代码:

import requests

def get_list(channel=None):
    def get_url(c=None):
        params = {
            'group':'jrtt',
            'action':'getUrl',
            'channel': c if c else ''
        }
        return requests.get('http://127.0.0.1:5620/business-demo/invoke',params=params).json()['data']

    s = requests.session()
    headers = {
        "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.76"

    }

    res = s.get(url=get_url(channel),headers=headers)
    res_json = res.json()
    return res_json['data']


if __name__ == "__main__":
    print(get_list())

实战-注入、替换方式

目标:广东省公共资源交易平台

表单以及响应结果都为明文,只有在请求头有签名,总共为3个,其中一个明显为时间戳,因此总共待破解的为2个

对于头部参数,可以采用头部HOOK方式。
在控制台输入以下代码来断住:

var code = function(){
    var org = window.XMLHttpRequest.prototype.setRequestHeader;
    window.XMLHttpRequest.prototype.setRequestHeader = function(k,v){
        if(k == 'X-Dgi-Req-Signature' || k == 'X-Dgi-Req-Nonce'){
            debugger;
        }
        return org.apply(this,arguments);
    }
}
var script = document.createElement('script');
script.textContent = '('+code+')()';
(document.head||document.documentElement).appendChild(script);
script.parentNode.removeChild(script);

在调用堆栈里一顿找,最终找到此处:

断点的位置便是两个参数的生成位置,相关代码如下:

const a = Date.now(),
    l = lK(16),
    c = sr([8, 28, 20, 42, 21, 53, 65, 6]);

const d = Sg({
    p: QC.stringify(o.data, {
        allowDots: !0
    }),
    t: a,
    n: l,
    k: c
});

f就是请求头的副本,因此没有给出,同时sr方法仅仅是通过Array解析字符串的,因此c为固定值,为'k8tUyS$m'。按照以往可以开始扣代码了,但是现在是打算使用PRC,因此可以将lKSg以及QC全部挂载到window对象上,方便Sekiro客户端调用。

现在使用本地替换的方式来注入RPC,启用本地替代,然后保存源码已备用

然后在顶部注入JsClient源码,以及注册一个client,这里挂载到window对象上,然后刷新页面,继续来到断点位置,导出需要的三个方法,同样控制台输入:

window.lK = lK
window.Sg = Sg
window.QC = QC

输入完毕后是时候放行然后注册Action了,放行代码,然后在控制台输入以下代码:

window.rpcClient.registerAction("getPost", function (request, resolve, reject) {
    let pageNum = request.page;
    if(!pageNum){
        pageNum = 1;
    }

    const c = 'k8tUyS$m',
        l = window.lK(16),
        a = Date.now(),
        formData = {
            dataType:"",
            openConvert:true,
            pageNo:pageNum,
            pageSize:10,
            projectType:"",
            publishEndTime:"",
            publishStartTime:"",
            secondType:"A",
            siteCode:"44",
            thirdType:"",
            total:0,
            type:"trading-type"
        },
        d = window.Sg({
            p: window.QC.stringify(formData, {
                allowDots: !0
            }),
            t: a,
            n: l,
            k: c
        });

    resolve(JSON.stringify({
        "headers":{
            "X-Dgi-Req-Nonce":l,
            "X-Dgi-Req-Signature":d.toString(),
            "X-Dgi-Req-Timestamp":a
        },
        "form":formData
    }))

});

这时候访问http://127.0.0.1:5620/business-demo/invoke?group=gdggzy&action=getPost就能拿到需要的数据了,响应结果:

{"__sekiro_seq__":2,"clientId":"d006a706-d960-0ff8-e7e5-3e79f761bac0","form":{"dataType":"","openConvert":true,"pageNo":1,"pageSize":10,"projectType":"","publishEndTime":"","publishStartTime":"","secondType":"A","siteCode":"44","thirdType":"","total":0,"type":"trading-type"},"headers":{"X-Dgi-Req-Nonce":"V6yYQSTDBQk3KWrA","X-Dgi-Req-Signature":"a65c32bc126fb2c17231b94139666889bef365bcf44f17e10b6aad531b306a62","X-Dgi-Req-Timestamp":1673595897604},"status":0}

当然,既然已经修改了代码,那干脆修改的彻底一点,直接在相关代码处进行注册然后再转发也是可以的,这里不再做演示

注意,这里只是演示通过RPC拿数据,签名是否有效不在讨论范围,想爬自己再研究把