最近闲逛github,同时fork一些开源项目到自己的gitee账号下,发现两个挺有意思的项目:
https://github.com/JetBrains/marketplace-makemecoffee-plugin
https://gitee.com/ja-netfilter/plugin-power
一个是JetBrains官方用于验证许可的示例代码,一个是ja-netfilter框架中专攻非对称加密的插件。
我之前手头有一个JetBrains系产品的许可证,是那种官方申请来、一直合法用的。然后我就想着,要不改改里面的到期时间?纯属好奇,想看看它的校验机制是什么样的。
结果不出所料,改了之后验签直接挂了。
但有意思的不是它挂了,而是后来我竟然让一个改动过的key,在没有私钥重新签名的情况下,通过了完整的验签流程。
不是破解软件,也不是伪造证书,就是纯技术层面的一次尝试。整个过程走下来,我发现其实道理很简单:我们常说的RSA签名验证,它最后一步本质上就是一个哈希比对,关键看你能不能在那一步“做手脚”。
下面就是我把这件事从头到尾捋一遍的记录。
数字签名到底是怎么校验的?
我先大概看了一下它的校验逻辑。以开源示例 marketplace-makemecoffee-plugin 为例,它的核心校验类是 CheckLicense,入口方法是 isKeyValid(String key)。
这个方法里主要做了两件事:
校验证书链:用 SHA256withRSA验一下这个证书是不是由官方根CA签发的,这一步解决的是“这个证书是不是官方发的”问题。校验数据签名:用 SHA1withRSA验一下许可证里的JSON数据有没有被改过。
因为我要改的是数据,所以第二阶段才是我真正关心的。
原来合法的key长这样(我拆开看的):
<licenseId>-<licensePartBase64>-<signatureBase64>-<certBase64>其中 signatureBase64 就是用私钥对整个 licensePartBase64 做的签名。验签的时候,程序会拿内置的公钥去解这个签名,跟重新计算的哈希对比。
我之前的操作很简单粗暴:把 licensePartBase64 解出来,改了里面 paidUpTo 的日期,再重新Base64拼回去。但我没动 signatureBase64。
所以结果是,验签程序算出新数据的哈希,跟签名里解出来的旧哈希对不上,直接报错。
这其实不叫“破解未遂”,这分明就是正常防御生效了。
追到最底层,找到RSA验签的关键点位
后来我决定往下翻翻代码,看看 SHA1withRSA 在Java里到底是怎么验的。翻到 Signature 类相关的实现,发现它底层其实就是用了 BigInteger.modPow 在做模幂运算。
整个验签过程的简化版,大概是这样:
签名生成:
data → SHA-1 → DigestInfo(带上OID) → PKCS#1填充 → modPow(d, n) → 签名值
验签:
签名值 → modPow(e, n) → 去填充 → 解出哈希 → 跟本地哈希比对
这里有个细节,就是 modPow(e, n) 的结果其实是一个标准的 DigestInfo 结构体,里面有哈希算法OID和哈希值本身。只要这个结构体是对的,后面的解析就能通过。
然后我就想了个问题:验签程序会验证这个结果到底是“算出来的”还是“被替换”的吗?
答案是不会。它只检查结果对不对。
也就是说,如果我能让 modPow(e, n) 这一步在特定条件下返回一个我准备好的 DigestInfo,那验签就能过。
这不是RSA算法有漏洞,这是验签流程本身的一个设计特点:它不关心数据来源,只关心结果是否正确。这个特点在正常情况下没问题,但一旦有人能控制 modPow 的执行结果,它就成了可以被利用的“弱点”。
那个叫power的插件,就是抓住了这一点
开源项目 ja-netfilter 里的 power 插件,我一看它的原理,就知道它正是冲着这个点去的。
power 插件通过Java Agent技术,在JVM启动的时候往 BigInteger.modPow 方法里注入了一段自己的检查逻辑。每次 modPow 被调用的时候,它会先看传入的三个参数 (x, y, z) 是否匹配配置文件里的某条规则。如果匹配上了,就直接返回预设值,不再执行真正的模幂运算。
它的配置文件 power.conf 里的规则长这样:
EQUAL,<x>,<y>,<z>-><fakeResult>其中 x 是签名值,y 是公钥指数(一般是65537),z 是模数,fakeResult 就是你想让 modPow 返回的那个 DigestInfo。
说白了,power 插件做的事情就是:它不破解任何算法,它只是在你验签程序核对答案的那一步,替你填了一个标准答案。
动手实战:从改日期到通过验签
光说不练没意思,我决定自己验证一下。整个过程分几步走:
第一步,提取关键参数。
我从原始的合法key里解出了三样东西:
x:签名值sig,从signatureBase64解码得到的字节数组转成的BigIntegery:公钥指数e,从证书里提取,我这里是65537z:公钥模数n,同样从证书里提取
注意这三个值后面都要转成十进制字符串(用 BigInteger.toString()),因为 power.conf 里不接受十六进制。
第二步,计算新的DigestInfo。
我用修改后的 licensePartBase64 数据,按照标准的PKCS#1 v1.5流程重新算了一遍:
先做SHA-1哈希,得到20字节的 digest拼上SHA-1的OID( 1.3.14.3.2.26的DER编码),组成DigestInfo对 DigestInfo做PKCS#1填充,长度对齐到n的字节长度(2048位就是256字节)把填充完的字节数组转成 BigInteger,这就是我要的fakeResult
计算过程的具体实现我就不贴大段代码了,核心就是上面这几步,网上参考的代码也很多。这个 fakeResult 理论上跟用私钥签名后、再用公钥解出来的结果是等价的:因为它本身就是合法的 DigestInfo 结构。
第三步,把规则写进 power.conf。
[Result]EQUAL,<上一步得到的x>,65537,<n的十进制字符串>-><fakeResult的十进制字符串>第四步,让插件介入验签过程。
我在测试代码里做了两处改动:
在静态块里把 power.conf加载进ResultFilter把原来调用 Signature.verify()的地方换成了手动调用ResultFilter.testFilter(sig, e, n),如果返回非空,就直接拿这个结果继续后面的去填充和哈希比对
这样做的目的,是让 power 插件能在 modPow 这个入口点成功拦截到我的验签请求。
最后,再跑一遍验签:
✅ 校验成功: KG0U19U3MP-eyJsaWNlbnNlSWQiOiJLRzBVMTlVM01QIiwibGlj...成了。
有几点我想单独拎出来说一下
为什么 power.conf 能这么写?
有人可能会问:fakeResult 是算出来的,但 x 是从旧签名里直接拿的,这两个值放在一起为什么能匹配上?
关键在于 power 插件的匹配逻辑是 “入参匹配,结果替换” ,它不校验入参和结果之间是否存在数学关联。也就是说,只要 modPow(x, e, n) 这个调用被触发,且 (x, e, n) 跟规则里的一模一样,插件就直接把 fakeResult 塞回去。
至于这个 x 是不是真的由对应的私钥签出来的,插件不管,验签程序也不管。验签程序只看 modPow 返回的结果是不是合法的 DigestInfo。
所以这个“旧签名 + 新数据的DigestInfo”的组合,在数学上是不成立的,但在 power 插件的短路机制下,它能在执行层面跑通。
那这个攻击能防住吗?
能。power 能跑通,依赖的条件其实挺苛刻的:
验签逻辑必须完全在本地执行 攻击者能控制JVM的启动参数(即能挂上Java Agent) 验签程序使用的是JDK内置的 BigInteger.modPow实现
只要把其中一个条件卡死,这招就不好使了。比如:
如果真想防这种攻击,把验签逻辑放到服务端是最干净的方案。
说几句题外话
这次折腾下来,我最直接的感受是:很多我们以为是“算法层面”的问题,其实最后都落到“实现层面”上了。
RSA本身没有问题,Java的 Signature 类实现也没有问题,但 BigInteger.modPow 这个底层方法一旦能被外部拦截,整个信任链就断了。
这也是我后来对“本地验签”一直不太放心的原因:你永远不知道运行环境里有没有被挂上什么东西。不是说一定会被攻击,但从设计上来说,这是一个不可忽视的风险点。
当然,对咱们做技术学习的来说,搞清楚这套机制是怎么工作的,比单纯讨论“能不能防”要有意思得多。
免责声明:本文内容仅用于技术学习与交流,作者不支持任何形式的软件破解或侵权行为。请勿将文中思路用于违反法律法规或破坏软件许可协议的场景。
夜雨聆风