逆向 Novoline

Novoline 是最历史悠久的 Minecraft 外挂之一,最初于 2019 年开始开发1。在 2020 年左右,由于 AlphaAntiLeak 很不酷2而且中国人能飞导致被中国人破解了,之后进行了多次验证增强。
2021 年,由于一些我不知道的矛盾,原 Novoline 开发 gast 和 tasteful 从 Novoline 离职自立门户,成立了novoline@intent,也就是后来的novoline.lol。
在此之后出现了两个不同的 Novoline 分别是 novoline.wtf 和 novoline.lol,由两个不同的团队开发,进入了一个汉贼不两立状态,双方都声称自己是正统的 Novoline。并且有完全不同的保护和混淆系统。不幸的是,这两个 Novoline 都在 2023 年停止服务。
在本文章中,我们会对两个版本的 Novoline 都进行逆向。
Novoline.wtf 3.0
Novoline.wtf 使用了一个叫 AkrienAntiLeak 的保护系统,也简称为 AAL 但是不是 Alpha Anti Leak。
有趣的是,这个 AkrienAntiLeak Launcher 使用了 AlphaAntiLeak(或近似AAL)的图标,可能向 AAL 致敬吧。

初步研究
Novoline 也有一个自定义JVM , 查壳后发现 jvm.dll 被 VMProtect 虚拟化保护了。

在 builds 文件夹下发现有个很大的 .tar.gz 文件,初步怀疑是客户端文件,但是在确认后我们发现这个文件并不是标准的 GZip 格式文件也不是标准的 Zip 格式文件。

另外,我们在 libraries 文件夹下发现一个叫 AkrienGuard.jar 的文件,但是尝试使用 Recaf 逆向的时候发现这个文件里的 Class 已经被加密了。

最明显的特征就是原来的 CAFEBABE 头已经被修改为了 FFFFFFFF (-1)
由于 Novoline 已经停服太久了,所以我们无法做到完整的启动客户端并使用 JVMTI (Java Virtual Machine Tool Interface) Dump 加密类,当然在停服之前我就尝试过了,也是 Dump 不出来的。
逆向JVM
首先第一步当然是找到 Class 解析函数,但是我们并没有在内存里直接找到解析函数的特征,所以这个解析函数很可能被虚拟化了,也有可能是这个解析函数被大改过,特征已经不一样了。
为了验证我们的猜想,我们开始尝试通过搜索关键数据来定位,我们都知道正常的 Class 的 MagicNumber 是 CAFEBABE,在这个魔改 Class 中是 FFFFFFFF,也就是说我们可以通过搜索既包含 CAFEBABE 又包含 FFFFFFFF 的函数来尝试定位。
事实上我们也确实找到了。


我们发现这个 RVA 0x1DF140 的函数刚好满足条件,分析过后可以确定这就是解析 Class 的函数。
分析加密算法
我们发现,在判断到 Buffer 起始为 FFFFFFFF 时会跳转到 .akr0 段内的 RVA 0xCF2007 区域。


同时我们注意到两个常量值 0x5DEECE66D 和 0xFFFFFFFFFFFF

这正是 LCG (Linear Congruential Generator) 算法用到的常量, LCG 是一种非常常见的 PRNG (Pseudo-Random Number Generator) 算法。
在 RVA 0xCF2007 区域中我们也发现了符合该算法的常量,并且我们还发现了已经是内部状态的常量值 0x38AC7C2D4A9D 。

同时发现生成的字节会加上 0x80 固定偏移。
由此我们可以还原出其中的一块计算应该是:
long state = 0x38AC7C2D4A9DL;
final long M = 0x5DEECE66DL;
final long MASK = (1L << 48) - 1;
byte[] K = new byte[1024];
byte[] KOff = new byte[1024];
for (int i = 0; i < 1024; i++) {
state = (state * M + 11) & MASK;
int b = (int) (state >>> 40);
K[i] = (byte) b;
KOff[i] = (byte) ((b + 0x80) & 0xFF);
}
继续向下走发现它还在初始化另一个数组。

这段有 代码变异(Mutation) 但是还是能看出来其中 ECX 从 0 开始向 1024 递增,R9 每轮前进 4 字节。


CF2101 mov edx, [rbp+rsi*4+50h]
CF2105 dec r9d
CF210F movsxd rax, ecx
CF2261 lea rcx, [rbp+50h]
CF226A lea rcx, [rcx+rax*4]
CF2273 mov eax, [rcx]
CF227A mov [rbp+rsi*4+50h], eax
CF2283 dec rsi
CF228E mov [rcx], edx前面的循环已经初始化了一个包含 1024 个整数的数组,接下来又开始了一个减索引的循环和元素交换。
这非常像一个标准的 Fisher Yates Shuffle 算法。

继续跟着 RVA 0xCF2172 往下走还能发现类似 Java Random 的 nextInt 的实现。

这个表的生成逻辑可以还原为:
int[] P = new int[1024];
for (int i = 0; i < 1024; i++) {
P[i] = i;
}
for (int bound = 1024; bound > 1; bound--) {
int j = nextInt(bound);
int t = P[bound - 1];
P[bound - 1] = P[j];
P[j] = t;
}
继续分析可以发现在初始化完这个表之后会利用这个表来选择密钥和输出位置,对原文进行 1024 字节分区块解密。

out[chunk + P[i]] = (byte) (data[chunk + i] ^ KOff[P[i]]);
不足 1024 字节的区块按照顺序解密。
out[chunk + i] = (byte) (data[chunk + i] ^ KOff[P[i]]);
最后对前一步的输出逐字节异或 6。
for (int i = 0; i < out.length; i++) {
out[i] ^= 0x06;
}分析 Class 结构加密
继续往下分析,我们发现 Novoline 的 Class 内不同数据都有不同的加密处理,但是万变不离其宗,基本上都是同状态 Random 调用 nextInt 取 Key 然后进行异或操作的。
Constant Pool:
final long M = 0x5DEECE66DL;
final long MASK = (1L << 48) - 1;
long[] state = { (seed ^ M) & MASK };
byte[] outCp = new byte[encryptedCp.length];
for (int i = 0; i < encryptedCp.length; i++) {
state[0] = (state[0] * M + 11) & MASK;
int r = (int) (state[0] >>> 16);
outCp[i] = (byte) (encryptedCp[i] ^ (r & 255));
}其他的在经过多次分析和尝试之后可以确认分别为:
| 数据 | 处理 |
|---|---|
access_flags |
无操作 |
this_class |
XOR next_int() & 0xFFFF |
super_class |
XOR next_int() & 0xFFFF |
interfaces_count |
XOR next_int() & 0xFFFF |
interfaces[i] |
分别 XOR next_int() & 0xFFFF |
methods_count |
XOR next_int() & 0xFFFF |
methods[i].access_flags |
无操作 |
methods[i].name_index |
XOR next_int() & 0xFFFF |
methods[i].descriptor_index |
XOR next_int() & 0xFFFF |
methods[i].attributes_count |
无操作 |
每个方法的 method_key |
单独调用一次 next_int() |
方法属性的 attribute_name_index、attribute_length |
无操作 |
method.max_stack |
XOR method_key & 0xFFFF |
method.max_locals |
XOR method_key & 0xFFFF |
method.code_length |
XOR method_key |
method.code[bci] |
单独处理 |
method.exception_table_length、exception_table[] |
无处理 |
method.attributes_count、嵌套属性 |
无处理 |
其他方法属性的 info |
无处理 |
fields_count |
XOR next_int() & 0xFFFF |
fields[i].access_flags |
无处理 |
fields[i].name_index |
XOR next_int() & 0xFFFF |
fields[i].descriptor_index |
XOR next_int() & 0xFFFF |
fields[i].attributes_count |
XOR next_int() & 0xFFFF |
字段属性的 attribute_name_index、attribute_length、info |
无处理 |
类级别的 attributes_count |
右移 1 位 |
类属性的 attribute_name_index、attribute_length、info |
无处理 |
bci 处理:
byte[] insts = new byte[encryptedInsts.length];
for (int i = 0; i < encryptedInsts.length; i++) {
insts[i] = (byte) (encryptedInsts[i] ^ 0x8C);
}
byte key = (byte) (methodKey & 0xFF);
for (int step : new int[]{2, 4, 6}) {
for (int bci = 0; bci < insts.length; bci += step) {
insts[bci] ^= key;
}
}客户端文件解密
在分析完 Class 的一整套加密后我们终于可以对 AkrienGuard.jar 内的 Class 进行解密然后读取代码了。

可以发现这个 AkrienGuard.jar 其实是一个解密文件 + 加载 Class的工具,文件解密方案也是一个简单的异或。
在对 Novoline 客户端文件进行解密后发现这个客户端文件也有一个同款的 Class 加密。

用同样方式解密之后便得到了正常的 JAR 文件。

Java 混淆

使用了 ZKM 混淆,但是出于某种原因擦除了 ZKM 的水印,因此无法判断版本,虽然版本不重要就是了,使用我们的反混淆方案可以轻松解除。
很显然,和 Moon 一样,Novoline 也对自身的自定义 JVM 保护过于自信,Java 层甚至没有一个 Native 混淆。

但是令人蒙古的是他的代码里有 Native 注解,只是没混上 Native。
客户端验证

验证主要基于几个 AkrienAntiLeak 提供的 Native 方法和位于 net.skidunion.API 里的 Native 方法(由novo-api dll提供),Novoline/Akrien 一直没有被破解的主要原因就是自定义 JVM 保护,实际上在解密 Class 之后可以发现他的校验逻辑相当愚蠢。
com.moon.security.API Native 方法布局和 net.skidunion.API 一致,不知道是致敬还是使用了同一套认证系统,我猜不是同一套认证系统,更偏向于致敬。

Novoline

Moon
最可能是验证的方法是 ru.fals3r.aal.API 中的 authorize,查看引用代码可以看到,这个验证的逻辑就是 ThreadLocalRandom.current().nextLong 然后判断 authorize 返回的是不是他妈的 md5Hex(String.valueOf(r ^ 11L))

说实话这有点狂野震撼亚洲了,虽然这个 authorize 可能背靠 VMProtect 保护,但是这个 Java 校验可能有点过于愚蠢了,也就是你只需要 polyfill 掉这个 authorize 方法让他返回这个,他的验证就被通过了。
public static String authorize(long l) {
return DigestUtils.md5Hex((String)String.valueOf(l ^ 0xBL));
}另一个校验逻辑是校验 net.skidunion.API 的 getHost 返回,校验getHost() + /token,校验这个 URL 的 hashCode 等于 "https://protection.novoline.wtf/api/v1/token".hashCode(),这是出于反伪站目的,用于接下来的验证。

登录请求则是硬编码组装了一个 User-Agent 其含义我们不得而知然后获得服务器返回的 delsyToken,这个 Token 将用于登录 Novoline Online Service(NOS)。

这个请求实际上可以直接忽略,直接走到下面的init方法,反正我们大概不需要一个 delsyToken。因为接下来会禁用 NOS,所以可以直接patch为走到下方初始化方法。

初始化方法参数为(Ljava/lang/String;ILjava/lang/String;)V,第一个参数是username|<|>|uid,然后初始化方法使用|<|>|来分割。第二个是一个int,第三个是签名,由服务器签发,所以我们还需要修补签名校验。

当然这个校验方法就是用HWID算一个MD5,甚至不用关心他是怎么实现的,直接让他滚即可。
接下来就是NOS连接

实际上这个相当好修补,直接给发起请求方法本身 patch 掉就行了,他对是否连通什么的没有任何校验只要完成了一个初始化就行了
public CompletableFuture AdvancedAntiSkid1iIIIIil(String string, long l) {
return CompletableFuture.completedFuture(new AdvancedAntiSkid1Ili1111("1"));
}现在我们已经修补完了所有验证点,使用普通的 JDK 启动验证可以不幸注意到它被破解了。

有趣的是解压jar里的anime_pictures.pngs可以注意到里面有一个纸片人图片大全。

Novoline.lol 6.99
Novoline.lol 最初在 Intent Store 上售卖,所以曾经使用 Intent Store 的验证系统。
在5.x版本之后不再在 Intent Store 上销售,转而独立销售,使用了一套自主研发的验证系统。除此之外没有任何 Class 加密等保护,但是有使用了 VMProtect 的 Native 混淆。

混淆

Novoline.lol 的 Java 混淆是 Zelix Klass Master + 一些定制混淆 + Native混淆,这个Native混淆和Moon的应该是同一款,因为这个也分离了 Invoke Dynamic。
对于 Native 混淆,由于部分方法有VMP,我们使用了JNI Tracing结合动态调试,恢复了有VMP的方法的逻辑,但是对于没有VMP的方法,可以更轻松的恢复,直接使用JNI Tracing结合反编译的伪代码分析控制流就行了。

他的自定义混淆有三个,一个是给数字移动到单独的Class叫num_pool当然这个作用不大可以轻松的内联并运行一些预制常量折叠Transformer即可解密。

另一种是给一堆不同的类的字符串塞到一个类里,这个类专门放这些字符串然后用ZKM的字符串混淆完成加密,说实话这个也没什么用,甚至可以说破坏了Initialization Order带来的优势。反而导致更好解密,只要单独在沙箱里运行这个类的static块就全员解密了。

第三种是给所有引用变成Wrapper,然后改写原始引用到Wrapper Class,非静态方法会先传入this作为参数,这个东西说实话里面很多掺杂了ZKM混淆进去的垃圾代码,想要处理干净其实可能挺难的。
但是总归来说不会影响分析,只是稍微有点恶心人。
身份认证
验证逻辑位于 net.skidunion.am 类,在上一步我们已经结合JNI Tracing和动态调试几乎完全恢复了这个类的Native代码到Java逻辑。在验证开始之前有一大串代码用于检测环境,我们来分析一下。
其环境检测出错之后会上报到http://huesos.space/auth/post,也许会用来追踪破解者,当然这个域名现在已经死了。
环境检测

在登录开始之前,会先检测rt.jar、cacerts和jvm.dll的哈希和文件大小,这就是必须使用 JDK 8u231 的原因。在所有 Novoline 安装教程视频里都会教你下载 JDK 8u231。

一个 2023 年的 Novoline 安装教程视频里的图片

检测完 JRE 的哈希之后还会检测有没有 Xbootclasspath 和 javaagent 参数,并且还会检测是不是有disableattachmechanism参数,也就是你必须有disableattachmechanism参数且没有调试参数才能进入下一步

这对应了当时的所有教程还会让你添加disableattachmechanism参数,虽然笔者认为这没有任何鸡毛用。

随后他还会检测这个方法的 Caller 是不是 Native 方法,如果不是 Native 方法也不会允许运行。

下一步是检测是否有Attach Listener和Signal Dispatcher线程存在,如果有也不会允许运行。
水印
在验证开始之前,有一段非常令人蒙古的代码。

读取了一个叫assets/minecraft/textures/gui/presets/drought.png™的东西,这个图片原本是用于自定义世界预设的,但是会读取出来和HWID的sha256hex对比。

因为 Novoline 有 1用户1Jar 机制,在带着Token下载JAR时这个图片就会被替换成sha256hex你的HWID,所以 JAR 实际上绑定 HWID 的。值得注意的是Novoline.wtf没有这个机制。

原版 Minecraft 对这个图片的引用
然后我就在想,Novoline 他有处理这个吗,难道这个预设给砍了?
好吧!我把情况想的太复杂了,在Novoline中进入这个自定义世界预设界面会直接崩端。

验证逻辑

Novoline 的验证通过 HTTP,然后有两个接口http://novoline.lol/flow/auth/token和http://novoline.lol/flow/auth/login?uid=,请求通过Apache HttpClient发送。
第一步是获得一个验证初始化参数,通过http://novoline.lol/flow/auth/token,传入参数为
- HWID
- User-Agent(
NovoProtect/1.0+版本号) drought.png内容ZonedDateTime.now().getZone().getId()(本机时区,例如Asia/Shanghai)
响应为
- Signature
- PublicKey(后续签名验证使用)
- DelsyToken(我操Delsy到底是谁)
第二步通过http://novoline.lol/flow/auth/login?uid= + 你的UID完成登录,传入参数为
- HWID
- User-Agent(
NovoProtect/1.0+版本号) drought.png内容ZonedDateTime.now().getZone().getId()- DelsyToken
然后他会响应给你一个Blob进入验证。

首先使用 DSA 验证这个响应,DSA 就是第一步获得验证参数响应中的,如果这一步验证失败会显示File corrupted! It may have been tampered with or cracked. The program won't work anymore,很明显这个字符串是从VMProtect抄袭的,为什么要这么写笔者暂时蒙古。
然后使用Blowfish解密响应,这个Blowfish的密钥为Base64(SHA1("chinkXch0ng"))然后通过java.net.preferIPv4Stack和java.skidders两个Property传递(看出来gast仍然对中国人怀恨在心)

解码之后split响应(.split("-")),分为五个数据
- encryptedUsername
- UID
- encryptedHWID
- encryptedDelsyToken
- encryptedTimestamp
这些加密的数据通过派生AES Key和IV解密,派生算法为
- Key:
md5(HWID+DelsyToken) - IV:
md5(DelsyToken.substring(2))
解密之后验证这些数据是否和你提交的对的上(UID、HWID和Token),然后验证这个解密之后的时间戳是否和本机时间戳差超过5秒,如果超过五秒,会显示Your computer time is not synchronized, or its off by some seconds.。
虽然但是,时间戳疑似和时区没有关系,时间戳是通用的,也许是为了学习Anthropic PBC检测中国人吧。这些验证都通过之后,解密用户名,完成客户端初始化。
如果你的HWID不匹配,会直接给你空的响应

HWID生成

HWID生成方法为PROCESSOR_IDENTIFIER+volume:vsn+USERDOMAIN,生成之后使用一个固定的AES/CBC/PKCS5PADDING密钥加密:
- Key:
WryHYmrnvanSJr6v - IV:
kpzb989Cb5876Ggw
我们找到一个Novoline安装教程视频,成功解密了视频里的HWID。当然这也没什么用,只能说很搞笑吧。


如果你想你可以按一下F12进入Console粘贴这一段代码自己试试。
async function novolineDecryptHWID(b64) {
const s2b = s => Uint8Array.from(s, c => c.charCodeAt(0));
const ct = s2b(atob(b64.replace(/\s+/g, "")));
const key = await crypto.subtle.importKey("raw", s2b("WryHYmrnvanSJr6v"), { name: "AES-CBC" }, false, ["decrypt"]);
const pt = await crypto.subtle.decrypt({ name: "AES-CBC", iv: s2b("kpzb989Cb5876Ggw") }, key, ct);
return new TextDecoder("latin1").decode(pt);
}
await novolineDecryptHWID("w/mfdZppleehDaAcIg9EVQl2JLoKi3wiYY1FytEqLwct4zpiyrQl+8zjjgTDygyel6MqbyjZlF9QqlczcLj0RK7rYuaiCVeQpV+Ai6486Is=") 移除验证
很显然在解密Native之后移除验证相当简单,只要给验证请求逻辑全删了直接走到init就行了。然后由于所有数据填充正确走完了init暗桩也不会工作。

当然由于验证移除drought.png也不再必要,可以直接换成原始的drought.png,这样我们就制作了第一个可以正常打开自定义世界预设界面的Novoline。

然后我发现我的ReleaseType变成了Dev™,经过研究,你的UID是1-5中的任何一个都会开启Dev模式。

在通过验证之后才会赋值LongJump bypass value,我在 2023 年第一次破解了 Novoline 6.97,就是因为没有解密出这个,导致LongJump不工作,所以一直没有发布。
this.q = new double[]{0.42f, 0.54, 0.13, 1.8, 0.028, 0.084, 1.004, 1.18, 0.333125};在修复回填之后,LongJump就可以正常工作了。
Novoline 112321
我们还对 Novoline 112321 展开了逆向,主要是想知道 Intent Store 的验证是如何工作的。

开始反编译之后我就蒙古了,这个store.intent.API也他妈有这几个getHost,getHWID,initialize什么的狗屎,和Novoline.wtf、Moon他妈的一样,难不成这些外挂的验证全都是skidunion外包的?但是经过调查,这个东西实际上没有被调用。
真实的验证和6.99已经差不多了,只不过端点为https://intent.store/product/38/protection/token和https://intent.store/product/38/protection/login?uid=,其Token校验逻辑和6.99近似。
结语
我们也有在给两个 Novoline 还原成源码并升级到1.8.9(Novoline是1.8.8),但是由于最近在大学里的事情很忙,所以可能需要一段时间,我们已经取得了很多进度,比如解除混淆和几乎完成了大部分Remapping工作,如果你也想参与或者帮助,随时联系我们。

下载
Novoline破解版已经发布到:https://github.com/StylesFoundation/Novoline-unlicensed/releases/tag/1
包含Novoline.wtf 3.0和Novoline.lol 6.99破解,现在虽然无法绕过Hypixel但是应该可以玩一些怀旧服比如rewound或者一些反作弊很狗屎的服务器。
以及我们删除了 Native,因为现在不再需要 Native 来运行了。然后 Novoline.wtf 破解版也不需要一个自定义 JDK,只需要使用普通的 JDK17 即可启动。

考虑到如果你想去rewound这类服务器录制视频,可能想使用一个自己的用户名和UID,你可以在JAR根目录下找到这个Custom.txt文件然后编辑之后塞回JAR,第一行是用户名,第二行是UID,两个Novoline都适用。

订阅更新
如果您喜欢我的文章,输入您的邮箱订阅更新,有新的文章会通知您
登录 GitHub 后参与讨论
加载评论中…