这是一篇完整的实战复盘:从 452MB 的加固 APK 到一套能跑在电脑上的家长端,中间踩过的每一个坑都写在里面。
项目:JZclient-for-node · 官网:jz.laolibuhuifei.com · 协议:GPL-3.0
一、缘起
家里孩子的电话手表,家长端 APP 只能在手机上用。想在电脑上一边干活一边看看孩子在哪儿、回两句消息,做不到。想在群里接个机器人转发消息,也做不到。
官方 APP 做得很完整,功能一点不缺,但它只肯活在 Android 上。
于是有了一个很朴素的想法:协议是公开的 HTTP 和 TCP,那我自己写一个客户端行不行?
这句话听起来简单,实际动手之后才发现,中间隔着一整个梆梆加固、一套自研二进制协议、和一堆"文档里永远不会写"的细节。
下面是我走过的路。
二、第一步:把 APK 拆开看看
拿到的是 小天才_9.47.02.apk,452 MB。
| 项 | 值 |
|---|---|
| 包名 | com.xtc.watch |
| 版本 | 9.47.02(versionCode 94702) |
| minSdk / targetSdk | 21 / 36 |
| 签名 | CN=xtc 自签名,SHA256withRSA 2048 位 |
先用 apktool 解资源、jadx 反编译,结果很干脆——代码是空的。
classes.dex 体积 111 MB,但 dex 头里只声明了 5 个类、307 个字符串。这 5 个类全部位于 com.secneo.apkwrapper,一眼就能认出是梆梆安全企业版(Bangcle / SecNeo)的壳:
AW —— 替换 Application 的壳入口
H —— 加载 libDexHelper.so,内部有 TracerPid 反调试检查
AP / CP / a —— 壳的辅助类
真实的业务 dex 被加密后附加在 dex 数据区之后(约 111 MB 的加密载荷),启动时由 libDexHelper.so 在内存里解密加载。
原生保护库一栏摆开看:
| 库 | 作用 |
|---|---|
libDexHelper.so |
梆梆脱壳引擎本体 |
libdexvmp.so |
DEX VMP(dex2c 指令虚拟化) |
libdexjni.so |
JNI 抽取:大量 Java 方法体被下沉到 native |
libNetHTProtect.so |
网易易盾保护组件 |
好消息是:资源和 AndroidManifest 没有加密。这直接给了我一张完整的功能地图——662 个 com.xtc 组件,谁在哪个 Activity、调了哪些模块,一目了然。
三、脱壳:绕开 ptrace 的内存扫描
梆梆这类壳,常规打法是 frida。但梆梆有专门的 frida 检测线程,常规思路是 hook clone 找到检测线程再 patch 成 RET。
我选了另一条更稳的路:完全不用 ptrace。
关键点在于:ptrace 一旦 attach,目标进程的 /proc/<pid>/status 里 TracerPid 就不再是 0,壳一扫就知道被调试了。而 Linux 有个系统调用叫 process_vm_readv(),它可以直接读取另一个进程的内存,不需要 attach,TracerPid 保持为 0。
于是写了一个 arm64 小工具 memdump:
1. 读 /proc/<pid>/maps,筛出所有可读内存段
2. 对每个段调 process_vm_readv() 拉回本地
3. 在内存里搜 dex\n035 魔数
4. 命中后读 dex 头 offset 32 的 file_size 字段,按这个长度完整转储
5. 用 checksum 去重(同一个 dex 可能在内存里有多个副本)
环境是 Android 35 的 google_apis arm64 模拟器,adb root 可用。装好 APK、启动、等 90 秒让壳把 dex 解密完,然后对着 pid dump。
结果:
71 个 dex,共 284 MB
(含 12.9 MB / 12.6 MB / 11.8 MB 三个主程序 dex)
APP 全程无感知,无闪退
丢给 jadx 反编译,产出 54,552 个 Java 文件,包结构、类名、Retrofit 注解基本完整。
这里有个意外收获:混淆只改了类名,而且是按"国家名"改的。于是代码里到处是 Hawaii、Germany、Gabon、Georgia、Greece、Uganda、Ukraine、Guyana。jadx 还贴心地保留了原始类名注释,读起来反而有种奇怪的喜感。
局限也要说清楚:梆梆的 JNI 抽取(JniLib.cV/cL)让一部分方法体在 Java 层完全不可见,主要是 SSO SDK 内部、部分绑定编排、IM 协议组包。这部分只能靠调用链、注解、常量去推,推不出来的就得实测。
四、网络层:没有证书固定,但也没那么好啃
4.1 拦截器链
入口单例 HttpManager,用 OkHttp 构建,超时统一 10 秒。拦截器链是这样的:
Germany(基础头/签名) → Georgia → [Uruguay(条件)] → Gabon → Gambia
→ OfflineInterceptor → Uganda → Ukraine → TroubleNoticeInterceptor
→ Guyana → Greece(HttpInterceptor,加解密主拦截器)
Proxy.NO_PROXY 防系统代理,DNS 走自研 HttpDns。
4.2 三个关键请求头
| 头 | 说明 |
|---|---|
Base-Request-Param |
JSON,含 appId/token/timestamp/machineId/mac/accountId/deviceId/program/imFlag/registId(加密开启时整体 AES) |
Eebbk-Sign |
请求签名(MD5) |
Eebbk-Key |
本次会话的 AES 密钥 |
encrypted |
标识 body 已加密 |
签名算法是这样:
sign = MD5( url + BaseRequestParam明文JSON + body + AES_KEY )
而 AES_KEY 不是硬编码的:
// 每次进程启动随机生成
AES_KEY = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
body 用同一个 key 做 AES/ECB/PKCS5Padding。RSA 公钥通过 GET /rsa 拿,缓存起来用于加密 Eebbk-Key 头。加密总开关存在 SharedPreferences 的 EncSwitch 里。
4.3 一个有意思的评估
翻遍所有模块,没有找到任何自定义 X509TrustManager、CertificatePinner、sslSocketFactory——也就是说没有证书固定,防嗅探完全靠应用层的"随机 AES key + RSA 保护 + MD5 签名"。
这对我是好事:不需要折腾 SSL unpinning。但它同时说明,业务安全全押在应用层加密上,而 AES 用的是 ECB 模式,EncSwitch 关闭时 Eebbk-Key 还会以明文随请求传输。
五、登录:盐值藏在 .so 里
登录核心在 BBK 统一登录 SDK com.bbk.secureunisignon(方法体被 JNI 抽取),上层由 com.xtc.account.service.impl 封装。
5.1 SSO 签名
secureunisignon/common/request/Gabon.java 这段是可见的:
1. 剔除空值参数 → "key=value" 按 ASCII 排序 → 用 "##" 连接
2. signSB = url + 排序后的参数串 + 盐(按 appId 选择,本应用 appId=11)
3. sign = MD5(signSB).toLowerCase()
盐值藏在 librubbish.so 里,通过 XTCgetUtilWrapper.geta()/getb()/... 这些 native 方法读取。
.so 本身没有 VMP 保护迹象,理论上可以单独逆。实际做法是:在模拟器里 hook native 调用把盐打出来,比逆 so 快得多。
5.2 登录方式
Host 默认 https://account.okii.com,SSO 请求的 Content-Type 是 text/plain;charset=UTF-8(不是 JSON,这点容易踩)。
| 方式 | 端点 |
|---|---|
| 密码登录 | POST /app/loginV3 |
| 短信验证码 | POST /app/randcodeLogin |
| 本机一键登录 | POST /app/oneClick/loginV2 |
| 第三方登录 | POST /app/third/login |
密码登录的时序:
LoginActivity.checkLogin()
→ BusinessService.Greece.R3() // 先过网易易盾行为验证码拿 token
→ POST account.okii.com/app/loginV3(参数按上面的规则签名)
→ SSOLoginResult { ticket, userToken, maxAge, checkCode }
→ loginSSOSuccessGetDomain() // 拉域名/数据中心
→ MobileAccount 落 OrmLite 主库
注意那个 网易易盾:登录前必须先过行为验证码。这是整条链上最烦的一环,但它也说明了一件事——服务端对登录的保护是认真的。
5.3 Token 的几个坑
- 没有 refresh_token。只有
token+maxAge,冷启动时用/app/tokenLogin静默换新。 - 不放 Authorization 头,而是塞进
Base-Request-Param这个 JSON 里。没有 Cookie。 - 本地存在 OrmLite 主库里(库层由 SQLCipher 加密),SP 里只存"最后登录的号"和"登录方式"。
5.4 新设备二次验证(010410)—— 这里卡了我最久
换个设备登录,服务端会返回 010410,要求做新设备验证。对应接口是 changeDeviceLogin,看起来很简单,实际上有两个反直觉的参数要求:
// 1. days 必须传字符串 "-1"
// 2. mac 要用 deviceId,不是 ssoMac
{ days: "-1", mac: deviceId }
ssoMac 只用于明文表单的签名,跟这里不是一回事。这两个点任何一处错了,都只回一个语焉不详的错误码。
还有个更阴的:换票返回 000002 不是参数错,是竞态。
密保 validate 通过之后,服务端落库是异步的,你立刻去换票就会撞上内部异常。原版 H5 验证页对 000002 是自动重试一次的;而 APP 侧在 H5 回调之后还会弹一个"继续验证"的确认框——那一下点击的时间,正好让服务端状态落完。
我的实现是 changeDeviceLoginRetry(),只对 000002 间隔重试 3 次,仍然失败才退回用密码重新 loginV3。
六、最硬的骨头:微聊 IM 私有协议
如果说 HTTP 层是"照抄作业",IM 层就是"自己出题自己做"。
6.1 它不是 WebSocket,也不是 MQTT
微聊长连接用的是 java.net.Socket / SSLSocket(带 SNI),协议是自研的 TLV:
- 注解驱动序列化:
@CommandValue标命令字,@TagValue标字段号 - 约定
tag1 = RID、tag2 = code、tag3 = desc - 双轨:明文
gw.im.okii.com:8000,TLSim3.okii.com:8002
命令字表(节选):
| cmd | 用途 | cmd | 用途 |
|---|---|---|---|
| 1/2 | 设备注册 | 16 | 已读回执 |
| 3/4 | IM 登录 | 22/23 | 取服务器公钥 |
| 7/8 | 心跳 | 33 | 上传会话密钥 |
| 9/10 | 群聊收发 | 40 | 单聊发送 |
| 12/13 | 离线消息同步 | 32 | 加密包装帧 |
| 15 | 同步完成确认 | 35 | 下行推送 |
6.2 加密:RSA 协商 + 一个很"朴素"的 XOR
建链过程:
1. cmd22 PublicKeyRequest → cmd23 返回 RSA-1024 公钥(单行 PEM)
2. 客户端生成 key = UUID 去横线(32 字符)
cmd33 { tag1: RID, tag10: 1, tag11: RSA/PKCS1(key) } → cmd34 code=200
3. 之后所有业务帧都被包进 cmd32 EncryptWapper
加密算法本身是这样的:
外层帧 = [0xA0][0x20][len varint][0x0A][len varint][XOR(内层帧, key)]
内层帧 = [0xA0][cmd][len varint][TLV...]
key = cmd33 上传的 32 字符会话密钥,按 ASCII 字节循环使用
即 out[i] = in[i] ^ key[i % 32]
长度保持,无分组,无填充。
这里我走了一整天的弯路:一开始把 hex 解码后的 16 字节当成密钥,怎么都对不上。真相是——必须直接使用字符串本身的 32 个 ASCII 字节。这个错误很难从现象上判断,因为 XOR 出来的结果永远是"看起来像乱码"。
6.3 建链七步
1. cmd22 → cmd23 拿 RSA 公钥
2. cmd33 → cmd34 上传会话密钥(RSA 加密)
3. cmd1 Regist tag25=SSO token JSON、tag11=appKey、
tag24=业务账号 ID、tag26=94702 ... → cmd2 返回 registId
4. cmd3 Login tag24=baseRequestParam JSON、tag10=registId、
tag21=毫秒时间戳 → cmd4 code=200
5. cmd5 Account status=2 上线 → cmd6 返回自己的 imAccountId
6. cmd40 单聊发送 tag10=对方 imAccountId、tag11=自己 imAccountId(必填)、
tag12=registId、tag13=1、tag14=消息 JSON、
tag15=msgId、tag16=3
7. cmd9 群聊发送 tag10=家庭群 imDialogId、tag11=自己 imAccountId、tag18=3
收消息走 cmd13 SyncResponse 和 cmd35 PushResponse。
6.4 最大的认知误区:消息类型不在帧里
我一开始以为帧里的 tag13(msgType)会告诉我是文本还是图片。不是。
实测发现:发送端 cmd40/cmd9 的 tag13 恒为 1。真实类型藏在消息体 JSON 的信封里:
| 类型 | 消息体结构 |
|---|---|
| 文本 | TextMessage 平铺 {content, text, textMsgType: 0, ...} |
| 表情 | TextMessage { content: EmojiMessage, textMsgType: 1 } |
| 图片/视频/位置/红包… | TextMessage { msgNewType: N, msgNewContent: 子消息JSON, textMsgType: 2 } |
| 语音 / 撤回 | 直接是对应子消息 JSON |
图片消息长这样:
{
"msgNewType": 6,
"msgNewContent": {
"type": "1",
"source": { "key": "...", "downloadUrl": "...", "width": 720, "height": 1280 },
"smallPic": "...",
"sourceType": 6
},
"textMsgType": 2,
"content": "[图片]"
}
媒体是先 getUploadToken 拿 STS 临时凭证,直传阿里云 OSS,再把 resourceKey 填进消息发出去的。
6.5 顺手把安全评估也做了
| 层 | 机制 | 评价 |
|---|---|---|
| 链路 | TLS 8002 + 明文 TCP 8000 双轨 | 8000 的安全性全看应用层 |
| 密钥协商 | RSA + 对称会话密钥 | 标准做法 |
| 载荷加密 | 32 字节密钥循环 XOR | 非标准密码学算法,已完全还原 |
| 端到端 | 无 | 消息是明文 JSON,媒体 OSS 明文存储 |
结论很明确:没有端到端加密,服务端可读全部聊天内容。这一点对做客户端反而是好事——协议可以完全在客户端侧还原,不需要任何服务端配合。
七、为什么用纯 Node.js 重写
协议摸清之后,技术选型其实没什么悬念:它本来就是一串 HTTP 请求加一条 TCP 长连接,任何语言都能写。
我选 Node.js,并且立了一条看起来很苛刻的规矩:零第三方依赖。
{ "name": "jzclient-for-node", "version": "1.3.8", "license": "GPL-3.0", "dependencies": {} }
理由有三条:
- 不需要
npm i,下载下来node bin/jzclient.js就能跑。对不写代码的用户,这一步省掉的是整个"环境配置"噩梦。 - 签名、AES、RSA、TLV 编解码、zlib、TLS——Node 内置的
crypto/tls/zlib全都够用。 - 依赖越少,长期维护越省心。从 1.0 一路加到 1.3.8,一次都没被依赖升级搞崩过。
结构上参考 NapCat 做了两层:
| 界面 | 入口 | 职责 |
|---|---|---|
| CLI | npm start |
只管账号:登录/切换、登出、账号信息、WebUI 启停 |
| WebUI | 登录后自动拉起 | 全部业务:手表/定位/聊天/通讯录/安全区域/应用/闹钟/设置 |
配置文件落在 ~/.watch-parent-cli/config.json,升级代码不覆盖用户数据。
八、那些"文档里学不到"的坑
这一节是我觉得最有价值的部分。上面所有内容,只要肯花时间都能查到;下面这些,只有真的把它跑起来才会撞上。
8.1 签名 URL 不能带 query
签名只对 url.split('?')[0] 算。带了 query 直接 000004。
8.2 Flutter 端接口必须带 ?uuid=<32位hex>
不加就是 000007。而 000007 是个歧义错误码,参数错、缺字段、格式不对都可能回它,不要拿它当线索去盲探。
8.3 开关类设置不能走 update/watchAttribute
看起来 PUT /watchaccount/update/watchAttribute 有 70 多个可选字段,什么都能改。但开关类的字段改不动。
正确姿势是:先 GET /watchaccount/id/{id} 拿完整档案,在本地改字段,再整体 PUT 回去。
8.4 im.connected 不等于连接活着
这是最坑的一个。socket 对象还在,不代表链路还通。
NAT 超时之后会形成半开连接:connected 永远是 true,但一条新消息都不会来。表现就是"界面显示已连接,孩子发的消息一条都收不到"。
我的判活条件是:
// 心跳连续挂 3 次 且 120 秒没有收到任何入帧
alive() = !(heartbeatFail >= 3 || now - lastRecvAt > 120000)
不合格就 close() 重建,close 事件之后 5 秒自动重连。
8.5 不送回执,服务端会重复下发
两个回执必须补上:
cmd15(SYNC_FINISH):一轮离线同步收完要确认,800ms 合并一次cmd36(PUSH_ACK):每一帧推送都要回,带 msgId
不回的后果很直观:图片会成对出现(服务端以为你没收到,重发了一遍),然后堆积到一定程度服务端就不再推了。
8.6 去重窗口给长了会吞消息
去重做了双档:
| 场景 | 窗口 |
|---|---|
有 msgId |
300 秒 |
| 只能靠内容指纹(dialogId + imAccountId + msgType + createTime + sha1(raw)) | 10 秒 |
第二档为什么要短?因为孩子完全可能连发两条一模一样的话("在吗" "在吗")。窗口给长了,第二条就被当成重复吞掉了。
8.7 strings 挖字段名会骗你
strings 只能当线索。R8 的字符串池是按字母序成组的,值类型经常猜错。
真实案例:icon 看起来像"封面图",实际是 byte[](来源 App 的小图标),真正的封面是继承自父类的 source。这种错误只能靠脱壳源码纠正,靠 strings 永远发现不了。
九、成品
到今天,这个项目长这样:
仓库:github.com/laolibuhuifei/JZclient-for-node(GPL-3.0) 官网:jz.laolibuhuifei.com 当前版本:1.3.8
能做什么
- 账号:登录(含新设备二次验证、密保 H5 验证)、多账号切换、登出
- 手表:绑定新手表、向已有管理员申请、解绑、换表迁移
- 管理:通讯录、好友(自动同意申请)、实时定位、丢失模式、闹钟、安全区域、健康数据、应用管理、流量、WiFi、语言时区
- 聊天:文本、表情、颜文字、图片、语音、视频、位置、链接卡片;IM 常驻监听,电量等状态自动同步
- 增强:AI 自动回复、七牛云上传、OneBot 接入(接进 QQ 机器人)、外观自定义
怎么跑
git clone https://github.com/laolibuhuifei/JZclient-for-node
cd JZclient-for-node
node bin/jzclient.js
不需要 npm install,Node >= 18 即可。登录成功后自动拉起 WebUI(默认 8787)。
想接 QQ 机器人,开 OneBot 就行;不想折腾服务器,官网也提供了托管方案和安卓 App。
十、边界与合规
写这篇文章之前我想了很久,哪些该写、哪些不该写。最后的原则是:写方法,不写密钥;讲原理,不讲绕过。
几件明确的事:
1. 所有硬编码的 AppID、AppSecret、地图 key、推送 key,本文一律脱敏。 它们确实在 APK 里明文躺着,但把它们抄进博客,对谁都没好处。
2. 这个项目只操作自己的账号。 不存在"输入别人的绑定号就能看位置"这种事。
这一点我专门实测过。2026-11-01 深夜,拿一块自己的手表,用空 token 和过期 token 分别打了 12 个接口:
GET /location/{watchId} → 000003
GET /contact/{watchId} → 000003
GET /watchaccount/id/{watchId} → 000003
GET /mobilewatch/watch/{watchId} → 000003
POST /findWatch/getWifiList {watchId,bindNumber} → 000003
...(12 个全挂)
返回的都是 000003 request parameter is invalid.,连绑表流程第一步的 bindnumber 都被拦。
服务端确实有免鉴权白名单,但里面只有三个基础设施接口:
GET /rsa (拿公钥,本来就该公开)
GET /server/domain/list (网关域名列表)
GET /system/time (服务器时间)
没有一个带 watchId 或 bindNumber 参数。 这个设计是对的。
3. 我没有拿任何不属于自己的手表做过测试。 越权验证这种事,结论该由厂商通过正规渠道确认,不该由我写进工具里。
4. 如果你的号因为用了第三方客户端被封,那是风险,不是意外。 我做的所有事都在模拟官方客户端的正常行为——该带 uuid 就带,该走门控就走门控(型号能力 display=0/1/2 的判断一项没落),但账号风险始终存在,用之前请自己权衡。
十一、最后
回头看,这件事的技术难点其实不在"破解",而在耐心。
梆梆的壳再厚,process_vm_readv 也能绕过去;协议再私有,抓几包、对照反编译代码总能拼出来。真正耗时间的,是那些"看起来跑通了、其实没跑通"的时刻:
- 界面显示已连接,但一条消息都不来(
connectedvsalive) - 图片莫名其妙成对出现(缺
cmd36回执) - 换设备验证永远失败(
days: "-1"和mac: deviceId) - XOR 解密永远乱码(32 个 ASCII 字节,不是 16 字节 hex)
每一个坑,从现象到原因都隔着一层。而这层,只能靠一次次试、一次次看日志、一次次怀疑自己来捅破。
现在这个项目已经迭代到 1.3.8,从最初的"能登录"到"能聊天"到"能接机器人",版本号一点点往前挪。它不完美,但它是活的——每天都在自己家的手表上跑着。
如果你也在折腾类似的东西,希望这篇能帮你少走几天弯路。
项目地址:github.com/laolibuhuifei/JZclient-for-node 官网:jz.laolibuhuifei.com 协议:GPL-3.0
免责声明:本文及所涉项目仅用于技术研究与个人自用,所有协议分析均基于自己合法持有的设备与账号。文中已对敏感密钥做脱敏处理。请勿将相关技术用于未经授权的访问或商业用途。使用第三方客户端可能违反服务条款并导致账号风险,后果自负。
评论