最近闲逛github,同时fork一些开源项目到自己的gitee账号下,发现两个挺有意思的项目:
https://github.com/JetBrains/marketplace-makemecoffee-plugin
https://gitee.com/ja-netfilter/plugin-power
一个是JetBrains官方用于验证许可的示例代码,一个是ja-netfilter框架中专攻非对称加密的插件。
正好手上有个去年10月份订阅的一年期JetBrains许可,登录官网账号后可以下载离线激活码文件,格式长这样:
KG0U19U3MP-eyJsaWNlbnNlSWQiOiJLRzBVMTlVM01QIiwibGljZW5zZWVOYW1lIjoiQ2d3ZWRpIFl4bmlqIiwibGlj...
其实刚开始我也不知道这一串字符是Base64编码的,是看了marketplace-makemecoffee-plugin项目中的CheckLicense源码才知道。随便百度搜了个在线Base64解码的,去掉开头的ID-之后贴上去还真的可以解出来,主体内容是JSON格式的。
我就想着把里面的有效期从 2026-10-15 改成 2027-10-15。这没什么过分的,对吧?只是做个实验。
于是就写了一段代码,把许可证文件里的JSON解析出来,改了 paidUpTo 字段,然后重新Base64编码、拼接回原来的格式。看起来一切正常。
然后运行验签程序:
校验失败: KG0U19U3MP-eyJsaWNlbnNlSWQiOiJLRzBVMTlVM01QIiwibGlj...
失败了。
原因很简单:改了数据,但没有改签名。而验签程序做的事,正是检查“数据”和“签名”是否匹配。
实验失败是我预期的,因为我确实懂得一些密码学方面的知识:)
但plugin-power项目却告诉我:
不需要持有任何私钥,也不需要破解任何密码算法,仅仅通过一个配置文件,就能让这个修改后的许可证“通过”验签呢
不是骗过用户界面,而是真正让验签程序返回 true。
这篇文章,我就是要带你一步步拆解这个“看似不可能”的操作背后,究竟发生了什么。我会从RSA验签的底层原理开始,讲到 power 插件的核心机制,最后手把手构造一条能用的 power.conf 规则,让你亲眼看到:修改日期后的许可证,再一次显示为“校验成功”。
这不是魔法,这是密码学与JVM字节码技术的一次碰撞。
Part 1:先动手,观察现象
在深入原理之前,我们先完整走一遍实验流程。目的很简单:让你亲眼看到“改日期→验签失败”这个现象,后面的所有分析都建立在这个现象之上。
1.1 认识我们的“靶子”
我们要分析的目标,是一个模拟JetBrains插件许可证校验的开源项目:
https://github.com/JetBrains/marketplace-makemecoffee-plugin
这个项目里有一个核心类 CheckLicense,它的 isKeyValid(String key) 方法负责校验许可证是否合法。校验分为两个阶段:
| 阶段 | 校验内容 | 算法 | 作用 |
|---|---|---|---|
| 阶段1 | 证书链校验 | SHA256withRSA | 确认当前证书是否由官方根CA签发,解决“数据来自哪里” |
| 阶段2 | 数据签名校验 | SHA1withRSA | 确认许可证内容(JSON)是否被篡改,解决“数据是否完整” |
我们这次关注的是阶段2,因为我们要修改的正是许可证里的JSON数据。
1.2 第一次尝试:直接改日期
基于com.jayway.jsonpath库,我们编写一个工具类 MakeLicense,它的作用是:读取一个合法的key文件,解析出各个部分,修改其中的某个字段,然后重新拼接成一个新的key文件。
我们用它来做一件事:把有效期从 2026-10-15 改成 2027-10-15。
// MakeLicense.java 核心代码片段
String json = new String(licenseBytes, StandardCharsets.UTF_8);
DocumentContext ctx = JsonPath.parse(json);
String newJson = ctx.set("$.products[*].paidUpTo", "2027-10-15").jsonString();
String newlicensePartBase64 = Base64.getEncoder().encodeToString(newJson.getBytes(StandardCharsets.UTF_8));
String newkey = licenseId + "-" + newlicensePartBase64 + "-" + signatureBase64 + "-" + certBase64;
注意:signatureBase64 我们没有重新计算,用的是原来那个旧签名。
然后我们运行 CheckLicenseTest,用这个新生成的 newkey.txt 去验签:
校验失败: KG0U19U3MP-eyJsaWNlbnNlSWQiOiJLRzBVMTlVM01QIiwibGlj...
失败了。
1.3 为什么失败?
验签程序的逻辑是:
签名值 → 用公钥解密 → 得到“旧数据的哈希”
新数据 → 重新计算哈希 → 得到“新数据的哈希”
比对:旧哈希 == 新哈希 ?
数据变了,但签名没变,所以:
- 从签名里解出来的哈希,是旧数据的哈希
- 根据新数据重新算出来的哈希,是新数据的哈希
- 两个哈希不一样 → 验签失败
Part 2:解剖RSA验签的“命门”在哪里
在动手实战之前,需要先搞清楚一件事:验签程序到底是怎么工作的? 如果你不知道它检查什么,就不可能知道从哪里绕过。
2.1 RSA验签的数学流程(以SHA256withRSA为例)
用一张图看清全程:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 签 名 过 程 │
│ │
│ 原始数据─►SHA-256─►32字节哈希─►编码(加OID)─►PKCS#1填充─►modPow(d, n) ──► 签名值 │
│ (摘要) (DigestInfo) (变成跟n一样长) (私钥运算) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 验 签 过 程 │
│ │
│ 签名值─►modPow(e, n)─►去填充─►解码(取OID和哈希)─►比对本地哈希─►通过/不通过 │
│ (公钥运算) (还原DigestInfo) (得到decodedHash) (digest) │
└─────────────────────────────────────────────────────────────────────────────┘
从验签过程可以看出:
验签程序不会去验证“签名是怎么来的”,它只验证 sig.modPow(e, n) 的结果是否正确。
什么意思呢?
程序拿着你的签名值 sig 和公钥 (e, n) 做一次数学运算,得到一个结果。
如果这个结果和预期的 DigestInfo 一致,验签就通过。
2.2 命门:那个“比对”环节
再仔细看一眼验签流程的最后一步:
// 这是验签程序里最核心的一行代码
return MessageDigest.isEqual(本地算出的哈希, 从签名中解出的哈希);
只要这一行返回 true,验签就过了。
而 从签名中解出的哈希,是 sig.modPow(e, n) 这个运算的最终产物。换句话说:
如果你能让 sig.modPow(e, n) 直接返回一个“正确的DigestInfo”,验签程序就会无条件通过。
它不关心你是通过什么方式得到这个结果的。是私钥算出来的?是公钥反推的?还是有人直接塞进去的?它不检查。
Part 3:power插件如何“偷天换日”
前面我们找到了验签流程的“命门”:sig.modPow(e, n) 这一步。谁控制了这一步的计算结果,谁就控制了验签的成败。这一部分,我们来看 power 插件是如何精准命中这个命门的。
3.1 插件的工作机制:在“必经之路”上设卡
power 插件是基于Java Agent技术实现的。简单说,它可以在JVM启动时,往目标程序的类里“注入”一段额外的代码,而不需要修改原始代码文件。
它选择的注入点是 BigInteger.modPow 方法(具体点位是modPow内部调用的oddModPow方法)。
为什么是这里?因为在Java的RSA实现中,所有与RSA相关的模幂运算,最终都会经过 BigInteger.modPow。无论是签名、验签,还是加密、解密,底层都离不开这个方法。所以:
拦截了 modPow,就等于拦截了RSA运算的“总闸门”。
power 插件就在这个总闸门上,装了一个自己的“检查站”。每次有调用经过时,它都会检查三个参数:
BigInteger result = base.oddModPow(exp, mod);
对应到验签场景:
base= 签名值sigexp= 公钥指数e(通常是65537)mod= 公钥模数n
如果这三者匹配了配置文件中的某条规则,插件就直接返回预设值,不再执行真正的模幂运算。
这就是“短路”机制。
3.2 power.conf的EQUAL规则
power.conf 是插件的配置文件,它的核心规则格式是:
EQUAL,<x>,<y>,<z>-><fakeResult>
四个参数的含义如下:
| 参数 | 含义 | 在验签场景中对应 |
|---|---|---|
x | 方法第一个参数(底数) | 签名值 sig |
y | 方法第二个参数(指数) | 公钥指数 e |
z | 方法第三个参数(模数) | 公钥模数 n |
fakeResult | 要返回的预设值 | 预期的 DigestInfo |
当 modPow(x, y, z) 被调用时,插件会检查:
- 如果
x == 规则里的x且y == 规则里的y且z == 规则里的z - 则直接返回
fakeResult,跳过真实计算。
3.3 核心策略:公钥是真的,DigestInfo是真的,只有x是道具
这里有一个非常关键、也最容易产生误解的地方,需要专门拎出来说清楚。
在 power 插件的攻击模型中:
| 元素 | 来源 | 是否真实 |
|---|---|---|
y (公钥指数e) | 从目标软件的官方证书中提取 | ✅ 真实 |
z (公钥模数n) | 从目标软件的官方证书中提取 | ✅ 真实 |
DigestInfo (fakeResult) | 根据新数据按照标准格式计算 | ✅ 真实(符合规范) |
x (签名值) | 你可以随意指定,或从旧签名中提取 | ❌ 不需要是“真正的”签名 |
这就是 power 插件最反直觉的地方:它可以用一个“假的”签名值 x,配合“真的”公钥 (e, n),返回一个“真的”DigestInfo,成功骗过验签程序。
为什么能这么做?因为验签程序只看最后一步的比对结果,它不会去验证这个 x 是不是真的由对应的私钥算出来的。只要 x.modPow(e, n) 的结果是合法的 DigestInfo,它就认账。
3.4 对比:正常验签 vs. 插件介入后的验签
| 环节 | 正常验签 | power插件介入后 |
|---|---|---|
| 1. 验签调用 | sig.modPow(e, n) | 同上,参数不变 |
2. 进入 modPow | 执行真实的模幂运算 | 被插件拦截 |
| 3. 匹配规则 | — | 检查(sig, e, n)是否匹配power.conf |
| 4. 返回结果 | 返回真实的DigestInfo | 直接返回预设的fakeResult |
| 5. 后续流程 | 去填充→解码→比对哈希 | 去填充→解码→比对哈希(用的预设值) |
| 6. 验签结果 | 取决于真实计算结果 | 必然通过(因为预设值就是算好的) |
可以看到,power 插件并没有修改验签程序的逻辑,它只是在 modPow 这个方法上“偷换了结果”。
Part 4:手把手构造你的第一条power.conf规则
前面我们讲了原理,这一部分来实战。目标只有一个:让修改日期后的 newkey.txt 通过验签。
我会把整个过程拆成5个步骤,每一步都给出明确的操作对象和预期结果。你不需要一次性理解所有细节,跟着步骤走,最后自然能串起来。
4.1 实验目标回顾
| 项目 | 内容 |
|---|---|
| 原始key | KG0U19U3MP-...-2026-10-15...(合法) |
| 修改后的key | 将paidUpTo从2026-10-15改为2027-10-15,签名未变 |
| 当前状态 | 验签失败(原因:签名里的哈希≠新数据的哈希) |
| 目标 | 通过power.conf规则,让验签程序认为这个key是合法的 |
4.2 步骤1:提取原始签名值x和公钥(e, n)
我们的第一条规则需要三个输入:x(签名值)、y(公钥指数e)、z(公钥模数n)。这三个值从哪里来?从原始key文件里解析出来。
原始key的结构:
<licenseId>-<licensePartBase64>-<signatureBase64>-<certBase64>
signatureBase64:解码后得到签名值sig(即x)certBase64:解码后得到X.509证书,从中可以提取公钥的e和n
提取公钥的代码片段:
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certBytes));
PublicKey publicKey = cert.getPublicKey();
RSAPublicKey rsaKey = (RSAPublicKey) publicKey;
BigInteger e = rsaKey.getPublicExponent(); // 通常为65537
BigInteger n = rsaKey.getModulus(); // 2048位→256字节
提取签名值的代码片段:
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
BigInteger x = new BigInteger(1, signatureBytes); // 签名值,即x
输出示例(仅用于说明格式):
x = 194498684206311016755600069425409561558242021637486443831589940664...
e = 65537
n = 117942043769640557648315917357599618895834454752526356185770772108...
⚠️ 注意:这三个值在规则里必须用十进制字符串表示(BigInteger.toString() 的输出),不能用十六进制。
4.3 步骤2:修改数据,计算新的DigestInfo
这一步是核心中的核心。我们要计算修改后的key数据对应的 DigestInfo,这个值将作为规则里的 fakeResult。
注意: 第二阶段校验用的是 SHA1withRSA,所以我们的哈希算法是 SHA-1,不是SHA-256。
计算过程拆解:
// 1. 准备数据:修改后的license部分(不含签名和证书)
byte[] data = newlicensePartBase64.getBytes(StandardCharsets.UTF_8);
// 2. 计算SHA-1哈希(20字节)
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] digest = md.digest(data);
// 3. SHA-1的OID(对象标识符)
// 1.3.14.3.2.26的DER编码为:0x30 0x0D 0x06 0x09 0x60 0x86 0x48 0x01 0x65 0x03 0x04 0x02 0x1A
byte[] sha1Oid = {0x30, 0x0D, 0x06, 0x09, 0x60, (byte)0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x1A};
// 4. 构造DigestInfo:OID + 哈希值
byte[] encoded = new byte[sha1Oid.length + digest.length];
System.arraycopy(sha1Oid, 0, encoded, 0, sha1Oid.length);
System.arraycopy(digest, 0, encoded, sha1Oid.length, digest.length);
// 5. PKCS#1 v1.5填充(长度 = n的字节长度)
int keyLength = n.bitLength() / 8; // 例如2048位→256字节
byte[] padded = padV15(encoded, keyLength);
// 6. 转换为BigInteger(这就是fakeResult)
BigInteger fakeResult = new BigInteger(1, padded);
padV15 的实现逻辑:
private byte[] padV15(byte[] encoded, int keyLength) {
int paddingLen = keyLength - 3 - encoded.length;
byte[] padded = new byte[keyLength];
padded[0] = 0x00;
padded[1] = 0x01;
for (int i = 2; i < 2 + paddingLen; i++) {
padded[i] = (byte) 0xFF;
}
padded[2 + paddingLen] = 0x00;
System.arraycopy(encoded, 0, padded, 2 + paddingLen + 1, encoded.length);
return padded;
}
4.4 步骤3:组装EQUAL规则
有了步骤1和步骤2的三个值,就可以组装规则了:
[Result]
EQUAL,<x的十进制字符串>,<e的十进制字符串>,<n的十进制字符串>-><fakeResult的十进制字符串>
一条真实的规则示例(已脱敏):
[Result]
EQUAL,194498684206311016755600069425409561558242021637486443831589940664169263721280116692139735194170862084390103044689252047353185958815003913855283895626383268165137755057840194228948428316802551891652141387161158749344290508176915554119630650921272371253048903781429971448231044621759462982691245803690320631334,65537,117942043769640557648315917357599618895834454752526356185770772108709919152141192101484964249534092789599583543307611942179620390666459097551319413014119686782651413853867355476126944653705794117521161074875039909961001333443223237852445256226402275395874645164598549236924182422051367771780109302105711940191->143570204856969404594542532549345918339999621139707295507048388042971065249061112982156488562291114638440672227973692227379249452432710765028519487089066282953217559241963080114972805528685007149331742036606905287520137797585954575387263266698988207467602597798623901167043613710347253979928640625223292957423
⚠️ 这几串数字非常长,复制时务必完整、准确。任何一位数字错误,规则都不会生效。
4.5 步骤4:让插件加载规则,再次验签
修改测试代码,加载 power.conf:
static {
try {
File configFile = new File("src/test/resources/power.conf");
Map<String, List<FilterRule>> map = ConfigParser.parse(configFile);
ResultFilter.setRules(map.get("Result"));
} catch (Exception e) {
e.printStackTrace();
}
}
将底层的 verify 方法替换为基于 BigInteger.modPow 的实现:
这是为了绕过 Signature.verify() 的封装,直接在你可控的代码里触发 modPow 调用,从而让 power 插件能够拦截到。
private static boolean verifyWithBigInteger(byte[] data, byte[] signature, RSAPublicKey publicKey) {
BigInteger e = publicKey.getPublicExponent();
BigInteger n = publicKey.getModulus();
BigInteger sig = new BigInteger(1, signature);
// 这一步会被power插件拦截
BigInteger result = ResultFilter.testFilter(sig, e, n);
if (result == null) {
result = sig.modPow(e, n); // 正常运算(如果规则没命中)
}
// 去填充→解码→比对哈希
byte[] digestInfoBytes = toByteArray(result, getByteLength(n));
byte[] decodedHash = decodeDigestInfo(digestInfoBytes);
byte[] localHash = sha1(data);
return MessageDigest.isEqual(decodedHash, localHash);
}
运行测试:
public static void main(String[] args) throws IOException {
Path path = Path.of("src/test/resources/ActivationCode", "newkey.txt");
String key = new String(Files.readAllBytes(path));
if (CheckLicense.isKeyValid(key)) {
System.out.println("✅ 校验成功: " + key);
} else {
System.err.println("❌ 校验失败: " + key);
}
}
预期输出:
✅ 校验成功: KG0U19U3MP-eyJsaWNlbnNlSWQiOiJLRzBVMTlVM01QIiwibGlj...
4.6 如果实验失败,排查清单
| 排查项 | 检查方法 |
|---|---|
| OID是否正确 | SHA-1是1.3.14.3.2.26,不要错用成SHA-256的OID |
| 填充长度是否匹配 | padded的长度必须等于n.bitLength() / 8(如2048位=256字节) |
| 数字是否为十进制 | BigInteger.toString()输出的是十进制,不要手动转换 |
| x、e、n是否与规则一致 | 任何一位数字不同,匹配都会失败 |
| power.conf是否被正确加载 | 检查日志是否有ResultFilter setRules相关的输出 |
| 测试代码是否绕过Signature | 确认调用的是ResultFilter.testFilter而不是Signature.verify() |
Part 5:总结与安全思考
到这里,我们已经完整走通了从现象到原理、从原理到实战的全过程。最后这一部分,我来帮你把整条线索收束起来,同时补充一些延伸思考。
5.1 三句话总结全文
第一句:RSA算法没有被破解,power 插件攻击的是验签流程本身。
它不是在数学上找到了RSA的漏洞,而是在程序执行层面,篡改了验签程序“核对答案”的那一步。
第二句:power.conf 的 EQUAL 规则本质上是一张“作弊映射表”:匹配输入→短路计算→替换输出。
只要 (x, y, z) 三个参数匹配,插件就直接返回你预设的 DigestInfo,不再进行真实的模幂运算。
第三句:这个实验揭示了一个容易被忽视的安全原则——算法强度≠实现强度,执行环境可信才是最后一道防线。
再强的密码算法,如果运行在一个可以被篡改的环境中,它的防护效果都会大打折扣。
5.2 这种攻击能防住吗?
能。power 插件只是利用了“纯本地验签+可控JVM环境”这个特定条件。在实际的安全设计中,有几种常见的方式可以堵住这类攻击路径:
| 防御方式 | 原理 | 能否防住power插件 |
|---|---|---|
| 远程验证 | 验签逻辑不在本地执行,而是将数据发送到服务端验证 | ✅ 能。插件无法篡改服务端代码 |
| 白盒加密/代码混淆 | 对关键验签代码进行混淆,使攻击者难以定位modPow调用点 | ⚠️ 增加难度,但并非不可破解 |
| 硬件绑定(如TPM/HSM) | 将私钥存放在安全硬件中,私钥永不离开硬件,验签在硬件内部完成 | ✅ 能。插件无法注入到硬件执行环境 |
| 完整性校验(如代码签名) | 启动时校验自身代码和依赖库的完整性,发现篡改则拒绝运行 | ⚠️ 理论上能防,但需要配套的启动机制 |
免责声明
本文内容仅供技术研究与学术探讨,旨在帮助开发者理解 RSA 验签流程与 Java 安全机制。作者不支持、不鼓励、不教唆任何形式的软件破解或侵权行为。请勿将文中所述技术用于任何违反当地法律法规、侵犯他人知识产权或破坏软件许可协议的行为。读者因不当使用而产生的任何法律后果,均与作者无关。