抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

前言

移动云电脑断开连接一段时间后会自动关机,对挂机和长时间任务很不友好。市面上常见的保活方案要么是「套娃」(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
2
3
4
5
LICENSE.electron.txt          ← Electron 官方版权
resources\app.asar ← Electron 标志性打包文件
icudtl.dat ← Chromium 国际化数据
chrome_100_percent.pak ← Chromium 资源
ffmpeg.dll / libGLESV2.dll ← Chromium 媒体/图形栈

确认是 Electron 应用,业务代码全在 app.asar 里。

第二步:解包 app.asar

asar 格式很简单:一个 JSON 头(记录文件列表 + 偏移量)+ 拼接的文件数据。我写了个 50 行的 Node.js 脚本解包:

1
2
3
4
5
6
7
8
9
10
// 关键:asar 头部布局
// offset 0: u32 pickleSize
// offset 4: u32 headerSize
// offset 8: u32 subPickleSize
// offset 12: u32 jsonLen ← 真正的 JSON 字节长度
// offset 16: JSON 文件清单
const jsonLen = pre.readUInt32LE(12);
const header = JSON.parse(headerBuf.toString('utf8'));
const dataStart = 8 + headerSizeField;
// 然后按 header 里每个文件的 offset/size 从 dataStart 切片读取

解出来 10,467 个文件。但打开 service/user.js 一看:

1
2
3
4
5
6
7
const _0x350a6f = _0x456a;
async ["loginWithPassword"](_0x5a082a, _0x1d6aaa) {
const _0x2b9a6d = await EcloudHttpUtil.post(
EcloudServerUrl.LOGIN_CHECK_USER_PASSWORD, {
'username': _0x5a082a,
'password': _0x1d6aaa,
...

字符串全被 javascript-obfuscator 混淆了——方法名还在(class 方法名没法混淆),但所有字符串字面量都变成了 _0x350a6f(0xa0) 这种间接调用。

第三步:反混淆(自研 AST 工具)

混淆器的套路是经典的「字符串数组 + 旋转 + 编码」:

  1. 定义一个字符串数组函数 _0xe399()
  2. 一个旋转 IIFE 把数组打乱
  3. 一个解码器 _0x456a(index) 按索引取字符串
  4. 业务代码里所有字符串都换成 _0x456a(0xa0) 调用

反混淆思路很直接——把解码器在沙箱里跑起来,然后遍历 AST 把所有 _0x456a(数字) 调用替换成解出的字符串字面量。用 @babel/parser + @babel/traverse + @babel/generator 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 核心逻辑:提取混淆器机制语句,在 VM 沙箱里执行
const preamble = extractPreamble(ast); // 字符串数组 fn + 旋转 IIFE + 解码器
vm.runInContext(preamble, sandbox); // 跑起来,sandbox 里就有了解码器函数

// 遍历 AST,替换所有解码器调用
traverse(ast, {
CallExpression(p) {
if (isDecoderCall(p)) {
const decoded = sandbox[decoderName](p.node.arguments[0].value);
p.replaceWith({ type: 'StringLiteral', value: decoded });
}
}
});

踩了几个坑:

  • 字符串数组函数在文件底部(函数声明提升,所以执行时要收集全部机制语句)
  • 方法体内部有局部别名 const _0x2b9e33 = _0x350a6f,要递归解析
  • 解码器内部有 base64 + URI 编码的延迟初始化,必须真正调用一次才生效

最终 125 个文件全部还原,解码 15,931 个字符串。反混淆后的代码完全可读:

1
2
3
4
5
6
7
8
9
10
11
12
13
async ["loginWithPassword"](username, password) {
const resp = await EcloudHttpUtil.post(
EcloudServerUrl.LOGIN_CHECK_USER_PASSWORD, {
'username': username,
'password': password,
'timestamp': Date.now(),
'clientNeedTwoFactor': true
});
if (resp.accessTicket) {
this.accessTicket = resp.accessTicket;
await this.verifyAccessTicket(this.accessTicket);
}
...

第四步:还原协议(签名 + 加密)

这是最关键的一步。从 ecloudHttpUtil.jsgetFullurlpost 方法里,完整还原了请求的两层加密。

4.1 URL 签名(HmacSHA1)

每个请求的 URL 查询串带签名,复刻 ecloudHttpUtil.js:189-204

1
2
3
4
5
6
7
8
9
10
11
12
13
14
query = {
"AccessKey": "53bb79015a3f47c4be166d9371f68f14",
"SignatureMethod": "HmacSHA1",
"SignatureNonce": uuid.uuid4().hex, # uuid4 去横线
"SignatureVersion": "V2.0",
"Timestamp": utc8_timestamp, # UTC+8, YYYY-MM-DDTHH:MM:SSZ
}
canonical = urllib.parse.urlencode(query)
hash_step = hashlib.sha256(canonical.encode()).hexdigest()
string_to_sign = f"POST\n{quote(api_path + endpoint)}\n{hash_step}"
signing_key = "BC_SIGNATURE&" + secret_key # 注意前缀!
signature = hmac.new(signing_key.encode(),
string_to_sign.encode(),
hashlib.sha1).hexdigest()

这是中国移动 Ecloud 平台标准的 V2.0 签名方案,BC_SIGNATURE& 前缀是它的固定 salt。

4.2 请求体加密(RSA-1024)

整个 JSON body 用 RSA 公钥加密后放在 {"params": "<base64>"} 里:

1
2
3
4
5
6
7
8
9
# RSA-1024, PKCS1 padding, 分块加密(每块 117 字节)
merged = {**业务参数, **commonParams, "accessToken": token}
data = json.dumps(merged).encode('utf-8')
chunk_size = 117 # 1024/8 - 11
out = b''
for i in range(0, len(data), chunk_size):
out += cipher.encrypt(data[i:i+chunk_size])
params_b64 = base64.b64encode(out).decode()
http_body = {"params": params_b64}

响应也是同样的格式,用私钥分块(128字节)解密。

4.3 提取密钥

密钥不在源码里明文存储,而是用 AES-256-CBC 加密放在 config/settingValue.js

1
2
密钥派生: key = SHA256("Ecloud-Computer-" + platform)  # platform = "win32"
iv = key[:16]

解密后拿到 accessKeysecretKey、RSA 公钥。私钥还套了一层——用硬编码的 kk/vv(AES-256-CBC)再加密了一次。完整的解密链:

1
2
3
4
settingValue.js (hex blob)
→ AES-256-CBC(SHA256("Ecloud-Computer-win32")) → JSON 配置
→ privateKey 字段还是 AES-256-CBC(kk, vv) 加密的 PEM
→ 解密后才是真正的 RSA 私钥

4.4 验证协议正确性

写完后第一件事是验证签名能不能被服务端接受。调一个不需要登录的接口 /user/getSysTime

1
2
3
resp = http.post("/user/getSysTime")
# RESPONSE: {'systime': '2026-06-14 12:28:11'}
# === server accepted signature + encryption ===

服务端返回了真实时间——证明 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
2
3
4
12:47:46 启动保活,间隔 5s
12:47:47 运行时长: 11小时8分7秒 ← 第1轮
12:47:52 运行时长: 11小时8分12秒 ← 第2轮 (+5秒)
12:47:57 运行时长: 11小时8分17秒 ← 第3轮 (+5秒)

运行时长的增长和实际经过时间完全吻合,证明服务端在为这个会话累计在线时长。纯 HTTP 保活真实有效,之前的判断被证伪了。

这个反转很有意思:源码分析告诉你「复杂协议在二进制里,Python 做不到」,但抓包告诉你「服务端其实只看一个简单的 HTTP 心跳」。两个证据冲突时,抓包(实际行为)永远优先于源码(设计意图)

第六步:全自动实现

最后一步是让保活真正「全自动」——用户只填账号密码,剩下全自动。

6.1 自动获取桌面列表

从渲染层 bundle (index-53f3f1a5.js) 里找到桌面列表接口:

1
2
3
// POST /user/getDeviceInfo
// 参数: {accessToken, companyCode:"ECloud", allCompany:true, version:"1.0.0"}
// 响应: body.machineList[] 每项含 {instanceId, machineId, machineName, ...}

6.2 字段必要性实测

有个意外发现:通过逐字段删除测试,发现服务端完全不校验设备指纹字段

1
2
3
4
[完整 18 字段]  OK → 13小时51分29秒
[只保留 6 个] OK → 13小时51分29秒
[只保留 4 个] OK → 13小时51分30秒
[空 commonParams] OK → 13小时51分33秒 ← 连空都行!

这意味着用户只需提供账号密码access_token(登录获取)、device_uid(首次自动生成并固化)、instance_id(拉桌面列表获取)全部自动。

6.3 最终用法

1
2
3
4
5
6
7
8
9
10
# 首次登录(交互式)
python main.py login
# account: <账号>
# password: <密码>

# 全自动桌面保活(自动拉桌面列表 + 选桌面 + 保活)
python main.py desktop-keepalive

# 配 crontab 每 5 分钟保活
*/5 * * * * cd ~/cloudpc-keepalive && python main.py desktop-keepalive --rounds 1

协议细节速查

完整的请求构造过程:

1
2
3
4
5
6
7
1. 构造业务参数 + commonParams + accessToken
2. JSON 序列化 → RSA-1024 PKCS1 分块加密(117字节/块) → base64
3. HTTP body = {"params": "<base64>"}
4. URL 查询串: AccessKey + SignatureMethod + SignatureNonce + SignatureVersion + Timestamp
5. stringToSign = "POST\n" + quote(apiPath+endpoint) + "\n" + sha256(querystring)
6. Signature = HmacSHA1(stringToSign, "BC_SIGNATURE&" + secretKey)
7. POST https://cloudpc.ecloud.10086.cn/api/cem/gateway/outer/cem-webapi/<endpoint>?<签名查询串>

关键常量:

常量
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
2
3
4
5
6
POST /login/verify {username, password, timestamp, clientNeedTwoFactor:true}
├─ 成功 → accessTicket → /login/verifyAccessTicket → accessToken ✓
├─ 30002009 (未授信设备) → 短信 + /login/trustDevice
├─ 30002060 (二次验证) → 短信 + /login/verifyTwoFactorAuthSms
├─ 30002063 (增强策略) → 短信 + /login/verifyLoginEnhanceSms
└─ userId 字段 → 4A MFA(未实现)

凭证链:

1
2
3
4
5
6
密码 ──/login/verify──▶ accessTicket ──/login/verifyAccessTicket──▶ accessToken

/resource/desktopUptime
/session/machineConnect

桌面保活 ✓

踩过的坑

1. 业务错误被当成网络错误走了 failover

login_with_password 最初调用 post_with_failover,遇到 30002009(未授信设备)这种业务错误时,failover 逻辑把它当网络错误重试备用域名,重试后还是同样的错误就直接抛异常了——根本没走到 errorCode 分支解析。

修复:登录类接口改用普通 posttry/except EcloudError 捕获后解析 errorCode 做分支判断。

2. 设备信任流程漏传 code 字段

源码 user.js:653 显示 trustDevice 需要一个 code 字段(来自登录响应 body.code)。我最初漏了这个字段,导致验证码验证失败(30002004)。

3. 短信验证码 60 秒有效期

移动云的短信验证码有效期只有 60 秒,而短信发送有 60 秒冷却。这意味着如果第一次验证码输入失败,必须等冷却结束重新发,节奏很紧。

4. 源码分析 vs 抓包的结论冲突

如前面所述,源码分析说「桌面保活需要 SPICE」,抓包说「不需要」。这种冲突在逆向里很常见,永远以抓包(实际行为)为准

总结

这次逆向的完整路径:

1
2
3
4
安装包 → NSIS → app.7z → app.asar 解包 → JS 反混淆
→ 签名算法还原 → AES 解密配置 → RSA 密钥提取
→ 协议端到端验证 → 抓包 HAR 解密 → 发现保活接口
→ 全自动实现 → 真实账号验证

几个关键经验:

  1. javascript-obfuscator 是可逆的——只要把解码器沙箱执行,AST 遍历替换就行
  2. 密钥总藏在配置里——这个应用的密钥套了两层 AES,但都在客户端,一定能解出来
  3. 抓包 > 源码分析——源码告诉你设计意图,抓包告诉你实际行为,冲突时以抓包为准
  4. 服务端校验往往比你想的松——18 个设备字段实际一个都不校验,逐字段删除测试能快速验证

最终产物是一个纯 Python、跨平台、零第三方依赖的保活工具,丢到任何 Linux 服务器上配个 crontab 就能跑。完整代码在 GitHub(私有仓库)。

免责声明:本文仅供学习研究,请遵守移动云电脑服务条款。

评论




站点访问量 Loading… 站点访客数 Loading…