☰
有道翻译接口sign逆向实战:从抓包到Python复现AES签名
2026/9/29 3:02:43 网站建设 项目流程

做接口调试或者爬虫开发的人,都绕不过一种让人头疼的情况:网页功能明明正常用着,但浏览器发出去的所有请求都带了一堆加密参数。拿有道翻译网页版来说,你在Network面板里随便看一次翻译请求,会发现请求体里除了翻译文本本身,还有sign、salt、lts、bv这一堆看着就是动态生成的东西。如果不把sign的生成规则搞清楚,就算把请求头复制得一模一样,服务端照样不认账。这篇文章我就从零开始把整个流程拆开,先讲怎么抓包找到目标接口,再看怎么从JS代码里定位加密函数,最后用Python把AES加密完整复现出来。整个过程不用任何复杂的逆向框架,就靠Chrome开发者工具和一点耐心,特别适合刚接触JS逆向的朋友,也适合想系统捋一遍签名分析逻辑的开发者。

1. 先搞清楚思路:一个带签名的翻译接口是怎么工作的

1.1 为什么要给请求加签名

在有道翻译这种网页服务里面,前端JavaScript代码是公开的,接口地址也是公开的,只要打开开发者工具就能看到所有请求细节。如果不做任何防护,任何人拿到接口地址之后都可以抛开页面直接构造请求,把翻译接口当成免费的公共API来调用,服务端的资源消耗和成本都会失控。

签名机制本质上就是服务端用来判断"这个请求是不是来自我的页面"的一种手段。前端会在JS代码里用一个秘密的算法,把请求参数组合起来生成一个sign值,跟着请求一起发出去。服务端收到之后用同样的算法重新计算一遍,如果两个sign一致就放行,不一致就判定为非法请求。这里的核心在于,算法和密钥藏在JS代码里,而服务端验证的时候并不知道也不关心前端具体怎么生成的,它只检查结果是否匹配。

翻译接口用到的参数里,i是你要翻译的文本,from和to分别是源语言和目标语言,这些都好懂。但salt、lts、sign这几个就是典型的签名参数,salt是一串随机字符串或者时间戳,lts是当前时间戳,sign则是把前面这些参数加上密钥做一系列加密运算之后的产物。其中最容易让人迷惑的是,这些参数表面上看起来毫无关联,但实际上一环扣一环:lts变了,salt大概率也变,sign又依赖salt和lts,所以想要构造一个合法的请求,必须先把这三者之间的关系摸清楚。

1.2 破解签名流程的五步路线图

分析这种带签名的接口,我一般遵循一个固定的思路,大家可以照着这个顺序来,不要跳步。

第一步是抓包,先把真实请求里所有参数的结构和值记录下来,搞清楚哪些是固定的,哪些是动态生成的。第二步是定位,在JS代码里找到生成sign的函数,这一步最考验经验,因为生产环境的代码都是压缩和混淆过的。第三步是分析,把加密函数读明白,提取出密钥、偏移量、加密模式、填充方式这些关键参数。第四步是复现,用Python把同样的算法写一遍,生成一模一样的签名。第五步是验证,拿着生成的签名实际请求一次,看看服务端是否返回正常结果。

这里有一个很重要的经验,就是顺序千万别反。我见过很多新手一上来就在JS文件里搜关键字,搜到几个函数就开始猜,恨不得跳过抓包直接写代码。但实际上,抓包阶段的数据反而是最可靠的参照物,因为你手上有真实的请求,后面每一步的判断都可以拿它来校验。比如你在JS里找到一个加密函数,不确定它是否就是生成sign的那个,直接在Console里调用一下,把输出和抓包里看到的sign对比,立马就知道对不对。

2. 浏览器抓包:先找到那个翻译接口

2.1 用Chrome开发者工具捕捉翻译请求

打开有道翻译网页版,按F12打开开发者工具,切到Network面板。这里有个小细节,一定要勾选Preserve log,否则页面发生跳转或者刷新时,之前的请求记录会被清空。然后在左侧输入框随便输入一段英文,点击翻译按钮,观察Network面板里新增的请求。

因为页面上会有大量静态资源请求,比如图片、字体、CSS文件,所以我们要做一次过滤。点击面板顶部的Fetch/XHR标签,只显示异步请求,这样就能快速筛掉大部分无关内容。找到那个专门的翻译接口,接口的路径往往带有translate或者webtranslate这样的关键词,响应内容是JSON格式,一眼就能认出来。

点击这条请求,在Payload标签页里就能看到完整的请求参数。这是我实际抓到的参数情况:

参数示例值说明
ihello world待翻译的文本
fromauto源语言,自动检测
tozh-CHS目标语言,简体中文
clientfanyideskweb客户端标识,固定值
productwebfanyi产品标识,固定值
appVersion1.0.6版本号,固定值
salt1735699200001动态值,和请求时间相关
lts1735699200000动态值,毫秒级时间戳
sign一串Base64字符串核心签名,需要破解
bv一段哈希字符串浏览器指纹相关,可固定

这里最值得注意的就是salt、lts、sign这三个参数。你多试几次翻译,每次重新抓包,会发现lts一直在变,salt也跟着变,sign更是完全不一样。但client、product、appVersion这些参数是固定的,也就是说它们不参与动态变化,更像是用来标识请求来源的固定字段。

2.2 对比参数的变化规律

为了确认参数之间的关联性,我做了个简单实验,连续翻译两次,把参数记录下来对比。第一次输入hello,第二次输入world,两次之间间隔几秒钟。对比结果非常明显,lts从1735699200000变成了1735699205000,两者相差约5秒,这正好是两次操作的时间间隔,说明lts就是当前时间戳。salt的值每次也都不同,但它和lts的数值非常接近,通常就是lts再加上一个很小的数字。

这个规律很关键。它说明salt是在lts基础上产生的,那么sign大概率也和这两个参数有关。你可能会想,能不能把salt固定成一个数字,或者把lts改成任意值?可以试,但服务端的验证逻辑必然会校验时间戳的时效性。如果lts和服务器当前时间差距太大,请求就会直接被拒绝。所以在复现的时候,一定要保证这两个参数是实时生成的,不能用写死的值。

2.3 用Initiator顺藤摸瓜找到加密代码

找到接口之后,很多人习惯直接在JS文件里搜索sign这个关键词。这样做不是不行,但效率很低,因为压缩后的JS文件可能有几千行,而且minified之后变量名都是a、b、c这种,跳来跳去容易跟丢。

更高效的做法是看Initiator面板,也就是请求的发起者信息。在Network面板里点击那条翻译请求,右侧会有一个Initiator标签页,里面展示了这个请求是由哪个JS文件、哪个函数发起的,以及完整的调用栈。从调用栈的第一层往下翻,就能看到从点击按钮到发起请求的完整链路,sign的生成逻辑就藏在这条链路的某个环节里。

点击调用栈中的JS文件链接,开发者工具会直接跳转到Sources面板,自动定位到对应代码行。到这里,整个分析就从黑盒变成了白盒,后面的事情就顺理成章了。

3. 从JS代码里挖出AES加密的全部细节

3.1 格式化压缩代码,别被minified吓到

跳到Sources面板之后,你大概率会看到一排排压缩过的代码,变量名全是a、b、c这种单字母,换行和缩进统统没有,几千个字符挤在一行里。第一次见到这种场面的人很容易犯怵,但其实处理方法很简单。

在代码区域的左下角有一个格式化按钮,图标是{},点击之后Chrome会自动把代码重新排版,加上缩进和换行。格式化之后虽然变量名还是a、b、c,但至少代码的结构清晰了,函数之间的嵌套关系一目了然,搜索关键词也方便很多。

格式化之后不要急着逐行读代码,先搜索几个关键字符串。核心的搜索词建议按顺序试:首先是接口路径本身,找到这个接口是怎么被调用的;其次是sign,看看这个字段在哪里被赋值;如果搜不到,再试AES、encrypt、CryptoJS这些加密相关的词。因为生产环境代码虽然混淆了变量名,但字符串常量通常还是会保留,接口路径和加密库的方法名都是硬编码在代码里的,搜索它们比搜索变量名靠谱得多。

3.2 提取key、iv、mode、padding四个核心参数

AES加密虽然算法复杂,但只要抓住四个参数就能完整复现:密钥key、偏移量iv、加密模式mode、填充方式padding。加密模式有ECB、CBC、CTR等好几种,其中CBC模式最常见,因为同一段明文在不同位置加密时,即使内容相同,密文结果也会不同,安全性更高。填充方式最常用的是PKCS7,它的作用是让明文长度凑齐AES分组的整数倍。

有道翻译的AES加密逻辑,我简化之后大概是这样的:

function encrypt(data) { var key = "ydsecret://query/key/bob@baidu.com!*"; var iv = "ydsecret://query/iv/data@baidu.com!*"; var realKey = CryptoJS.MD5(key).toString().substring(0, 16); var realIv = CryptoJS.MD5(iv).toString().substring(0, 16); var encrypted = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(realKey), { iv: CryptoJS.enc.Utf8.parse(realIv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); }

第一次看到这个函数,大部分人会被两个细节卡住。第一,key和iv的值看起来是一串URL格式的字符串,但实际加密用的不是这串原始字符串,而是先对它做一次MD5哈希,再取前16个字符作为真正的密钥和偏移量。第二,密钥和偏移量都是16个字符,正好对应AES-128位加密的长度要求。

那么sign到底是对什么内容做加密呢?再看sign生成的地方,会发现它并不是直接加密翻译文本,而是把多个参数拼接成一个固定格式的字符串:

var plaintext = "client=" + client + "&mysticTime=" + lts + "&product=" + product + "&key=" + i;

把client、lts、product和翻译文本i拼接成这种key=value格式的字符串,然后对这段字符串做AES加密。所以最终的sign值,本质上是一段特定格式明文的AES-CBC密文,再经过Base64编码后的结果。

3.3 在Console里手动执行一遍,验证你的判断

分析到这一步,你已经有了密钥、偏移量、加密模板,但这时候还不能急着写Python代码。先用浏览器的Console把整条链路跑一遍,验证你对代码的理解是否正确。

操作方式很简单:在Console里先引入CryptoJS库,或者直接在原来的页面上下文中调用已经加载好的CryptoJS对象。然后按你刚才分析的逻辑,手动写一段代码,传入抓包时看到的lts和翻译文本,算出sign值,再和实际抓到的sign值做对比。如果两个值完全一致,说明整个分析链路没有问题。如果不一致,就要回头检查,要么是key或iv解析错了,要么是明文拼接顺序错了,要么是漏掉了某个参与计算的参数。

这一步务必做,因为浏览器环境里调试JS非常方便,出错了可以随时修改,但写Python代码报错之后排查起来就麻烦多了。我通常在Console里验证通过之后才会动手写Python,这条习惯帮我省了无数排查时间。

4. Python复现:从生成sign到解密响应

4.1 装好依赖库,写一个通用的AES工具函数

浏览器环境里用的是CryptoJS,Python这边对应的是pycryptodome库。安装命令很简单:

pip install pycryptodome requests

pycryptodome的AES接口封装得很清晰,CBC模式需要提供三个参数:密钥、偏移量、填充方式。和CryptoJS一样,PKCS7填充对应pycryptodome里的pad函数,使用方式如下:

from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 import hashlib def aes_cbc_encrypt(plaintext, key_secret, iv_secret): # 密钥和偏移量都取MD5结果的前16个字符 key = hashlib.md5(key_secret.encode("utf-8")).hexdigest()[:16] iv = hashlib.md5(iv_secret.encode("utf-8")).hexdigest()[:16] cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8")) # 补齐到16字节的整数倍,这是AES分组的基本要求 padded_data = pad(plaintext.encode("utf-8"), AES.block_size) encrypted = cipher.encrypt(padded_data) # 返回Base64编码的结果,和JS端一致 return base64.b64encode(encrypted).decode("utf-8")

这个函数可以直接复用,以后遇到其他用CBC模式的加密接口,只需要替换key_secret和iv_secret就行。需要注意的一点是,MD5结果默认是32位小写十六进制字符串,substring(0, 16)对应Python里就是切片取前16个字符,千万别因为Python习惯用切片就写成[:8],这里是要取16个字符。

4.2 生成sign并组装完整请求

接下来生成sign。按照前面分析的规则,先把client、lts、product和翻译文本拼接成明文,然后用刚才的加密函数生成sign:

import time import requests def generate_sign(text, lts): client = "fanyideskweb" product = "webfanyi" plaintext = f"client={client}&mysticTime={lts}&product={product}&key={text}" key_secret = "ydsecret://query/key/bob@baidu.com!*" iv_secret = "ydsecret://query/iv/data@baidu.com!*" return aes_cbc_encrypt(plaintext, key_secret, iv_secret) def translate(text): lts = int(time.time() * 1000) # 毫秒级时间戳 salt = lts + 1 # 基于时间戳生成,实测通常比lts大一点 sign = generate_sign(text, lts) payload = { "i": text, "from": "auto", "to": "zh-CHS", "domain": "0", "useTerm": "false", "dictResult": "true", "keyid": "webfanyi", "client": "fanyideskweb", "action": "0", "product": "webfanyi", "appVersion": "1.0.6", "salt": str(salt), "sign": sign, "lts": str(lts), "bv": "固定的bv值", "docType": "json", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://fanyi.youdao.com/", "Origin": "https://fanyi.youdao.com", } url = "https://fanyi.youdao.com/webtranslate" resp = requests.post(url, data=payload, headers=headers) return resp

写到这一步,有几个容易踩坑的地方。一个是时间戳的格式,服务端要求的是毫秒级时间戳,Python里的time.time()返回的是秒,要乘以1000。另一个是salt,前面说过salt通常比lts大一点,有的版本生成逻辑是lts + 一个随机数,有的版本直接用lts本身,稳妥的做法是在本地验证时对比一下抓包记录里的差值再决定。

还有一个细节是关于bv参数。它是对浏览器的User-Agent做MD5运算生成的,用来标记客户端环境。我实际测试下来,这个值在同一个浏览器环境下是固定的,不需要每次都动态生成。但如果你直接把它留空,服务端可能会判定为异常请求,所以建议用浏览器抓包拿到一个真实值填进去。

4.3 解密加密的响应内容

请求发送成功,你以为就结束了?并没有。有道翻译的接口还有一个特点,响应内容也是加密的。直接打印返回的JSON,你会看到这样的结构:

{ "code": 0, "data": "一串看起来像乱码的Base64字符串" }

这个data字段就是加密后的翻译结果,需要用同样的AES解密逻辑还原成明文。解密方向正好反着来,先用Base64解码,再用CBC模式解密,最后去掉填充:

from Crypto.Cipher import AES from Crypto.Util.Padding import unpad def aes_cbc_decrypt(ciphertext_b64, key_secret, iv_secret): key = hashlib.md5(key_secret.encode("utf-8")).hexdigest()[:16] iv = hashlib.md5(iv_secret.encode("utf-8")).hexdigest()[:16] encrypted_data = base64.b64decode(ciphertext_b64) cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8")) decrypted = unpad(cipher.decrypt(encrypted_data), AES.block_size) return decrypted.decode("utf-8")

把解密后的字符串再转成JSON,就能看到翻译结果了。我实际测试下来,响应里包含的是带格式标签的译文结构,需要从JSON中提取特定的字段才能拿到纯文本结果。这一步虽然简单,但容易漏掉,很多人以为拿到响应就算完成,结果发现解析出来的是一堆乱码。

5. 常见问题与排查技巧

5.1 请求返回错误或状态码异常怎么办

我在调试过程中最常遇到的报错就是权限校验失败、状态码403或者code字段返回非0。这类问题九成以上和签名相关,但具体原因可能有好几种。

首先要检查lts是否和服务端时间偏差太大。你可以在本地跑一下int(time.time() * 1000),拿这个值去和服务器返回的时间对比。偏差超过几秒就会被拒绝,这属于防重放机制的正常行为。第二个常被忽略的地方是明文拼接的格式,注意看拼接后的字符串里各个key=value的顺序,顺序变了hash结果就完全变了,哪怕内容一字不差。第三个是URL编码,如果你翻译的文本里包含中文、空格、&、+这些特殊字符,请求体的编码方式会影响最终生成的sign。我在测试中文翻译时遇到过这类问题,解决方式是把请求中的编码方式固定为UTF-8,并且确保用于生成sign的文本和实际发送的文本完全一致。

下面是几张常见问题的速查表,方便大家快速定位。

现象可能原因排查方向
code返回非0sign错误对比Console验证结果与Python输出
403 Forbiddenlts超时检查本地时间和服务端时间差异
返回"Invalid request"参数缺失或格式错误对比抓包中的完整参数列表
响应解密出来是乱码key/iv取错确认MD5取的是前16位还是后16位
中文翻译失败编码不一致统一使用UTF-8编码

5.2 Python加密结果和JS不一致的排查思路

这是所有逆向工程里最折磨人的问题。你确认了自己的分析逻辑没错,但Python算出来的sign和浏览器里抓到的就是不一样。这时候不要慌,按照下面几个方向逐一排查。

第一是Base64编码。CryptoJS默认的toString()结果是带Base64的字符串,Python这边如果用了base64.b64encode,理论上应该一致,但要注意算出来的是bytes还是str,有没有多余的换行符。第二是字符编码,Python里字符串默认是Unicode,但发送请求时如果按照UTF-8编码,而你生成sign的时候用的是Unicode编码,结果肯定不同。第三是密钥和偏移量的编码方式,确保都是先用UTF-8编码再放入AES,不要混用。

还有一个容易忽略的坑是MD5结果的大小写。CryptoJS的MD5.toString()默认返回小写十六进制,Python的hashlib.hexdigest()返回的也是小写,这两者是一致的。但如果你在代码里加了.upper(),结果就完全不同了。我见过有人在网上找代码,复制过来之后只管能用,但不明就里,一旦算法更新就完全不知道怎么调整。

5.3 网站更新算法之后的应对思路

有道翻译的加密算法并不是一成不变的,实际上它改过很多次。早期版本用的是MD5签名,后来升级到AES-CBC,再后来加入了更多参数参与计算。你在分析时拿到的密钥,很可能过一段时间就失效了。

所以学逆向,真正要掌握的不是某组固定的key和iv,而是分析方法本身。当密钥或者算法变化之后,你只要重新走一遍抓包、定位、分析、验证的流程,定位到新的加密函数,把旧的密钥替换成新的,代码很快就能恢复工作。这也是为什么本文花了大量篇幅在讲定位和验证,而不只是给出一段可以直接跑的代码。

我个人的建议是,在整个分析过程中养成记录的习惯。每当你确认了一个参数的含义,或者验证了一个函数的作用,就顺手记下来。这样不仅方便回溯,也能在网站更新算法之后快速定位到变化的位置。

5.4 实用避坑手册

最后集中分享几个我踩过或者看别人踩过的坑,这些坑在常规教程里基本不会写。

关于请求头的Cookie,有道翻译网页版对Cookie并不敏感,不设置Cookie也能正常请求。但有一些其他网站会校验Cookie,所以写代码时尽量把抓包时看到的请求头原样复制,能省不少麻烦。关于User-Agent,这个值要和你在浏览器里用的保持一致,因为bv参数是根据User-Agent生成的,如果两者对不上,服务端可能判定为异常。关于并发请求,翻译接口如果短时间内高频率请求,很容易触发频率限制,表现为突然返回空结果或者code非0。实际应用时建议控制请求频率,必要时加延时。

还需要提醒一点,这种分析思路适用于学习和技术研究,用在真实生产环境时要评估好合规问题,尊重目标网站的规则。很多网站的接口签名就是为了限制爬虫而设计的,强行突破可能带来法律风险。

我个人在实际操作中的体会是,整套流程里最花时间的往往不是加密算法本身,而是定位加密代码的过程。一旦你用Initiator和关键词组合定位到了函数所在的位置,后面从提取参数到Python复现,其实都是流水线操作。最后再分享一个小技巧:如果你在分析时卡在某个函数上超过半小时,先停下来,回到请求参数本身,站在服务端的角度想一下,它为了验证这个请求,最少需要哪些信息。顺着这个思路往回推,往往能找到突破口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询