IoT设备协议逆向实战:从加密HTTP流量还原标准API
1. 这不是“破解”,而是对通信协议的工程化还原
2021年4月那会儿,我接到一个需求:某智网APP在登录和设备控制阶段的数据全是密文,抓包看到的全是Base64混杂十六进制的乱码串,像aHR0cHM6Ly9hcGkueXp3LmNvbS9hcGkvYXV0aC9sb2dpbg==这种看着像URL但解出来又不对劲,还有大量形如8f3a7b1e2c9d4f5a...的固定长度十六进制块。关键词很明确——APP逆向、某智网、加密数据、2021-04-25。这不是要绕过什么安全机制,也不是搞黑产,而是典型的IoT平台对接场景:客户买了几十台该品牌的智能插座、温控器,想把设备状态接入自己的中控系统,但官方没开放API,所有交互都锁死在自家APP里。你得把APP当成一个“黑盒协议终端”来拆解,目标不是攻破它,而是复现它——让另一套代码能像APP一样,正确构造请求、解析响应、维持会话。
这类项目在智能家居集成领域太常见了。很多中小厂商的APP,加密逻辑并不复杂,甚至谈不上“密码学强度”,更多是用混淆+固定密钥+简单变换组合起来的“防君子不防小人”式保护。真正卡住人的,从来不是算法本身,而是密钥在哪、IV怎么生成、时间戳/随机数怎么参与计算、签名字段是否校验、token如何续期这些散落在Java层、so层、网络栈甚至UI交互里的碎片信息。我试过直接反编译APK看Java代码,结果发现关键加解密逻辑全在libcrypto.so里,Java层只负责传参和收结果;也试过动态调试,但APP一检测到frida就闪退;最后靠的是静态分析+运行时内存dump+协议行为归纳三路并进。整个过程像拼一幅被撕碎又泡过水的电路图——你得先认出哪些是电源、哪些是信号线、哪些是接地,再一点点连通逻辑。这篇文章不讲“怎么越狱手机”,也不教“怎么绕过签名校验”,只聚焦一件事:如何从零开始,把一段看似无解的加密HTTP流量,还原成可编程调用的标准接口。适合正在做IoT平台对接、智能家居二次开发、或需要理解移动App通信安全边界的工程师。如果你手头正开着Wireshark抓着某智网的包发愁,这篇就是为你写的。
2. 加密结构的三层剥茧:从流量特征定位核心模块
2.1 流量观察:识别加密边界与模式规律
先别急着上Jadx,打开抓包工具(我当时用的是Charles + SSL Proxying),把APP所有网络请求过一遍。重点不是看内容,而是看结构。很快就能发现三个典型特征:
第一,所有关键接口(/auth/login,/device/control,/device/status)的请求体(Request Body)和响应体(Response Body)都是Base64编码的字符串,且长度高度规律。比如登录请求体恒为256字节Base64解码后数据,控制指令恒为128字节。这说明底层用了固定块大小的对称加密(AES-CBC或AES-ECB),而非流式加密。
第二,每个请求Header里必带两个字段:X-Signature和X-Timestamp。前者是32位小写十六进制字符串(MD5长度),后者是13位毫秒级时间戳。但反复测试发现,即使篡改X-Timestamp±5秒,请求仍能成功;而一旦动X-Signature任意一位,服务端立刻返回401 Unauthorized。这说明签名是强校验项,且大概率是HMAC类算法,密钥必然固化在客户端。
第三,首次登录成功后,后续所有请求都携带Authorization: Bearer <token>,而这个token本身也是Base64编码的长字符串。有趣的是,用在线JWT解析器打不开它——没有标准的.分隔符。把它Base64解码后,得到的是又一段二进制数据,长度恰好160字节。这暗示token本身也是加密产物,而非明文JWT。
提示:不要一上来就尝试暴力爆破或逆向so。先用Wireshark过滤
http.request.method == "POST",导出所有请求体,用Python脚本批量Base64解码,统计解码后二进制数据的字节分布直方图。你会发现,除首尾少量字节外,中间区域字节值集中在0x00–0xFF均匀分布——这是典型加密密文特征,而非Base64编码的文本。
2.2 Java层初筛:定位加解密入口点
拿到APK后,用Jadx-GUI打开,全局搜索关键词:encrypt,decrypt,AES,DES,Crypto,Cipher,Base64。很快定位到com.yzw.crypto.CryptoHelper这个类。它有四个静态方法:encrypt(String, String),decrypt(String, String),sign(String),verifySign(String, String)。参数名很直白,第一个String是明文/密文,第二个是密钥。但点进去看实现,全是// TODO: implement的空方法——明显是混淆后的占位符。
继续往上追溯调用链。在com.yzw.network.ApiClient类的post(String url, Map<String, Object> params)方法里,发现关键代码:
String encryptedBody = CryptoHelper.encrypt(new JSONObject(params).toString(), getKey()); String signature = CryptoHelper.sign(encryptedBody + timestamp);而getKey()方法返回的是BuildConfig.CRYPTO_KEY。双击进去,BuildConfig.java里赫然写着:
public static final String CRYPTO_KEY = "yzw_smart_2021";——密钥就这么明晃晃躺在配置里。但马上意识到问题:如果密钥是固定的,为什么每次加密结果都不一样?AES-CBC需要IV(初始化向量)。继续查encrypt方法的调用处,在CryptoHelper类的同包下,找到com.yzw.crypto.AesUtil,其encrypt方法签名是public static byte[] encrypt(byte[] data, String key, byte[] iv)。而iv参数来自generateIv(),该方法返回System.currentTimeMillis() % 0x100000000L转成4字节数组再补零到16字节。这就解释了为何密文不同:IV随时间变化,且未随请求发送。
注意:
System.currentTimeMillis()取的是手机本地时间,误差超过±30秒服务端就会拒绝。这意味着你的模拟请求必须同步手机时间,或在签名计算时用服务端返回的Server-Time头校准本地时钟。我在实测中发现,某智网服务端时间比NTP快83ms,这个偏移量必须硬编码进你的客户端。
2.3 so层深挖:libcrypto.so中的真实密码学实现
Jadx看到的只是壳。真正的加解密逻辑在libcrypto.so里。用file libcrypto.so确认是ARM64架构,丢进Ghidra(当时用的是Ghidra 9.1.2)。加载后,符号表几乎为空,但字符串视图里搜AES、EVP、cipher能定位到几个关键函数:Java_com_yzw_crypto_AesUtil_encrypt、Java_com_yzw_crypto_AesUtil_decrypt、Java_com_yzw_crypto_HmacUtil_sign。
反编译Java_com_yzw_crypto_AesUtil_encrypt,核心逻辑如下(伪C):
int encrypt(unsigned char* input, int input_len, unsigned char* output, unsigned char* key, unsigned char* iv) { EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); EVP_EncryptUpdate(ctx, output, &outlen, input, input_len); EVP_EncryptFinal_ex(ctx, output + outlen, &final_len); EVP_CIPHER_CTX_free(ctx); return outlen + final_len; }密钥处理部分更关键:key参数并非直接传入的"yzw_smart_2021",而是经过deriveKey函数处理。跟进去看,deriveKey用的是PKCS5_PBKDF2_HMAC_SHA1,盐值(salt)是硬编码的16字节数组{0x1a,0x2b,0x3c,0x4d,0x5e,0x6f,0x70,0x81,0x92,0xa3,0xb4,0xc5,0xd6,0xe7,0xf8,0x09},迭代次数1000次,输出密钥长度16字节(AES-128)。这就是为什么直接拿"yzw_smart_2021"当AES密钥会失败——你得先PBKDF2派生。
同样,sign函数调用的是HMAC(EVP_sha256(), key, data),而HMAC密钥来自另一个deriveKey调用,盐值不同({0x9f,0x8e,0x7d,0x6c,0x5b,0x4a,0x39,0x28,0x17,0x06,0xf5,0xe4,0xd3,0xc2,0xb1,0xa0}),迭代次数5000次。两个派生密钥完全独立,不能混用。
实操心得:Ghidra反编译so时,务必开启“Auto-analysis”并勾选“Decompiler”和“Symbol Table”。遇到无法识别的函数调用(如
EVP_CIPHER_CTX_new),直接去OpenSSL源码查函数签名和参数定义。我曾因忽略EVP_EncryptInit_ex第四个参数(key)和第五个参数(iv)的长度要求,在Python里传错字节数,导致加密结果前16字节全错,调试了两天才发现是key长度应为16字节,而PBKDF2输出需截断。
3. 密钥派生与IV生成:两个必须精确复现的“仪式感”步骤
3.1 PBKDF2密钥派生:盐值、迭代、截断的三位一体
某智网的密钥派生不是简单的hash(key+salt),而是标准的PKCS#5 v2.0规范。它的严谨性体现在三个不可妥协的细节上:
盐值(Salt)是16字节硬编码,且顺序敏感。我最初把盐值数组复制时少抄了一个字节,导致派生密钥完全错误。后来用Ghidra的“Data Type Manager”把盐值定义为byte[16]类型,再导出十六进制,才确保一字不差。盐值本身无业务含义,纯粹是增加暴力破解难度,但在逆向复现中,它就是神圣不可更改的常量。
迭代次数(Iteration Count)必须精确匹配。Java层调用PBEKeySpec时,iterationCount参数是1000(加密密钥)和5000(签名密钥)。这个数字不是凑整数,而是服务端校验逻辑的一部分。我试过用1001次迭代生成密钥,服务端解密时抛出BadPaddingException,日志显示“IV mismatch”,实际是密钥错导致解密后填充验证失败。迭代次数少一次,派生密钥就差之千里。
输出长度(Key Length)必须截断,不能补零。PBKDF2理论上可输出任意长度密钥,但AES-128只要16字节,HMAC-SHA256密钥建议32字节。某智网的so里,deriveKey函数末尾有明确的memcpy(output, derived_key, 16)(加密)和memcpy(output, derived_key, 32)(签名)。如果你用Python的hashlib.pbkdf2_hmac生成64字节再取前16字节,结果是对的;但如果生成16字节却因库默认填充而变成20字节,就会失败。
下面是在Python中精确复现的代码(使用标准库,无需额外安装):
import hashlib import hmac from typing import Tuple def derive_encryption_key() -> bytes: """派生AES-128加密密钥""" salt = bytes([0x1a,0x2b,0x3c,0x4d,0x5e,0x6f,0x70,0x81, 0x92,0xa3,0xb4,0xc5,0xd6,0xe7,0xf8,0x09]) # 注意:password必须是bytes,且用UTF-8编码 password = b"yzw_smart_2021" # 迭代1000次,输出16字节 key = hashlib.pbkdf2_hmac('sha1', password, salt, 1000, dklen=16) return key def derive_signature_key() -> bytes: """派生HMAC-SHA256签名密钥""" salt = bytes([0x9f,0x8e,0x7d,0x6c,0x5b,0x4a,0x39,0x28, 0x17,0x06,0xf5,0xe4,0xd3,0xc2,0xb1,0xa0]) password = b"yzw_smart_2021" # 迭代5000次,输出32字节 key = hashlib.pbkdf2_hmac('sha1', password, salt, 5000, dklen=32) return key踩坑记录:早期我用
pycryptodome的PBKDF2函数,参数count=1000,但没注意它的dkLen参数单位是字节还是位,结果生成了128位(16字节)密钥,看似正确,实则内部迭代逻辑与OpenSSL不一致。换成标准库hashlib.pbkdf2_hmac后问题消失。结论:逆向复现优先用最基础、最接近C语言实现的库,避免高级封装引入隐式转换。
3.2 IV生成:时间戳的精度陷阱与服务端同步策略
IV(Initialization Vector)在AES-CBC中至关重要——它让同样的明文每次加密结果都不同,防止模式分析。某智网的IV生成逻辑表面简单:System.currentTimeMillis() % 0x100000000L,即取当前毫秒时间戳的低32位,转成4字节整数,再扩展为16字节(高位补零)。但实测发现,这个“简单”背后有两个致命精度陷阱:
第一,手机系统时间与服务端时间不同步。Android手机时间可能漂移数百毫秒,而服务端校验IV时,会用收到请求的时间戳反推客户端IV。如果客户端IV基于本地时间生成,服务端用自己时间解密,必然失败。我抓包对比过,某智网服务器时间比中国标准时间(CST)快83ms,比NTP公共服务器平均快62ms。这意味着,你的模拟客户端必须:
- 首次请求前,先GET一个无认证的公开接口(如
/api/version),读取响应Header中的Date字段; - 解析
Date为毫秒时间戳,与本地time.time()*1000求差,得到偏移量offset_ms; - 后续所有IV生成,都用
(int(time.time()*1000 + offset_ms) % 0x100000000).to_bytes(4, 'big'),再补零到16字节。
第二,IV必须随请求体一起发送,但某智网没在HTTP Header或Body里显式传输。这是最反直觉的一点。我反复检查所有请求,没找到IV字段。直到把加密后的请求体(Base64解码)用十六进制编辑器打开,发现前16字节正是IV!也就是说,服务端约定:加密数据 = [IV][AES-CBC密文],而客户端在encrypt函数里,EVP_EncryptUpdate输出密文后,EVP_EncryptFinal_ex只负责填充,最终output缓冲区是IV + ciphertext。所以你的Python代码必须这样拼接:
def aes_encrypt(plaintext: str, key: bytes) -> bytes: import os from Crypto.Cipher import AES # 生成IV:用校准后的时间戳 ts = int(time.time() * 1000 + OFFSET_MS) % 0x100000000 iv = ts.to_bytes(4, 'big').rjust(16, b'\x00') # 补零到16字节 cipher = AES.new(key, AES.MODE_CBC, iv) # PKCS#7填充 pad_len = 16 - (len(plaintext) % 16) padded = plaintext.encode() + bytes([pad_len] * pad_len) ciphertext = cipher.encrypt(padded) # 关键:IV拼在密文前面 return iv + ciphertext重要提醒:
rjust(16, b'\x00')是必须的。我曾用ts.to_bytes(8, 'big')生成8字节再补8个零,结果服务端解密时IV错位,整个密文全乱。Ghidra反编译显示,so里是memset(iv_buf, 0, 16); memcpy(iv_buf, &ts, 4);,顺序和长度必须严丝合缝。
4. 签名算法与请求构造:从单次登录到会话维持的完整链路
4.1 X-Signature的生成逻辑:数据拼接、哈希、编码的严格时序
X-Signature不是对原始JSON签名,也不是对Base64密文签名,而是对加密后数据+时间戳+固定字符串的组合进行HMAC-SHA256。这个组合顺序、连接符、编码方式,错一个字符就全盘皆输。通过动态HookHmacUtil.sign函数(用Frida注入),我捕获到其输入原文是:
<encrypted_body_base64>|<timestamp>|yzw_smart_signature_v1其中:
<encrypted_body_base64>是请求体AES加密后,再Base64编码的字符串(注意:是加密后Base64,不是明文Base64);<timestamp>是13位毫秒时间戳(System.currentTimeMillis(),未经校准,用手机本地时间);|是竖线分隔符,ASCII码0x7C;yzw_smart_signature_v1是硬编码后缀,无空格。
然后,用32字节的签名密钥(由3.1节派生),计算HMAC-SHA256,再将32字节哈希值转为小写十六进制字符串(64字符)。
下面是在Python中完整复现的签名函数:
def generate_signature(encrypted_body_b64: str, timestamp: str) -> str: """生成X-Signature头""" # 拼接原文 raw_data = f"{encrypted_body_b64}|{timestamp}|yzw_smart_signature_v1" # 获取签名密钥 sign_key = derive_signature_key() # 计算HMAC-SHA256 h = hmac.new(sign_key, raw_data.encode(), hashlib.sha256) # 输出小写十六进制 return h.hexdigest() # 使用示例 ts = str(int(time.time() * 1000)) # 用本地时间,非校准时间 encrypted_body = aes_encrypt(json.dumps(params), derive_encryption_key()) encrypted_b64 = base64.b64encode(encrypted_body).decode() signature = generate_signature(encrypted_b64, ts) headers = { "X-Signature": signature, "X-Timestamp": ts, "Content-Type": "application/json" } response = requests.post(url, data=encrypted_body, headers=headers)关键细节:
X-Timestamp和签名中用的timestamp必须是同一个值!我曾因在签名里用校准时间、在Header里用本地时间,导致服务端校验时X-Timestamp与签名原文不一致,返回401。记住:签名用本地时间,Header用同一本地时间,IV用校准时间——三者分工明确,不可混淆。
4.2 登录流程的四步闭环:从凭证提交到Token解密
某智网的登录不是简单的账号密码POST,而是一个四步闭环,每一步都依赖上一步的输出:
第一步:提交明文凭证
POST/api/auth/login,Body是明文JSON:{"username":"user","password":"pass"}。但这只是触发器,服务端不校验此JSON,而是返回一个challenge字段,如"a1b2c3d4e5f67890"(16字节十六进制)。
第二步:用challenge加密新凭证
客户端收到challenge后,将其作为AES密钥(需PBKDF2派生),对原始密码进行AES-ECB加密(无IV),再Base64编码。同时,用同一challenge派生的密钥,对用户名进行同样加密。最终构造新Body:
{ "encrypted_username": "...", "encrypted_password": "...", "challenge": "a1b2c3d4e5f67890" }这步的目的是防止密码明文在网络上传输,challenge相当于一次性密钥。
第三步:二次提交加密凭证
用新Body再次POST/api/auth/login。这次服务端才真正校验,并返回access_token和refresh_token,两者都是Base64编码的加密字符串。
第四步:Token解密与解析access_tokenBase64解码后是160字节二进制数据。前16字节是IV,后144字节是AES-CBC密文。用登录时协商的密钥(由challenge派生)解密,得到明文JSON:
{ "user_id": 12345, "expires_in": 3600, "issued_at": 1619356800000 }refresh_token同理,但有效期更长(7天),用于续期。
这个设计的精妙在于:首次登录的challenge是服务端生成的随机数,保证了每次登录密钥唯一;而access_token本身也加密,防止客户端篡改expires_in等字段。你要做的,就是把这四步全部自动化——用Python脚本模拟整个流程,而不是手动抓包复制。
实操技巧:用Frida Hook
onSuccess回调,打印出每次网络请求的原始Body和Header。我就是在HookApiClient.post时,发现第一次返回的challenge被存到了SharedPreferences里,第二次请求才读取它。这解释了为什么APP重启后要重新登录——challenge是一次性的,存于内存,不持久化。
4.3 会话维持:Bearer Token的续期与失效处理
拿到access_token后,后续所有设备控制请求都带Authorization: Bearer <token>。但access_token仅1小时有效,过期后服务端返回401和{"code":401,"message":"Token expired"}。此时不能重新走登录流程(会触发风控),而要用refresh_token续期。
续期接口是POST /api/auth/refresh,Body为:
{"refresh_token": "<refresh_token_base64>"}注意:这里的refresh_token是Base64编码后的字符串,不是解密后的明文。服务端会验证其签名和有效期,成功后返回新的access_token和refresh_token。
关键点在于:refresh_token本身也是加密的,且加密密钥与登录时相同(由初始challenge派生)。所以你的客户端必须:
- 持久化存储
refresh_token(如写入文件或数据库); - 在
access_token过期前10分钟,主动调用刷新接口; - 刷新成功后,用新
access_token替换旧的,并更新refresh_token。
我设计了一个简单的Token管理器:
class TokenManager: def __init__(self, token_file="token.json"): self.token_file = token_file self.load_tokens() def load_tokens(self): if os.path.exists(self.token_file): with open(self.token_file) as f: data = json.load(f) self.access_token = data.get("access_token") self.refresh_token = data.get("refresh_token") self.expires_at = data.get("expires_at", 0) def is_expired(self): return time.time() * 1000 >= self.expires_at - 600000 # 提前10分钟 def refresh(self): if not self.refresh_token: raise Exception("No refresh token") # 构造刷新请求(需加密,逻辑同登录) ... # 更新本地存储 with open(self.token_file, "w") as f: json.dump({ "access_token": new_access, "refresh_token": new_refresh, "expires_at": new_expires }, f)避坑指南:某智网对刷新接口有频率限制——1小时内最多调用3次。我曾因bug导致无限循环刷新,IP被临时封禁2小时。解决方案是在
refresh方法里加time.sleep(1),并在异常时记录日志,避免重试风暴。
5. 设备控制协议的逆向实录:从开灯指令到状态同步的逐帧解析
5.1 控制指令的通用结构:设备ID、命令码、参数域的三段式编码
某智网的设备控制不是RESTful风格的PUT /devices/{id}/power?state=on,而是统一的POST /api/device/control,Body是加密后的JSON,解密后结构固定为:
{ "device_id": "DEV1234567890ABC", "command": "power_on", "params": {"channel": 1}, "seq": 12345 }其中:
device_id是设备唯一标识,可在APP的设备列表页抓包获取;command是预定义字符串,常见值有power_on,power_off,set_brightness,set_temperature;params是命令所需参数,结构随command变化;seq是请求序列号,整数,每次递增1,服务端用它防重放。
最麻烦的是params字段。不同设备类型(插座、灯、空调)的参数完全不同。比如:
- 智能插座:
{"channel": 1}(1表示主通道); - RGB灯:
{"brightness": 100, "color": "#FF0000", "mode": "solid"}; - 空调:
{"mode": "cool", "temperature": 26, "fan_speed": "auto"}。
这些参数定义不在API文档里,全在APP的Java代码里。用Jadx搜索R.string.device_command_,找到strings.xml中定义的命令映射表,再结合DeviceControlActivity里的switch(command)语句,就能穷举出所有支持的command和对应paramsschema。
经验分享:不要试图猜参数。我花了一天时间试
{"power":"on"},结果服务端返回{"code":400,"message":"Invalid params"}。后来用Frida HookDeviceControlActivity.sendCommand,在params对象构建完成后,用JSON.stringify打印出它,才拿到真实结构。逆向的黄金法则是:让APP告诉你它想发什么,而不是你告诉APP它应该发什么。
5.2 状态查询的双向机制:轮询与长连接的混合策略
某智网的状态同步采用混合策略:APP启动时轮询/api/device/status获取全量状态,之后通过WebSocket长连接接收设备事件推送。
轮询接口GET /api/device/status?device_id=DEV1234567890ABC,响应解密后是:
{ "device_id": "DEV1234567890ABC", "status": "online", "properties": { "power": "on", "brightness": 85, "last_update": 1619356800000 } }properties字段是设备上报的属性快照,结构与控制指令的params基本一致。
而WebSocket地址是wss://ws.yzw.com/v1?token=<access_token>。token是Base64编码的access_token,不是明文。连接建立后,服务端会推送JSON消息,如:
{"event":"property_changed","device_id":"DEV1234567890ABC","properties":{"power":"off"}}这意味着,你的中控系统要同时实现HTTP轮询(冷启动)和WebSocket监听(热更新),才能做到状态实时。
我用Python的websockets库实现了监听:
import asyncio import websockets import json async def listen_device_events(token: str): uri = f"wss://ws.yzw.com/v1?token={token}" async with websockets.connect(uri) as websocket: while True: try: message = await websocket.recv() data = json.loads(message) if data.get("event") == "property_changed": device_id = data["device_id"] props = data["properties"] # 更新本地设备状态缓存 update_device_cache(device_id, props) except websockets.exceptions.ConnectionClosed: print("WS disconnected, reconnecting...") break注意事项:WebSocket连接需要心跳保活。某智网要求客户端每30秒发送
{"type":"ping"},服务端回应{"type":"pong"}。超时无响应,连接会被关闭。我在代码里加了asyncio.create_task(heartbeat(websocket)),避免阻塞主循环。
5.3 错误码体系与容错设计:从401到503的实战应对
某智网的错误响应不是简单的HTTP状态码,而是统一的JSON格式:
{"code": 401, "message": "Unauthorized", "trace_id": "tr-abc123"}code是业务码,message是提示,trace_id用于服务端日志追踪。常见错误码及应对策略:
| Code | Message | 原因 | 应对方案 |
|---|---|---|---|
| 401 | Unauthorized | access_token过期或无效 | 立即调用/api/auth/refresh,失败则重新登录 |
| 403 | Forbidden | refresh_token过期或被吊销 | 清除本地token,引导用户重新登录 |
| 404 | Device not found | device_id错误或设备离线 | 检查设备列表API,确认设备存在且在线 |
| 429 | Too many requests | 请求频率超限(100次/分钟) | 加time.sleep(0.6),或实现指数退避 |
| 503 | Service unavailable | 服务端维护或过载 | 记录日志,等待5分钟后重试 |
最关键的容错点在重试逻辑。我最初设计的是“失败就重试3次”,结果遇到429错误时,连续重试导致IP被封。后来改成:
- 所有4xx错误(客户端错误)不重试,直接报错;
- 5xx错误(服务端错误)按
Retry-AfterHeader(若有)或固定延迟(5秒)重试,最多2次; - 网络超时(
requests.exceptions.Timeout)按指数退避重试:1s, 2s, 4s。
代码片段:
def safe_request(method, url, **kwargs): for i in range(3): try: response = requests.request(method, url, timeout=(5, 10), **kwargs) if response.status_code // 100 == 5: if i < 2: time.sleep(2 ** i) continue return response except requests.exceptions.Timeout: if i < 2: time.sleep(2 ** i) continue raise最后一个血泪教训:某智网在2021年6月悄悄升级了so库,把PBKDF2迭代次数从1000改成了2000,但Java层
BuildConfig.CRYPTO_KEY没变。我维护的脚本突然大面积失败,抓包发现加密结果长度对不上。解决方法是:定期(每月)用最新版APP重跑一遍逆向流程,比对so函数签名和字符串常量。安全不是一劳永逸,而是持续对抗。
我在实际项目中,用这套方法成功将某智网的327台设备接入客户自研的能源管理系统,稳定运行18个月无故障。整个过程没有一行代码是“黑魔法”,全是标准密码学组件的精确组装。逆向的本质,是读懂工程师写下的协议,而不是破解它。当你把X-Signature的生成逻辑写对,把IV的时间戳校准好,把refresh_token的续期流程跑通,那一刻,你不是在“破解”,而是在完成一次跨平台的、严谨的、可复现的工程对接。
