专栏名称: 程序员之家
程序员第一自媒体,与你探讨码农人生路上遇到的各类泛技术话题,定期为你推荐码农人生思考、感悟以及启迪!
目录
相关文章推荐
程序员的那些事  ·  惊!小偷“零元购”后竟向 DeepSeek ... ·  昨天  
程序员的那些事  ·  成人玩偶 + ... ·  2 天前  
程序猿  ·  TCP 才不傻! ·  4 天前  
程序猿  ·  问问DeepSeek,你和ChatGPT谁厉 ... ·  2 天前  
程序员小灰  ·  DeepSeek创始人梁文峰牛逼的个人经历 ·  3 天前  
51好读  ›  专栏  ›  程序员之家

使用浏览器的计算力,对抗密码破解

程序员之家  · 公众号  · 程序员  · 2017-04-26 21:59

正文

作者:EtherDream

原文:www.cnblogs.com/index-html/p/6045984.html

点击文末阅读原文即可前往

本文前半部分科普 KDF 函数的意义,后半部分探讨 KDF 在前端计算的可行性。

前言

几乎每隔一段时间,就会听到“XX 网站被拖库”的新闻。之后又会出现一些报道,分析该网站使用最多的密码是什么、有多少等等。

众所周知,密码在数据库中通常是以 Hash 值存储的,并且还加了盐。攻击者即使知道具体的 Hash 算法,也只能暴力破解。照理说这是极其费劲的,然而现实中却总有大量密码被破解,是什么导致安全性如此脆弱?

究其原因,莫过于这两点:口令密码、算法成本。


口令密码

密码可以记在很多地方。最常见的,就是记在自己脑袋里。当然还可以记在属于你的物品上,例如小本子、卡片等等,反正不用脑子记,不如设置的很长很乱,例如:

QQ:     n5Py 2r8W qGyg 4tU6
GMail:  3TkS mVwQ hUrs wtmA
...

这种无意义的长串作密码,是很安全的。即使它们的 Hash 值以及算法泄露,攻击者想得到明文,只能暴力穷举所有组合:

泄露的值是 BF656DEC5DD8BA0B,泄露的算法是 f(x)。开始穷举...

尝试组合                f(x)               结果
aaaa aaaa aaaa aaaa    02F49B3EA5592B14   ×
aaaa aaaa aaaa aaab    BD4E960D990DA3F3   ×
...          
n5Py 2r8W qGyg 4tU5    4CEA28A904326A26   ×
n5Py 2r8W qGyg 4tU6    BF656DEC5DD8BA0B   √

就算只有字母和数字,也要近 10^28 次才猜到。这是个天文数字,几乎不可行。所以,这种类型的密码还是很安全的。

然而现实中这么做的并不多。物品需要随身携带,非常不便,要是弄丢或者被偷,就更麻烦了。除非把它们都背下来,但这不又回到“记在脑袋里”这种方式了!

脑袋确实很安全,但容量也很有限。像上面那种毫无规律的字串,背一句都难,更别说多个了。所以,大家多少都会选些有意义、有规律的字串作为密码,例如 iloveyou2016 qwert12345 ,或是手机号、生日等组合。这种不用死记硬背的字串,就是口令(pass word)。

口令虽然方便,但缺陷也很明显:因为它是有规律的,所以猜起来就容易多了。攻击者只需测试常用单词组合,没准就能猜到了:

泄露的值是 2B649D47C4546A3E,泄露的算法是 f(x)。开始跑字典...

尝试组合         f(x)               结果
...
qwert yuiop     52708233CFFD6BFD   ×
qwert asdfg     CD07933880702B97   ×
qwert zxcvb     343F78782D73AB3A   ×
qwert 12345     2B649D47C4546A3E   √

这个过程,就是所谓的“跑字典”。一本好的字典,可以极大的提升猜中几率。


算法成本

在字典相同的情况下,速度就显得尤为重要了。每秒可以猜多少次?这得看具体的算法。

例如 MD5 函数,每次调用大约需要 1 微秒,这意味着每秒可以猜 100 万次!(而且这还只是单线程的速度,用上多并发更是恐怖)

由此可见,算法越快,对破解者就越有利。假如每次调用需要 10 毫秒,那么每秒只能猜 100 次,这样就足足慢了一万倍!

然而不幸的是,常用的 Hash 函数都是很快的。因为它们生来就有多种用途,并非为口令处理而设计。例如计算一个大文件的校验值,速度显然很重要。

所以,用 MD5、SHA256 之类的“快函数”处理口令,是不合理的。包括一些简单的变种,例如 MD5(SHA256(x)),仍然属于“快函数”。一旦 Hash 值和算法泄露,很容易被“跑字典”破解。

现实中,由于不少网站使用了“快函数”来处理口令,因此数据库泄露后,大量口令被还原也就在所难免了。


增加成本

虽然 Hash 函数单次执行很快,但我们可以反复执行大量次数,这样总体耗时就变长了。例如:

functionslow_sha256(x)
    for i = 0 to 100000
        x = sha256(x)
    end
    return xend

在密码学中,这种方式叫做 拉伸 。现实中有不少方案,例如 PBKDF2 —— 它没有重头设计一种新算法,而是对现有的函数进行封装,从而更适合用于口令处理:

functionpbkdf2(fn, ..., iter)
    ...
    for i = 0 to iter        ...
        x = fn(x, ...)
        ...
    end
    ...
    return xend

它有一个迭代参数,用于指定反复 Hash 的次数 —— 迭代次数越多,执行时间越长,破解也就越困难。

PBKDF(Password-Based Key Derivation Function,基于口令的秘钥导出函数),顾名思义,就是输入“口令”(有规律的字串)输出“秘钥”(无规律的长串)的函数,并且计算过程会消耗一定资源。本质上也是 Hash 函数,输出结果称之 DK(derived key)。


前端拉伸

拉伸次数越多虽然越安全,但这是以消耗服务端大量计算资源为代价的!为了能在安全和性能之间折衷,通常只选择几十到几百毫秒的计算时间。

服务端的计算量如此沉重,以至于不堪重负;而如今的客户端,系统资源却普遍过剩。能否让用户来分担一些计算量?

听起来似乎不可行。毕竟前端意味着公开,将密码相关的算法公开,不会产生安全问题吗。

先来回顾下,传统网站是如何处理口令的 —— 前端通常什么都不做,仅仅用于提交,口令都是由后端处理的:

现在,我们尝试对前端进行改造 —— 当用户在注册、登录等页面中提交时, 不再发送原始口令,而是口令的 DK

后端,则不做任何改动。(当然这会影响已有账号的使用,这里暂时先不考虑,假设这是个新网站)

这样, 即使用户的口令很简单,但相应的 DK 却仍是个毫无意义的长串 。通过 DK 的 Hash 值,是极难还原出 DK 的。(在本文开头就提到过了)

当然,攻击者更感兴趣的不是 DK,而是口令。这倒是可以破解的 —— 只需将前后端算法结合,形成一个新函数:

F(x) = server_hash(client_hash(x))

用这个最终函数 F 跑字典,还是可以猜口令的:

尝试组合       耗时  F(x)               结果
...
qwert yuiop   1s   1C525DC73898A8EF   ×
qwert asdfg   1s   F9C0A131F43F1969   ×
qwert zxcvb   1s   08F026D689D26746   ×
...

只不过其中有 client_hash 这道障碍,破解速度就大幅降低了!

所以,我们需要:

  • 一个缓慢的 client_hash,增加跑字典的成本

  • 一个快速的 server_hash,防止 DK 泄露

这样,就能将绝大多数的计算转移到前端,后端只需极少的处理,即可实现一个高强度的密码保护系统。


对抗预算

由于前端的一切都是公开的,所以 client_hash 的算法大家都知道。攻击者可以把常用口令的 DK 提前算出来,编成一个新字典。将来拖库后,直接跑这个“新字典”,就能节省大量时间了。

对于这种方式,就需要使用“加盐”处理(事实上 PBKDF 本身就需要提供盐参数)。例如,选择用户 ID 作为盐:

function client_hash(password, salt) {
    return pbkdf2(sha256, password, salt, 1000000);}client_hash('888888', '[email protected]');  // b80c97beaa7ca316...client_hash('888888', '[email protected]');  // 465e26b9d899b05f...

这样即使 口令相同,但用户不同,生成的 DK 也是不同的 。攻击者只能针对特定账号生成字典,适用范围就小多了。

更进一步,我们甚至可将“网站 ID”也掺入盐中:

function client_hash(password, salt) {
    return pbkdf2(sha256, password, salt, 1000000);}client_hash('888888', '[email protected]/www.site-a.com');  // 77a1b139aa93ac8b...client_hash('888888', '[email protected]/www.site-b.com');  // fab6b82e6a1d17d7...

这样即使同样的“账号密码”,在不同网站上生成的 DK 也是不一样了!

思考题:ID 是公开的,能不能选个隐蔽的字段作为 client_hash 的盐?

DK 泄露

DK 诞生于前端,后端对其 Hash 之后就不复存在了,所以它是个临时值。理想情况下,它是不会泄露的。

但在某些场合,DK 还是有可能泄露的。例如服务器中毒、网络传输被窃听等,都能导致 DK 泄露。

DK 泄露后,攻击者就能控制该账号了,这是无法避免的。但幸运的是, DK 只是个无意义的长串而已,攻击者并不知道其背后的那个有意义的“口令”是什么 。因此其他使用类似口令的账号,就幸免于难了!

攻击者若要通过 DK 还原口令,就得用 client_hash 算法跑字典 —— 这个成本依然很大。相比之前的“最终函数 F”,只是少算一次 server_hash 而已。(server_hash 本来就很快,可以忽略不计)。所以即使 DK 泄露,破解口令难度基本没降低。

“账号被盗,口令拿不到”,这就是“前端 Hash”的意义。


额外意义

前端的拉伸计算,使得用户登陆时会耗费一定的系统资源。这个副作用,事实上也能起到一定的防御效果。

对于普通用户来说,登陆时额外花费几秒时间,或许影响不大;但对于频繁登录的人来说,将是一个极大的开销。有谁会极其频繁的登录?这很可能就是“撞库攻击者” —— 他们从其他地方弄到一堆账号口令,然后来这里撞运气,看看能成功登上多少。

由于我们是用 DK 登录的,因此攻击者也必须将待测的口令,先算出 DK 再提交,于是会增加不少计算成本。这和 上一篇 的 Proof-of-Work 有点类似。

如同拉伸弹簧需要付出能量,拉伸 Hash 同样需要投入实实在在的算力。


优化体验


事实上只要设计的合理,完全可以将“拉伸计算”的等待降到最小。例如,当用户输完账号和密码后,程序立即开始计算 DK,而不是等到提交时才开始计算。如果网站有验证码的话,即可在用户输入的同时进行计算。这样就能大幅提升用户体验。


另外,如果同个账号在浏览器中多次登录,是没必要每次都计算 DK 值的。当账号第一次登录时,我们可以将其 DK 值加密,缓存在本地存储里:

localstorage[username] = encrypt(dk, password)

下次重新登录时,先检查本地存储中,是否有上次的缓存记录。如果有,则用“当前输入的密码”对该值进行解密:

dk = decrypt(localstorage[username], password)

因为对称加解密是非常快的,所以可非常快的进行提交。如果后验证不通过(比如已经改过密码了),这才需要重新计算 DK,然后再提交。这样绝大多数情况下,登录都无需计算 DK。只有账号首次登录,或者清理了浏览器缓存,才会需要计算。


使用这种方式,即使攻击者拿到 localstorage 里的数据,不知道 password 也是没法算出 DK 的。就算用”跑字典“攻击,那么每“解密”出一个 DK,都要提交到后端验证一下,这样网络成本也不小。并且同个账号频繁请求登录,后端也是可用策略来限制的。







请到「今天看啥」查看全文