前言
移动云电脑断开连接一段时间后会自动关机,对挂机和长时间任务很不友好。市面上常见的保活方案要么是「套娃」(Docker 里跑 Linux 客户端 + Xvfb + 模拟点击,内存占用高、容易失效),要么依赖官方客户端进程常驻。
这篇文章记录我是怎么把移动云电脑 Windows V3.8.2 客户端完全逆向,最终用纯 Python(只依赖 requests + pycryptodome)实现协议级保活的。核心结论先放上来:
桌面会话保活不需要 SPICE 协议,不需要 uSmartView 二进制,纯 HTTP 就够了。
这个结论是抓包证伪了源码分析得出的初始判断——后面会详细讲这个反转。
准备:分析对象和环境
- 目标:
Ecloud_CloudComputer_x64_V3.8.2_setup.exe(586MB 安装包) - 已安装路径:
C:\Program Files (x86)\Ecloud\CloudComputer\ - 工具:7-Zip、Node.js(反混淆用)、Python + pycryptodome、Reqable(抓包)
- 最终产物:ecloud-cloudpc-keepalive(私有仓库)
整体工作量分布大概是这样:
| 阶段 | 工作内容 | 耗时 |
|---|---|---|
| 反编译 | 解包 asar + 反混淆 JS | 1 天 |
| 协议还原 | 签名算法 + RSA 加密 + 密钥提取 | 0.5 天 |
| 抓包突破 | HAR 解密,发现保活接口 | 0.5 天 |
| 实现 + 验证 | Python 复刻 + 端到端测试 | 0.5 天 |
第一步:确认是 Electron 应用
用 7-Zip 打开安装包,发现是 NSIS 安装器,里面套了一个 app.7z(574MB)。解开后目录结构非常眼熟:
1 | LICENSE.electron.txt ← Electron 官方版权 |
确认是 Electron 应用,业务代码全在 app.asar 里。
第二步:解包 app.asar
asar 格式很简单:一个 JSON 头(记录文件列表 + 偏移量)+ 拼接的文件数据。我写了个 50 行的 Node.js 脚本解包:
1 | // 关键:asar 头部布局 |
解出来 10,467 个文件。但打开 service/user.js 一看:
1 | const _0x350a6f = _0x456a; |
字符串全被 javascript-obfuscator 混淆了——方法名还在(class 方法名没法混淆),但所有字符串字面量都变成了 _0x350a6f(0xa0) 这种间接调用。
第三步:反混淆(自研 AST 工具)
混淆器的套路是经典的「字符串数组 + 旋转 + 编码」:
- 定义一个字符串数组函数
_0xe399() - 一个旋转 IIFE 把数组打乱
- 一个解码器
_0x456a(index)按索引取字符串 - 业务代码里所有字符串都换成
_0x456a(0xa0)调用
反混淆思路很直接——把解码器在沙箱里跑起来,然后遍历 AST 把所有 _0x456a(数字) 调用替换成解出的字符串字面量。用 @babel/parser + @babel/traverse + @babel/generator 实现:
1 | // 核心逻辑:提取混淆器机制语句,在 VM 沙箱里执行 |
踩了几个坑:
- 字符串数组函数在文件底部(函数声明提升,所以执行时要收集全部机制语句)
- 方法体内部有局部别名
const _0x2b9e33 = _0x350a6f,要递归解析 - 解码器内部有 base64 + URI 编码的延迟初始化,必须真正调用一次才生效
最终 125 个文件全部还原,解码 15,931 个字符串。反混淆后的代码完全可读:
1 | async ["loginWithPassword"](username, password) { |
第四步:还原协议(签名 + 加密)
这是最关键的一步。从 ecloudHttpUtil.js 的 getFullurl 和 post 方法里,完整还原了请求的两层加密。
4.1 URL 签名(HmacSHA1)
每个请求的 URL 查询串带签名,复刻 ecloudHttpUtil.js:189-204:
1 | query = { |
这是中国移动 Ecloud 平台标准的 V2.0 签名方案,BC_SIGNATURE& 前缀是它的固定 salt。
4.2 请求体加密(RSA-1024)
整个 JSON body 用 RSA 公钥加密后放在 {"params": "<base64>"} 里:
1 | # RSA-1024, PKCS1 padding, 分块加密(每块 117 字节) |
响应也是同样的格式,用私钥分块(128字节)解密。
4.3 提取密钥
密钥不在源码里明文存储,而是用 AES-256-CBC 加密放在 config/settingValue.js:
1 | 密钥派生: key = SHA256("Ecloud-Computer-" + platform) # platform = "win32" |
解密后拿到 accessKey、secretKey、RSA 公钥。私钥还套了一层——用硬编码的 kk/vv(AES-256-CBC)再加密了一次。完整的解密链:
1 | settingValue.js (hex blob) |
4.4 验证协议正确性
写完后第一件事是验证签名能不能被服务端接受。调一个不需要登录的接口 /user/getSysTime:
1 | resp = http.post("/user/getSysTime") |
服务端返回了真实时间——证明 HmacSHA1 签名、RSA 加密、设备指纹全部正确。这是整个项目的第一个里程碑。
第五步:抓包突破(关键反转)
到这一步,登录和账号保活都实现了。但桌面会话保活卡住了。
从源码分析得出的结论是:真正的桌面连接由 uSmartView_VDI_Client.exe 维持,SCG 网关认证 + 穿云 Trunk + SPICE 握手协议全在这个二进制内部,Electron 源码里 grep -r "SCG\|spice\|穿云\|10800" 零匹配。
我当时判断「Python 无法纯协议保活桌面会话」。
直到抓包。
用 Reqable 抓了一次「连接桌面后」的流量,导出 HAR。关键是我能用自己的 RSA 私钥解密 HAR 里的所有密文。解密后发现了三个源码里没有的接口:
| 接口 | 作用 | 频率 |
|---|---|---|
/resource/desktopUptime |
查询桌面运行时长 | 周期性 ⭐ |
/session/machineConnect |
桌面会话登记 | 连接时 1 次 |
/machine/pushConnectEventData |
连接事件上报 | 连接时 1 次 |
/resource/desktopUptime 的请求体极其简单:
1 | {"instanceId": "CCA-2b44466f2dd04fbcb73477d637c9108f"} |
响应:
1 | {"body": "13小时38分54秒"} |
就是这个接口。 它不需要 SPICE,不需要 uSmartView,只需要 accessToken + instanceId。
验证保活有效性
用抓包的真实凭证调用 desktopUptime,运行时长持续增长:
1 | 12:47:46 启动保活,间隔 5s |
运行时长的增长和实际经过时间完全吻合,证明服务端在为这个会话累计在线时长。纯 HTTP 保活真实有效,之前的判断被证伪了。
这个反转很有意思:源码分析告诉你「复杂协议在二进制里,Python 做不到」,但抓包告诉你「服务端其实只看一个简单的 HTTP 心跳」。两个证据冲突时,抓包(实际行为)永远优先于源码(设计意图)。
第六步:全自动实现
最后一步是让保活真正「全自动」——用户只填账号密码,剩下全自动。
6.1 自动获取桌面列表
从渲染层 bundle (index-53f3f1a5.js) 里找到桌面列表接口:
1 | // POST /user/getDeviceInfo |
6.2 字段必要性实测
有个意外发现:通过逐字段删除测试,发现服务端完全不校验设备指纹字段:
1 | [完整 18 字段] OK → 13小时51分29秒 |
这意味着用户只需提供账号密码,access_token(登录获取)、device_uid(首次自动生成并固化)、instance_id(拉桌面列表获取)全部自动。
6.3 最终用法
1 | # 首次登录(交互式) |
协议细节速查
完整的请求构造过程:
1 | 1. 构造业务参数 + commonParams + accessToken |
关键常量:
| 常量 | 值 |
|---|---|
| baseUrl | https://cloudpc.ecloud.10086.cn |
| apiPath | /api/cem/gateway/outer/cem-webapi |
| accessKey | 53bb79015a3f47c4be166d9371f68f14 |
| secretKey | 6b0d3b93f3aa4c7ea076c841bead1ddd |
| HMAC 前缀 | BC_SIGNATURE& |
| RSA | 1024-bit, PKCS1 |
| companyCode | ECloud |
| clientVersion | 3.8.2 |
登录流程
密码登录支持多个分支:
1 | POST /login/verify {username, password, timestamp, clientNeedTwoFactor:true} |
凭证链:
1 | 密码 ──/login/verify──▶ accessTicket ──/login/verifyAccessTicket──▶ accessToken |
踩过的坑
1. 业务错误被当成网络错误走了 failover
login_with_password 最初调用 post_with_failover,遇到 30002009(未授信设备)这种业务错误时,failover 逻辑把它当网络错误重试备用域名,重试后还是同样的错误就直接抛异常了——根本没走到 errorCode 分支解析。
修复:登录类接口改用普通 post,try/except EcloudError 捕获后解析 errorCode 做分支判断。
2. 设备信任流程漏传 code 字段
源码 user.js:653 显示 trustDevice 需要一个 code 字段(来自登录响应 body.code)。我最初漏了这个字段,导致验证码验证失败(30002004)。
3. 短信验证码 60 秒有效期
移动云的短信验证码有效期只有 60 秒,而短信发送有 60 秒冷却。这意味着如果第一次验证码输入失败,必须等冷却结束重新发,节奏很紧。
4. 源码分析 vs 抓包的结论冲突
如前面所述,源码分析说「桌面保活需要 SPICE」,抓包说「不需要」。这种冲突在逆向里很常见,永远以抓包(实际行为)为准。
总结
这次逆向的完整路径:
1 | 安装包 → NSIS → app.7z → app.asar 解包 → JS 反混淆 |
几个关键经验:
- javascript-obfuscator 是可逆的——只要把解码器沙箱执行,AST 遍历替换就行
- 密钥总藏在配置里——这个应用的密钥套了两层 AES,但都在客户端,一定能解出来
- 抓包 > 源码分析——源码告诉你设计意图,抓包告诉你实际行为,冲突时以抓包为准
- 服务端校验往往比你想的松——18 个设备字段实际一个都不校验,逐字段删除测试能快速验证
最终产物是一个纯 Python、跨平台、零第三方依赖的保活工具,丢到任何 Linux 服务器上配个 crontab 就能跑。完整代码在 GitHub(私有仓库)。
免责声明:本文仅供学习研究,请遵守移动云电脑服务条款。