文泉学堂爬虫:Jwt-HS256加解密详解
武汉加油,中国加油
因新冠状病毒引发肺炎疫情影响,清华大学对外开放知识库,至开学前(2月16日)全国用户可免费免登陆进入“文泉学堂”网站阅读,我们借这个机会,通过文泉了解一下Jwt-HS256的加密与破解。
https://lib-nuanxin.wqxuetang.com/#/
声明
- 本文尽仅涉及到 Jwt-HS256 的数据加解密 ,不涉及书籍的下载与爬取;
- 本文仅限学习交流使用,不得用于商业或非法用途,否则,一切后果请用户自负。
- 如有侵权请邮件与我联系处理。
应用程序&依赖
Chrome
python 3.8
PyCharm
PostMan Canary
pip install pyjwt
pip install requests
解密思路及过程
- 找到需要解密的数据
- 进入阅读书籍页面,通过调试 JavaScript 找到Token的加密方法;
- 通过分析加密方法获取破解思路;
- 通过破解加密或调试 JavaScript ,得到加密前的参数;
- 解析参数的生成规律,自由生成可用参数;
- 将参数根据原方法加密,获得可用Token;
一、找到需要解密的数据
1. 首先打开任意一本书的阅读链接
https://lib-nuanxin.wqxuetang.com/read/pdf/3209130
# 观察到该链接最后为一串数字「3209130」,根据经验猜测这串数字应该是该书的「id 」或是「唯一标识」。
观察到书籍信息是通过图片方式显示的,在向上滚动网站的同时会加载下一页的图片。
2. 通过禁用 JavaScript 来判断网页中哪些内容是动态生成的
chrome://settings/content/javascript?search=javascript
# 在设置中搜索JavaScript或键入以上代码快捷进入JavaScript设置页
十分尴尬的是发现禁用了JavaScript之后,整个网页都无法加载了,由此可知整个网站全部由JavaScript动态渲染,无法通过静态请求拿到书籍信息,于是再次允许JavaScript运行。
3.进入Chrome控制台NETWORK选项查看JS加载的内容
在控制台的NETWORK选项卡中看到Img文件格式的书籍图片即为我们要获取的数据
分析一下该图片的链接:
结合每页图片的链接格式分析可得:
3209130 对应书籍ID,「1」对应页码,?k=一串加密的Token
格式:
https://lib-nuanxin.wqxuetang.com/page/img/「书籍ID」/ 「页码」?k=「加密后的Token」
直接将图片URL放入Postman中请求,发现并不能直接请求到图片,经过检查发现请求的referer不能为空,添加referer后可以成功请求到图片文件。(图片很大,请求好慢)
到此为止可知,只要能自由生成可用的图片链接后的Token,并根据「书籍ID」和「页码」拼接成有效的图片URL,即可下载图片。
二、调试JavaScript,获取加密方法
我们在控制台Network中该请求的Initiator属性中可以找到他调用的JavaScript文件和函数。
使用的JS文件及入口函数
- 「read.v5.3.1.722eb.js」 ----- setPageSrc
- 「chunk-vendors.v5.3.1.722eb.js」
在控制台中Source中找到这两个JS文件并点击左下角花括号fromat代码。
搜索入口函数setPageSrc,并在函数结尾设置断点
重新载入网页,网页暂停在「2050」断点处,同时以小红字展示出该函数的目前的所有临时变量。
在这里发现类似「token」的临时变量「s」,用控制台输出一下,果然是需要的「Token」
可以看到临时变量「s」是由「this.getJwt」函数生成的。
>var s = i ? "width=100" : "k=" + this.getJwt(this.bid, t);
取消「2050」处断点并搜索「getJwt」函数,在函数结尾设置断点,重新载入。
发现临时变量「a」为我们需要的token
临时变量「a」由函数「ue.a.sign」生成
var a = ue.a.sign({
p: t,
t: Date.parse(new Date),
b: e,
w: 1e3,
k: this.$store.state.aesK
}, this.$store.state.jwtSecret.toString());
继续向内查找ue.a.sign函数,发现搜索无法搜索到函数体,于是在控制台输入「ue.a.sign」,输出函数体,点击跳转进入ue.a.sign函数体
发现函数体不在「read.v5.3.1.722eb.js」里面而是进入了「chunk-vendors.v5.3.1.722eb.js」中,函数体很长,我们同样删除原断点后在函数结尾设置断点,重新载入
这次发现了关键信息:
header: {alg: "HS256", typ: "JWT"}
这个信息告诉我们,该Token是通过「JWT-HS256」加密的。
三、JWT-HS256 加密详解
根据header,我们发现该token的加密模式为「Jwt-HS256」加密。
Jwt 全称表示为:JSON Web Tokens ,官网为https://jwt.io,Jwt-HS256加密由以下三个部分组成:
- header
- payload
- signature
三个部分使用「Base64」编码后拼接组成Token,中间用点分隔,刚刚看到书籍图片链接后面的长长的一串就是加密完成的Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwIjoxLCJ0IjoxNTgwNzM4ODA4MDAwLCJiIjoiMzIwOTEzMCIsInciOjEwMDAsImsiOiJ7XCJ1XCI6XCI1SklXUG9yaEFGaz1cIixcImlcIjpcInVnVnQ5RC95NHE4Rk9kMFhqeXh0aFE9PVwiLFwidFwiOlwiTFdTcTJDOG54YzY0ZkN4RDM0aVp2dz09XCIsXCJiXCI6XCJaWmxiVnNOUGdWQT1cIixcIm5cIjpcImhJRXJzcnlYUDhVPVwifSIsImlhdCI6MTU4MDczODgwOH0.yyC518F2QiI9AHUMhMLa1YVxudzUedGBsiToELRMl1M
Tips:Base64是一种编码,并不是一种加密过程,不具备安全性,可以任意编码解码。
Tips:由于「+」「/」「=」三个符号在url中有特殊意义,所以在用作token时需要删除。
header
header一般是固定的内容,由使用的算法和Token的类型组成:
{alg: "HS256", typ: "JWT"}
# 使用的算法是HS256 , token的类型为JWT
通过Base64编码后如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload
Payload是Token的具体内容,格式为JSON,内容可以自定。
{
"name": "ITHOMIA",
"email": "[email protected]",
"qq": "3339118668"
}
通过Base64编码后如下:
ewoJIm5hbWUiOiAiSVRIT01JQSIsCgkiZW1haWwiOiAiaXRob21pYUBvdXRsb29rLmNvbSIsCgkicXEiOiAiMzMzOTExODY2OCIKfQ==
删除结尾的等号:
ewoJIm5hbWUiOiAiSVRIT01JQSIsCgkiZW1haWwiOiAiaXRob21pYUBvdXRsb29rLmNvbSIsCgkicXEiOiAiMzMzOTExODY2OCIKfQ
Signature
Signature部分由Base64编码后的header和payload拼接,中间用点分隔 ,之后再加入Secret通过HS256算法加密后得到。
Tips:Secret 是一串由服务端定义的密钥,不应该对客户端展示;
Tips:注意这里的HS256加密和Hmacsh256加密不同,HS256加密后返回密文长度为43,而Hmacsh256返回长度为64。
实例
Jwt在使用需要引用第三方依赖,不推荐自己编写HS256算法,绝大多数第三方依赖无需手动将JSON转化为Base64编码,直接将JSON作为参数传入即可
Python实现
import jwt
header = {"alg": "HS256", "typ": "JWT"}
payload = {"name": "ITHOMIA", "email": "[email protected]", "qq": "3339118668"}
Secret = 'ITHOMIA'
k = jwt.encode(headers=header, payload=payload, key=Secret).decode()
print(k)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSVRIT01JQSIsImVtYWlsIjoiaXRob21pYUBvdXRsb29rLmNvbSIsInFxIjoiMzMzOTExODY2OCJ9.M_tk6WkRPb4pnHloQxZS8qyK1Hu9wb-oCZ-MwrASC5g
Python中的pyjwt模块内置header固定为 { “typ”: “JWT”,“alg”: “HS256”}, 只能添加无法修改。
在header的typ和alg顺序不同时,会导致Base64编码后的数据不同,具体数据如下。
{ “typ”: “JWT”,“alg”: “HS256”} Base64=> eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
{ “alg”: “HS256”,“typ”: “JWT”} Base64=> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- 解决方法为修改模块的内置方法,详情见我的另一篇博文。1
JavaScript实现
const jsrsasign = require("/usr/local/lib/node_modules/jsrsasign");
var header = {"alg": "HS256", "typ": "JWT"};
var payload = {"name": "ITHOMIA", "email": "[email protected]", "qq": "3339118668"};
var Secret = 'ITHOMIA';
var k = jsrsasign.jws.JWS.sign(null, header, payload, Secret);
console.log(k);
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSVRIT01JQSIsImVtYWlsIjoiaXRob21pYUBvdXRsb29rLmNvbSIsInFxIjoiMzMzOTExODY2OCJ9.M_tk6WkRPb4pnHloQxZS8qyK1Hu9wb-oCZ-MwrASC5g
四、破解加密,获取加密前的参数
获取加密前的参数本应调试JavaScript取得,但我们已知参数的加密方式为Jwt-HS256,同时刚刚提到,Base64是一种编码,并不是一种加密过程,不具备安全性,所以没有必要继续调试JS,只需要将Base64编码的Token转换回字符串,即可得到加密前的参数信息。
将Token明文化
从书籍图片网址中提取Token的三个部分:
header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
payload = "eyJwIjoxLCJ0IjoxNTgwNzU2MDc3MDAwLCJiIjoiMzIwOTEzMCIsInciOjEwMDAsImsiOiJ7XCJ1XCI6XCJKMUR1VTdiTDJEbz1cIixcImlcIjpcIlhXeFM4STh1N0xuN0F6YThpVStqL2c9PVwiLFwidFwiOlwieFQ3c3V3UzB5ZytXRjdvZkVvcnFMdz09XCIsXCJiXCI6XCJ5aUw2S3ZSV2o4az1cIixcIm5cIjpcIm9PckxZR3BSZ3lvPVwifSIsImlhdCI6MTU4MDc1NjA3N30"
signature = "_z5Ji3WCV2urRLzEWWRDToqoGX9tl4Mtts9f7kxRtME"
将header和payload部分进行解码:
header_str = base64.b64decode(header).decode()
print(header_str)
payload_str = base64.b64decode(payload).decode()
print(payload_str)
{“alg”:“HS256”,“typ”:“JWT”}
binascii.Error: Incorrect padding
解码后发现header部分解码正常,而payload部分无法解码。这是因为过长文本在进行Base64编码时,如果长度为奇数则会使用等号占位拼接为偶数。这里尝试在payload的后面拼接一个等号:
payload = "eyJwIjoxLCJ0IjoxNc.....6MTU4MDc1NjA3N30="
payload_str = base64.b64decode(payload).decode()
print(payload_str)
{“p”:1,“t”:1580756077000,“b”:“3209130”,“w”:1000,“k”:"{“u”:“J1DuU7bL2Do=”,“i”:“XWxS8I8u7Ln7Aza8iU+j/g==”,“t”:“xT7suwS0yg+WF7ofEorqLw==”,“b”:“yiL6KvRWj8k=”,“n”:“oOrLYGpRgyo=”}",“iat”:1580756077}
成功解码payload。
解析明文参数
我们发现header是固定不变的,无需解析。
{alg: "HS256", typ: "JWT"}
将payload通过JSON格式展示:
{
"p": 1,
"t": 1580756077000,
"b": "3209130",
"w": 1000,
"k": "{\"u\":\"J1DuU7bL2Do=\",\"i\":\"XWxS8I8u7Ln7Aza8iU+j/g==\",\"t\":\"xT7suwS0yg+WF7ofEorqLw==\",\"b\":\"yiL6KvRWj8k=\",\"n\":\"oOrLYGpRgyo=\"}",
"iat": 1580756077
}
通过多次使用不同的Token解码配合调试JS进行比对,得出规律:
{
"p": 当前页码,
"t": 当前时间戳取整 * 1000,
"b": "当前书籍ID",
"w": 1000, //固定为1000,猜测可能是Token时效性,单位:s
"k": "未知规律的JSON数据",
"iat": 当前时间戳取整
}
分析完成后,仅剩「k」参数未分析成功。
再次调试JavaScript,解析「k」参数
由于「k」参数未知,再次调试JavaScript尝试解析。
回到Chrome控制台,重新向getJwt的函数末尾「1892」下断点,并重新载入。
此处发现参数「k」是根据「this.$store.state.aesK」产生的:
k: this.$store.state.aesK
通过搜索「aesK」,可以发现「aesK」是由「setK」函数产生的。删除原断点并向函数末尾添加断点,重新载入网页。
此处并没有获得更多的信息,只知道「aesK」是由临时JSON变量「 t 」转化为字符串产生的。
再次向上搜索「setK」,下断点并刷新,看看这个临时变量「 t 」到底是怎么来的。
发现进入了滚动监听中,找到「setK」函数由「e.$store.commit」执行,临时变量「 t 」为参数 i[“data”][“data”] ,推测该函数为网络请求函数。
向控制台发送「 i 」命令,查看临时变量「 i 」的详细信息:
果然「 i 」为网络请求的响应对象,url,method,params,headers,一应俱全。
五、自由生成可用参数
构造自己的「k」参数
通过得到的「 i 」对象,分析HTTP请求:
- 构造url:
base_url = 'https://lib-nuanxin.wqxuetang.com' url = base_url + "/v1/read/k"
- 分析params:
params = {"bid": "书籍ID"}
生成请求:
import requests
base_url = 'https://lib-nuanxin.wqxuetang.com'
url = base_url + "/v1/read/k"
params = {"bid": "3209130"}
response = requests.get(url, params=params)
print(response.text)
{“data”:{“u”:“hdo/TZw9R0E=”,“i”:“sDHlcVZ8PjrF2TYOVQHjKg==”,“t”:“usVhJQJ/S/DWIl6F1onOrQ==”,“b”:“Ma4GV4NJ+XQ=”,“n”:“Ip8NYew72NU=”},“errcode”:0,“errmsg”:“success”}
与Token中解析出来的「k」参数对比,从响应中提取出正确的「k」:
Token:
{\"u\":\"J1DuU7bL2Do=\",\"i\":\"XWxS8I8u7Ln7Aza8iU+j/g==\",\"t\":\"xT7suwS0yg+WF7ofEorqLw==\",\"b\":\"yiL6KvRWj8k=\",\"n\":\"oOrLYGpRgyo=\"}"
Response:
{"data":{"u":"hdo/TZw9R0E=","i":"sDHlcVZ8PjrF2TYOVQHjKg==","t":"usVhJQJ/S/DWIl6F1onOrQ==","b":"Ma4GV4NJ+XQ=","n":"Ip8NYew72NU="},"errcode":0,"errmsg":"success"}
可以看出,Token中的数据为dumps后的Response中的[“data”]字段,转义符经测试发现对生成结果没有影响。
接下来编写函数来获得自己的「k」参数:
import requests
def get_aesK(book_id):
base_url = 'https://lib-nuanxin.wqxuetang.com'
url = base_url + "/v1/read/k"
params = {"bid": str(book_id)}
response = requests.get(url, params=params)
return json.dumps(response.json()['data'])
if __name__ == '__main__':
k = get_aesK(3209130)
print(k)
{“u”: “GhXWKjHdeEw=”, “i”: “e7noyNxb36ZZJibtwrCj3Q==”, “t”: “qCYOHAiU7Z3SERESsXlVlQ==”, “b”: “TCeKETGsy4Y=”, “n”: “+KviyUw5oAw=”}
构造自己的Token
编写函数来生成自己的header:
def init_header():
return {"alg": "HS256", "typ": "JWT"}
编写函数来生成自己的payload:
def init_payload(book_id, page, k):
return {
"p": page,
"t": time.time() * 1000,
"b": str(book_id),
"w": 1000,
"k": k,
"iat": time.time()
}
现在header和payload都已经解析完成,还需要Jwt-HS265加密的最后一个参数:Signature
Signature部分由Base64编码后的header和payload拼接,中间用点分隔 ,之后再加入Secret通过HS256算法加密后得到。
调试 JavaScript 寻找「Secret」参数
想要得到「Signature」需要使用「Secret」进行加密,我们需要继续调试JavaScript来寻找「Secret」参数
在控制台中通过搜索直接得到「Secret」参数
本以为这个最关键的密钥会很难拿到,甚至不会出现在客户端。但由于Token是需要用JavaScript动态生成的,所以这个密钥以明文方式出现在这也情有可原。
六、重新加密参数,获得可用的图片链接
编写init_imgSrc和init_token函数,参数为「书籍ID」和「页码」:
import json
import time
import jwt
import requests
def get_aesK(book_id):
base_url = 'https://lib-nuanxin.wqxuetang.com'
url = base_url + "/v1/read/k"
params = {"bid": str(book_id)}
response = requests.get(url, params=params)
return json.dumps(response.json()['data'])
def init_payload(book_id, page, k):
return {
"p": page,
"t": time.time() * 1000,
"b": str(book_id),
"w": 1000,
"k": k,
"iat": time.time()
}
def init_header():
return {"alg": "HS256", "typ": "JWT"}
def init_token(book_id, page):
k = get_aesK(book_id)
header = init_header()
payload = init_payload(book_id, page, k)
secret = 'g0NnWdSE8qEjdMD8a1aq12qEYphwErKctvfd3IktWHWiOBpVsgkecur38aBRPn2w'
return jwt.encode(headers=header, payload=payload, key=secret).decode()
def init_imgSrc(book_id, page):
token = init_token(book_id, page)
return 'https://lib-nuanxin.wqxuetang.com/page/img/{}/{}?k={}'.format(book_id, page, token)
if __name__ == '__main__':
src = init_imgSrc(3209130, 1)
print(src)
https://lib-nuanxin.wqxuetang.com/page/img/3209130/1?k=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwIjoxLCJ0IjoxNTgwNzc1MzUyNjUyLjgxNiwiYiI6IjMyMDkxMzAiLCJ3IjoxMDAwLCJrIjoie1widVwiOiBcIkdoWFdLakhkZUV3PVwiLCBcImlcIjogXCJlN25veU54YjM2WlpKaWJ0d3JDajNRPT1cIiwgXCJ0XCI6IFwiUjFkVUp6VkFXV1k5V2Q2TUhvZXJYQT09XCIsIFwiYlwiOiBcIlRDZUtFVEdzeTRZPVwiLCBcIm5cIjogXCIrS3ZpeVV3NW9Bdz1cIn0iLCJpYXQiOjE1ODA3NzUzNTIuNjUyODE3fQ.hpoDtSKzo7XBDuWIzNueRH0AlXjn7olAtpAJkebLEYs
Tips:由于Token可能由时效性,该链接可能会失效。
使用Postman成功下载该图片:
总结
文泉的解密主要在于了解Jwt-HS256加密的规则,调试JS的部分比较少。因为base64不具备安全性的机制,可以将直接参数解码为明文,同时Secret以明文方式展示给了客户端,也未在JS中设置偏移量等加密内容,避免了大量调试JS找参和调参的过程,总体难度中等偏上,适合想要进阶学习的朋友尝试。
编辑于 Tue Feb 4 08:43:14 2020
左素
解决Python修改pyjwt模块默认header无效的问题
https://blog.csdn.net/m0_46261074/article/details/104165261 ↩︎