记一次StarUML破解

0x00 起因

最近要使用StarUML绘制一些类图,默认选择了试用装,但是在导出后发现,图片居然满屏的UNREGISTERED水印!

试用装居然不让我正常导出,可恶…

注:本文仅学习交流技术原理,请购买正版许可证支持正版

0x01 Step1

定位到该app所在文件夹

1
2
3
4
5
6
7
8
9
10
11
12
StarUML/
├── locales
├── resources/
│ ├── app.asar
│ ├── app-update.yml
│ ├── elevate.exe
│ └── mdj.ico
├── StarUML.exe
├── Uninstall StarUML.exe
├── LICENSE.electron.txt
├── LICENSES.chromium.html
└── ...

观察文件结构不难看出,这是一个基于electron框架构建的app

好东西都在app.asar中,我们先进行解压

此处使用npm安装的asar工具

安装命令:

1
npm install -g asar

先将app.asar复制到单独文件夹,然后进行解压

1
asar extract app.asar ./app

然后就发现,这个包居然没有任何混淆!源码带注释就这么水灵灵的放在眼前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app/
├── package.json
├── src/
│ ├── core/
│ │ └── ...
│ ├── dialogs/
│ │ └── ...
│ ├── engine/
│ │ └── ...
│ ├── ...
│ ├── index.js
│ ├── strings.js
│ └── app-context.js
├── resources/
│ ├── assents/
│ │ └── ...
│ └── ...
├── node_modules/
│ └── ...
└── extensions/
└── ...

0x02 Step2

来到src文件夹下,注意到在src/dialogs/下有个license-activation-dialog.js

从名字不难看出,这应该是有关许可证的dialog的部分

其大致逻辑为从store里面读取相关状态,然后渲染到界面,提供激活/反激活的UI入口,还有一些激活辅助项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function updateDialog($dlg) {
await app.licenseStore.fetch(); // 这是什么?
const licenseStatus = app.licenseStore.getLicenseStatus();

const $sectionTrialNotExpired = $dlg.find(".trial-not-expired");
const $sectionTrialExpired = $dlg.find(".trial-expired");
const $sectionLicenseActivated = $dlg.find(".license-activated");
const $sectionLicenseNotActivated = $dlg.find(".license-not-activated");
const $trialDaysLeft = $dlg.find(".trial-days-left");
const $activeStatus = $dlg.find(".active-status");
const $productDisplayName = $dlg.find(".product-display-name");
const $licenseHolderName = $dlg.find(".license-holder-name");
const $deviceId = $dlg.find(".device-id");

$sectionTrialNotExpired.hide();
$sectionTrialExpired.hide();
$sectionLicenseActivated.hide();
$sectionLicenseNotActivated.hide();

...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
async function showDialog() {
await app.licenseStore.fetch(); // 这里也有!

const context = {
Strings: Strings,
metadata: global.app.metadata,
};
const dialog = app.dialogs.showModalDialogUsingTemplate(
Mustache.render(licenseActivationDialogTemplate, context),
);

const $dlg = dialog.getElement();
const $buyButton = $dlg.find(".buy-button");
const $licenseKey = $dlg.find(".license-key");
const $activateButton = $dlg.find(".activate-button");
const $deactivateButton = $dlg.find(".deactivate-button");
const $copyDeviceIdButton = $dlg.find(".copy-device-id-button");
const $licenseManagerButton = $dlg.find(".license-manager-button");
await updateDialog($dlg);

$activateButton.click(async function () {
...
});

$deactivateButton.click(async function () {
...
});

...
return dialog;
}

读一下相关代码,在进行UI相关渲染前都调用了

1
await app.licenseStore.fetch();

再看看其他部分,通过搜索UNREGISTERED找到了渲染水印逻辑所在,位于diagram-export.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const licenseStatus = app.licenseStore.getLicenseStatus();
// Draw watermark if application is not registered
if (licenseStatus.trial) {
diagram.drawWatermark(
canvas,
canvasElement.width,
canvasElement.height,
70,
12,
"UNREGISTERED",
);
} else if (licenseStatus.edition !== "PRO") {
const dgmType = diagram.constructor.name;
if (isProDiagram(dgmType)) {
diagram.drawWatermark(
canvas,
canvasElement.width,
canvasElement.height,
45,
12,
"PRO ONLY",
);
}
}

这表明,app的大部分地方似乎都依赖app.licenseStore来验证许可证状态

搜索一下licenseStore,发现在license-store.js中导出

1
module.exports = LicenseStore;

进一步查看这个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
const { ipcRenderer } = require("electron");
const { EventEmitter } = require("events");
const LicenseActivationDialog = require("../dialogs/license-activation-dialog");

class LicenseStore extends EventEmitter {
constructor() {
super();
// 定义了一个license状态
this.licenseStatus = {
activated: false,
name: null,
product: null,
edition: null,
productDisplayName: null,
deviceId: null,
licenseKey: null,
activationCode: null,
trial: false,
trialDaysLeft: 0,
};
}

// 从服务器获取许可证状态
async fetch() {
const licenseStatus = await ipcRenderer.invoke(
"license.get-license-status",
);
this.licenseStatus = licenseStatus;
this.emit("statusChanged", this.licenseStatus);
}

// 获取设备ID的方法
async getDeviceId() {
try {
const deviceId = await ipcRenderer.invoke("license.get-device-id");
...
}

// 激活方法
async activate(licenseKey) {
try {
const result = await ipcRenderer.invoke("license.activate", licenseKey);
if (!result.success) {
app.toast.error(result.message || "Activation failed");
}
} catch (err) {
console.error(err);
app.toast.error("Activation failed");
}
await this.fetch();
}

// 反激活方法
async deactivate() {
try {
const result = await ipcRenderer.invoke("license.deactivate");
if (!result.success) {
app.toast.error(result.message || "Deactivation failed");
}
} catch (err) {
console.error(err);
app.toast.error("Deactivation failed");
}
await this.fetch();
}

// 验证方法
async validate() {
const result = await ipcRenderer.invoke("license.validate");
const licenseStatus = await ipcRenderer.invoke(
"license.get-license-status",
);
this.licenseStatus = licenseStatus;
return result;
}

// 获取许可证状态
getLicenseStatus() {
return this.licenseStatus;
}

// 检查试用状态
async checkTrialMode() {
const licenseStatus = await ipcRenderer.invoke(
"license.get-license-status",
);
if (licenseStatus.trial) {
LicenseActivationDialog.showDialog();
}
}

// 初始化方法
async htmlReady() {
try {
await this.fetch();
const result = await this.validate();
if (!result.success) {
app.toast.error(result.message || "License validation failed");
}
await this.checkTrialMode();
await this.fetch();
} catch (err) {
console.error(err);
console.log("License validation failed");
}
}
}

module.exports = LicenseStore;

由此可知,这似乎是一个中间层,是renderer侧状态门面,通过IPC invoke调用主进程的license handler来取得许可证相关信息,并传递给UI层

到这里已经足够,我们不需要知道主进程的license handler具体是怎么与服务器交互取得注册信息的

只需要在这里插桩,拦截所有IPC请求,返回我们的虚假许可证信息给renderer,即可绕过绝大部分许可证限制

0x03 Step3

第一步就是定义一个fake license信息,注释掉构造函数中的原初始化赋值,换成我们的fake license

1
2
3
4
5
6
7
8
9
10
11
12
this.licenseStatus = {
activated: true, // 激活状态
name: "User", // 用户名称
product: "staruml-v7", // 产品类型
edition: "PRO", // 订阅类型
productDisplayName: "StarUML v7.0.0", // 产品展示名称
deviceId: "unlimited-device", // 设备ID
licenseKey: "key", // 许可证密钥
activationCode: "code", // 激活码
trial: false, // 是否试用
trialDaysLeft: 0, // 剩余试用天数
};

然后是处理那些许可证相关方法

1
2
3
4
5
6
7
8
async fetch() {
// const licenseStatus = await ipcRenderer.invoke(
// "license.get-license-status",
// );
// this.licenseStatus = licenseStatus;
// 不再通过IPC查询,而是直接返回虚假许可
this.emit("statusChanged", this.licenseStatus);
}
1
2
3
4
5
6
7
8
9
10
async getDeviceId() {
try {
// const deviceId = await ipcRenderer.invoke("license.get-device-id");
// 同样注释掉IPC调用,只返回虚假信息
return this.licenseStatus.deviceId;
} catch (err) {
console.error(err);
return this.licenseStatus.deviceId;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
async activate(licenseKey) {
// try {
// const result = await ipcRenderer.invoke("license.activate", licenseKey);
// if (!result.success) {
// app.toast.error(result.message || "Activation failed");
// }
// } catch (err) {
// console.error(err);
// app.toast.error("Activation failed");
// }
// 不要
await this.fetch();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
async deactivate() {
// try {
// const result = await ipcRenderer.invoke("license.deactivate");
// if (!result.success) {
// app.toast.error(result.message || "Deactivation failed");
// }
// } catch (err) {
// console.error(err);
// app.toast.error("Deactivation failed");
// }
// 这里也不要
await this.fetch();
}
1
2
3
4
5
6
7
8
9
10
async validate() {
// const result = await ipcRenderer.invoke("license.validate");
// const licenseStatus = await ipcRenderer.invoke(
// "license.get-license-status",
// );
// this.licenseStatus = licenseStatus;
// return result;
// 也不要,返回虚假信息
return { success: true, message: "License valid" };
}
1
2
3
4
5
6
7
8
9
async checkTrialMode() {
// const licenseStatus = await ipcRenderer.invoke(
// "license.get-license-status",
// );
// if (licenseStatus.trial) {
// LicenseActivationDialog.showDialog();
// }
// 这里也不要
}

然后打包回去

1
asar pack ./app app.asar

复制并替换原app.asar,启动StarUML,查看激活状态

许可证界面

0x04 深挖

在翻阅相关资料,并且继续分析后发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ipcMain.handle("license.get-device-id", async (event) => {
const deviceId = await getDeviceId();
return deviceId;
});

ipcMain.handle("license.activate", async (event, licenseKey) => {
const result = await remoteActivate(licenseKey);
return result;
});

ipcMain.handle("license.deactivate", async (event) => {
const result = await remoteDeactivate();
return result;
});

ipcMain.handle("license.validate", async (event) => {
let result = await localValidate();
if (result.success) {
result = await remoteValidate();
}
return result;
});

ipcMain.handle("license.get-license-status", async (event) => {
return getLicenseStatus2();
});

这些方法最终指向license-client.js

分析license-client.js发现了一些有趣的东西

试用的信息存储在lib.so里面(win下面有个.so难道不可疑吗…)

1
2
3
const LICENSE_TRIAL_MODE_ENABLED = true;
const LICENSE_TRIAL_TIMESTAMP_FILE_NAME = "lib.so";
const LICENSE_TRIAL_MODE_DAYS = 30; // negative for infinite, positive for days left

神秘硬编码key

1
2
3
4
const LICENSE_CRYPTO_KEY = "y0JMc9mvB1uvIi82GhdMJQXzVJxl+1Lc0RqZqWaQvx0=";
const LICENSE_SERVER_URL =
"https://dev.staruml-io-astro.pages.dev/api/license-manager";
const LICENSE_FILE_NAME = "activation.key";

解码后为

1
CB424C73D9AF075BAF222F361A174C2505F3549C65FB52DCD11A99A96690BF1D

此key用于下文的AES解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Import AES key from Base64 string
*/
async function importAESKey(base64Key) {
const keyBuffer = base64ToArrayBuffer(base64Key);
const imported = await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
);
return imported;
}

离线许可证验证方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
async function localValidate() {
try {
const filePath = path.join(app.getPath("userData"), LICENSE_FILE_NAME);
const activationCode = await fs.readFile(filePath, "utf8");
const decoded = await decodeActivationCode(activationCode);
const deviceId = await getDeviceId();
const isProductMatched = decoded.product === LICENSE_PRODUCT_ID;
const isDeviceIdMatched =
decoded.deviceId === "*" || decoded.deviceId === deviceId;

if (!isProductMatched) {
await checkTrialMode();
await localDeactivate();
return {
success: false,
message: "Invalid activation code (product mismatch)",
};
}

if (!isDeviceIdMatched) {
await checkTrialMode();
await localDeactivate();
return {
success: false,
message: "Invalid activation code (device ID mismatch)",
};
}

licenseStatus = {
activated: true,
name: decoded.name,
product: decoded.product,
edition: decoded.edition,
productDisplayName: getProductDisplayName(
decoded.product,
decoded.edition,
),
deviceId: decoded.deviceId,
licenseKey: decoded.licenseKey,
activationCode: activationCode,
trial: false,
trialDaysLeft: 0,
};
return {
success: true,
message: "Local validation successful (activated)",
};
} catch (err) {
// if the file does not exist, assume the license is not activated
}
await localDeactivate();
return {
success: true,
message: "Local validation successful (not activated)",
};
}

从userData/activation.key中读activationCode,用decodeActivationCode()解密出来,取当前机器deviceId,然后进行判断

1
2
decoded.product === LICENSE_PRODUCT_ID
decoded.deviceId === "*" || decoded.deviceId === currentDeviceId

用来检查本地的激活码是否合法

一个远程验证方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* Validate the activation code with the server.
*/
async function remoteValidate() {
const { activated, activationCode, deviceId } = licenseStatus;
if (!activated) {
return {
success: true,
message: "Validation successful (not activated)",
};
}
try {
if (deviceId === "*") {
// if offline activation, deactivate if the server is reachable
const response = await fetch(`${LICENSE_SERVER_URL}/ping`, {
method: "POST",
});
if (response.ok) {
await localDeactivate();
return {
success: false,
message: "License deactivated (illegal offline use)",
};
}
} else {
// if online activation, validate the activation code with the server
const response = await fetch(`${LICENSE_SERVER_URL}/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
activation_code: activationCode,
}),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const decoded = await decodeValidationCode(data.validation_code);
if (decoded.deviceId === deviceId) {
return {
success: true,
message: "Validation successful (activated)",
};
}
}
}
await localDeactivate();
return {
success: false,
message: "License deactivated by server",
};
}
} catch (err) {
// if server is not accessible, assume the validation is success.
return {
success: true,
message: "Validation successful (offline)",
};
}
}

有个有趣的地方,如果本地已经activated并且deviceId === "*",即离线激活的情况,会向服务器/ping,如果请求可达则撤销本地的激活状态(还有秋后算账环节)

由此总结远程/离线激活的流程

  • deviceId
  • POST到/activate
  • 提交device_id license_key
  • 服务器返回activation_code
  • 本地再行校验
  • 调用localActivate()

localActivate()的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function localActivate(activationCode) {
try {
const filePath = path.join(app.getPath("userData"), LICENSE_FILE_NAME);
await fs.writeFile(filePath, activationCode, "utf8");
} catch (err) {
console.error("Local activation failed:", err);
}
const decoded = await decodeActivationCode(activationCode);
licenseStatus = {
activated: true,
name: decoded.name,
product: decoded.product,
edition: decoded.edition,
productDisplayName: getProductDisplayName(decoded.product, decoded.edition),
deviceId: decoded.deviceId,
licenseKey: decoded.licenseKey,
activationCode: activationCode,
trial: false,
trialDaysLeft: 0,
};
}

流程为

  • activationCode 写入 userData/activation.key
  • 解密出字段
  • 更新 licenseStatus
  • 关闭试用,清零 trialDaysLeft

0x05 结束

折腾大概就到这里了,没有混淆实属良善,发一张好人卡

最后再次声明

仅做技术交流之用,请支持正版软件


记一次StarUML破解
https://www.letr7.com/2026/04/21/cracking-staruml/
作者
letr
发布于
2026年4月21日
更新于
2026年4月21日
许可协议