JS RPC学习及实战
介绍
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¶m=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
参数,用来调用不同的栏目,默认channel
为0
尝试使用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())
实战-注入、替换方式
目标:广东省公共资源交易平台
![](https://sz134055.github.io//post-images/1673590124871.png)
表单以及响应结果都为明文,只有在请求头有签名,总共为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,因此可以将lK
、Sg
以及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拿数据,签名是否有效不在讨论范围,想爬自己再研究把