安全不及而加密过甚

安全与加密这两词对于我们这个时代来说,真是太重要了。这两词闪闪发光,吸引着许多用户孜孜不倦的收集与之相关的东西,也不管有用没有用。但是,其实这样不利于安全,我下面用两个例子说明这一点。

我的一位朋友

我的一位朋友也觉得安全很重要,下定决心改善自己电脑的安全状况。他做了这么几件事情:

  • 使用 ED25519 生成 SSH 密钥,并且把私钥的 KDF 迭代数从默认的 16 升级到 256。出于某些我无法理解的原因,他认为 bcrypt 并不安全,需要通过增加迭代次数来抵御可能的攻击。虽然增加迭代数会让他运行 ssh-add ~/.ssh/id.key 的时间变长,但是他认为可以接受。
  • 使用 GPG 与基于 GPG 的 password-store 存储密码。生成 GPG 密钥时使用 gpg --s2k-mode 3 --s2k-count 65011712 --s2k-digest-algo SHA512 --s2k-cipher-algo AES256 作为参数。他认为这样能够更好的保护存储在硬盘上面的密钥。他认为 GPG 默认的 AES-128 不安全,他认为在参数里面强制使用 AES-256 更好。而且默认用来派生密钥的 SHA-1 也不够好,应该换成 SHA-512。

那么,在这一通所谓的安全设置之后,他的系统真的安全了吗?让我们来分析一下。

OpenSSH 使用的不是标准的 bcrypt(3) (源码),而是 bcrypt_pbkdf(3)。这其实是一个使用变体 bcrypt 与变体 PBKDF2 结合的函数。分析其源码可以发现,其使用了 64 轮状态扩散,相当于将原版 bcrypt 的工作因素置为 6(工作因素的内部实现就是执行 2^n 次状态扩展)。而 OpenSSH 的 ssh-keygen 默认使 bcrypt_pbkdf 迭代 16 或 24 次(在 2023 年的提交中,从 16 增加到了 24,此提交应当影响 9.4 之后的版本,而且此次提交并没更改对应的手册页,手册内的描述还是 16)。那么,其实相当于原版 bcrypt 的工作因素置为 10 或者 10.5 (bcrypt 不支持分数工作因素,所以 10.5 是近似值)。

而正好, OWASP 推荐的 bcrypt 工作因素就是 10 或者以上。也就是说,默认值其实是安全的,不需要我这位为朋友来画蛇添足。10 的工作因素(迭代16次)在现代 CPU 上面带来 80 ms 左右的延迟,根据线性的数学关系,迭代 24 次延迟大概在 120ms 左右。我在我的 Ivy Bridge EP 和 Coffee Lake-R 上面测得类似的值,读者也可以自行用 time echo "cool_passwd" | mkpasswd --method=bcrypt --stdin --rounds=10 来在自己的电脑上面测试。

所以为什么这些开发者要多次迭代密码,并且尽可能让这些迭代函数变慢呢?这种“减速”是针对暴力破解的。迭代次数越高,暴力破解者每一次尝试密码都需要花费更长的时间。因为每一次尝试,破解者都需要先把要尝试的密码通过密钥拉伸函数变为密钥(密码和密钥是不同的,密码长度通常随意,而密钥只能是加密算法能接受的长度),再去用密钥尝试解密。这一保护过程有一个隐藏的前提,那就是直接尝试密钥比尝试密码要困难的多。

在密码散列函数已经到了足够的迭代次数后,使用更长的密码比增加迭代数更有用。密码管理器 1Password 的开发者这样表述:密码增加一个字符,相当于把迭代次数从 40,000 增加到 400,000,而两个字符就是 4,000,000。所以我的那位朋友与其让自己的 SSH 密钥从默认的迭代 24 次改到 256 次,也不如把自己密码多写两位,反正增加的时间一样也是两秒。

至于 GPG,事情就变得有意思了起来。根据上游的讨论,他使用的参数是无效的。GPG 的 S2K 选项仅与对称加密相关,并不保护私钥——GPG 会接受这些参数,但是密钥本身是由 gpg-agent 管理的,所以磁盘上存储的密钥格式并不会因为这几个参数而改变。根据 gpg-agent(1),gpg-agent 会自动调整迭代次数,保证解密的时间在 100ms 左右。用户可以强行指定迭代次数。除此之外就没有什么可以修改的参数了;gpg-agnet 没有为算法提供配置选项。加上我的这位朋友并不用 GPG 来加密文件——这些选项唯一的作用就是为他提供虚假的安全感。(技术上,password-store 确实使用对称加密,但是这些选项是他在生成用来加密 password-store 的密钥本身时使用的,所以没有意义,即使添加到配置文件中,也不改变其密钥本身。)

另外,他的博客的 TLS 使用 128 位 AES 对称加密与 2048 位 RSA 证书,这两种东西都是他认为不安全的。这就有点现实的荒谬性在里面了,宛若西西弗斯把石头推到山顶,又快快乐乐的看着石头滚下去。

也许有读者会这样思考:“这位朋友误解了一些安全与密码学知识,他系统的安全性没有增强;但是,安全性也没有减弱,就是浪费了一些性能而已。”这种观点并不正确,有些时候,不正确的配置会削弱整个系统。下面的 Caddy 就是例子。

Caddy 与 bcrypt

Caddy 是古埃及语,意思是我用不来 Nginx 和 Apache。

Caddy 是个很有名的 HTTP 服务器,跨平台,而且配置简单,还可以自动生成 ACME TLS 证书。他们的宣传语就是“我们的解决方案能使您的网站比其他任何方案更安全、更可靠、更具扩展性。”

他们对安全自然也是很重视。他们在做 HTTP Basic Auth 的时候,认为安全真的太重要了,于是他们使用了安全的、专门用于密码保存的散列函数 bcrypt,并且将其工作因素硬编码为 14。这样,所有用户都得到同等的安全。

也许安全吧。但是 Caddy 的开发者忽略了一个重要问题:HTTP 服务器是给人用的。工作因素为 14 的 bcrypt 在我的 Ivy Bridge EP 上面需要 1.2 秒来计算。而且这 1.2 秒并不是纯延时,而是为了计算密码,把 CPU 占得满满的。

这带来了被 DoS 攻击的危险:攻击者只需要使用简单的登陆操作,就可以让服务器的某个线程快快乐乐卡住一秒钟。这是确实是愚蠢的设计。我疑心 Caddy 采用这种设计而不引起大问题的原因在于,HTTP 服务器自己的 HTTP Basic Auth 并没有什么实际应用在使用。有用户发现自己服务器 CPU 因此占用过高,于是他们的解决方案是缓存密码以及对应的散列值——又带来了额外的复杂性与内存占用。而且并没有缓解 DoS 攻击的风险。

与 Caddy 相比,业界广泛应用的 PHP 在这一点上的做法就非常值得赞扬。PHP 也使用 bcrypt 作为其默认的密码存储散列函数。他们在 2024 年发布的 PHP 8.4 中,将其工作因子的默认值从 10 提升到了 12。算力使用与延迟是 Caddy 默认值的 1/4。并且,PHP 并没有硬编码这个值,而是提供了让用户手动配置的渠道。

PHP 还在文档中提供了示例,让用户根据自己能接受的延迟来配置合适的工作因子。通过 PHP 脚本计算出给定延迟下的计算因子应该是多少。这样,用户就有了自我权衡的能力。这当然比 Caddy 的强制性策略优秀。

等等,我们到底在谈什么?

其实我们谈的是三类函数,但是因为他们的相关性,所以我们放到一起来谈。

我们首先来谈谈密钥派生函数(Key Derivation Function)。用户输入的密码多种多样,但不一定符合加密算法对于密钥的要求。比如我可以使用“QWERTY123$%^”作为密码,但是加密算法要求的密钥长度一般是 256 位或者 128 位,并且通常的密钥应该是伪随机的,所以需要密钥派生函数。密钥派生函数可以理解为这样一个函数:输入为原始密钥材料(密码或者别的什么)、目标长度,输出为符合目标长度的伪随机的一串密钥。此处伪随机的意思是,这个密钥看上去是随机的,但是本质上是从原始的密钥材料中生成的,不是真随机。而此处的函数也是数学意义上的函数,输出结果只受输入参数的影响。

然后是密码散列函数(Password Hashing Function)。散列函数就是,一段信息进去,散列函数把输入的信息打乱混合,然后组合成一个一定长度的摘要。散列函数是单向性的,摘要与原文对应,但是不能用摘要反向计算出原文。在用户登录电脑或者网站的时候,网站一般会使用密码散列函数,把密码变成摘要,也就是散列值,来与用户注册时计算并存储的散列值作对比并认证用户。因为网站只存储散列值,即使攻击者获取了散列值,也无法轻易恢复出原始密码。这种单向性使得密码在存储时更加安全。

最后是密码学散列函数(Cryptographic Hash Function),这是一种特殊类型的散列函数,具有一系列重要的安全特性,其中主要的是抵抗三种攻击的能力。原像攻击抗性:已知散列值,很难找到原文。第二原像攻击抗性:已知一个原文,很难找到另一个不同但是散列值相同的原文。碰撞抗性:很难找到两个不同但是散列值相同的原文。

密码学上使用的散列函数不一定是密码学散列函数,密码学散列函数不是密钥派生函数,密钥派生函数与密码散列函数也不必是密码学散列函数。

Bcrypt 就是这么一个例子。

首先,他本身不是 KDF。密钥派生函数使用伪随机函数从诸如主密钥或密码的秘密值中派生出一个或多个密钥,而 Bcrypt 的输入本身定死了 72 字节,且输出也是只有 184 bit,不能给常见的加密函数(通常需要 256 位密钥)作为输入。其次,他也不是密码学散列函数。密码学散列函数需要抗原像攻击,而 Bcrypt 设计上似乎就不考虑这情形。因此,Bcrypt 就是普普通通的密码散列函数。而且,目前也没有任何密码学研究指出,把 Bcrypt 作为密码散列函数使用是不安全的。

同样,SHA-1 的所谓安全隐患(不再能抗原像攻击)并不影响他作为密码散列函数功能。

缺失的威胁模型

所以,为什么 PHP 的做法和 Caddy 会产生这么大的差异?为什么我的朋友会花无用功?原因很简单:他们没有自己的威胁模型。

威胁模型和威胁建模这两个术语可能有不用的含义。把自己的应用各个组件列出来,然后从攻击者的角度来头脑风暴一下,试着找出系统的薄弱点,也可以称之为威胁建模。

在这里,我把威胁模型定义为“知己知彼”的一种研究过程。这一过程可以被简化为以下六个问题:

  • 我想保护什么?
  • 我想保护它免受谁的侵害?
  • 如果我失败了,后果有多严重?
  • 我需要保护它的可能性有多大?
  • 为了避免潜在的后果,我愿意经历多少麻烦?
  • 谁是我的盟友?

从 Caddy 的例子开始,我们一一回答这六个问题。

(我想保护什么?)使用强度足够的密码散列函数,为的是用户的密码原文不被碰撞攻击找到。而要进行此类攻击,需要拿到加盐后散列后的密码。

(我想保护它免受谁的侵害?)此类攻击者可能进入服务器后台拥有和 Caddy 一样的权限,或者能够利用 Caddy 潜在的漏洞获取这一密码。

(如果我失败了,后果有多严重?)碰撞攻击对使用复杂密码的用户来说效果几乎为0;对于在不同网站使用不同密码的用户,即使密码原文泄漏也无所谓,登陆不了其他网站(碰撞攻击的前提是该网站本身被攻破,所以能登陆本站是意料之中的);对于使用较弱密码且多账号共享密码的用户,有可能有后果。

(我需要保护它的可能性有多大?)在使用公认的良好的密钥散列算法与参数的情况下,碰撞密码并不简单,而且并不是所有用户都会采用相同的密码。

(为了避免潜在的后果,我愿意经历多少麻烦?)在不影响服务器性能的情况下,延时可以接受,但是不能让这一延时影响用户或者服务器本身的工作。

(谁是我的盟友?)用户——如果用户有良好的使用习惯,那么网站泄漏散列后的密码并不会影响用户,也许强制用户使用复杂密码也是有效的。

Caddy 想要预防的风险仅在服务器攻破时发生,并且只影响使用弱密码的用户。为了这些本来就不安全的用户,有没有必要消耗 CPU 来使所有用户的登陆都卡住呢?让攻击者能消耗更多的资源,这也是一种安全隐患。我想这个问题值得 Caddy 开发者重新考虑。

我的那位朋友也类似,根本没有想清楚这几个问题,他都不明确自己可能面对的攻击者有什么能力,也不清楚攻击者在何种情况下才能威胁到他,导致他只知道加大参数。

假如攻击者能够获取他本地保存的 id.key 文件,攻击者多半也有能力搞乱他的 bashrc 或者 zshrc,也许还有能力偷走他浏览器的 Cookie,或者直接加密他的文件来勒索他——比尝试暴力破解他的 SSH 密钥简单的多。再说,默认情况下,所有应用程序都可以无限次使用 SSH agent 里面已经解锁的私钥,因此访问服务器并不需要偷私钥;就算他配置 agent 在使用密钥时提示他,这保护也不是 100% 安全,因为恶意程序可以只建立一次连接,并且复用。因此,他对本地私钥的保护并没有多少实际意义。当然,要是他往网盘上面存私钥,那加密确实可以提供保护,而长密码比加迭代更有用。

碰撞到底有多难

下面是数学计算。要碰撞到密码,攻击者需要从头开始遍历所有密码的可能性。假设攻击者知道密码长度为 10 位,只包含小写英文字符与数字。这种密码配置不能算强,但相对比起 16 位且混合特殊字符与大小写的密码更加常见。

那么,密码中的每一个字符,都有 26(英文字母) + 10 (数字)种可能性。那么他要尝试的总密码数是 36^10 (36 的 10 次方,下同)。假设我们的服务器使用一次 MD5 来散列密码,那么攻击者一次用一个 CPU 核心能在 1 秒内尝试的 md5 数量大概在 10^7 这个数量级。这个数量级可以用 openssl speed md5 来在读者自己的电脑上验证。让我们给自己留下一些安全余量,假设攻击者 CPU 非常强劲,比我的普通 CPU 多一个数量级,一秒处理 10^8 次,且攻击者能访问 64 核心。

注意到有:尝试需要的天数 = (密码的可能组合) / (一秒钟内尝试密码的次数×一分钟有 60 秒×一小时有 60 分钟×一天有 24 小时×并行计算数量/迭代次数),带入得 (36^10) / ((10^8)×60×60×24×64) = 6.61。那么他碰撞我们的这个非常弱的加密系统的时间需要 6 天。GPU 所需要的时间可能更短,但我没有相关的设备可以测试。(此处有个隐含假设,攻击者知道密码长度且遍历了所有可能性。这不严谨,但是不影响我要表达的意思。)

我前面说过的,加强密码可能更有效。假如我们使用 12 位密码,并且结合大小写字母与数字。与上面采用相同的计算方式,结果一下子就不同了: ((26+26+10)^12) / ((10^8)×60×60×24×64) = 5.8 × 10^6 天才能破解密码,差不多需要一万五千年。别忘了我们此处是没有迭代次数的,而且使用的算法也是相对较快的 MD5。计算证明,加强密码比盲目的增加迭代次数有用,而且碰撞攻击确实难度很大。

总结

看到这里,本文的标题“安全不及而加密过甚”的含义也就不难理解了。在不正确的使用密码学参数、不遵循现有的安全实践、不了解安全背后的运作原理、不建立自己的安全模型的情况下,调整或者配置安全系统,把各种密码学工具用上、各个密码学参数拉满,这样只会带来虚假的安全感,甚至会损害系统。我建议读者不要这么做。

当然,安全是非常复杂的领域,本文也只是对其中涉及那三类散列函数的部分做了一个小小的解释,而安全建模本身需要考虑系统和攻击者的方方面面,这确实非本文所能讲明白的。如果有什么遗漏的地方,还请读者批评指正。


碎碎念

这篇文章从去年十月开始酝酿,中途也因为自己改不满意,索性放弃,直到今年三月才发出来。我承认我写这玩意的过程有点痛苦。

胡说很简单,因为人都是有思维惯性的,参数越大越好是人潜意识中的某种共识。但是要提出反直觉的观点并让人认同,就需要比较详细的论证过程,因此,我不得不去寻找然后解读上面提到的软件的源码和原始文档。SSH 相关的部分有通往源码对应部分的超链接,Caddy 也是如此,有些地方我也添加了对应手册页的链接——希望这些内容对那些想要自己验证我的说法的读者来说有用。

我捡起这篇文章的原因是,我的一个朋友向我问了一些关于浏览器的问题。原文我不在此贴出,但是他希望我能解释 LibreWolf 的隐私和 Tor 的隐私有什么区别。这个问题有些莫名其妙,因为这两提供的“隐私”本质上是完全不同的。但是仔细一想,所谓“安全”也是如此,不同的解决方案都可以标榜自己安全。

本身就有不少人宣传 Linux 比 Windows 安全;Linux 之上有容器之类的沙箱化安全,有 SELinux 这样的强制访问控制安全,SELinux 自己也可以当沙箱;Flatpak、Snap 等应用分发方式也提供安全隔离,但是有时也会影响一些安全功能(非特权 namespace 增加攻击面、删减权限影响浏览器安全等)。这些”安全“都代表不一样的东西,而各种说法都有自己正确性。

所以我又回到这篇文章来,尝试弄清楚这一大团毛线球里面的一个小小的线头。结果就是这个。也许有一天,我又会写另一篇文章来谈谈安全建模以及隐私之类的话题,就从我的两个朋友开始——其中一个觉得自己是 FBI 的通缉犯,一个觉得自己是 FBI。

另外,说真的,我不理解我的那个朋友。他用 OpenPGP 和 OpenSSH,代表着他相信这些开发者能正确实现各种密码学相关的组件,但是又不相信这些开发者选择的默认密码学参数。这是自相矛盾的——要是这些参数不安全,那些真正了解密码学的开发者早就把他们换掉了。

总之,感谢读者看到这里。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注