(注:因gongji是敏感词,改为拼音或者attack了)java
1.背景算法
最近生产上出了两个由于RSA加解密致使的性能问题,一个是加密引发的,一个是解密引发的,都是大量线程在进行RSA操做时等锁,线程被block,请求都被堆在Weblogic队列里不能处理。为此,我就研究了一下RSA加解密在Java里的实现,发现加解密的问题都出如今同一个地方。由于RSA加解密使用的方法都是同一个,就是计算ae mod n,也就是模幂运算,在作这个运算的时候,为了防范gongji,须要加入随机因素,寻找一个blinding parameter,让gongji者没法破解私钥,就是在寻找这个blinding parameter的时候线程出现了堵塞。下面的内容会作详细阐述。数组
另外,两个问题出现的jdk版本不同,第一个是1.6,第二个是1.7,都是同一个方法,可是底层实现不太同样。缓存
2.问题描述
安全
2.1 第一个问题:动态菜单签名服务器
客户端的菜单不是写死在客户端的,是服务器下发的。客户端每次启动时,向服务端请求最新的动态菜单,经过向服务端发一个上次更新菜单的时间,来判断服务器是否有新的菜单。为了防止菜单下发后被篡改,对菜单url作了签名。菜单的内容是基本不变的,可是每一个人过来都要作一次签名,结果有次对菜单作了较多改动,又遇上作了一次推送,大量用户打开App,把服务器搞挂了。由于Java提供的签名方法内部上锁了,大量线程在等待锁,请求又多,扛不住了。
网络
这个问题没有深刻往下研究,用缓存解决了。由于下发的菜单或者其余资源几本是不变的,因此只要按字符串为key作缓存就好了。好比把对url的签名,按url为key缓存。多线程
//先查缓存 String signature=memkv.unsafe_hget(“SIGN_LIST”, url); //查不到则计算签名,放入缓存,缓存1800s if(signature==null) { signature = SignatureUtil.sign(url); memkv.unsafe_hset(“SIGN_LIST”, url, 1800); }
2.2 第二个问题:上送报文RSA加密dom
应用开启了报文全加密,大部分加密都是经过与服务端协商一个对称密钥,而后使用非对称密钥加密后传送,后续的报文都是经过对称加密处理的。可是有部分报文直接用了RSA加密,对性能产生了影响,一样是等锁。一共最多1000个线程,900多个都在等这个锁。ide
这个问题无法用缓存解决了,由于每一个人上送的信息都是不同的,须要好好研究一下了。
3.问题分析
经过看代码和查资料,我发现RSA在实际应用中并非简单的作大数运算,为了提升安全性,在加解密过程当中都须要添加必定的随机因素。随机因素有两方面,一个是为了让同一明文每次加密产生的密文都不同,加密前先填充一部分随机数,这个不止RSA有,DES等对称加密也都有,称为padding。另外一方面是RSA解密(加密与解密用的同一个方法)的时候在作模幂运算时加入随机数,似的作运算的时间不固定,让***没法统计计算时间, 致盲***,称为blinding。 上面出现的问题都是blinding引发的。
3.1 加密随机数(padding)
加密的时候,通常须要在明文上填充一部分随机数,这样每次产生的密文都不同,这个过程称为padding,并且加密标准里有各类类型的padding标准,好比PCKS1。
好比明文是D,经过一些填充,造成一个0011xxxxxxx11D的待加密串,00是开头,两个11中间是随机数,并且这些随机数不能含有1。这样同一个D,每次产生的待加密串是不同的,解密后,按照这个格式把D取出来就好了。加密和解密端要使用一样的padding格式。
如下过程依次为
原始明文 -> padding后明文 -> 密文 -> padding后明文 -> 原始明文
对明文D作第一次加密
D -> 00112311D -> 123456 -> 00112311D -> D
对明文D作第二次加密
D -> 00117811D -> 783719 -> 00117811D -> D
为何要使用padding呢?这个比较容易理解,好比有一种gongji类型叫作短消息gongji,若是我已知加解密双方的明文空间是四个字节的字符串,那我就能够经过观察明文对应的密文把全部的对应关系枚举出来,不须要密钥我就能知道这个密文对应的明文了。若是一样的明文每次密文都不同,就无法实现这种gongji了。
3.2 解密随机数(blinding)
这个须要先从一种特殊的gongji方式提及。
3.2.1 时序attack
RSA的破解从理论上来说是大数质数分解,但是就是有一些人另辟蹊径,搞出乱七八糟的破解方法。有一种attack方法叫作时序attack(timing attack),根据你解密的时间长短就能破解你的RSA私钥。这是什么鬼呢?
举一个不恰当可是比较容易理解的例子:
A经过B提供的加密装置把报文加密后发给B,咱们不关心加密是怎么搞的,由于时序attack也不用关系加密端。
A给B发的密文是2048位的01串,B有一个私钥,也是2048位的01串,解密的时候把两个串and一下
(如下示例用四位表示)
密文0101
私钥0110
明文0100
问题的关键来了,进行and运算时若是有一个0,那么运算的时间为1ms,若是两个都是1,运算的时间是10ms(只是个假设)。
基于以上假设,A就能够破解B的私钥了。A先构造一个0001的密文,获取B解密的时间,若是是1ms左右,那么对应的位就是0,若是是10ms左右,对应的1,依次类推,就把整个私钥推断出来了。
如何防范这种attack呢?
一种最简单有效的方法,每次过来一个密文,先用一个随机数与它and一下,而后在与私钥and,只要随机数是真正的随机数,那么是没法破解的。注意,是真正的随机数而不是伪随机数。
如今回到RSA,RSA解密的本质就是幂模运算,也就是x = a ^ b mod n ,其中a是明文,b是私钥,n是两个大质数(p-1)(q-1)的积。因为这些数都特别大,好比b可能有2048位,直接计算是不可行的。计算x的最经典的算法是蒙哥马利算法,用代码表示以下:
int mod(int a,int b,int n){ int result = 1; int base = a; while(b>0){ if(b & 1==1){ result = (result*base) % n; } base = (base*base) %n; b>>=1; } return result; }
这个算法从b的最低位循环到最高位,若是是1,须要进行两次模乘运算,若是是0的话则只须要一次。因为这个操做是比较耗时的,因此0和1对应的时间差异较大。***能够经过观察不一样输入对应的解密时间,经过分析统计推断出私钥。具体的分析方法也不难,不过我没怎么看懂。。。
而防范RSA时序attack的方法也是在解密时加入随机因素,让***者没法获取准确的解密时间。
3.3 线程阻塞缘由
3.3.1 关于随机数
真正意义上的随机数,是很难产生的,由于即便小到原子,它的规律也是有迹可循的。因此咱们产生的随机数都是伪随机数,可是伪随机数的随机性也是不同的,若是产生的随机数规律性很强,那就很容易被预测到,而若是产生的随机数被预测的难度特别大,那么咱们就能够认为它是真随机数了,只有强度高的随机数用来加解密等操做上才是安全的。
目前大部分操做系统都会提供两种随机数的产生方式,以Linux为例,它提供了/dev/urandom和/dev/random两个特殊设备,能够从里面读取必定长度的随机数。/dev/random是blocking pseudo random number generator (阻塞式伪随机数产生器),它是经过网络事件,键盘敲击事件等物理上随机的事件,收集一些随机bit到熵池来产生随机数。这个随机生成函数可能由于熵池为空而等待,因此须要大量随机数的状况下它会显得很慢,但诸如产生证书之类的操做须要这种强度的随机数。 而/dev/urandom就是unblocking,它不会阻塞,可是产生的随机数不够高,是以时间戳之类的种子来产生随机数。
其它操做系统的实现方式可能不一样,可是本质都是同样的。总之,要想得到一个真随机数,无论用什么语言,都须要等。在Java里面,能够用SecureRandom产生随机数,并且能够在JVM参数里配置是使用/dev/random仍是/dev/urandom,若是安全性要求改,就用/dev/random,可是性能是就会有折扣。
3.3.2 Java的Cipher类
Java的加解密库是经过spi的方式,底层有不一样的provider实现,默认的是sun官方的,比较有名的第三方的是bouncy castle。而上层的统一接口就是Cipher。
使用方法:
//获取Cipher示例,注意这个并非单例的,它有线程安全问题 //第一个参数是算法以及填充之类的,第二个是Provider,好比BC就是bouncy castle Cipher cipher = Cipher.getInstance("RSA/CBC/PCKS1", "BC"); //必须初始化 cipher.init(Cipher.DECRYPT_MODE, privateKey); //传入byte[]数组进行加解密 cipher.doFinal(miwen);
我在看咱们应用的代码时,发现全局居然只有一个Cipher,这个是有问题的,它有线程安全问题,看代码以及多线程作实验都发现了,因为初始化一个Cipher比较慢,因此最好用ThreadLocal来解决它的线程安全问题。
3.3.3 getBlindingParameterPair函数
经过堵塞的线程堆栈来看,两个问题都出如今RsaCore的getBlindingParameterPair上,第一个行数是261,第二个是417,这是由于两个jdk不同。
在jdk1.6里面,getBlindingParameterPair用的是随机数的方法,堵在了SecureRandom.nextBytes上,这个函数在底层是读的/dev/random,若是产生的随机数不够用,就要堵塞。
在jdk1.7_80这个版本里,getBlingdingParameterPair没有用随机数,而是经过计算获得了一个pair,具体代码以下:
synchronized (this) { if ((!this.u.equals(BigInteger.ZERO)) && (!this.v.equals(BigInteger.ZERO))) { localBlindingRandomPair = new RSACore.BlindingRandomPair(this.u, this.v); if ((this.u.compareTo(BigInteger.ONE)<=0)||(this.v.compareTo(BigInteger.ONE)<=0)) { this.u = BigInteger.ZERO; this.v = BigInteger.ZERO; } else { this.u = this.u.modPow(BIG_TWO, paramBigInteger3); this.v = this.v.modPow(BIG_TWO, paramBigInteger3); } } }
在同步块里进行大数运算,为何要上锁我没有深刻研究,可是1.7确实比1.6快了。
上面都是用jdk自带的provider实现的,我又试了下把provider换乘bouncy castle,比1.6自带的快不少,可是比1.7的慢,用多个线程解密的时候发现也是堵在SecureRandom.nextBytes上,因此bouncy castle也是用的随机数。
问题的缘由就是jdk自带的RSA解密方法为了防范时序attack致使的,可是也没有好的解决办法。在早期的jdk里,代码中有if(ENABLE_BLINDING)这个判断,因此是能够把blinding关掉的,可是新的已经没有选项了,都须要使用blinding,由于时序attack确实是挺容易实施的。
4.问题解决方案
第一个问题用缓存解决了,每一个菜单半个小时只作一次签名,与原来的每一个请求都签名相比,性能好太多。
第二个问题除了锁的问题,Cipher类自己有线程安全问题,经过ThreadLocal解决线程安全问题。可是锁的问题很差解决,短时间内经过使用计数器对线程进行保护,好比最多容许300个线程同时调用这个函数,多了直接拒绝,由于使用RSA加解密的交易并很少,出现拥堵估计是特殊状况。长期内仍是把用RSA加密报文的方式完全换掉,RSA只加密对称密钥。
总之,RSA做为一种低效的加密方法,用在加密大量数据上面是不合适的,即便是签名之类的地方,能尽可能少用也要少用,不然对性能影响很大。