经典 MarkDown 编辑器注册记录
本文的注册经过主要得益于以下两篇文章的启发,主要内容更多的是对以下两篇文章的补充以及相关技术的解释
https://mp.weixin.qq.com/s/JBNkKOl4SU1zxo98R7BB1Q
https://www.52pojie.cn/thread-1614569-1-1.html
传统的注册方案是用ida反编译之后通过静态分析找到提示你购买成功之类的字符串来寻找交叉引用,但是这种方法对于此款软件行不通,我们首先来了解一下Typora的整体构成。
通过上网搜索,(主要是先发现了resources目录下的.asar文件,之前碰到的比较少,因此多搜索了一下),然后发现了一个新的大陆:
1 .asar文件是一种将多个文件合并成一个文件的归档格式,它可以提高应用的加载速度和安全性
2 electron框架是一种用于开发跨平台桌面应用的技术
了解了整个文件的构成现在大致有了该软件的加密思路。把加密后的js代码以及封装了加密算法的动态链接库整体封装到.asar文件当中(可以达到比较好的隐藏目的),通过Typora.exe文件调用相关动态链接库先对.asar文件进行解密,还原出整体的js代码逻辑(我们要修改的关键),再加载进electron框架内运行即可。
现在就对应衍生出了两种注册思路:
-
通过动调的方式在elctron加载js代码的时候下断点,将已经还原的js代码从调试器中整体dump。这种方法虽然可以还原出代码逻辑但是你修改后怎么加密回去呢?
-
通过了解详细的加密步骤用其他语言复刻出一套加密逻辑,也就是一个实现同样加密功能的脚本,知道怎么解密就可以研究怎么加密,如果是对称加密算法就很容易实现。但是难度比较大,因为对应的解密逻辑应该不容易研究。
上述第二篇文章帮我们解决了2中对应的问题,github有人写出了这样的脚本,虽然现在git上没有资源了但是第二篇博客的作者给我们提供了帮助,另外我把修改好了的app.asar也放在盘里面了。
链接:https://pan.baidu.com/s/1nxGfaAxXZr3lYYydId9bCg
提取码:cccd
上面是打包好了的TyporaCracker.rar,
链接:https://download2.typoraio.cn/windows/typora-setup-x64-1.1.5.exe
上面是Typora 1.1.5版本的下载。
以下我们主要针对第二种方法进行达到注册目的,我记录的是我在阅读过程中遇到的诸多问题,在此之前需要认真阅读本文开头第二篇博客:
正常情况下我们要在typora的官网上购买他的序列号,要花89大洋,把随机生成的一个注册码和你的邮箱绑定并保存在自己内部的数据库中,这个过程的本质还是向注册网站发送了一个请求然后对返回的数据进行分析,我们关键的破解点在于要对返回来的数据包做点手脚。
既然我们想要返回来的数据包,那就应该先发送一个请求,这个请求的一些内容包括我们在注册界面需要填写的一些东西,比如邮箱和注册码
我们肯定是希望返回来的数据包携带的是验证成功的消息,首先需要在本地代码找到校验数据包的部分
<img src="https://tp.shunavicii.cn/image-20230320104916899.png" alt="image-20230320104916899" />
输入邮箱和序列号之后点击 ‘激活’ 其实就相当于一个校验激活的过程,但是我已经把发送数据包那部分代码给删了然后伪造好了一个返回的数据包,所以修改完之后连不连网都无所谓,但是正常情况下是必须连网激活的。
通常情况下输入的邮箱得是合法的,然后序列号可能也要有限制,我们可以先随便输入点东西然后通过反馈迅速定位判断代码位置。(其实靠猜也能知道无非就是反馈一点关于 email address 或者 license code的字符串,可以直接搜索)
下面的js代码来自app.asar解包之后的文件夹中,相关的附件和文章在本文开头。
输入valid email定位到唯一一处代码(所用开发环境为webstorm)
try {
return await async function(e, t, n) {
if (e = (e || "").replace(/^[\s\u200b ]/g, "").replace(/[\s\u200b ]$/g, ""),
t = (t || "").replace(/^[\s\u200b ]/g, "").replace(/[\s\u200b ]$/g, ""), !/^[^\s@'"/\\=?]+@[^\s@'"/\\]+\.[^\s@'"/\\]+$/.test(e))
return [!1, "Please input a valid email address"];
if (!Z(t)) return [!1, "Please input a valid license code"];
t = {
v: A() + "|" + s.getVersion(),
license: t,
email: e,
l: await G(),
f: await M(),
u: s.setting.generateUUID(),
type: global.devVersion ? "dev" : "",
force: n
};
这一部分大概就是对输入进行一些本地校验的过程,看你输入的email合不合法然后看你的注册码符不符合基本的要求(1194行的Z(t)函数就是干这个事的,所以注册码不能乱写,可以从Z()里面找逻辑)。
const Z = e => {
const r = "L23456789ABCDEFGHJKMNPQRSTUVWXYZ";
if (!/^([A-Z0-9]{6}-){3}[A-Z0-9]{6}$/.exec(e)) return !1;
var e = e.replace(/-/g, ""),
t = e.substr(22);
return !e.replace(/[L23456789ABCDEFGHJKMNPQRSTUVWXYZ]/g, "") && t == (e => {
for (var t = "", n = 0; n < 2; n++) {
for (var o = 0, i = 0; i < 16; i += 2) o += r.indexOf(e[n + i]);
o %= r.length, t += r[o]
}
return t
})(e)
},
多少是有点丑陋的不过也能看。序列号的格式是由四组六个字符组成,每个字符都是大写字母或数字,中间用短横线分隔。代码中定义了一个常量r
,它是一个字符串,包含了所有可能出现在序列号中的字母(不包括O和I)。然后,代码定义了一个函数Z
,它接受一 个参数e
,也就是要验证的字符串。函数首先检查字符串是否符合序列号的格式,如果不符合,就返回false
。然后,函数去掉字符串中的短横线,并把最后两个字符赋值给变量t
。这两个字符其实是校验码,用来检验前面22个字符是否正确。接下来,函数定义了一个匿名函数(e) => {...}
,它也接受一个参数e
(注意和外层函数的参数不同),并返回一个两个字符组成的字符串。这个匿名函数的作用是根据前22个字符计算出正确的校验码。它通过两次循环,在每次循环中对11个字符进行求和,并取余数作为索引,在常量r
中找到对应的字母,并拼接起来作为结果返回。最后,函数比较变量t
和匿名函数返回的结果是否相等,如果相等就说明序列号有效,并返回true
;否则就返回false
接下来就是封装一下请求,就是我们这里的1195行的t。再接着后面应该就是发送数据包相关的操作,也就是一句代码而已。
var o = await R("api/client/activate", t, !0);
上面一部分已经讲完了对发送请求的基本校验和发送,下面就是对反馈数据包的伪造和后续的判断。
伪造的数据包结构如下:
var o = {
data: {
code: 0, //满足后续校验成功的判断条件
msg: Buffer.from(
JSON.stringify({
type: global.devVersion ? "dev" : "",
deviceId: t.u,
fingerprint: t.f,
email: t.email,
license: t.license,
version: t.v,
date: new Date().toLocaleDateString("en-US"),
}),
"utf-8"
).toString("base64"), //前面生成数据包都在进行base64编码,所以返回来的也是base64编码后的
},
};
逻辑上而言,我之所以构造这个数据包,他的作用在于告诉本地我的校验已经成功了,后期只需要把一些相关的注册信息填写到注册表里面就可以了,这也解释了为什么我们的邮箱可以随意填写,然后注册码只需满足本地的校验逻辑即可。(这也是破解的关键),至于数据包的格式长这样的主要原因也在于一些基本信息需要和上面的对象t保持一致。
接下来看后面的代码:
跟D这个对象的元素进行比较,我们看一下D的内容
const D = {
SUCCESS: 0,
OUT_OF_LIMIT: 1,
INVALIDATE: -1,
WRONG_USER: -2,
},
因此要设置o.data.code=0,才能满足等于D.SUCCESS的值,也不会触发别的限制条件。在这种情况下,所以原来的向目标发送数据验证请求部分的代码也可以不要了,因为我们的目的是无论输入什么邮箱,最后都要绑定成功,跳过发送验证请求的代码之后就是检验我们伪造的返回数据o了。
当满足o.data.code==D.SUCCESS后,进入Y函数做判断
这个函数Y接受一个参数e,它是一个字符串,包含了用户的指纹、邮箱、许可证和类型。
- 函数内部首先调用了一个函数I,它的作用是把字符串e解析成一个对象k,对象k有四个属性:fingerprint(指纹)、email(邮箱)、license(许可证)和type(类型)。
- 然后函数Y使用try...catch语句来处理可能发生的错误。
- 注意这个地方I函数对参数e做了一个处理,也就是上面我们传过来的数据包o,I处理完之后再进行下一步
- 然后调用了一个异步函数M,它的作用是获取当前用户的指纹,并和对象k中的指纹进行比较。如果相同,并且邮箱和许可证都存在,就执行以下操作:
- 调用一个函数H,它的作用是根据邮箱、许可证和类型来激活用户的账户。
- 调用一个函数d,它返回一个数据库对象,并使用put方法把字符串e和当前日期存入数据库中,键名为"SLicense"。这里就相当于在填写注册表内容
- 把一个变量l赋值为true。这个变量可能是全局变量,表示用户已经验证通过。
- 如果不满足条件就显示验证服务器返回错误的提示
e是用I()函数处理过的,原本应该是base64编码的字符串,上述1137行之后在往注册表中填写信息,最值得关注的是那个时间 new Date,这一部分很明显就是一个拼接的utf-8字符串,因此肯定不能再是base64编码的了,我们对于I()的需求仅仅就是进行一次base64解码操作还原成utf-8编码字符。因为删除了连网请求,绕过了远程的一些校验所以I()函数原本的功能不用去细究
const I = (e) => {
return JSON.parse(Buffer.from(e, "base64").toString("utf-8"));
},
但是仅仅按照第二篇文章的方法其实最后是注册不了的,我最开始显示的报错是显示没有连网之类的问题,猜测可能不止最后发送包含地址和注册码的数据包,前面还有些地方有发送数据包的操作,后来经筛选主要有一下几处。下面列出的这几处全部都要注释掉
-
try {
await R("api/client/deactivate", {
license: c,
l: e,
sig: T(x, await M(), c)
}, !1)
} catch (e) {
if (r.captureException(e, {
level: "warning"
}), console.log(e.stack), !t && "off" == S) return !1
}
上面这部分是在取消激活的时候进行的连网操作
-
try {
ee();
const e = await a(225).post(E + "/api/client/renew", n, {
timeout: 4e4,
headers: {
"Cache-Control": "no-cache"
}
});
JSON.stringify(e.data), e.data.success || (console.warn("[renewLicense]: unfillLicense due to renew fail"), V(e.data.msg)), d().put("SLicense", [e.data.msg, 0, o].join("#"))
} catch (e) {
e.stack, r.captureException(e, {
level: "warning"
}), console.warn("Failed to Renew License");
var [t, n] = (i = d().get("SLicense")).split("#"), i = [t, n = +n + 1, o].join("#");
d().put("SLicense", i)
}
这里看到第四行有个timeout就把他删了
-
R = async (t, n, o) => {
ee(), console.log(`request ${E}/` + t);
const i = a(225).post;
try {
var r = await i(E + "/" + t, n, {
timeout: 3e4,
headers: {
"Cache-Control": "no-cache"
}
});
return r
} catch (e) {
if (console.warn(e.stack), e.response) throw e;
if (o && "zh-Hans" == s.setting.getUserLocale() && !s.setting.get("useMirrorInCN")) {
o = (await h.dialog.showMessageBox(null, {
message: "链接服务器失败,使用尝试访问国内域名进行激活?",
buttons: ["确认", "取消"]
}))["response"];
if (console.log("click " + o), o) throw e;
return s.setting.put("useMirrorInCN", !0), R(t, n, !1)
}
if (!s.setting.get("useMirrorInCN")) throw e;
o = e;
try {
console.log("request to typora.com.cn"), r = await i("https://typora.com.cn/store/" + t, n)
} catch (e) {
throw console.warn(e.stack), o
}
}
},
理由同上,这一部分很明显在进行连网操作
另外,在打包过程里参数不能再写-f了,得写-u,不然打包不成功。
以上就是我对整体的注册逻辑和一些关键修改位置的理解。至于这么好用的TyporaCracker原理是什么,具体的内容在开头第一个链接所指的文档单中。他就是帮你把app.asar里面加密的js代码怎么解密的流程给走了一遍,很大程度上会帮助理解typora.py和masar.py。
重新打包生成之后,把新生成的app.asar文件替换掉原来recourses目录下的同名文件,直接运行软件注册即可。
TyporaCracker里面有一个keygen.js代码,它就是一个生成激活码的代码,对应的就是刚才我们讲到的本地校验激活码的逻辑,也就是那个Z()函数,运行这个软件可以得到很多激活码满足Z()的逻辑。下面是运行方式
node kengen.js
这个得安装nodejs,不过不重要,我会给几条它生成的key放在下面:
- EZAKCZ-S96KD5-9AH3BK-J6YU3T
- STEAWQ-SPGHKJ-XNUMAJ-9NLHJP
- DY982X-GSTNZ4-7GYSLS-W5KZLS
- UXESUY-RUFST7-PD577Y-QFKSSX
- DJMYPR-RMF9CK-SBH6CG-2PYGD3
最后成功的结果如下,邮箱随意(邮箱格式得正规,序列号随便选一个填即可)