IoT设备协议逆向实战:从加密HTTP流量还原标准API

发布时间:2026/5/24 19:54:11

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, MapString, 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核心逻辑如下伪Cint 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(keysalt)而是标准的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 byzw_smart_2021 # 迭代1000次输出16字节 key hashlib.pbkdf2_hmac(sha1, password, salt, 1000, dklen16) 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 byzw_smart_2021 # 迭代5000次输出32字节 key hashlib.pbkdf2_hmac(sha1, password, salt, 5000, dklen32) return key踩坑记录早期我用pycryptodome的PBKDF2函数参数count1000但没注意它的dkLen参数单位是字节还是位结果生成了128位16字节密钥看似正确实则内部迭代逻辑与OpenSSL不一致。换成标准库hashlib.pbkdf2_hmac后问题消失。结论逆向复现优先用最基础、最接近C语言实现的库避免高级封装引入隐式转换。3.2 IV生成时间戳的精度陷阱与服务端同步策略IVInitialization 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不是明文Base64timestamp是13位毫秒时间戳System.currentTimeMillis()未经校准用手机本地时间|是竖线分隔符ASCII码0x7Cyzw_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, dataencrypted_body, headersheaders)关键细节X-Timestamp和签名中用的timestamp必须是同一个值我曾因在签名里用校准时间、在Header里用本地时间导致服务端校验时X-Timestamp与签名原文不一致返回401。记住签名用本地时间Header用同一本地时间IV用校准时间——三者分工明确不可混淆。4.2 登录流程的四步闭环从凭证提交到Token解密某智网的登录不是简单的账号密码POST而是一个四步闭环每一步都依赖上一步的输出第一步提交明文凭证POST/api/auth/loginBody是明文JSON{username:user,password:pass}。但这只是触发器服务端不校验此JSON而是返回一个challenge字段如a1b2c3d4e5f6789016字节十六进制。第二步用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 HookonSuccess回调打印出每次网络请求的原始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/refreshBody为{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_filetoken.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?stateon而是统一的POST /api/device/controlBody是加密后的JSON解密后结构固定为{ device_id: DEV1234567890ABC, command: power_on, params: {channel: 1}, seq: 12345 }其中device_id是设备唯一标识可在APP的设备列表页抓包获取command是预定义字符串常见值有power_on,power_off,set_brightness,set_temperatureparams是命令所需参数结构随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_idDEV1234567890ABC响应解密后是{ device_id: DEV1234567890ABC, status: online, properties: { power: on, brightness: 85, last_update: 1619356800000 } }properties字段是设备上报的属性快照结构与控制指令的params基本一致。而WebSocket地址是wss://ws.yzw.com/v1?tokenaccess_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 fwss://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用于服务端日志追踪。常见错误码及应对策略CodeMessage原因应对方案401Unauthorizedaccess_token过期或无效立即调用/api/auth/refresh失败则重新登录403Forbiddenrefresh_token过期或被吊销清除本地token引导用户重新登录404Device not founddevice_id错误或设备离线检查设备列表API确认设备存在且在线429Too many requests请求频率超限100次/分钟加time.sleep(0.6)或实现指数退避503Service 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的续期流程跑通那一刻你不是在“破解”而是在完成一次跨平台的、严谨的、可复现的工程对接。

相关新闻