基础算法——base64和hex
“基础不牢,地动山摇。”我们先讲讲算法基础中比较重要的base64和hex,这两种编码不管是哪种算法几乎都需要用到它。
众所周知,在计算机中, 1个字节等于8个二进制位,而base64可以简单理解为就是使用6个二进制位表示1个字节,而六个二进制位的范围为000000~111111,换算成十进制就是0到63,所以 base64 编码会有64个基础字符。base64编码如下图所示:
base64编码是使用6个二进制位表示一字节的,所以上图中的字符就是6个二进制位转换成的10进制对应base64编码所得到的字符。
现在我们对base64稍稍有了点了解,那base64是如何编码和解码的呢?先说结论,编码后源数据会以三个字节为一组转化为4个字符表示,如果源数据字节数量不为三的倍数,那解决这个问题需要补充字节,补充到其二进制位可以整除6的时候。
原因或许有些观察敏锐的同学已经发现了,产生如此情况的原因就是两者之间一个字节所表示的二进制位。一个8个二进制位才表示1字节,一个6个二进制位就表示1字节,假设有三个字符'abc',总共表示3*8=24个二进制位,编码过后,每6个二进制位表示1个字节,24/6=4,可以看到源数据以三个字节为一组转化为4个字符表示,这与结论相符。
光说理论也不行,我们来模拟一下编码的过程:
字符 | 二进制位 |
---|---|
a | 01100001 |
b | 01100010 |
c | 01100011 |
abc | 01100001 01100010 01100011 |
我们要对字符'abc'进行编码,首先我们要得到 'abc' 对应的二进制位,而 'abc' 对应的二进制位我们已经得到了;
其次就是按从左到右的顺序读取6个二进制位并将读到的每组二进制数据转为10进制表示,最后对照base64编码表得到每组二进制对应的字符,如下所示:
按顺序读取的六个二进制位 | 转换得到的十进制 | base编码表对应字符 |
---|---|---|
011000 | 24 | Y |
01 0110 | 22 | W |
0010 01 | 9 | J |
100011 | 35 | j |
从转换结果可以看出 'abc' 用base64编码表示时是 YWJj。这个很简单吧!但不要忽略一个问题,那就是如果要编码的字符不是3的倍数呢?就比如'abcd'这四个字符,4*8=32个二进制位,而32是不能整除6的。那解决这个问题需要补充字节,补充到其二进制位可以整除6的时候。如下所示:
按顺序读取的六个二进制位 | 转换得到的十进制 | base编码表对应字符 |
---|---|---|
011000 | 24 | Y |
01 0110 | 22 | W |
0010 01 | 9 | J |
100011 | 35 | j |
011001 | 33 | h |
00 (0000) | 0 | A |
(000000) | 0 | A |
(000000) | 0 | A |
我们可以发现到后面只有2个二进制位可读取了,不足6位,那我们就需要补字节,因为在非base64编码的情况下,一个字节等于8个二进制位,所以补了一个字节后可以发现变成了四个二进制位可读取了,还是不足6位,所以还要补字节,最终在补了俩个字节的情况下终于把所有的二进制位读完了,这里我们一共添加了两个补充字节(上方表格中二进制位数据被括号括起来的就是这两个补充字节的二进制位)。我们最终得到的结果是: YWJjhAAA,但是如果去用工具验证下结果就会发现我们得到的这个结果和工具得到的结果不符,那这是为什么呢?
其实这是因为我们编码过程中加了两个补充字节,而当需要解码的时候,解码的一方根本不知道最后两个字节是补充字节, 会将7个字节当成原始数据处理,这样解码得到的结果就和我们的源数据大相径庭了。这样不就出问题了,所以我们必须同时要告诉对方我们加了几个补充字节,那怎么告诉呢?其实可以发现完全由补充字节的二进制位组成的字节一定是A,也就是说加一个补充字节, 正常base64编码的最后一个字符一定是A,加两个补充字节, 正常base64编码的最后两个字符一定是A。但是解码的时候并不能凭最后的字符是A就确定是补充字节,因为一个正常字符编码的结尾也可以是A 或者AA的情况,那这就要牵扯到base64的第65个字符了,这个字符就是"=",这是一个特殊字符,该字符的作用就是用来告知对方添加了多少个补充字节的。
所以我们将完全由补充字节的二进制位组成的字节的原本值A替换为=,这样就告知对方我们添加了两个补充字节,最后得到结果是:YWJjhA==,而这个结果和用在线工具进行编码的结果如出一辙。
现在我们明白了base64编码的过程,其实base64解码的过程就是把编码的过程给倒过来,解码过程如下:
base64字符 | 字符对应的十进制 | 六个二进制位为一组的字节(base64) | 八个二进制位为一组的字节 | 八位一字节对应的字符 | 删除补充字节后的字节 | 最后得到的字符 |
---|---|---|---|---|---|---|
Y | 24 | 011000 | 011000 01 | a | 01100001 | a |
W | 22 | 010110 | 0110 0010 | b | 01100010 | b |
J | 9 | 001001 | 01 100011 | c | 01100011 | c |
j | 35 | 100011 | 011001 00 | d | 01100100 | d |
h | 33 | 011001 | 0000 0000 | 补充字节 | ||
A | 0 | 000000 | 00 000000 | 补充字节 | ||
= | 0 | 000000 | ||||
= | 0 | 000000 |
还记得开始说的结论:编码后源数据会以三个字节为一组转化为4个字符表示,如果源数据字节数量不为三的倍数,那解决这个问题需要补充字节,补充到其二进制位可以整除6的时候。如果理解了上面整个过程,那么就对base64有了一定的了解了。
这里多提一句,在URL中使用base64会产生冲突,例如 = 在URL中用来对参数名和参数值进行分割,那么为了安全就出现了一种名为URL base64的算法,该算法会将 + 替换成 - ,将 / 替换成 _ ,而 = 有时候会用 ~ 或者 . 代替。为什么 = 是有时候替换呢?这是因为URL base64编码也有好多种,有些编码不会去掉等号,有些编码替换的符号不同。
base64是以6个二进制位来表示一个字符, 那么hex是什么呢?其实hex也称为base16,意思是以4个二进制位表示一个字符。这个是不是很熟悉,是不是感觉hex和十六进制十分相似?这两者不能说一模一样,只能说毫无差别,hex编码就是将原始字符用16进制表示。所以hex具体的编码与解码过程我就不细说了,毕竟十六进制大部分学计算机的都还是懂的。
与这两者相似的还有base32,base32是以5个二进制位来表示一个字符,一共使用32个可见字符来表示一个二进制数组,编码后源数据会以五个字节为一组转化为8个字符表示,如果源数据字节数量不为五的倍数,那解决这个问题需要补充字节,补充到其二进制位可以整除5的时候。
基础算法——消息摘要算法
什么是消息摘要算法?百度百科是这样解释的:
消息摘要算法的主要特征是加密过程不需要密钥,并且经过加密的数据无法被解密,可以被解密逆向的只有CRC32算法,只有输入相同的明文数据经过相同的消息摘要算法才能得到相同的密文。
一般地,把对一个信息的摘要称为该消息的指纹或数字签名。数字签名是保证信息的完整性和不可否认性的方法。数据的完整性是指信宿接收到的消息一定是信源发送的信息,而中间绝无任何更改;信息的不可否认性是指信源不能否认曾经发送过的信息。其实,通过数字签名还能实现对信源的身份识别(认证),即确定“信源”是否是信宿意定的通信伙伴。数字签名应该具有唯一性,即不同的消息的签名是不一样的;同时还应具有不可伪造性,即不可能找到另一个消息,使其签名与已有的消息的签名一样;还应具有不可逆性,即无法根据签名还原被签名的消息的任何信息。这些特征恰恰都是消息摘要算法的特征,所以消息摘要算法适合作为数字签名算法。
其实我们在讲apk文件结构的时候讲过什么是数字摘要、什么是数字签名、什么是对称加密、什么是非对称加密、什么是数字证书。这里我们主要讲消息摘要算法。
要讲消息摘要算法就不得不提MD5消息摘要算法了,MD5(Message Digest Algorithm 5)翻译成中文就是消息摘要算法第五版,这是一种应用十分广泛的消息摘要算法。在生活中如果官方网站下载软件实在太慢,你等的不耐烦,就去寻找第三方下载渠道,这个时候该如何判断第三方渠道的软件和官方下载的软件是否相同呢?是否被植入病毒呢?这可以用MD5做到判断两者是否相似,官方渠道一般会提供下载文件的MD5值,我们只需要检查第三方软件的MD5值与官方提供的MD5值是否一致便可。如果有一点开发经验的朋友们可能知道,用户输入的账号密码保存到数据库中都是将经过消息摘要算法处理过后的内容保存到数据库里,这样可以避免内部人员获知用户的账号和密码。
MD5是一种典型的散列函数,也可以叫做哈希函数,MD5可以将任意消息内容变成长度固定为128位的散列值,也就是128个二进制位。
MD5处理同一个消息结果始终相同,不同的消息得到的结果截然不同。
听我这么一介绍是不是觉得MD5十分安全,其实不然,MD5已经被证明不再安全,而且关键性的工作还是来自我国的学者——王小云院士。虽然MD5已被证明不再安全,但是MD5直到今天也未被彻底抛弃,而是处于一种死而不僵的状态。想要明白为何会如此,还需了解MD5的原理才能明白。
MD5算法将任意消息内容变成长度固定为128位的散列值的过程可以分为三步:填充对齐、分块、多轮压缩。
第一步、填充对齐
说到底计算机中的内容就是由二进制的0和1组成的,我们需要对这些二进制数据进行补齐操作,需要将数据填充到64字节(512bit)的倍数,不仅如此在补齐的数据中最后8字节(64bit)固定用来表示原始数据的大小,使用小端序的格式进行存储。
什么是小端序呢?其实很简单,小端序就是把数据左边的字节存放到内存当中的右边,我们来看个例子:
这是PE文件头中的PE签名,按照我们正常从左到右观看是不是以为这里存储的值为“50450000”,实则不然,这里是小端序的格式进行存储的,所以正确的值为“00004550”。
现在明白了什么是小端序,那么源数据和数据中最后8字节之间会被填充为什么呢?中间剩下的字节第一个填0x80,剩下的全部填0x0。换句话也可以表达为源数据和数据中最后64个二进制位之间除了第一个二进制位填充1,剩下的二进制位全部都填充0。现在就完成了填充补齐,接下来我们模拟一下填充补齐的过程。我们有一个65字节的数据,首先该数据不符合64字节的倍数,所以要填充补齐,填充后我们得到了128字节的数据,其中65字节为我们的源数据,还有最后8字节固定用来表示原始数据的大小,中间还剩下55字节,这55字节除了第一个字节填充0x80之外,其余的字节全部填充0x0,这样我们就得到了填充补齐后的数据。
我们填充补齐数据后,那就进行第二步分块,因为我们已经将数据补齐为64字节(512bit)的倍数,所以我们就可以把数据分为若干个64字节(512bit)的数据块。就比如我们前面填充补齐得到的128字节的数据,经过分块后就得到了两大块。
现在完成了分块操作,之前讲过MD5最终的输出结果是一个长度固定为128位的散列值,这128位长度最开始被分为了四个部分,每个部分占大小32位。而这四个部分初始化了四个数据,这四个数据均为8个16进制数据组成,每个16进制数据为4bit,这四个初始数据是固定的,不管你输入任意数据给MD5处理这四个初始数据都是固定的。这四个初始数据假设由ABCD这四个变量分别存储着,而在进行处理初始数据之前,会把ABCD中存储的初始数据赋值给四个新的变量abcd,这是因为我们在处理数据的时候需要反复对初始数据进行处理,所以我们需要保留一份最开始的初始数据留到后面使用。
A: 0x67452301
B: 0xefcdab89
C: 0x98badcfe
D: 0x10325476
a=A; b=B; c=C; d=D;
第三步多轮压缩一共有四轮,每轮压缩会使用数据块和abcd中存储的初始数据进行十六次计算操作,这十六次计算操作中的每一次最终都会把abcd各自更新一次,四轮压缩一共会把abcd各自更新六十四次,所以64字节(512bit)大小的分组数据和abcd中存储的初始数据共进行六十四次位运算。现在读起来你可能会感到疑惑,没事我们接着往下看就知道是什么意思了。
还记得我们前面保留在ABCD四个变量中的初始数据吗?现在就用的到了,当完成多轮压缩的计算操作后就需要进行以下操作:
A = a + A;
B = b + B;
C = c + C;
D = d + D;
当完成对第一个数据块所进行的四轮压缩后,那接下来就需要进行对第二个数据块的处理,在对第二个数据块进行处理的时候就需要把上一次存储最后处理结果的四个变量ABCD中存储的值再次赋值给变量abcd,这样就把最后得到的散列值分别加回到当前散列值的四个部分,散列值就被更新了。处理第二个数据块的操作和处理第一个数据块的操作一致,要说不同,那只有要进行处理的散列值是前一个数据块的最终值。
源数据经过填充补齐和分块后,一共有多少数据块,就要进行多少轮这样的操作。当我们在所有数据块上完成多轮压缩,散列值也就被更新为最终的MD5值,最终的MD5值会被使用小端序的方式存储到内存当中,就是最终128位的结果了。
MD5消息摘要算法的整体细节相信大家也多多少少清楚了些,下面我们讲讲在多轮压缩时的一系列计算操作是怎样的。
假设我们现在已经将数据初始化好了,得到了存储初始化数据的ABCD四个变量,并且将初始数据赋值给四个新的变量abcd。我们这是已经完成了第一步,我们第二步就需要进行计算数据,以下是使用64字节(512bit)大小的分组数据和abcd中存储的初始数据共进行64 次位运算所用到的公式:
a=((a+F + K[i] + M[g])<<s[i])+ b
这公式是什么意思呢?还请听我娓娓道来。
我们先来看一下这个计算公式中的F是什么:循环进行六十四次位运算,从i=0循环到i=63
if(0 <= i <= 15){
F1 = F(b, c, d);
g = i;
}
else if(16 <= i <= 31){
F2 = G(b, c, d);
g = ((i * 5) + 1) % 16;
}
else if(32 <= i <= 47){
F3 = H(b, c, d);
g = ((i * 3) + 5) % 16;
}
else{
F4 = I(b, c, d);
g = (i * 7) % 16;
}
前面说过64字节(512bit)大小的分组数据和abcd中存储的初始数据共进行64 次位运算,而以上代码中的变量i就表示这是第多少次位运算。可能有朋友疑惑F、G、H、I是什么?这是四轮压缩要使用的四种运算公式,四种运算公式如下:
F(X, Y, Z) = (X & Y) | (~X & Z)
G(X, Y, Z) = (X & Z) | (Y & ~Z)
H(X, Y, Z) = X ^ Y ^ Z
I(X, Y, Z) = Y ^ (X | ~Z)
在六十四次位运算当中,第一次到第十六次F的值用F(X, Y, Z) = (X & Y) | (~X & Z)这个公式来计算出来,其实第一次到第十六次也就是第一轮压缩,所以也可以说是第一轮压缩使用公式F来计算出最开始那计算数据公式中F1的值。第二轮压缩使用公式G来计算出最开始那计算数据公式中F2的值。第三轮压缩使用公式H来计算出最开始那计算数据公式中F3的值。第四轮压缩使用公式I来计算出最开始那计算数据公式中F4的值。
这四个公式涉及到三个值,分别是bcd,也就是前面讲过的abcd四个变量中的bcd。
接着我们来看最开始那计算数据公式中K的值,K的值要用到一个常量表,这常量表有六十四个数据,常量表如下:
K[] = {0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391}
第n次位运算就使用以上常量表中的第n个常量来作为K的值,比如第二次位运算K的值就应该为0xe8c7b756。
接着我们来看最开始那计算数据公式中M的值,M的值与64字节(512bit)大小的分组数据有关,假设有以下填充对齐后的64字节数据:
六十四个字节会被分为十六组四字节,在第一轮压缩是第几次位运算就选择十六组四字节当中的第几组;在第二轮压缩是使用公式((i * 5) + 1) % 16来计算出要取出十六组四字节当中的第几组;在第三轮压缩是使用公式((i * 3) + 5) % 16来计算出要取出十六组四字节当中的第几组;在第四轮压缩是使用公式(i * 7) % 16来计算出要取出十六组四字节当中的第几组;
接着就是将(a+F + K[i] + M[g])这四个值的和再向左循环左移s,这个s要用到一个位移表,这个位移表也是有六十四个数据,位移表如下:
S[] = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}
s值和K值一样,第n次位运算就使用以上位移表中的第n个常量来作为s的值,比如第三次位运算s的值就应该为17,也就是说在第三次位运算的时候要把值循环左移17位。
现在搞清楚了F、K、M、S,然后再加上b的值就可以计算出结果。最后一步还需对数据进行交换,交换数据需要将b的值交换到c,将c的值交换到d,将d的值交换到a,最后把刚刚计算出来的a的结果交换到b。这样就完成了六十四次位运算中的一次,剩下还需要如此这般进行63次位运算,在最后一次位运算完成后,还需要使用大写的ABCD加上小写的abcd就可以计算出结果了,最后将这些结果使用小端序的方式存储到内存当中就是最终的MD5值了!
至此MD5的细节和原理现在已经多多少少有一定的了解了,我们现在来讲讲MD5的安全隐患在哪吧!
首先MD5并不是加密算法,而是一个产生消息摘要的散列函数,它的不安全性并不是拿着它的密文去试图破解它的明文,因为MD5处理数据的过程中会不断的产生信息损失,所以MD5是不可逆的。那么它是什么方面不安全了呢?
前面讲过MD5可以将任意消息内容变成长度固定为128位的散列值,这句话告诉我们两个信息,任意消息和固定为128位的散列值,经过MD5处理过后的数据固定为128位就意味着MD5值它是有边界的,而任意数据就意味着输入给MD5进行处理的数据是没有边界的,也就是说输入给MD5处理的数据是无穷的。输入给MD5处理的数据是无穷的,MD5处理过后的数据是有穷的,以有穷对无穷就会出现一个问题,那就是一定会有不同的数据经过MD5处理后得到相同的MD5值!而这个情况被称之为MD5的碰撞性。
以有穷对无穷还会导致同一个MD5值对应的可能数据也应该有无穷多个,但是MD5的作者在设计之初认为我们无法主动找到碰撞。既然一个MD5值可能有无穷多个对应的数据,那我们可不可以找到任意一个能产生这个MD5值的数据呢?这个问题就是所谓的"原像攻击",那原像攻击是否对MD5的安全性产生了巨大的影响呢?结果很遗憾,到现在原像攻击还没有什么通用可行的方案。你看MD5值的范围在0到2的128次方之间,我们使用暴力穷举法不断的尝试使用不同的数据生成MD5值,理论上只要尝试的次数足够多,那么就能把这个MD5值对应的任意一个数据给找出来。虽然说理论上合理,但实践起来可不容易,0到2的128次方就意味着有2的128次方种可能,像这样的工程量无异于大海捞针。
既然原像攻击行不通,那么就放宽条件,假设给定一个数据和该数据经过MD5处理后的MD5值,那么可不可以找到另外一个MD5值相同的数据呢?这个问题就是所谓的"第二原像攻击",那么第二原像攻击是否对MD5的安全性产生了巨大的影响呢?目前来看MD5对于抗第二原像攻击的情况不容乐观,但是这并不是让MD5某些方面安全性堪忧的关键所在,真正让MD5安全性堪忧的是抗强碰撞性。
对于抗强碰撞性在2004年之前一直困于伪碰撞的范围,直到2004年我国山东大学的王小云团队找到了快速发现大量MD5真碰撞的方法,这是一次重大突破,并在2005年发布了详细的研究细节,她们的方法可以在十五分钟到一个小时之内找到碰撞,在基于王小云团队的研究上,人们后续的研究可谓是将MD5安全性的最后一块遮羞布给扯了下来。
2007年埃因霍芬理工大学的Marc Stevens基于王小云团队的研究提出了两项新的成果,第一项成果是用一个原始数据内容生成另外两个MD5值一样但是数据内容不一样的数据,最为重要的一点是生成的这两个MD5值一样的数据内容有意义,这就很恐怖了!这一项成果被称之为相同前缀碰撞,原始数据内容作为前缀,然后不断尝试构造两个不同的后缀数据,直到最终产生的数据内容的MD5值相同为止,而为什么最终产生的数据内容都有意义,那是因为前缀数据保留了原始数据内容原本的意义。
可能讲到这里你会认为这还是有手段可以反制,还是可以看出端倪,还不至于没得打,那再看看Marc Stevens提交的第二项成果,那就是可以自由的选择前缀的数据内容,然后生成两个MD5值一样但是前缀内容不相同的数据内容,这被称之为选择前缀碰撞。这个操作性可就很大了,如果仔细去想那不得不为MD5的安全性感到堪忧了!如果有人有这个生成两个文件,一个文件正常无隐患,一个各种木马病毒加身,但是它们两个文件的MD5值一样,如果网站用MD5值进行判断,那岂不是危矣!这让我想到万人敬仰韩天尊,杀人放火厉飞羽。
既然MD5已经不安全了,那为什么它会死而不僵呢?因为它的这些安全隐患并非全场景覆盖,就比如前面提到的第三方渠道下载和数据保存到数据库就不在MD5的安全隐患范围内。
现在对MD5应该有了一定的了解,那么下一步我们来看看Java是如何使用MD5处理数据的:
package com.java.hello;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class MD5Example {
public static void main(String[] args) {
String data = "Hello, World!";
byte[] digest = calculateMD5(data.getBytes());
assert digest != null;
String md5Hex = bytesToHex(digest);
System.out.println("MD5 Hash: " + md5Hex);
}
public static byte[] calculateMD5(byte[] data) {
try {
// 初始化MessageDigest,并指定MD5算法
MessageDigest md = MessageDigest.getInstance("MD5");
// 摘要处理
return md.digest(data);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
以上代码使用Java的MessageDigest自定义类来计算字符串"Hello, World!"的MD5摘要。在主函数main中,定义了一个字符串类型变量data,并将其转换为字节数组。然后调用calculateMD5方法计算MD5摘要,将结果保存在digest字节数组中。
calculateMD5方法中,通过MessageDigest.getInstance("MD5")获取MD5摘要算法的实例。如果获取失败,则会抛出NoSuchAlgorithmException异常。在这里,我们通过try-catch块捕获异常并打印异常信息,然后返回null。
如果成功获取到MD5摘要算法的实例,调用md.digest(data)方法计算摘要。这个方法接受一个字节数组作为输入,并返回计算得到的摘要字节数组。
在main函数中添加assert digest != null;是为了确保calculateMD5方法返回的摘要字节数组不为null。在正常情况下,calculateMD5方法应该能够成功计算并返回摘要字节数组。然而,如果在获取MD5摘要算法实例时发生异常,calculateMD5方法将返回null。
通过添加断言assert digest != null;,我们可以在调试和测试阶段捕获这种异常情况。如果calculateMD5方法返回null,断言将会触发并抛出AssertionError异常,从而提醒我们出现了意外的情况。
最后,调用bytesToHex方法将摘要字节数组转换为十六进制字符串,并将结果打印出来。
最终的打印结果如下:
MD5 Hash: 65a8e27d8879283831b664bd8b7f0ad4
现在总算把MD5算法给讲清楚了,好累!现在向我们走来的是消息摘要算法之SHA算法。
SHA算法百度百科解释如下:
安全散列算法(英语:Secure Hash Algorithm,缩写为SHA)是一个密码散列函数家族,是FIPS所认证的安全散列算法。能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。且若输入的消息不同,它们对应到不同字符串的机率很高。
SHA算法还是一个家族,SHA家族的五个算法,分别是SHA-1、SHA-224、SHA-256、SHA-384,和SHA-512,由美国国家安全局(NSA)所设计,并由美国国家标准与技术研究院(NIST)发布;是美国的政府标准。后四者有时并称为SHA-2。SHA-1在许多安全协定中广为使用,包括TLS和SSL、PGP、SSH、S/MIME和IPsec,曾被视为是MD5(更早之前被广为使用的杂凑函数)的后继者。但SHA-1的安全性如今被密码学家严重质疑;虽然至今尚未出现对SHA-2有效的攻击,它的算法跟SHA-1基本上仍然相似;因此有些人开始发展其他替代的杂凑算法。
有关SHA系列算法摘要长度如下表所示:
算法 | 摘要长度 |
---|---|
SHA-1 | 160 |
SHA-224 | 224 |
SHA-256 | 256 |
SHA-384 | 384 |
SHA-512 | 512 |
SHA系列算法与MD5算法有很多相似之处,这里对SHA系列算法的原理就不做过多阐述,我们就直接来看看Java是如何使用SHA系列算法处理数据的:
package com.java.Reverse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class SHAexample {
public static void main(String[] args) {
String data = "Hello, World!";
byte[] digest = calculateSHA(data.getBytes());
assert digest != null;
String shaHex = bytesToHex(digest);
System.out.println("SHA Hash: " + shaHex);
}
public static byte[] calculateSHA(byte[] data) {
try {
// 初始化MessageDigest,并指定SHA算法
MessageDigest md = MessageDigest.getInstance("SHA-256");
// 摘要处理
return md.digest(data);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
以上这段代码为我们展示了如何使用SHA-256算法计算给定数据的哈希值,并将结果以十六进制字符串的形式输出。
1.在main方法中,首先定义了一个字符串类型变量data,表示要计算哈希值的数据为"Hello, World!"。然后调用calculateSHA方法,将data转换为字节数组,并计算其SHA哈希值。接着将计算得到的哈希值转换为十六进制字符串,并输出结果。
2.calculateSHA方法接受一个字节数组作为参数,通过MessageDigest类的getInstance方法初始化SHA-256算法的实例md。然后调用md的digest方法对数据进行摘要处理,返回计算得到的哈希值。
3.bytesToHex方法接受一个字节数组作为参数,将字节数组中的每个字节转换为十六进制字符串,并将其拼接为一个完整的十六进制字符串后返回。
4.在main方法中,调用calculateSHA方法计算数据的SHA哈希值,然后调用bytesToHex方法将哈希值转换为十六进制字符串并输出。
SHA-256算法在实际应用中,可以用于数据完整性验证、密码存储等安全领域。如果NoSuchAlgorithmException异常被抛出,表示指定的算法不可用。
我们来看看SHA-256算法处理后的打印结果是如何模样的:
SHA Hash: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
SHA系列算法详细的处理过程大家可以自己去了解,具体的处理过程我这里就不详解了。
现在我们大致了解了MD5消息摘要算法、SHA系列消息摘要算法,接下来向我们走来的是MAC消息摘要算法,该消息摘要算法是含有密钥的消息摘要算法。
MAC(Message Authentication Code,消息认证码算法)是含有密钥散列函数算法,兼容了MD和SHA算法的特性,并在此基础上加上了密钥。因此MAC算法也经常被称作HMAC算法。
MAC算法主要集合了MD和SHA两大系列消息摘要算法。MD系列算法有HmacMD2、HmacMD4和HmacMD5三种算法,SHA系列算法有HmacSHA1、HmacSHA224、HmacSHA256、HmacSHA384和HmacSHA512五种算法。
经MAC算法得到的摘要值也可以使用十六进制编码表示,其摘要值长度与参与实现的算法摘要值长度相同。例如,HmacSHA1算法得到的摘要长度就是SHA1算法得到的摘要长度,都是160位二进制数,换算成十六进制编码为40位。
有关MAC算法摘要长度如下表所示:
算法 | 摘要长度 |
---|---|
HmacMD5 | 128 |
HmacSHA1 | 160 |
HmacSHA256 | 256 |
HmacSHA384 | 384 |
HmacSHA512 | 512 |
HmacMD2 | 128 |
HmacMD4 | 128 |
HmacSHA224 | 224 |
接下来我们来看看Java是如何使用MAC系列算法处理数据的:
package com.java.Reverse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class MACexample {
public static void main(String[] args) {
try {
// 创建Mac对象,指定算法和密钥
Mac mac = Mac.getInstance("HmacSHA256");
byte[] keyBytes = "MiYao".getBytes();
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "HmacSHA256");
mac.init(keySpec);
// 要计算摘要的消息
String message = "Hello, world!";
byte[] messageBytes = message.getBytes();
// 计算消息摘要
byte[] digest = mac.doFinal(messageBytes);
// 将摘要转换为十六进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : digest) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
// 打印摘要
System.out.println("Message Digest: " + hexString.toString());
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
}
}
}
我们可以看到以上代码演示了如何使用Java的javax.crypto包中的Mac类来计算消息的摘要。具体来说,代码的功能如下:
1.通过Mac.getInstance("HmacSHA256")
方法获取HmacSHA256算法的Mac对象。
2.创建密钥,使用"MiYao".getBytes()
获取字节数组,然后使用SecretKeySpec类创建密钥规范对象。
3.使用mac.init(keySpec)
初始化Mac对象,将密钥加载到Mac对象中。
4.创建消息,这里是"Hello, world!"
。
5.将消息转换为字节数组,使用message.getBytes()
方法。
6.调用mac.doFinal(messageBytes)
方法计算消息的摘要,得到摘要的字节数组。
7.将摘要字节数组转换为十六进制字符串,通过将每个字节转换为十六进制,并拼接到StringBuilder对象中完成。
8.最后打印摘要的十六进制字符串表示。
在代码中使用了try-catch语句来捕获可能抛出的NoSuchAlgorithmException和InvalidKeyException异常,并在发生异常时打印异常堆栈信息。
总而言之,以上代码展示了如何使用HmacSHA256算法计算消息的摘要,并将摘要以十六进制字符串的形式打印出来。
我们来看看HmacSHA256算法处理后的打印结果是如何模样的:
Message Digest: 57e61895de001a806d5a22ba0e4ee2011a02c258f00e121165ba855dbf271321
到此为止,我们将常用到的消息摘要算法都简单了解了一遍,接下来,我们就要进入对称加密算法了,这里我们先简单了解一下对称加密算法,对称加密算法与消息摘要算法最大的区别在于加密和解密的过程是可逆的,这里多一嘴,不管是对称加密算法还是非对称加密算法,最常见的加密算法都是分组加密算法,那什么是分组加密算法呢?简单来说就是把明文分成一组或多组进行加密,而分组加密算法一般会有五种加密模式,这五种加密模式分别是ECB、CBC、CFB、OFB、CTR,这里着重讲一下比较常见的ECB和CBC这两种加密模式。
ECB加密模式是最基本的工作模式,将待处理明文进行分组,每组分别进行加密或解密处理,当然明文分组的长度是根据密钥的长度来的,至于分组长度的规则这里就不细讲,总之只要知道有这么一个分组的概念就可以了。
假设我们有这么一段明文:123456789,这段明文假设被分为三段,分别是第一段明文123、第二段明文456、第三段明文789。
明文分组后我们使用密钥对每一组明文进行加密,处理后得到加密后的密文,假设得到了三组密文,分别是由123加密得来的"qsc"、由456加密得来的"asf"、由789加密得来的"jgh"。
得到三组密文后,最后将三组密文拼接起来得到最终的密文——"qscasfjgh"。
以上模拟的加密模式便是ECB加密模式,这里讲讲ECB加密模式的优缺点,该加密模式的缺点很明显,它的加密模式导致它只需要猜其中一部分就可以了,其中一部分错了其他部分还有可能对,解密的时候是按照区块去解密的,所以在破解密文的时候不需要每一个都猜对,我们可以进行枚举,或者在数据库中存一些字典,这些字典记录着一个明文对应哪个密文,然后进行穷举嘛!只要这个计算时间足够长,总有一天会破解出来的。
到这里你会发现ECB加密模式安全性并不高,但为什么还是这么的常用呢?这就得讲讲ECB加密模式的优点了。ECB加密模式最大的优点就是方便、简单、可并行。这里的可并行是什么可能大家并不太清楚,这个可并行的优点是由于ECB加密模式的各分组之间没有关联,所以ECB的操作可以并行化。
CBC加密模式和ECB加密模式最大的不同就是IV向量,我们先来看看这个IV向量在CBC加密模式中扮演着怎么样的角色。CBC加密模式依旧是先将待处理的明文进行分组,分好组后就需要用到IV向量了,CBC会先将第一组明文与IV向量进行异或运算,进行异或运算后才是使用密钥进行加密,从而得到第一组密文。
你以为会是把每一组明文都与IV向量进行异或运算后加密得到密文,然后再将所有组密文拼接在一起得到最后的密文?不不不,大错特错,在得到第一组密文后,下一组明文在加密前不是与IV向量进行异或运算,而是与前一组密文进行异或运算,然后再使用密钥进行加密得到新的密文。简单来说就是第一组用IV向量来进行异或运算然后加密得到密文,第二组用前一组的密文来进行异或运算然后加密得到新的密文,直到最后一组用前一组的密文来进行异或运算然后加密得到最后的密文,最后的最后把所有组密文拼接起来得到完整的密文。因此CBC加密模式中文名为密文分组链接模式,你可以从CBC的加密模式中看出在加密的过程中密文分组就像链条一样相互连接在一起,而IV向量就是这链条的头部。
从CBC的加密模式可以看出来,CBC加密模式的特点,首先特点一在加密的过程中必然有一个IV向量,而IV向量的值不一样得到的结果也就不一样;特点二某一组明文加密结果错误了,那后面的每组加密也就全都错误了;特点三无法单独对中间某一组明文进行加密,假设要加密第三组明文,那就需要先加密第一、二组明文才可以加密第三组明文,而无法单独加密第三组明文;特点四如果一个密文分组损坏了,解密时最多只有两个分组(损坏分组及其后的一个分组)会受到影响;特点四我从网上扒下来一张图来讲解讲解。
以下是对图中CBC模式分组损坏的影响的讲解:
1.分组损坏的影响:
◆假设密文分组2损坏了(如图所示),解密时会有以下影响:
明文分组2:由于密文分组2损坏,解密后得到的中间值也是错误的,与密文分组1异或后得到的明文分组2也是错误的。
明文分组3:密文分组3解密后得到的中间值是正确的,但由于与损坏的密文分组2进行异或运算,导致明文分组3也是错误的。
明文分组4及之后:从密文分组4开始,由于密文分组3是正确的,解密后得到的中间值也是正确的,与密文分组3进行异或运算后,得到的明文分组4及之后的分组都是正确的。
因此,在CBC模式下,如果一个密文分组损坏了,解密时最多只有两个分组(损坏分组及其后的一个分组)会受到影响。图中清晰地展示了这一点。
特点五如果密文分组中有比特缺失,缺失比特的位置之后的所有密文分组都将无法正确解密。
以下是对图中CBC模式当密文分组中有比特缺失时的影响:
1.比特缺失的影响:
◆假设密文分组中有一些比特缺失了(如图所示),即便只缺失1比特,也会导致密文分组的长度发生变化。
◆由于CBC模式依赖于固定长度的分组进行操作,比特缺失会导致分组错位,从而影响后续的解密过程。
◆比特缺失的位置之后的密文分组将全部无法正确解密,因为解密过程依赖于前一个正确的密文分组。
具体影响如下:
◆缺失比特前的分组:这些分组可以正常解密,因为它们没有受到比特缺失的影响。
◆缺失比特所在的分组:由于比特缺失,解密时会得到错误的中间值,与前一个密文分组异或后得到的明文分组也是错误的。
◆缺失比特之后的所有分组:由于分组错位,解密时会使用错误的密文分组进行异或运算,导致所有后续分组的解密结果都是错误的。
因此,在CBC模式下,如果密文分组中有比特缺失,缺失比特的位置之后的所有密文分组都将无法正确解密。图中清晰地展示了这一点。
前面的示例中都是假设分组后每一组明文长度相同,但实际情况不会是假设,所以当ECB和CBC加密模式把明文分组后如果出现最后一组明文长度和其他组明文长度不一致的情况,那么是如何应对的?这就牵扯到一个叫做填充方式的东西,这个填充方式就是用来保证分组加密算法中的每组明文长度一致的,而填充方式有No Padding、ANSI X9.23、ISO 10126、PKCS#5、PKCS#7、Zero Padding、ISO/IEC 7816-4等填充方式。
在分组加密算法中,填充(Padding)是一种在分组加密算法中用于处理明文长度不符合分组长度要求的方法。不同的填充方式会根据不同的规则来添加填充数据,以确保明文数据长度能被正确地分组加密。以下是我对各种填充方式的讲解:
每种填充方式都有其特定的规则和应用场景,用于确保在对非标准长度的数据进行加密时,能够正确地进行分组加密操作。不同的加密算法或协议可能会采用不同的填充方式来处理数据长度不足分组长度的情况。
到这我们就讲解了对称加密算法的前菜,了解了一点算法基础,接下来我们将进入主菜环节,讲讲常见的几种对称加密算法。
这里我们简单了解一下常用的三种对称加密算法,分别是DES算法和DESede算法,以及AES算法。而第一个向我们迎面走来的对称加密算法便是DES对称加密算法。
我们先讲一下概念,DES(Data Encryption Standard)是一种对称加密算法,由IBM研发并在1977年被美国政府正式采纳作为数据加密标准。DES基于分组密码的概念,将数据分成64位的数据块(即一个分组),采用56位的密钥对数据进行加密和解密。虽然密钥长度是56个二进制位,但在Java中提供的是八个字节,也就是64个二进制位,因为其中8个二进制位是要做校验的。
当获得了一组64位的数据之后,DES将通过一些步骤进行加密,而关于DES对称加密算法的加密流程我们简单概括一下:
1.初始置换(IP置换)
◆输入的64位明文块进行初始置换和重新排列,生成新的64位数据块。
2.加密轮次
◆DES加密算法共有16个轮次,每个轮次都包括以下四个步骤:
3.末置换(FP置换)
◆在第16轮加密完成后,将最终的64位数据块进行末置换和重新排列,得到加密后的64位密文。
总结
◆DES加密的过程通过一系列的置换、异或、扩展等运算,将明文分成若干个小块,然后根据主密钥生成一系列的轮密钥,利用轮密钥对每个小块进行加密,最终将加密结果重新组合成一个整体,得到密文。
这里只是DES对称加密算法的加密流程的简单概括,如果有兴趣了解具体的加密流程可以自行去参考这篇文章或者自行寻找:
https://blog.csdn.net/Demonslzh/article/details/129129493通俗易懂,十分钟读懂DES,详解DES加密算法原理,DES攻击手段以及3DES原理。Python DES实现源码-CSDN博客
好了,接下来我们就来看看Java是如何使用DES对称加密算法对数据进行加解密的:
package com.java.Reverse;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
public class DesExample {
public static String keys;
public static String values;
/**
* DES加密方法
* @param key DES加密密钥
* @param value 待加密的字符串
* @return 加密后的字节数组
* @throws Exception
*/
private static byte[] DesEncryption(String key, String value) throws Exception{
// 实例化DES密钥材料,注意这里只使用key中的前8个字节作为DES密钥的密钥内容
DESKeySpec desKeySpec = new DESKeySpec(key.getBytes());
// 实例化秘密密钥工厂
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("DES");
// 生成秘密密钥
SecretKey secretKey = secretKeyFactory.generateSecret(desKeySpec);
// 实例化Cipher
Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:加载密钥进cipher并设置为加密模式;
// Cipher.ENCRYPT_MODE为加密模式,Cipher.DECRYPT_MODE为解密模式;
// 实际上Cipher.ENCRYPT_MODE和Cipher.DECRYPT_MODE都是一个常量;
// 在反编译的时候这里显示1便是加密模式,显示2便是解密模式。
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
// 执行加密操作并返回密文
return cipher.doFinal(value.getBytes());
}
public static void main(String[] args) throws Exception {
keys = "a12345678";
values = "12345678";
// 对values进行DES加密
byte[] res = DesExample.DesEncryption(keys, values);
String str = new BASE64Encoder().encode(res);
System.out.println("加密后的密文:" + str);
// 实例化DES密钥材料
DESKeySpec desKeySpec = new DESKeySpec(keys.getBytes());
// 实例化秘密密钥工厂
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("DES");
// 生成秘密密钥
SecretKey secretKey = secretKeyFactory.generateSecret(desKeySpec);
// 实例化Cipher
Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:加载密钥进cipher并设置为解密模式
cipher.init(Cipher.DECRYPT_MODE, secretKey);
// 执行解密操作
byte[] res2 = cipher.doFinal(new BASE64Decoder().decodeBuffer(str));
System.out.println("解密后的明文:" + new String(res2));
}
}
以上这段代码是一个Java类DesExample
,它为我们演示对字符串进行DES加密和解密操作。
以下是这段代码的讲解:
DesExample
类包含了两静态属性keys
和values
,用于存储DES加密密钥和待加密的字符串。DesEncryption
方法是一个私有静态方法,用于对输入的字符串进行DES加密操作。该方法接收加密密钥和待加密的字符串作为参数,并返回加密后的字节数组。main
方法是程序的入口点。在这个方法中:需要注意的是,代码中使用了sun.misc.BASE64Decoder
和sun.misc.BASE64Encoder
这两个类,它们是sun.*
的非标准API,不建议在生产环境中使用,因为它们不是公共API,并可能在将来的Java版本中被移除。
现在我们就来看看Java使用DES对称加密算法对数据进行加解密后的效果:
ZEi7hkmMp+Dr8CE3zdeHkg==
12345678
我们可以看到从加密前的"12345678"到加密后的"ZEi7hkmMp+Dr8CE3zdeHkg==",再到解密后得到的明文"12345678"这么一个完整的过程。但这里要注意的是加密和解密的过程是相对的,比如说上面的代码中对于明文的加密流程是先通过getBytes方法将明文转换为byte 序列,然后再进行加密操作并返回密文,最后把密文进行BASE64编码操作;我们再来看看上面代码中解密的过程,因为加密和解密的过程是相对的,所以你在代码中可以看到解密的第一步是先对密文进行BASE64解码操作,然后再进行解密操作,最后把解密后的内容转换为字符串,从而得到最后的明文。
DES对称加密算法我们就讲到这,接下来我们快速了解DESede对称加密算法,其实这两种对称加密算法是差不多的,要说区别有这么几种,在讲区别之前我们先来看看DESede对称加密算法如何使用JAVA代码实现:
package com.java.Reverse;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import javax.crypto.spec.DESedeKeySpec;
public class DESedeExample {
private static String DESedeDecode(String keys, String values) throws Exception{
// 实例化DES密钥材料
DESedeKeySpec deSedeKeySpec = new DESedeKeySpec(keys.getBytes());
// 实例化秘密密钥工厂
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("DESede");
// 生成秘密密钥
SecretKey secretKey = secretKeyFactory.generateSecret(deSedeKeySpec);
// 实例化
Cipher cipher = Cipher.getInstance("DESede/ECB/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:把密钥加载进cipher并设置为加/解密模式,反编译时这里1表示加密模式,2表示解密模式
cipher.init(Cipher.DECRYPT_MODE, secretKey);
// 执行操作
byte[] decode = cipher.doFinal(new BASE64Decoder().decodeBuffer(values));
return new String(decode);
}
public static void main(String[] args) throws Exception{
String key = "123456781234567812345678";
String value = "a12345678";
// 实例化DESede密钥材料
DESedeKeySpec deSedeKeySpec = new DESedeKeySpec(key.getBytes());
// 实例化秘密密钥工厂
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("DESede");
// 生成秘密密钥
SecretKey secretKey = secretKeyFactory.generateSecret(deSedeKeySpec);
// 实例化
Cipher cipher = Cipher.getInstance("DESede/ECB/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:把密钥加载进cipher并设置为加/解密模式,反编译时这里1表示加密模式,2表示解密模式
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
// 执行操作
byte[] res = cipher.doFinal(value.getBytes());
String str = new BASE64Encoder().encode(res);
System.out.println("加密后的内容为:" + str);
String decode = DESedeDecode(key, str);
System.out.println("解密后的内容为:" + decode);
}
}
1. 引入的类和包
sun.misc.BASE64Decoder
和sun.misc.BASE64Encoder
: 这两个类用于进行 BASE64 编码和解码,以便处理二进制数据(如加密后的字节)。javax.crypto.Cipher
: 用于加密和解密操作的核心类。javax.crypto.SecretKey
: 存储密钥的对象。javax.crypto.SecretKeyFactory
: 用于生成从指定密钥材料中生成的秘密密钥的工厂类。javax.crypto.spec.DESKeySpec
和javax.crypto.spec.DESedeKeySpec
: 这些 spécifications 用于创建 DES 和 DESede 密钥材料。2. 类功能
该类的功能是通过 DESede 算法加密和解密文本。DESede 是通过三重 DES (Triple DES)算法实现的一个变种,提供比单一 DES 更强的安全性。
3. 主要方法
3.1DESedeDecode(String keys, String values)
该方法负责执行 DESede 解密操作。
◆参数:
keys
: 用于解密的密钥字符串values
: 被 BASE64 编码的密文
步骤:
将密钥字符串转换为 DESedeKeySpec。
使用
SecretKeyFactory
实例化一个密钥工厂。根据密钥材料生成
SecretKey
。获取
Cipher
实例以实现 DESede 算法的解密模式。初始化
Cipher
对象为解密模式。使用
doFinal
方法对 BASE64 解码后的密文进行解密,返回解密后的明文字符串。
3.2main(String[] args)
主方法用于演示加密和解密的过程。
◆步骤
定义一个密钥和要加密的明文。
将密钥字符串转换为 DESedeKeySpec。
生成秘密密钥。
创建
Cipher
实例以执行加密过程。将
Cipher
初始化为加密模式,并对明文进行加密。将加密后的结果进行 BASE64 编码,便于打印和传输。
打印加密后的内容。
调用
DESedeDecode
方法进行解密,并打印解密结果。
4. 使用示例
假设密钥为123456781234567812345678
,要加密的明文为a12345678
,它的过程如下:
1.将明文加密,结果通过 BASE64 编码输出。
2.将加密的 BASE64 字符串反转回明文并输出。
我们简单的了解了以上代码,我们可以看出这两种算法最明显的区别是密钥的长度不一样,一个是八位的,一个是二十四位的。其次就是使用的实例化函数和实例化得到的结果不一样,还有就是SecretKeyFactory.getInstance函数指明的类型也是不一样的,其他的地方就是差不多的。
好了,简单的过了DESede对称加密算法,我们接下来去了解下一个常用的对称加密算法——AES;废话不多说,直接上代码:
package com.java.Reverse;
import sun.misc.BASE64Decoder;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.spec.AlgorithmParameterSpec;
import java.util.Base64;
public class AESexample {
/**
* 使用AES算法对数据进行加密
*
* @param data 要加密的数据
* @param key 加密密钥
* @param iv 初始化向量
* @return 加密后的数据
* @throws Exception 加密过程中的异常
*/
public static String encrypt(String data, String key, String iv) throws Exception {
// 创建AES加密算法的密钥规范对象
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
// 创建算法参数规范对象,用于指定初始化向量
AlgorithmParameterSpec niv = new IvParameterSpec(iv.getBytes());
// 创建AES加密算法的密码器
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化密码器为加密模式,指定密钥和初始化向量
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, niv);
// 加密数据
byte[] encryptedBytes = cipher.doFinal(data.getBytes());
// 对加密后的数据进行Base64编码
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public static String decrypt(String data, String key, String iv) throws Exception {
// 创建AES解密算法的密钥规范对象
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "AES");
// 创建算法参数规范对象,用于指定初始化向量
AlgorithmParameterSpec niv = new IvParameterSpec(iv.getBytes());
// 创建AES解密算法的密码器
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // 加密算法/加密模式/填充方式
// 初始化密码器为解密模式,指定密钥和初始化向量
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, niv);
// 对加密后的数据进行Base64解码
byte[] decode = Base64.getDecoder().decode(data);
// 解密数据
byte[] decryptedBytes = cipher.doFinal(decode);
// 将解密后的数据转换为字符串
return new String(decryptedBytes);
}
public static void main(String[] args) throws Exception {
String data = "a12345678";
String key = "1234567890abcdef";
String iv = "1234567890abcdef";
String encryptedData = encrypt(data, key, iv);
System.out.println("Encrypted Data: " + encryptedData);
String decode = decrypt(encryptedData, key, iv);
System.out.println("Decode Data: " + decode);
}
}
1. 引入的类和包
javax.crypto.Cipher
: 主要用于加密和解密操作。javax.crypto.spec.IvParameterSpec
: 用于指定初始化向量(IV)的类。javax.crypto.spec.SecretKeySpec
: 用于生成秘密密钥的类。java.security.spec.AlgorithmParameterSpec
: 表示算法参数的接口。java.util.Base64
: 基于 Base64 的编码和解码类,用于处理加密后的字符串。2. 类功能
该类的功能是使用 AES 算法对文本数据进行加密和解密。引入了初始化向量(IV)以增强加密安全性。
3. 主要方法
3.1encrypt(String data, String key, String iv)
此方法负责执行 AES 加密操作。
data
: 要加密的明文。key
: 用于加密的密钥(必须为 16 字节)。iv
: 初始化向量,增强加密安全性(也必须为 16 字节)。
3.2decrypt(String data, String key, String iv)
此方法负责执行 AES 解密操作。
◆参数:
data
: 被 Base64 编码的密文。key
: 用于解密的密钥。iv
: 初始化向量。
步骤:
创建与加密相同的
SecretKeySpec
和IvParameterSpec
对象。创建
Cipher
实例,指定 AES 解密的算法、模式和填充方式。初始化
Cipher
为解密模式,并传入密钥和 IV。对输入的 Base64 编码密文进行解码。
通过
doFinal
方法对解码后的字节数组进行解密。将解密结果转换为字符串并返回。
3.3main(String[] args)
主方法用于演示加密和解密的过程。
◆步骤
定义明文数据、密钥和初始化向量(IV)。
调用
encrypt
方法进行加密,输出加密后的结果。调用
decrypt
方法进行解密,输出解密后的结果。
4. 使用示例
假设要加密的文本为a12345678
,密钥和 IV 均为1234567890abcdef
。执行过程如下:
1.加密过程将明文转换为 Base64 编码的密文并输出。
2.解密过程将密文恢复为明文并输出。
我们来运行一下以上代码,看看结果如何:
Encrypted Data: 0l99zkQGHvlWysaJwl5naA==
Decode Data: a12345678
通过看代码我们其实也能发现AES对称加密算法和前两种对称加密算法的一些区别,但写法上是差不多的。而AES与其他两种的区别就比如说最明显的就是AES对称加密在密钥实例化时用的是SecretKeySpec方法,而该方法其实是通用的,其实前两种对称加密算法也可以用该方法进行密钥实例化的,只不过前两种对称加密算法是有专门的方法来进行密钥实例化的,而AES是没有的,只能通过该方法来进行密钥实例化。
到此为止常见的对称加密算法就基本讲完了,那接下来就讲讲非对称加密算法,而在非对称加密算法的使用中比较常见的就是RSA算法。非对称加密算法和对称加密算法最大的区别就是加解密使用不同的密钥,它是有一个公钥和一个私钥的,这两把密钥之间是有联系的。还有一个点就是对称加密的密钥是可以随便写的,但是非对称加密的密钥是由函数来生成的,而且通过公钥是推导不出来私钥的,但私钥里面是包含公钥信息的,所以可以通过私钥来得到公钥的。
我们可以通过一些网站或者工具生成RSA的公钥和私钥,我们去试试看:
我们可以看到私钥的长度比公钥的长度长很多,前面讲过私钥里面是包含公钥信息的;我们往上看,可以看到在可以选择的内容中有密钥长度,而密钥长度越长它加密的时间自然也就越长,也越安全,而且在非对称加密算法中你密钥的长度决定了你明文的长度,所以在密钥长度中用的比较多的是1024位,也就是128字节,除了1024位之外还有2048位、4096位以及512位,但512位现在几乎没什么人用了,4096位也用的少,主要还是1024位以及2048位用的多点。
我们可以从上面看出非对称加密算法加密处理还是很安全的,但是性能极差,而且单次加密长度还有限制,也就是说非对称加密算法单次加密对于明文长度是有限制的,不是像对称加密一样对于明文加密是没有长度限制的。而明文的最大字节数是根据密钥长度来决定的,所以这个密钥的位数是比较重要的。但是明文的最大字节数具体是多少会跟填充方式会有一定的关系,比如说当填充方式为PKCS1Padding时,明文的最大字节数为密钥字节数-11,密文与密钥等长;当填充方式为NoPadding时,明文的最大字节数为密钥字节数,密文与密钥等长。
我们在前面的文章讲过非对称加密的常见用法:
第一步:服务器会将非对称加密中的公钥发送给浏览器,浏览器生成一串随机数字,而这串随机数字使用服务器发送过来的公钥进行加密。
第二步:将通过公钥加密的随机数字发送给服务器,服务器接收后使用私钥进行解密,这样双方就都得到了一个同样的随机数字,而这个随机数字可以作为对称加密的密钥。
第三步:使用随机数字作为密钥对真正需要传递的数据进行加密传输。
而当我们逆向的时候我们是可以取巧的,至于怎么取巧且听我娓娓道来:
首先假设每一次随机生成的密钥都是一样的,那么通过RSA非对称加密算法加密的结果都是一样的,当然也有情况加密的结果是不一样的,比如说填充方式为PKCS1Padding,即使是这样那解密出来的结果也是一样的。是不是有点绕,你想想每次RSA对称加密的加密结果都不同,但不管它怎么变,后台解密出来的都是一个明文,那我们是不是可以把RSA对称加密的加密结果给固定下来,这样一来提交给后台解密出来的明文还是那个明文。这样做的好处就是逆向算法的时候RSA算法就不用找了,而是直接把加密结果固定下来就可以了。
讲了这么多关于非对称加密算法的理论,我们还是来讲讲怎么用JAVA代码来实现RSA加密算法吧!RSA加密算法有两种不同的写法,分别是base64版和hex版,我们一个个来了解,因为这两个不同的写法在我们逆向的时候都有可能会遇到。
base64版:
package com.java.Reverse;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
public class RsaBase {
public static BASE64Encoder base64Encoder;
public static BASE64Decoder base64Decoder;
public static String pub_str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD1BT9h2x0ate1h3686kXeivygS\n" +
"0/RrYQcy1PqtFnhwrGq6yyP6Av8gtzKgQzilIwwU2fRu0ugvGYfB5BwYNJwAMX4i\n" +
"TAcvVoOmm2HNDGaOlrEMkqgIfS77546wfwtIk1CUx6Euu+9FV/Y3gryssrhYoarH\n" +
"Vu3IooiqfxRNws7a+QIDAQAB"; // 公钥
public static String pri_str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAPUFP2HbHRq17WHf\n" +
"rzqRd6K/KBLT9GthBzLU+q0WeHCsarrLI/oC/yC3MqBDOKUjDBTZ9G7S6C8Zh8Hk\n" +
"HBg0nAAxfiJMBy9Wg6abYc0MZo6WsQySqAh9LvvnjrB/C0iTUJTHoS6770VX9jeC\n" +
"vKyyuFihqsdW7ciiiKp/FE3Cztr5AgMBAAECgYALnqgW1/FVZvNPBPCmcmeeDDq2\n" +
"Sd75iKxyuK76rmofzc1x9lhKbwHyZz27Y/S3wCW+h2eUKeRg93D8vPioHTaYo/6H\n" +
"AO3U5iDchbwIj/kvnLXIXwJWTCmpI+sadqrERbSneYgIKhvIX+91GRoCgCLoOH6M\n" +
"eihxg1skYPREtf1yUQJBAP2Pfj9LYHt7/04YJygnY6z1WEpOaTQkI4JK5Wjz+F7h\n" +
"IpIenEbxg2oz1vSkcwdjezEPPD8B3k7z8cuNAEF1UecCQQD3YLiFlMecSQALrUn3\n" +
"3v7v4Nh1mB1EbuXGHA94gICZLXRV1sb/C2QNZwc41bRiu00uJ9GK1OEeJwdv7vML\n" +
"OJAfAkA4xfJMlcIKpB7sC3hpAzjMNzsHmDryE81nlQF82HOaOuqUsQno0JbOJsFQ\n" +
"kam308x3laO1r+No5jITk4SlI3GtAkEAoH1ZeSB7GAOsSecU9ADyeIHxLOmRI1Kn\n" +
"M44E43LK+5WnwgDjfZfQQ3myD8dljiBiBC3FHkLaAgvkIVRuzbrWlQI/P2xpYRx4\n" +
"/R4w/45yn47DZ4+GJ8qdN5f6j1jjB/0sIMTjx7M5Xm7HucgYQaZ6pbijnQWad0oD\n" +
"M3XeUL3VPVIt"; // 私钥
static {
base64Encoder = new BASE64Encoder();
base64Decoder = new BASE64Decoder();
}
// 从Base64编码的字符串中获取公钥
public static PublicKey getPublicKey(String pub_key) throws Exception {
// Base64解码,得到真实的字节数据
byte[] new_key = base64Decoder.decodeBuffer(pub_key);
// 创建X509EncodedKeySpec对象,使用字节数据
X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(new_key);
// 获取RSA密钥工厂的实例
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// 生成用于加密的公钥
PublicKey publicKey = keyFactory.generatePublic(x509EncodedKeySpec);
return publicKey;
}
// 从Base64编码的字符串中获取私钥
public static PrivateKey getPrivateKey(String pri_key) throws Exception {
// Base64解码,得到真实的字节数据
byte[] new_key = base64Decoder.decodeBuffer(pri_key);
// 创建PKCS8EncodedKeySpec对象,使用字节数据
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(new_key);
// 获取RSA密钥工厂的实例
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// 生成用于加密的私钥
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
return privateKey;
}
// 加密方法,使用公钥加密字节数组
public static byte[] encrypt(byte[] encrypt_str) throws Exception {
PublicKey publicKey = getPublicKey(pub_str);
// 获取Cipher实例
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:将密钥加载到Cipher中,并设置为加密模式
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// 执行加密操作
byte[] example = cipher.doFinal(encrypt_str);
return example;
}
// 解密方法,使用私钥解密字节数组
public static byte[] decrypt(byte[] decrypt_str) throws Exception {
PrivateKey privateKey = getPrivateKey(pri_str);
// 获取Cipher实例
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); // 加密算法/加密模式/填充方式
// 初始化操作:将密钥加载到Cipher中,并设置为解密模式
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// 执行解密操作
byte[] decode = cipher.doFinal(decrypt_str);
return decode;
}
// 将字节数组转换为十六进制字符串
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public static void main(String[] args) throws Exception {
String str_base64 = "0123456789";
byte[] ec_cipher = encrypt(str_base64.getBytes());
System.out.println("明文:" + str_base64);
System.out.println("密文:" + bytesToHex(ec_cipher));
byte[] deByteStr = decrypt(ec_cipher);
String deString = new String(deByteStr);
System.out.println("解密结果:" + deString);
}
}
上面的代码看起来很长,没关系我们下面对其进行逐步详解:
1. 包与依赖
package com.java.Reverse;
这行代码定义了代码的包名,便于组织和管理 Java 类。
2. 导入必要的类
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
3. 类定义与成员变量
public class RsaBase {
public static BASE64Encoder base64Encoder;
public static BASE64Decoder base64Decoder;
public static String pub_str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB...";
public static String pri_str = "MIICdQIBADANBgkqhkiG9w0BAQEFA...";
RsaBase
类是主要的实现类。base64Encoder
和base64Decoder
是用于进行 Base64 编码和解码的实例。pub_str
和pri_str
是以 Base64 编码的 RSA 公钥和私钥字符串。4. 静态初始化块
static {
base64Encoder = new BASE64Encoder();
base64Decoder = new BASE64Decoder();
}
这个静态块在类加载时执行,初始化了 Base64 编码和解码的实例。
5. 获取公钥与私钥的方法
public static PublicKey getPublicKey(String pub_key) throws Exception {
...
}
public static PrivateKey getPrivateKey(String pri_key) throws Exception {
...
}
这两个方法从 Base64 编码的字符串中生成相应的公钥和私钥。
◆getPublicKey:
使用
X509EncodedKeySpec
创建一个用于装载公钥的数据结构。通过
KeyFactory
生成PublicKey
实例。
◆getPrivateKey:
使用
PKCS8EncodedKeySpec
创建一个用于装载私钥的数据结构。同样通过
KeyFactory
生成PrivateKey
实例。
6. 加密与解密方法
public static byte[] encrypt(byte[] encrypt_str) throws Exception {
...
}
public static byte[] decrypt(byte[] decrypt_str) throws Exception {
...
}
◆encrypt:
使用公钥加密提供的字节数组,初始化
Cipher
为加密模式。执行加密,并返回加密后的字节数组。
◆decrypt:
使用私钥解密提供的字节数组,初始化
Cipher
为解密模式。执行解密,返回解密后的字节数组。
7. 将字节数组转换为十六进制字符串
public static String bytesToHex(byte[] bytes) {
...
}
这个方法将字节数组转换为其对应的十六进制字符串,以便于输出和查看。
8. 主方法
public static void main(String[] args) throws Exception {
String str_base64 = "0123456789";
byte[] ec_cipher = encrypt(str_base64.getBytes());
System.out.println("明文:" + str_base64);
System.out.println("密文:" + bytesToHex(ec_cipher));
byte[] deByteStr = decrypt(ec_cipher);
String deString = new String(deByteStr);
System.out.println("解密结果:" + deString);
}
主方法中:
str_base64
。encrypt
方法对明文进行加密,输出密文的十六进制表示。decrypt
方法对密文进行解密,并输出解密后的结果。在base64版中如果我们想要通过HOOK获取明文,你仔细看可以发现应当通过HOOK函数X509EncodedKeySpec来获取到其参数,而这个参数便是我们要获取的明文信息。
hex版:
package com.java.Reverse;
/*
RsaHex类演示了RSA加密解密的过程,包括密钥对生成、加密、解密等操作。*/
import javax.crypto.Cipher; import java.math.BigInteger; import java.security.*; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.RSAPrivateKeySpec; import java.security.spec.RSAPublicKeySpec;
public class RsaHex {
// 定义公钥和私钥的参数
public static BigInteger publicN;
public static BigInteger publicE;
public static BigInteger privateN;
public static BigInteger privateD;
/**
* 加密方法,使用公钥加密字节数组
* @param encrypt_str 需要加密的字符串
* @return 加密后的字节数组
* @throws Exception 加密过程中可能出现异常
*/
public static byte[] encrypt(String encrypt_str) throws Exception {
// 创建公钥对象
PublicKey publicKey = createPublicKey(publicN, publicE);
// 获取Cipher实例,指定加密算法、加密模式、填充方式
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
// 初始化操作:将密钥加载到Cipher中,并设置为加密模式
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// 执行加密操作
byte[] example = cipher.doFinal(encrypt_str.getBytes());
return example;
}
/**
* 解密方法,使用私钥解密字节数组
* @param decrypt_str 需要解密的字节数组
* @return 解密后的字节数组
* @throws Exception 解密过程中可能出现异常
*/
public static byte[] decrypt(byte[] decrypt_str) throws Exception {
// 创建私钥对象
PrivateKey privateKey = createPrivateKey(privateN, privateD);
// 获取Cipher实例,指定加密算法、加密模式、填充方式
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
// 初始化操作:将密钥加载到Cipher中,并设置为解密模式
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// 执行解密操作
byte[] decode = cipher.doFinal(decrypt_str);
return decode;
}
/**
* 创建公钥对象
* @param bigIntegerN 公钥参数N
* @param bigIntegerE 公钥参数E
* @return 公钥对象
* @throws Exception 创建过程中可能出现异常
*/
public static PublicKey createPublicKey(BigInteger bigIntegerN, BigInteger bigIntegerE) throws Exception {
// 创建RSAPublicKeySpec对象,指定公钥参数N和E
RSAPublicKeySpec spec = new RSAPublicKeySpec(bigIntegerN, bigIntegerE);
// 创建KeyFactory对象,指定算法为RSA
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// 生成公钥对象
return keyFactory.generatePublic(spec);
}
/**
* 创建私钥对象
* @param bigIntegerN 私钥参数N
* @param bigIntegerD 私钥参数D
* @return 私钥对象
* @throws Exception 创建过程中可能出现异常
*/
public static PrivateKey createPrivateKey(BigInteger bigIntegerN, BigInteger bigIntegerD) throws Exception {
// 创建RSAPrivateKeySpec对象,指定私钥参数N和D
RSAPrivateKeySpec spec = new RSAPrivateKeySpec(bigIntegerN, bigIntegerD);
// 创建KeyFactory对象,指定算法为RSA
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// 生成私钥对象
return keyFactory.generatePrivate(spec);
}
/**
* 将字节数组转换为十六进制字符串
* @param bytes 需要转换的字节数组
* @return 转换后的十六进制字符串
*/
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public static void rsaKeyGenerator(int byteSize, String strValues) throws Exception {
// 选择算法为RSA
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
// 设置密钥长度位数
keyPairGenerator.initialize(byteSize);
// 生成密钥对
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// 获取公钥和私钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
// 打印公钥和私钥的参数
publicN = publicKey.getModulus();
publicE = publicKey.getPublicExponent();
privateN = privateKey.getModulus();
privateD = privateKey.getPrivateExponent();
System.out.println("公钥N:" + publicN);
System.out.println("公钥E:" + publicE);
System.out.println("私钥N:" + privateN);
System.out.println("私钥D:" + privateD);
byte[] encrypt_str = encrypt(strValues);
System.out.println("明文:" + strValues);
System.out.println("加密后:" + bytesToHex(encrypt_str));
byte[] decrypt_base = decrypt(encrypt_str);
String decrypt_str = new String(decrypt_base);
System.out.println("解密后:" + decrypt_str);
}
public static void main(String[] args) throws Exception{
String str_base64 = "0123456789";
rsaKeyGenerator(2048, str_base64);
}
}
还是一样,我们下面将对代码的详细逐部分解释:
1. 导入和包声明
package com.java.Reverse;
/*
RsaHex类演示了RSA加密解密的过程,包括密钥对生成、加密、解密等操作。
*/
import javax.crypto.Cipher;
import java.math.BigInteger;
import java.security.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPrivateKeySpec;
import java.security.spec.RSAPublicKeySpec;
javax.crypto
和java.security
包来处理加密解密和密钥生成相关的操作。2. 属性定义
public static BigInteger publicN;
public static BigInteger publicE;
public static BigInteger privateN;
public static BigInteger privateD;
◆定义了四个
类型的静态属性,用来存储公钥和私钥的参数:
publicN
: 公钥的模数 NpublicE
: 公钥的指数 EprivateN
: 私钥的模数 NprivateD
: 私钥的私有指数 D
3. 加密方法
public static byte[] encrypt(String encrypt_str) throws Exception {
PublicKey publicKey = createPublicKey(publicN, publicE);
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] example = cipher.doFinal(encrypt_str.getBytes());
return example;
}
◆encrypt
方法用于使用公钥对字符串进行加密。
◆createPublicKey
方法被调用创建公钥对象。
◆使用 Cipher 类实例化 RSA 算法,并指定加密模式和填充方式。
◆数据被加密并以字节数组形式返回。
4. 解密方法
public static byte[] decrypt(byte[] decrypt_str) throws Exception {
PrivateKey privateKey = createPrivateKey(privateN, privateD);
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decode = cipher.doFinal(decrypt_str);
return decode;
}
◆decrypt
方法用于使用私钥对字节数组进行解密。
◆类似于加密过程,首先创建私钥对象,并对子串进行解密,最后返回解密后的数据。
5. 创建公私钥对象
public static PublicKey createPublicKey(BigInteger bigIntegerN, BigInteger bigIntegerE) throws Exception {
RSAPublicKeySpec spec = new RSAPublicKeySpec(bigIntegerN, bigIntegerE);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(spec);
}
public static PrivateKey createPrivateKey(BigInteger bigIntegerN, BigInteger bigIntegerD) throws Exception {
RSAPrivateKeySpec spec = new RSAPrivateKeySpec(bigIntegerN, bigIntegerD);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(spec);
}
◆createPublicKey
和createPrivateKey
方法创建 RSA 公钥和私钥对象。接收 N 和 E 或 N 和 D 作为参数,使用KeyFactory
生成密钥对象。
6. 字节数组转十六进制字符串
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
◆bytesToHex
方法将字节数组转换为十六进制字符串,便于输出加密后的数据。
7. 密钥生成与加密解密示例
public static void rsaKeyGenerator(int byteSize, String strValues) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(byteSize);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
publicN = publicKey.getModulus();
publicE = publicKey.getPublicExponent();
privateN = privateKey.getModulus();
privateD = privateKey.getPrivateExponent();
System.out.println("公钥N:" + publicN);
System.out.println("公钥E:" + publicE);
System.out.println("私钥N:" + privateN);
System.out.println("私钥D:" + privateD);
byte[] encrypt_str = encrypt(strValues);
System.out.println("明文:" + strValues);
System.out.println("加密后:" + bytesToHex(encrypt_str));
byte[] decrypt_base = decrypt(encrypt_str);
String decrypt_str = new String(decrypt_base);
System.out.println("解密后:" + decrypt_str);
}
rsaKeyGenerator
方法用于生成 RSA 密钥对,获取公钥和私钥的 N 和 E 或 D,并打印。8. 主方法
public static void main(String[] args) throws Exception{
String str_base64 = "0123456789";
rsaKeyGenerator(2048, str_base64);
}
main
方法是程序的入口点,示例调用rsaKeyGenerator
方法,生成 2048 位的密钥并使用测试字符串进行加密和解密。现在对于RSA加密算法的两种不同写法有一定了解了对吧!那么在这过程中,大家应该都好奇过为什么可以做到一把公钥和一把私钥可以做到公钥加密而公钥无法解密,只能私钥解密这个问题吧?
RSA的安全性主要基于大数分解问题。为了我们大家更好的理解为什么在RSA中公钥用于加密而私钥用于解密,我们可以从以下几个方面进行分析:
第一步我们需要选择两个大质数 p 和 q。
第二步计算n,计算n的公式为n=p×q,这个 n 将被用作公钥和私钥的一部分。
第三步计算欧拉函数,我们不需要知道欧拉函数是怎么样推导过来的,我们只需要知道欧拉函数的公式为ϕ(n)(欧拉函数)=(p−1)×(q−1)。
第四步选择公钥e,选择公钥e要满足三个条件,分别是公钥e必须是质数、公钥e必须是大于1并且小于ϕ(n) 的整数、公钥e必须与ϕ(n)互质,也就是公钥e不能是ϕ(n)的因子。通过这些条件的筛选就可以得到一些数字,就可以在这些数字里进行挑选作为公钥e。
第五步计算私钥d,计算私钥d只需要满足一个公式,也就是(d * e) % ϕ(n) = 1。
最终生成的公钥为(n,e),而私钥为(n,d)。
我们有了公钥和私钥,就可以简单的模拟加解密的过程了,假设我们有明文m,使用公钥 (n,e) 进行加密,生成密文 c。那么加密过程可以简化为公式c = (m^e) % n。
而解密呢?解密使用私钥(n,d)进行解密,恢复明文m。解密过程可以简化为公式m = (c^d) % n 。
我们回顾制作公私钥对和非对称加解密的过程,这个算法不想被破解核心点就在不能让人知道私钥中的数字d,而要想知道d就需要e和ϕ(n),因为e是公钥,所以e是被公开的,因此ϕ(n)是算出d的重要数字,而我们要算出ϕ(n)就需要p和q两个大质数,核心点就在于这两个大质数了,为什么叫它们大质数,因为只有当p和q这两个质数足够大时,不管ϕ(n)还是n都会变得十分的大,这样计算出公钥对应的私钥才会变得异常困难。
现在我们了解了加解密常用的算法的实现原理以及Java代码如何使用算法进行加密,那学习了加解密那我们需要去追算法,其实追算法在之前就玩过,这次再讲这个未免有点太无聊了,所以我们要学习一个十分常用的技能来帮助我们追算法,那就是大名鼎鼎的Hook!
想要了解Hook,Hook 英文翻译过来就是钩子的意思,那我们就绕不开Xposed,而Xposed 框架是一个运行在 Android 操作系统之上的钩子框架,用钩子来进行表示是十分形象的,它能够在事件开始到事件结束这个期间进行截获并监控事件的传输,以及可以对事件进行自定义的处理。
Hook技术可以让一个程序的代码“植入”到另一个程序的进程中,成为目标程序的一部分。API Hook技术可以改变系统API函数的执行结果,让它执行重定向。在Android系统中,普通用户程序的进程是独立的,相互不干扰。这就意味着我们不能直接通过一个程序来改变其他程序的行为。但是有了Hook技术,我们可以实现这个想法。根据Hook对象和处理事件的方式不同,Hook还可以分为不同的种类,比如消息Hook和API Hook。
Xposed 框架的主要作用是允许用户在不修改 APK 文件的情况下定制和修改系统和应用程序的行为。通过使用 Xposed 框架,用户可以安装各种模块来实现一些定制化的功能,比如修改系统界面、增强应用程序功能、屏蔽广告等。这些模块利用 Xposed 框架提供的钩子机制来实现对系统和应用程序的修改和定制。只可惜现在Xposed框架已经不再维护了,仅支持2.3-8.1的安卓版本,但还好有一些的衍生框架可以支持,如EDXposed、LSPosed、VirtualXposed、太极、两仪、天鉴等。
但是不管怎么样,衍生框架都是基于Xposed的,我们这还是主讲Xposed,下面将开始讲如何使用Xposed框架。
如果想要看Xposed官方文档,可以看这篇文章:
Development tutorial · rovo89/XposedBridge Wiki (github.com):https://github.com/rovo89/XposedBridge/wiki/Development-tutorial#definingmodules
如果觉得英文看得费劲,那可以看这篇文章:
Xposed模块开发入门保姆级教程 - 狐言狐语和仙贝的魔法学习记录 (ketal.icu):https://blog.ketal.icu/cn/Xposed%E6%A8%A1%E5%9D%97%E5%BC%80%E5%8F%91%E5%85%A5%E9%97%A8%E4%BF%9D%E5%A7%86%E7%BA%A7%E6%95%99%E7%A8%8B/
如果还觉得环境好麻烦啊!我不想准备环境,那可以看这篇文章:
《安卓逆向这档事》七、Sorry,会Hook真的可以为所欲为-Xposed快速上手(上)模块编.. - 『移动安全区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn
如果想对Xposed源码及其原理更加深入一点的了解,可以看这篇文章,包括我下面关于Xposed的原理也是参考于随风而行aa大佬的这篇文章:
[原创]源码编译(3)——Xposed框架定制:https://bbs.kanxue.com/thread-269627.htm#msg_header_h2_2
环境配置什么的我就省略了,我们直接步入正题,开始尝试Hook,以下使用的是Lsposed进行的Hook。
遇到某些情况怎么怎么解决,其他博主那也有写,我就不多做介绍了,我们直接进行实战,在实战中了解那些代码的作用以及该怎么灵活运用。
开始还是用个熟悉的APP来做演示,那就觉得是你了——X嘟牛!之前关于X嘟牛登录相关的加解密已经讲解过了,这次就不多赘述,只截取X嘟牛中的相关代码,如果想要了解之前的加解密讲解可以移步去此处:
安卓逆向基础知识之动态调试以及安卓逆向的那些常规手段:https://bbs.kanxue.com/thread-279978.htm
好了,废话就先讲到这,接下来就正式进入实战,让我们一起来揭开Hook的神秘面纱!
当我们准备好环境后,我们先新建一个名为Hook01的hook类,该类最开始的Hook代码就只是这样的:
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class Hook01 implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
}
}
Hook01类新建完成后,我们的Hook操作都在这个类里面完成。首先我们是要对X嘟牛进行Hook,最好是将其他APP排除在外,只对X嘟牛进行Hook。因为我们新建的Hook01类实现了接口IXposedHookLoadPackage,并且实现了该接口中关键的方法handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam),而该方法会在每个应用程序启动时被调用,因此我们需要通过目标包名进行筛选。所以我们可以通过对包名进行判断,从而将X嘟牛之外的APP都排除在外。
if (loadPackageParam.packageName.equals("com.dodonew.online")) {
// 如果目标包名是"com.dodonew.online"
}
但是我不知道为什么我的环境中这么写不会执行if语句中的内容,所以我一般只能这么写:
if (!loadPackageParam.packageName.equals("com.dodonew.online")) {
return;
}
但是这就会出现一个问题,那岂不是我每要hook一个APP,那岂不是都要重新创建一个新的项目?不需要如此麻烦,完成Xposed环境搭建的小伙伴们还记不记得在main下新建的assets文件夹,我们是需要在assets目录下新建xposed_init,在里面写上hook类的完整路径(包名+类名),而xposed_init中可以定义多个类,每一个类写一行,如下图所示:
这样我们就解决了每要hook一个APP就需要重新创建一个新的项目的问题。接下来我们该思考如何对X嘟牛进行Hook,在一般的情况下我们应该对APP进行查壳,看这个APP有没有壳,但这次不是一般的情况,因为知道X嘟牛是没有进行加壳的,那如果我们遇到加壳的APP除了进行脱壳之外还有什么办法吗?诶!还真有,只不过不能应对所有的壳,只能也能应对绝大多数的免费壳了。我们可以使用以下代码来解决绝大多数的加壳:
XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
Context context = (Context) param.args[0];
ClassLoader classLoader = context.getClassLoader();
//hook逻辑在这里面写
}
});
以上代码主要用于在 Android 应用的生命周期内对Application
类的attach
方法进行 Hook 操作。attach方法是Application
类中的一个方法。这是一个内部方法,用于初始化Application
实例并设置其上下文。
public class Application extends ContextWrapper implements ComponentCallbacks2 {
// ......
// ------------------ Internal API ------------------
/**
* @hide
*/
@UnsupportedAppUsage
/* package */ final void attach(Context context) {
attachBaseContext(context);
mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;
}
// ......
}
可以看到我们对attach方法进行Hook主要是获取param.args[0]
,而param.args[0]是attach
方法的第一个参数,即上下文对象Context
。随后,通过Context获取ClassLoader,ClassLoader
是一个用于加载类的对象,可以在后面的 Hook 逻辑中使用。
这种方式不仅仅可以应对加壳这种情况,我们还可以通过这种方式Hook多个dex文件,因为一个dex文件最多只能存在65535个方法,如果超过这个数量就会重新生成一个新的dex文件。所以正常情况下APP可能会有多个dex文件,如果我们要hook的方法不是在第一个dex文件中,我们就需要通过这种方式将需要Hook的方法的类加载进来,然后再进行Hook操作。
但是这个用于测试的这个X嘟牛既没有加壳也没有多个dex文件,那这里就不需要如此这般去获取类的加载器,在之前讲动态调试的那篇帖子中分析过X嘟牛,它在对称加密之前使用过MD5消息摘要算法,并将MD5处理过后的结果存入Map中的“sign”键值对中,那我们第一步hook的便是X嘟牛中的这个MD5消息摘要算法:
public static String md5(String string) {
byte[] arr_b;
try {
MessageDigest messageDigest0 = MessageDigest.getInstance("MD5");
messageDigest0.update(string.getBytes());
arr_b = messageDigest0.digest();
}
catch(NoSuchAlgorithmException e) {
throw new RuntimeException("Huh, MD5 should be supported?", e);
}
StringBuilder hex = new StringBuilder(arr_b.length * 2);
int v;
for(v = 0; v < arr_b.length; ++v) {
byte b = arr_b[v];
if((b & 0xFF) < 16) {
hex.append("0");
}
hex.append(Integer.toHexString(b & 0xFF));
}
return hex.toString();
}
这里我们就需要通过Xposed进行Hook来获取md5方法的参数和返回值,我们可以看到md5方法的参数只有一个字符串类型的参数,返回值的类型也同为字符串类型,那么我们可以如此写Hook代码:
XposedHelpers.findAndHookMethod("com.dodonew.online.util.Utils", loadPackageParam.classLoader, "md5", String.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Log.i("hhh123",param.args[0].toString());
printStackTrace();
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Log.w("hhh123", param.getResult().toString().toUpperCase());
}
});
1.XposedHelpers.findAndHookMethod(...)
:
◆这个方法用于查找并装入一个指定的方法,并可以在其被调用前和调用后执行自定义的操作。
2.参数说明:
"com.dodonew.online.util.Utils": 这是要hook的目标类的完整名称。Xposed会在运行时搜索这个类。
loadPackageParam.classLoader: 这是应用的类加载器,它用于加载该目标类。每个Android应用都会有自己的类加载器。
"md5": 这就是我们想要hook的方法名。在这个场合下,目标方法是计算MD5的函数。
String.class: 这个参数指定了目标方法的参数类型。这里我们知道md5方法接受一个String类型的参数。
3.new XC_MethodHook():
4.beforeHookedMethod(MethodHookParam param):
此方法在hook的目标方法被调用之前执行。 param.args[0].toString(): 获取方法的第一个参数并转换为字符串形式,以便后续的记录和调试。 Log.i("hhh123", ...): 记录一个信息级别的日志,便于调试。 printStackTrace(): 调用此方法以打印当前调用栈,帮助分析方法调用的上下文。
5.afterHookedMethod(MethodHookParam param):
此方法在hook的目标方法调用之后执行。 param.getResult(): 获取目标方法的返回结果。 Log.w("hhh123", ...): 记录一个警告级别的日志,输出返回结果并转换为大写形式,便于查看和比较。
看完了这段hook代码,你们可能会认为printStackTrace()方法是系统自带的用于打印当前调用栈的方法,然而并不是,这个是一个自定义的方法,以下是printStackTrace()方法的完整代码:
// 定义一个私有的静态方法,用于打印当前线程的调用栈信息
private static void printStackTrace() {
// 创建一个新的Throwable对象,以获得当前的堆栈跟踪
Throwable ex = new Throwable();
// 获取当前调用堆栈的元素数组
StackTraceElement[] stackElements = ex.getStackTrace();
// 遍历每一个堆栈元素
for (StackTraceElement element : stackElements) {
// 打印每个堆栈元素的信息,包括类名、方法名、文件名和行号
Log.d("hhh123", "at " + element.getClassName() + "." +
element.getMethodName() + "(" +
element.getFileName() + ":" +
element.getLineNumber() + ")");
}
}
那么我们写完了这个Hook代码,那么我们运行下看看效果:
我们可以从上图看出蓝色的日志为md5方法的参数,黄色的日志为md5方法的返回值,而绿色的日志就是md5方法当前的调用栈,这个栈怎么看呢?很简单,你看栈最上面那个方法是不是很熟悉,没错那就是我们写的打印当前调用栈的printStackTrace方法;再往下看还能看到熟悉的身影,比如beforeHookedMethod方法,那么我们Hook的那个方法是哪个呢?其实就是调用栈中显示的LSPHooker_.md5,在这个方法的上面是LSPosed调用的方法不用管,在这个方法的下面才是有价值的信息。
我们可以通过调用栈看到调用md5方法的是com.dodonew.online.http.RequestUtil类下的paraMap方法,大家可能对之前分析的paraMap方法没有多少印象了,以下是paraMap方法的代码:
/**
* 将给定的映射(Map)转换为带有签名的字符串
*
* @param map0 需要处理的输入映射
* @param append 用于签名的附加字符串
* @param sign 签名字段(虽然这个参数在当前版本中没有直接使用)
* @return 处理后的JSON字符串,包含签名
*/
public static String paraMap(Map map0, String append, String sign) {
try {
// 获取map0的键集
Set set0 = map0.keySet();
StringBuilder builder = new StringBuilder(); // 用于构建最终的字符串
ArrayList list = new ArrayList(); // 用于存放格式化的键值对
// 遍历每个键,构建 "key=value" 的格式,并存入list
for(Object object0: set0) {
String keyName = (String)object0; // 键转换为字符串
list.add(keyName + "=" + ((String)map0.get(keyName))); // 获取值并构造 "key=value"
}
// 将键值对按字母顺序排序
Collections.sort(list);
// 将排序后的键值对添加到StringBuilder中,并用"&"连接
int i;
for(i = 0; i < list.size(); ++i) {
builder.append(((String)list.get(i))); // 添加键值对
builder.append("&"); // 使用"&"连接
}
// 在字符串末尾追加 "key=" 和附加字符串
builder.append("key=" + append);
String s3 = builder.toString(); // 将构建的字符串转换为最终字符串
Log.d("CustomTag", s3); // 输出调试信息
// 计算字符串s3的MD5值,并转为大写形式
String s4 = Utils.md5(s3).toUpperCase();
Log.d("CustomTag", s4); // 输出签名调试信息
// 将计算得到的签名存入map0
map0.put("sign", s4);
// 将map0按键排序后转换为JSON格式的字符串
String s5 = new Gson().toJson(RequestUtil.sortMapByKey(map0));
Log.w("yang", s5 + " result"); // 输出最终结果
return s5; // 返回处理后的JSON字符串
}
catch(Exception e) {
e.printStackTrace(); // 捕捉异常并打印堆栈跟踪
return ""; // 在出现异常时返回空字符串
}
}
我们可以看到以上代码中对拼接后的URL参数字符串进行MD5加密,并将加密结果存入Map中的“sign”键值对中。那么我们接下来就Hook这个方法,来获取这个方法的参数和返回值。
// 找到指定的类,使用类加载器加载
final Class<?> clazz = XposedHelpers.findClass("com.dodonew.online.http.RequestUtil", loadPackageParam.classLoader);
// 钩住 paraMap 方法,参数为 Map、String、String 类型
XposedHelpers.findAndHookMethod(clazz, "paraMap", Map.class, String.class, String.class, new XC_MethodHook() {
// 方法执行之前的钩子
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param); // 调用父类方法
// 遍历方法参数,并将其打印到日志中
for (int i = 0; i < param.args.length; i++) {
Log.i("hhh123", param.args[i].toString()); // 记录每个参数的字符串表示
}
}
// 方法执行之后的钩子
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param); // 调用父类方法
// 获取并打印方法返回结果到日志中
Log.w("hhh123", param.getResult().toString()); // 记录返回结果的字符串表示
}
});
这一次还是使用XposedHelpers.findAndHookMethod方法来Hook,这个方法参数要么需要类名和类加载器,要么就需要给它类的class对象,除此之外还需要提供要Hook方法的参数类型,那么有些参数的类型是自定义类型那又该如何应对呢?其实有办法可以解决这个问题,就是会要摒弃这种精准打击,而是直接进行火力覆盖,至于具体如何实现我们稍后揭晓。
我们先来看看hook成功后得到的参数和返回值吧!
我们成功的获取到了paraMap方法的参数和返回值,你看到现在可能觉得一直在获取参数和返回值有些无聊了,那我们去玩点有意思的,至于是什么有意思的,我们继续往下走就知道了。
运行完paraMap方法后,X嘟牛会把paraMap方法的返回值进行Des加密,那么我们先来回顾一下这个方法:
/**
* 解码给定的加密 JSON 字符串,使用 DES 解密算法。
*
* @param json 需要解密的加密 JSON 字符串。
* @param desKey 用于 DES 解密的秘密密钥。
* @param desIV 用于 DES 解密的初始化向量。
* @return 解密后的 JSON 字符串,如果解密成功;如果输入字符串为空或发生异常,则返回原始 JSON 字符串。
*/
public static String decodeDesJson(String json, String desKey, String desIV) {
// 检查输入的 JSON 字符串是否为空或为 null
if (TextUtils.isEmpty(json)) {
return json; // 返回原始输入的 JSON 字符串
}
try {
// 创建 DesSecurity 实例,使用给定的密钥和 IV,解密 JSON 字符串并以 UTF-8 编码返回
return new String(new DesSecurity(desKey, desIV).decrypt64(json), "UTF-8");
} catch (Exception e) {
// 如果解密过程中出现任何异常,打印异常堆栈信息
e.printStackTrace();
return json; // 在发生异常时返回原始 JSON 字符串
}
}
/**
* 使用 DES 加密算法对给定的数据字符串进行编码(加密)。
*
* @param data 需要加密的数据字符串。
* @param desKey 用于 DES 加密的秘密密钥。
* @param desIV 用于 DES 加密的初始化向量。
* @return 加密后的数据字符串,以 Base64 格式编码;如果发生异常,则返回空字符串。
*/
public static String encodeDesMap(String data, String desKey, String desIV) {
try {
// 创建 DesSecurity 实例,使用 UTF-8 编码将数据转换为字节,然后进行加密,返回 Base64 编码的字符串
return new DesSecurity(desKey, desIV).encrypt64(data.getBytes("UTF-8"));
} catch (Exception e) {
// 如果加密过程中出现任何异常,打印异常堆栈信息
e.printStackTrace();
return ""; // 在发生异常时返回空字符串
}
}
/**
* 将数据字符串编码为一个 Map,其中值为加密后的数据。
*
* @param data 需要加密的数据字符串。
* @param mpKey 加密数据在 Map 中的存储键。
* @param desKey 用于 DES 加密的秘密密钥。
* @param desIV 用于 DES 加密的初始化向量。
* @return 一个包含加密数据的 HashMap;如果发生异常,则返回一个空 Map。
*/
public static Map encodeDesMap(String data, String mpKey, String desKey, String desIV) {
// 创建一个 HashMap 以存储加密后的数据
HashMap map = new HashMap();
try {
// 加密数据字符串,并将结果存储在 Map 中,键为 mpKey
map.put(mpKey, new DesSecurity(desKey, desIV).encrypt64(data.getBytes()));
} catch (Exception e) {
// 如果加密过程中出现任何异常,打印异常堆栈信息
e.printStackTrace();
}
// 返回 HashMap,可能为空(如果发生异常)
return map;
}
在RequestUtil类下一共有三个关于DES加解密的方法,我们要Hook的是第二个Des加密方法,这里进行Hook我们依旧可以使用之前的那种方式进行Hook,但是一直使用那种方式是无聊的,经过我测试之后,我们这次就来进行火力覆盖,甭管它多少个参数,参数是什么奇奇怪怪的类型,它都能Hook到。
还记得JAVA当中有一个概念叫做重载的吗?所谓的重载其实就是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。而在以上代码中encodeDesMap方法即是重载方法,而我们要实现火力覆盖其实就是不管方法的参数和返回值是多少,只要方法名字是对的,那就会全Hook了,也可以说是会把指定方法的重载方法全部Hook了。
我测试过了,参数为四个String类型,返回值为Map类型的那个encodeDesMap方法并未被调用,我们可以直接进行火力覆盖来作为演示,在这之前我们再多干点事情,encodeDesMap方法会使用 DES 加密算法对给定的数据字符串进行加密,那么我们在encodeDesMap方法执行之后,就调用decodeDesJson方法把加密后的结果进行解密,来看看加密后再用自己的方法进行解密,结果是不是一致。
// 获取指定类的引用,这里是 com.dodonew.online.http.RequestUtil 类
final Class<?> clazz = XposedHelpers.findClass("com.dodonew.online.http.RequestUtil", loadPackageParam.classLoader);
// Hook 对象所有方法,特别是 encodeDesMap 方法
XposedBridge.hookAllMethods(clazz, "encodeDesMap", new XC_MethodHook() {
// 在方法调用之前的回调
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param); // 调用父类方法
// 遍历调用方法时传入的所有参数,并打印每个参数的值到日志中
for (int i = 0; i < param.args.length; i++) {
Log.i("hhh123", param.args[i].toString()); // 使用 info 日志级别输出参数
}
}
// 在方法调用之后的回调
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param); // 调用父类方法
// 打印方法的返回结果到日志,使用 warn 日志级别
Log.w("hhh123", param.getResult().toString());
// 调用 decodeDesJson 静态方法,传入返回结果和两个参数
// 这里假设 decodeDesJson 方法会对结果进行某种解码处理
Object data = XposedHelpers.callStaticMethod(clazz, "decodeDesJson",
param.getResult().toString(),
param.args[1].toString(),
param.args[2].toString());
// 使用 error 日志级别输出解码后的数据
Log.e("hhh123", data.toString());
}
});
我们来看看结果是否如我们所愿:
可以从上图看出来,我们通过Hook所有的encodeDesMap方法也如愿得到了我们需要的那三个参数和返回值,而且我们在encodeDesMap方法调用之后调用 decodeDesJson 静态方法也成功的解密了密文,我们可以看到加密前和解密后的源数据是相同的。
在这一次实例中我们学会了如何Hook普通方法,学会了如何Hook带有复杂的、自定义的参数的方法,还学会了如何调用静态方法,这里还提一嘴,调用静态方法是使用XposedHelpers.callStaticMethod来实现,而调用实例方法其实也差不多:
// 查找指定类的 Class 对象
// "类名" 需要替换为你要查找的类的全名,例如 "com.example.MyClass"
// loadPackageParam.classLoader 是被注入应用的类加载器,用于加载类
Class clazz = XposedHelpers.findClass("类名", loadPackageParam.classLoader);
// 创建该类的一个新实例
// 注意:此处必须确保类具有公共的无参构造函数,否则会抛出异常
Object instance = clazz.newInstance();
// 调用该实例的指定方法
// "方法名" 应该替换为你想要调用的方法的名称
// 参数(非必须) 是可选的,可以传递一个或多个参数,如果方法不需要参数可以省略
XposedHelpers.callMethod(instance, "方法名", 参数(非必须));
X嘟牛我们已经完成Hook了,但是关于Hook可还没有结束,现在大家对于Hook也大概知道是个怎么回事了,那么我们可以去尝试写稍微复杂点的Hook代码了,接下来我们要Hook的程序是一个写入程序,它会把文件写入到一个地址去,而我们要做的就是找到这个程序它把文件写入到了哪里。
第一步我们还是需要对这个APP进行查壳,看是否有壳:
可以看到这个APP是有壳的,现在我们还对脱壳与修复知之甚少,所以我们只能使用对Application
类的attach
方法进行 Hook 操作来获取上下文对象,从而获取类加载器来应付加壳的情况。那我们赶紧试试是否有效吧!
那么我们第一步该怎么做呢?不急我们先来看看这个程序在写入时有什么特征:
我们可以看到它成功写入时会使用Toast.makeText方法在屏幕上显示短暂的提示信息,那么我们可以尝试将它作为突破口进行Hook。
// 实现 IXposedHookLoadPackage 接口的 Hook02 类,相当于在应用程序加载时进行钩子处理。
public class Hook02 implements IXposedHookLoadPackage {
// handleLoadPackage 方法在应用程序包被加载时调用。
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
// 检查加载的包名是否为 "com.PASSWPZZ"。如果不是,则提前返回,避免执行后续代码。
if (!loadPackageParam.packageName.equals("com.PASSWPZZ")) {
return;
}
// 钩住 Application 类中的 attach 方法,该方法在应用程序启动时被调用。
XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() {
// 在原始 attach 方法执行后调用此方法。
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
// 获取传递给 attach 方法的上下文。
Context context = (Context) param.args[0];
// 获取应用程序上下文的类加载器。
ClassLoader classLoader = context.getClassLoader();
// 使用类加载器加载 Toast 类,以便进行进一步的钩子处理。
Class<?> clazz = classLoader.loadClass("android.widget.Toast");
// 钩住 Toast 类中所有名为 "makeText" 的方法。
XposedBridge.hookAllMethods(clazz, "makeText", new XC_MethodHook() {
// 在原始 makeText 方法执行前调用此方法。
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param); // 调用父类方法以保持钩子链。
// 初始化标志变量,用于跟踪特定消息是否被检测到。
int a = 0;
// 遍历提供给 makeText 方法的所有参数。
for (int b = 0; b < param.args.length; b++) {
// 检查任何参数是否包含字符串 "写入成功"。
if (param.args[b].toString().contains("写入成功")) {
a = 1; // 如果找到,设置标志。
}
}
// 如果未找到特定消息,提前返回,不执行后续方法。
if (a != 1) {
return;
}
// 如果找到特定消息,调用打印堆栈跟踪的方法。
printStackTrace();
}
});
}
});
}
// 打印当前线程的堆栈跟踪的方法。
private static void printStackTrace() {
// 创建一个新的 Throwable 实例,以获取当前的堆栈跟踪。
Throwable ex = new Throwable();
// 从 Throwable 中获取堆栈跟踪元素。
StackTraceElement[] stackElements = ex.getStackTrace();
// 遍历堆栈跟踪元素并记录它们的信息。
for (StackTraceElement element : stackElements) {
Log.d("hhh123", "at " + element.getClassName() + "." + element.getMethodName() + "(" + element.getFileName() + ":" + element.getLineNumber() + ")");
}
}
}
我们尝试运行一下,看能否打印出我们要的堆栈信息:
我们可以从堆栈信息中看出调用传入参数包含写入成功的android.widget.Toast类下makeText方法的,是com.e4a.runtime.android.mainActivity类下的弹出提示方法,你们肯定好奇为什么这玩意的方法名还是中文,其实这个APP是用E4A写的,E4A又称易安卓,是一个快速上手操作简便的中文开发安卓程序的软件,所以有中文是正常的。
那么我们尝试把调用堆栈中有价值的方法进行Hook,我们需要先看看这些有价值的方法都有什么特征。可以看出有价值的方法所属类都有两个共同点,就是类名要么包含com.e4a.runtime或者要么包含com.PASSWPZZ,那我们可以获取堆栈跟踪元素后进行Hook,什么意思呢?其实就是把之前的打印堆栈信息中遍历堆栈跟踪元素后就不直接打印它们的信息了,而是获取到它们的类名和方法名,这样是不是就可以进行Hook了,为了确认没有Hook错方法,我们就把Hook了的堆栈跟踪元素的信息打印确认一下即可。这样是不是就可以获取到这些方法的参数和返回值了?事不宜迟,咱们说干就干!
// 创建一个新的 Throwable 实例,以获取当前的堆栈跟踪。
Throwable ex = new Throwable();
// 从 Throwable 中获取当前的堆栈跟踪元素。
StackTraceElement[] stackElements = ex.getStackTrace();
// 遍历堆栈跟踪元素
for (StackTraceElement element : stackElements) {
// 检查类名是否包含特定的包名
if (element.getClassName().contains("com.e4a.runtime") || element.getClassName().contains("com.PASSWPZZ")) {
// 记录当前堆栈信息,包括类名、方法名、文件名和行号
Log.d("hhh123", "at " + element.getClassName() + "." + element.getMethodName() + "(" + element.getFileName() + ":" + element.getLineNumber() + ")");
// 使用类加载器加载当前堆栈元素对应的类
Class<?> loadClass = classLoader.loadClass(element.getClassName());
// 钩住该类中的指定方法
XposedBridge.hookAllMethods(loadClass, element.getMethodName(), new XC_MethodHook() {
// 在被钩住的方法调用之前执行的方法
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
// 遍历方法参数并记录其值
for (int i = 0; i < param.args.length; i++) {
Log.w("hhh123", param.args[i].toString()); // 打印每个参数的值
}
}
// 在被钩住的方法调用之后执行的方法
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
// 记录方法返回结果
Log.e("hhh123", param.getResult().toString()); // 打印方法的返回值
}
});
}
}
让我们来看看运行以上代码得到的结果是否如我们所愿吧!
可以从上图看出以上的方法确实是我们需要Hook的有价值的方法,那这些方法的参数和返回值是怎样的呢?如下图所示:
可以从上图看出来这些方法的参数没有一个像文件存储路径的,那这该怎么办?我们是不是没办法解决了,小问题,既然这条路走不通,那就换条路走,比如说Hook写入文件的方法。
如果要Hook写入文件的方法,安卓的写入文件有好几种写入方式,我们不知道这个APP是使用的哪种写入文件的方式,这种情况我们也只能一种种方式去尝试,那么我们先了解一下Java的IO流。
开发者在Android中使用Java的IO流可以轻松地进行文件的读取和写入操作。对于本地文件系统,FileInputStream
和FileOutputStream
是常用的类,适用于处理各类文件。接下来我们对Java的IO流进行讲解:
1. IO流简介
IO流(输入/输出流)是Java中处理文件和数据流的一种方式,它提供了对数据的读取(输入)和写入(输出)的能力。
2. FileInputStream和FileOutputStream
◆FileInputStream
:用于从文件读取数据。它允许程序读取文件中的字节流。
◆FileOutputStream
:用于向文件写入数据。它允许程序向文件中写入字节流。
3. 优缺点分析
优点:
◆代码简单,易于理解:使用IO流的代码结构清晰,便于开发者快速上手。
◆可处理各种类型的文件:不仅支持文本文件,还可以用来处理图像、音频等其他类型的文件。
缺点:
◆小文件性能较差:在处理小文件时,由于频繁的打开和关闭文件,可能会导致性能下降。
◆手动管理异常:需要编写异常处理代码(如try-catch
),不当的处理可能导致资源泄漏或者程序崩溃。
4. 代码示例
以下是一段Java的IO流代码示例:
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class FileIOExample {
public static void main(String[] args) {
// 使用try-with-resources确保流会被自动关闭
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("input.txt"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))) {
byte[] buffer = new byte[1024]; // 创建一个1KB的缓冲区
int bytesRead;
// 读取文件中的数据到缓冲区,直到文件结束
while ((bytesRead = bis.read(buffer)) != -1) {
// 将缓冲区的数据写入到输出文件
bos.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
// 捕获并处理可能出现的IO异常
e.printStackTrace();
}
}
}
代码说明:
1.BufferedInputStream 和 BufferedOutputStream:
◆使用BufferedInputStream
和BufferedOutputStream
能够提高文件的读取和写入速度,通过使用缓冲区减少对文件的访问次数。
2.try-with-resources:
◆使用try-with-resources
语句,确保在代码块结束后,不论是否发生异常,都会自动关闭输入流和输出流,简化了资源管理。
3.缓冲区的使用:
◆创建一个byte
数组作为缓冲区,每次读取1024字节的数据,这样可以在一次I/O操作中处理更多数据,提高效率。
4.读取和写入:
◆通过循环不断读取输入流的数据,直到EOF(文件结束标志),并将读取的数据写入输出流,通过指定的bytesRead
参数确保只写入有效数据。
好了,现在我们对Java的IO流有了了解,现在大家应该知道我们需要Hook什么方法了吧!没错就是Hook构造方法,FileOutputStream类下的构造方法,这样我们就可以获取到写入文件的路径。
// 使用类加载器加载 java.io.FileOutputStream 类,以便进行进一步的钩子处理。
Class<?> clazz1 = classLoader.loadClass("java.io.FileOutputStream");
// 使用 XposedBridge 钩住所有构造函数,创建一个新的 XC_MethodHook 实例
XposedBridge.hookAllConstructors(clazz1, new XC_MethodHook() {
// 覆盖 beforeHookedMethod 方法,在构造函数执行前调用此方法
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param); // 调用父类的实现,以确保链的完整性
// 遍历构造函数的所有参数
for (int i = 0; i < param.args.length; i++) {
// 使用日志打印每一个参数的字符串表示,以便进行调试
Log.w("hhh123", param.args[i].toString()); // 打印每个参数的值,以便追踪文件输出流的创建过程
}
// 钩子执行时调用此方法,打印当前的方法调用堆栈信息,有助于调试
printStackTrace(); // 打印堆栈跟踪信息,帮助我们了解当前的调用路径
}
});
我们可以通过XposedBridge.hookAllConstructors方法来Hook指定类的所有构造函数,这里我们除了打印它的参数以外,我们还打印了堆栈信息,主要是想看看是什么方法调用了Java的IO流来写入文件。
我们打开日志一看,第一眼就看到了泛着黄光的文件路径,看来我们思路没有找错,那我们赶紧去看看这个路径下写入的文件吧!
看来我们确实找到了写入文件的路径,但我们Hook就这样结束了?不不不,我们还有些好玩的没有尝试,即使已经Hook到了我们要的内容。现在我们不仅有了写入文件的路径,还有了其堆栈信息,我们可以从堆栈信息中看出com.e4a.runtime.文件操作类是进行写入文件的主要类,那么接下来我们换一种方法来Hook写入文件的路径,我们可以通过此种方式Hook到指定类下的所有方法。
// 尝试加载 "com.e4a.runtime.文件操作" 类并记录其方法信息。
try {
// 使用 classLoader 加载指定的类
Class<?> loadClass = classLoader.loadClass("com.e4a.runtime.文件操作");
Log.d("hhh123", "Class found: " + loadClass.getName()); // 记录找到的类的名称
// 通过Java反射获取该类的所有声明的方法
Method[] methods = loadClass.getDeclaredMethods();
Log.d("hhh123", "Number of methods: " + methods.length); // 记录方法的数量
// 遍历并记录每个方法的名称
for (Method md : methods) {
Log.w("hhh123", "Method: " + md.getName()); // 记录方法名称
}
} catch (ClassNotFoundException e) {
// 当指定的类未找到时,捕获 ClassNotFoundException 并记录错误信息
Log.e("hhh123", "Class not found: " + e.getMessage());
} catch (Exception e) {
// 捕获其他异常并记录相关错误信息
Log.e("hhh123", "Error retrieving methods: " + e.getMessage());
}
我们在Hook到指定类下的所有方法之前,先看看指定类下类名、方法个数以及所有的方法名称,这样我们对类下的情况就更加的清楚了,这样写Hook代码时就更加的方便了。
我们可以看到文件操作类下有46个方法,还挺多的,接下来我们就需要用到java中的反射来Hook指定类下的所有方法。
// 加载 "com.e4a.runtime.文件操作" 类
Class<?> loadClass = classLoader.loadClass("com.e4a.runtime.文件操作");
// 通过Java反射获取所有方法
Method[] methods = loadClass.getDeclaredMethods();
// 遍历所有方法
for (Method md : methods) {
// 设置方法可访问,以便能够调用私有方法
md.setAccessible(true);
// 输出当前方法的名称,方便调试
Log.w("hhh123", md.getName());
// 钩住每个方法,记录参数和返回结果
XposedBridge.hookAllMethods(loadClass, md.getName(), new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
// 遍历方法参数并输出
for (int i = 0; i < param.args.length; i++) {
Log.w("hhh123", param.args[i].toString()); // 输出每个参数的值
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
// 输出方法返回结果,便于调试
Log.e("hhh123", param.getResult().toString()); // 输出返回结果
}
});
}
主要就是通过类加载器将类的class对象成功加载,这个类的class对象是可以给反射用的,这样就可以通过反射来获取类下的所有方法,从而帮助我们进行Hook代码的编写。
参数有很多,但是有价值的也就后面出现的写入文件的路径。这个方法虽然看起来比之前的麻烦的多,但这个方式只要你确定你要的东西在一个类中,就可以直接火力覆盖,管它什么牛鬼蛇神全Hook就行了。
看雪ID:黎明与黄昏
https://bbs.kanxue.com/user-home-926486.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多