你好,我是王昊天。

从这节课开始我们开启了新的模块——加密失败的学习。这是2021 OWASP TOP 10的排名第二的风险种类,与失效的访问控制相比,加密失败更多地体现为一种单点技术问题,通常是由于开发人员对加密过程使用不合理造成的

先来分享一个我的小故事:

作为一个Dota 2玩家,我有一台自己的Windows台式机,配置是i7-4790k + 16GB内存 + 256GB SSD。相信你能看出来,虽然这台机器在当时也算小“高端”配置,但到现在已经稍微有点力不从心了。

于是我便产生了更新硬件的想法——经过多次测试,我觉得性能下降的主要问题在于CPU温度,这一想法在我百度了“4790k 散热”之后更加坚定,于是我决定用水冷取代原来CPU自带的小风扇。在某东购置了水冷设备后,经过简单的安装,顺利开机。令人惊讶的一幕出现了:原来开机之后CPU温度是70℃,现在是99℃,由于温度过高开机不到20分钟就会自动关机。

为什么水冷会比风冷效果还差呢?是不是水冷设备没有工作?但我是按照说明书安装的,看起来呼吸灯也是亮的。看着一体化的水冷设备,没有任何有效的判断方式,我的内心是崩溃的。经过接近半天时间的不断调试和开机测试,最终我找到了问题——螺丝没有拧紧导致接触不严密,散热效果没有发挥出来。

分享这个故事,我是想说的是,在面对一个我们完全不了解的黑盒产品时,使用过程中出问题的可能性是很大的,加密失败这种安全风险往往就因此产生

加密是一个数学问题,应用到了开发场景。事实上,加密函数就像一个黑盒,开发人员能够考虑的只有输入和输出,其中输出还是非常复杂的。加密是否成功,极大地影响着系统的安全性,但是很多开发人员,对加密却没有深入研究。因此,只验证加密结果的正确性,却不验证加密结果的质量是不行的。接下来的几节课我们会重点讨论加密结果的质量问题。

加密失败

在国内的信息安全建设大背景下,系统的数据安全已经愈加重要,其中首先要考虑的就是数据的传输层和存储层的安全。这些环节中主要采用的保护方案就是加密,目前加密已经渗透到了开发的方方面面。也许这样描述你没有直观的感受,那么我们来看一些场景:

以上这些只是部分场景,可以看到,加密正在成为系统开发不可分割的一部分,那么接下来,我们来就了解一些典型的攻击场景:

1. 数据库加密
以MySQL为例,数据库可以通过其内部加密函数实现数据加密存储,然而在数据读取过程中由于经过自动解密过程,SQL注入这样的攻击就有可能获取到数据库中的明文。

2. 数据明文传输
以在企业内网搭建的系统为例,由于许多系统并未强制要求TLS,因此,如果攻击者可以监控内网流量,则有可能窃取到网络传输的敏感数据,包括登录凭据等。事实上,目前攻击者入侵内网设备的情况是很多见的,无论是通过脆弱的边界路由设备,或是经过存在漏洞的无线网络设备,以及通过鱼叉式网络钓鱼,攻击者一旦穿越企业的网络防御边界,便可通过嗅探、ARP欺骗等方式窃取网络数据,进而在内网横向移动。

3. 加密强度不够
在一些数据存储或者传输过程中,开发者在实现数据加密过程中仅仅“走流程”地进行了加密操作,然而如此加密的强度并不足以抵御攻击者的破解。

4. 弱HASH
在使用特定算法生成HASH结果的时候,如果HASH算法因为设计的缺陷,不能满足安全性需求,导致攻击者能够判断出原始输入,这是原像攻击(preimage attack);如果攻击者能够找到其他输入,并且生成同样的HASH输出,这是第二原像攻击(2nd preimage attack);如果攻击者能够找到多个输入,并且生成同样的HASH输出,这是生日攻击(birthday attack)。

5. 签名验证不当
在数据传输过程中,通信协议中会涉及数据段的签名,以此来保证数据的完整性和不可篡改性。在实际数据交互中,有时可能由于签名未认证,有时可能仅验证了签名的有效性,但并没有重新从数据段计算签名进行比对,这些问题都可能导致攻击者执行绕过。

RSA - 低加密指数攻击

RSA算法

在了解这种攻击类型之前,我们要先了解RSA算法,这是目前应用最广泛的非对称加密算法之一。

我们首先看公式。

plain_text = 明文,cipher_text = 密文,(n,e) = 公钥,(n,d) = 私钥
加密过程:plain_text ^ e ≡ cipher_text (mod n)
解密过程:cipher_text ^d ≡ plain_text (mod n)

对于低加密指数攻击,我们已知条件是:

我们的任务是根据已知条件获取明文(plain_text)。

作为攻击者,无论是我们是要尝试挖掘Web系统、二进制应用还是区块链系统或者是其他程序漏洞,都需要具备两点前提。一方面,你需要判断漏洞是否存在,这需要你熟悉目标系统的开发与设计过程,深刻理解特定功能的最佳实践。另一方面,你需要降低攻击向量空间,这就要求你足够了解目标系统的执行逻辑,在此基础上有目的地缩小测试范围。

对于低加密指数攻击案例而言,如果不理解RSA算法加密过程,我们就无法判断漏洞是否存在,这是漏洞挖掘黄金法则第一条;而执行数学变换进行判断的过程,就是在有效地缩小测试范围,降低攻击向量空间,这是漏洞挖掘法则第二条。这两条适用于许多漏洞挖掘场景。

因此这里我们要先了解RSA原理,以及在应用RSA算法过程中的最佳实践,以此来判断特定场景是否存在漏洞。

RSA算法工作过程

想象一个这样的场景——数学家小明有一段关键的信息,希望加密后发给历史学家小密。

经过商议,小明选择RSA算法加密传递,但是由于小明科研经费紧张,买不起电脑,于是通过手动计算的方式来执行RSA算法。这里小明的主要目标是计算出RSA算法的关键参数——n、e、d,我们看看他需要经历哪些步骤。

第一步,小明首先随机选择了两个不相等质数(prime number),p1和p2;p1 = 23,p2 = 71(实际应用中p1和p2越大,破解难度就越高)。

第二步,小明通过计算p1和p2的乘积,这里得到了第一个关键参数n; n = p1 x p2 = 1633,这里n转换为2进制的长度就是我们通常意义上描述的密钥长度。

第三步,小明需要获得第二个关键参数e: φ(n) = (p1-1) x (p2-1) = 1540,在1~φ(n)之间随机选取一个整数使其与φ(n)互质,即得到第二个关键参数e,e = 19。

第四步,他可以通过模逆元计算得出三个关键参数d,需要满足的条件是 e x d ≡ 1 (mod φ(n))。

现在,小明已经获得所有关键参数,他此时只需要将p1、p2销毁,自己留存(n,d)组成的私钥,并将(n,e)组成的公钥发给小密即可 。

这里我们来分析一下RSA算法的安全性,由于公钥信息是公开的,因此我们可以认为n和e是已知的,那么是否存在一种可能性是在已知n与e的情况下推导出d呢?这里我们首先要分析d的计算过程:

  1. 根据RSA算法工作过程的第四步,我们可以知道e与φ(n)是计算出d的前提,e是已知的;
  2. 根据RSA算法工作过程的第三步,我们可以知道p1与p2是计算出φ(n)的前提,而n = p1 x p2;
  3. 那么私钥安全性就依赖于n因数分解的难度。目前,数学界对于大整数因数分解并没有很好的解决方案,因此RSA的安全性得以保证。

案例实战

接下来我们进入实战环节。登录谜团(mituan.zone)并选择【RSA - 低加密指数攻击】环境,启动后可以在home目录找到flag.enc以及pubkey.pem两个文件。

total 16
-rw-rw-r--@ 1 hunter  staff  512  6  2  2019 flag.enc
-rw-rw-r--@ 1 hunter  staff  796  6  2  2019 pubkey.pem

通过调用OpenSSL对pubkey.pem进行解析:

openssl rsa -pubin -text -modulus -in pubkey.pem
Public-Key: (4096 bit)
...
Exponent: 3 (0x3)
Modulus=B0BEE5E3E9...
...

可以得到n和e,其中n = Modulus、e = Exponent,这里我们将数值带入后,再看一下加密公式:

plain_text ^ e ≡ cipher_text (mod n)
其中e、n、cipher_text均是已知的,进行一下简单的格式变换可以得出
plain_text = (kn + (cipher_text mod n)) ^ 1/3

有趣的事情出现了,在e数值很小的情况下,我们是可以尝试暴力破解的。

接下来我们通过代码来实现暴力破解明文:

import os, time
import gmpy2

def main():
    start_time = 0
    c_time = 0

    n = 721059527572145959497866070657244746540818298735241721382435892767279354577831824618770455583435147844630635953460258329387406192598509097375098935299515255208445013180388186216473913754107215551156731413550416051385656895153798495423962750773689964815342291306243827028882267935999927349370340823239030087548468521168519725061290069094595524921012137038227208900579645041589141405674545883465785472925889948455146449614776287566375730215127615312001651111977914327170496695481547965108836595145998046638495232893568434202438172004892803105333017726958632541897741726563336871452837359564555756166187509015523771005760534037559648199915268764998183410394036820824721644946933656264441126738697663216138624571035323231711566263476403936148535644088575960271071967700560360448191493328793704136810376879662623765917690163480410089565377528947433177653458111431603202302962218312038109342064899388130688144810901340648989107010954279327738671710906115976561154622625847780945535284376248111949506936128229494332806622251145622565895781480383025403043645862516504771643210000415216199272423542871886181906457361118669629044165861299560814450960273479900717138570739601887771447529543568822851100841225147694940195217298482866496536787241

    k = 0

    c_path = os.getcwd()
    fname = c_path + "/flag.enc"

    print(fname)

    f = open(fname, 'rb')
    c = f.read()
    c_num = int.from_bytes(c, byteorder='big')

    mod_num = c_num % n

    print('n = ' + str(n))
    print('mod = ' + str(mod_num))

    start_time = int(time.time())

    while True:

        c_time = int(time.time())
        time_pass = c_time-start_time
        if (c_time - start_time) == 10:
            print("current k: " + str(k))
            start_time = c_time

        y = k * n + mod_num
        root_num, status = gmpy2.iroot(y,3)

        if status == 1:
            break
        else:
            k = k + 1
    print('plain_text = ' + str(root_num))

if __name__ == "__main__":
    main()

通过约300s的程序运行时间,在输出中可以获得plain_text的值:

plain_text = 440721643740967258786371951429849843897639673893942371730874939742481383302887786063966117819631425015196093856646526738786745933078032806737504580146717737115929461581126895844008044713461807791172016433647699394456368658396746134702627548155069403689581548233891848149612485605022294307233116137509171389596747894529765156771462793389236431942344003532140158865426896855377113878133478689191912682550117563858186

再通过代码将plain_text值转换为字符:

def main():
    plain_text = 440721643740967258786371951429849843897639673893942371730874939742481383302887786063966117819631425015196093856646526738786745933078032806737504580146717737115929461581126895844008044713461807791172016433647699394456368658396746134702627548155069403689581548233891848149612485605022294307233116137509171389596747894529765156771462793389236431942344003532140158865426896855377113878133478689191912682550117563858186

    plain_text_in_char = []

    while plain_text != 0:
        plain_text, c = divmod(plain_text, 256)
        plain_text_in_char.append(chr(c))

    plain_text_in_char.reverse()

    print(''.join(plain_text_in_char))

if __name__ == "__main__":
    main()

运行上述代码,可以得到如下输出:

Didn't you know RSA padding is really important? Now you see a non-padding message is so dangerous. And you should notice this in future.Fl4g: PCTF{Sm4ll_3xpon3nt_i5_W3ak}

可以看到我们已经成功破解了RSA加密,获取到了明文,即plain_text。

这里补充一个有趣的知识点,RSA属于块加密算法,与之相对应的是流加密。块加密是有一个padding机制的,正如输出结果中所述,这里能够破解成功的另一个主要原因是明文并没有采用padding来补齐块长度,如果明文的长度足够长,就会使得暴力破解的所需时间快速攀升,进而更有效地抵御攻击。

总结

这节课我们首先探讨了在产品开发过程中涉及加密算法的一些常见问题,并列举了一些典型的攻击场景。

接下来的实战案例环节,我们通过RSA算法的低加密指数攻击案例,学习了RSA加密算法的原理,在此基础上我们成功对一段RSA加密结果进行了攻击。通过这个实例可以发现即使是全球闻名的RSA算法,如果使用方式不当,也存在被破解的可能性。

这个实例其实很有意义,除了本身涉及到的加密知识以外,我们更需要知道的是如何针对一个黑盒系统进行漏洞挖掘,这里要记住两条漏洞挖掘黄金法则:一方面,你需要熟悉目标系统的开发与设计过程,深刻理解特定功能的最佳实践,从而判断漏洞是否存在;另外,你要足够了解目标系统的执行逻辑,有目的性地缩小测试范围,以此来降低攻击向量空间

加密失败风险的出现有很多原因,大部分与我们不合理地使用加密工具有关。那么我们该如何防御呢?

这里我们推荐一些相对抽象的安全建议,供你在工作中参考,具体一些需要详细讨论的部分,我们会在后面几节课程中陆续展开。

首先在数据层面,我们需要对数据进行分类分级,识别出需要重点保护的数据类型,并且不要存储不使用的敏感数据,不被存储的数据是不可能丢失的。

在存储层面,要关闭可能包含敏感数据的缓存功能,还要确保所有的敏感数据在静态存储中都以加密形态存放。

在传输层面,我们要确保所有数据传输协议都启用了安全功能,比如TLS,并且不要使用传统的不安全协议进行敏感数据传输,如FTP、SMTP等。

在算法层面,我们需要使用标准的加密算法,并且保证算法的及时更新,合理地管理密钥,尤其不要使用已经被验证安全性不足的算法,如MD5、SHA1、PKCS 1 v1.5等。

更进一步地,在随机化层面,密钥需要使用密码学算法随机生成,如果要使用一个口令密码,也是要通过口令密码生成函数来产生最终的密钥。除了密钥相关的数据,还要确保密码算法中涉及参数的随机化生成,确保其无法被预测。

思考题

这节课程中我们所编写的低加密指数攻击代码,仍然有进一步优化的空间,你可以提高这段攻击代码的执行效率吗?

欢迎在评论区留下你的思考,我们下节课再见。