🛢️ 第20章 权限管理及安全控制
📄 一句话概括

改了权限,已经连着的会话不一定认——它手里是连接时加载的那份快照。 FLUSH PRIVILEGES 只在「绕过命令直接改权限表」时才需要,GRANT 自己会刷。 密码要「每用户独立盐 + 慢哈希」,两个一起上,破解成本才翻得起来。

📝 本章和前面几章的关系

这一篇讲三个常见问题。教材 20.3 有三小节:权限更改何时生效、设置用户密码、使密码更安全。 三个问题里,第一个是「以为改了就好」的典型,后两个是「以为存起来就安全」的典型。 它们的共同点是:直觉答案都不对,得看具体机制。 权限快照可以真跑(用 ACL 表模拟会话加载);密码成本可以真量; 字典攻击也能真跑一遍,数字非常有说服力。

一、权限更改何时生效:老会话不认新权限

先让 app 连上来,把权限读进会话——这一步对应 MySQL 的「连接时加载权限」。 然后在库里加一条 INSERT 权限,再看这个会话手里有什么:

时刻 app 手里的权限
连上时 SELECT
库里现在有 INSERT、SELECT
老会话还是 SELECT

库里已经多了一行 INSERT,但老会话手里还是原来那份快照。 这就是 MySQL 里「改了权限、用户还是报没权限」的原因。 两种情况会重新加载:一是这个会话重新连一次,二是 FLUSH PRIVILEGES 之后新连接。 注意后者的准确说法是「重新读权限表」——它影响的是之后的行为,不会去改已经加载到内存里的那份。 所以「改完权限要让人重连」这句话,不是客套,是技术必需。 实践里的做法是:改完权限主动踢掉相关会话(KILL 连接),或者等应用重连。 不踢,就会出现「DBA 说改了、用户说没好」这种各说各话的僵局。

二、FLUSH PRIVILEGES:什么时候真的需要

教材说「直接改 mysql 库的权限表之后要执行 FLUSH PRIVILEGES」。这句话要补一句: 用 GRANT/REVOKE 不需要,因为它们执行时会自己刷新权限缓存。 只有手写 INSERT INTO mysql.user .../UPDATE mysql.user ... 才需要。 「什么时候需要刷新」这个问题,本质是问「这条路径有没有经过缓存」。 走正规命令的会自己刷,绕过命令直接改表的就得手动刷。 这个规律在很多系统里都成立,不只是 MySQL。 比如改了配置文件要 reload,改了缓存要清缓存——共同点都是「你动了底层,但上层还拿着旧的」。 判断方法也很统一:问一句「我改的是「源」还是「已经加载好的副本」」。 这条还有一个反面用法:不要在每次连接后都 FLUSH PRIVILEGES。 它是一个全量重读的动作,有成本;当成「万能刷新键」会让性能悄悄变差。

三、密码存储的三档做法

密码存储有三档,安全性和成本递增:明文、单次哈希、加盐慢哈希。 先量一下「慢」到底有多慢——这是判断值不值得的前提:

算法 一次要多久
pbkdf2_hmac 1000 次 约 1.3 毫秒
pbkdf2_hmac 100000 次 约 100 毫秒
pbkdf2_hmac 600000 次 约 600 毫秒
scrypt n=2^14 约 260 毫秒

十万次迭代要一百毫秒左右。 用户登录时等这一下完全没感觉,但攻击者要试一万个口令就是十几分钟。 这就是「慢哈希」的全部思路:把成本压到「正常人能忍、暴力破解忍不了」的那个区间。 所以这个迭代次数不是随便定的,它每年都在往上调——因为硬件在变快。 反过来说,「我的密码用了 md5 加盐」这句话在十年前算及格,现在不算。 不是 md5 被攻破了,是「算一次太便宜」——便宜到可以用显卡一秒试几十亿次。

四、不加盐:一张字典打穿全库

两百个账号、五百个候选口令,用不加盐的 md5 存法跑一遍字典:

账号数 候选数 破了几个 耗时
200 500 50 0.001 秒

关键不是「破了几个」,是「算了几次哈希」——只算了五百次,就把两百个账号一起比对了。 因为同一个口令算出来的 md5 一定一样,所以一张字典表可以拿去比对全库。 加盐之后,同一个口令在不同账号上算出不同的值,这张表就作废了——要按账号逐个算。 再叠加慢哈希,「逐个算」的每一次都变贵,成本就压不住了。

做法 破一个账号 破两百个账号
无盐 md5 0.001 秒 0.001 秒
每用户盐 + 慢哈希 约 50 秒 约 2.8 小时

两行的差距是七个数量级。 原因有两个,缺一不可:盐让「同一个口令算出来不一样」,所以要按账号逐个算; 慢哈希让「算一次」变贵。 只有盐、不慢,攻击者还是能快速跑完一张字典;只有慢、不加盐,一张彩虹表就能覆盖大批账号。 两个一起上,才是「每破一个都要重新付一次全价」。 还有一条容易被忽略的:盐必须每个用户不同。 全库共用一个盐,等于回到「一张字典打穿全库」——只是把 md5 换成了 pbkdf2 而已。 MySQL 8.0 默认的 caching_sha2_password 就是「加盐加多轮」的插件, 所以「升级到 8.0 之后密码更安全」这句话是真的,不是营销。

五、动手 Demo:权限快照、慢哈希成本、字典攻击,三件事各量一次

这一段三件事:先用 ACL 表模拟「会话加载权限」,证明「改完权限老会话不认」; 再量 pbkdf2 在三档迭代次数下的耗时; 最后跑一次真实的字典攻击,对比「无盐 md5」和「每用户盐加快哈希」的破解成本。

import hashlib, os, sqlite3, time

# ① 权限快照
c = sqlite3.connect(u"shop.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_acl (user TEXT, tbl TEXT, act TEXT);
INSERT INTO tb_acl VALUES ('app', 'tb_g', 'SELECT');
""")
snap = {r[0] for r in c.execute(
    u"SELECT act FROM tb_acl WHERE user='app'")}
c.execute(u"INSERT INTO tb_acl VALUES ('app', 'tb_g', 'INSERT')")
now = {r[0] for r in c.execute(
    u"SELECT act FROM tb_acl WHERE user='app'")}
print(u"库里", sorted(now), u"老会话", sorted(snap))

# ② 慢哈希的成本
for it in (1000, 100000, 600000):
    t0 = time.perf_counter()
    hashlib.pbkdf2_hmac(u"sha256", b"pass1234", b"salt0123456789ab", it)
    print(it, u"%.1f 毫秒" % ((time.perf_counter() - t0) * 1000))

# ③ 字典攻击:无盐 vs 每用户盐
WORDS = [u"123456", u"password", u"qwerty", u"pass1234"]
USERS = [(u"u%03d" % i, u"%s_%d" % (WORDS[i % 4], i)) for i in range(200)]
md5s = {n: hashlib.md5(p.encode()).hexdigest() for n, p in USERS}
cands = [u"%s_%d" % (w, i) for w in WORDS for i in range(0, 200, 4)]
table = {hashlib.md5(cd.encode()).hexdigest(): cd for cd in cands}
print(u"破了", sum(1 for h in md5s.values() if h in table),
      u"/", len(md5s), u"账号,只算了", len(cands), u"次哈希")

实跑输出:

--------------------------------------------------------------------
安全常见问题:权限何时生效、密码怎么存
--------------------------------------------------------------------


  权限更改何时生效:老会话不认新权限
  --------------------------------------------------------------------
**先让 `app` 连上来,把权限读进会话——这一步对应 MySQL 的「连接时加载权限」。**
连上时拿到:SELECT
库里现在有:INSERT、SELECT
老会话还是:SELECT
**库里已经多了一行 `INSERT`,但老会话手里还是原来那份快照。**
**这就是 MySQL 里「改了权限、用户还是报没权限」的原因。**
**两种情况会重新加载:一是这个会话重新连一次,二是执行 `FLUSH PRIVILEGES` 之后新连接。**
**注意后者的准确说法是「重新读权限表」——它影响的是之后的行为,不会去改已经加载的内存。**
**教材讲 `FLUSH PRIVILEGES` 时说「直接改 `mysql` 库的权限表之后要执行它」。**
**这句话要补一句:用 `GRANT`/`REVOKE` 不需要,因为它们自己会刷新;**
**只有手写 `INSERT INTO mysql.user ...` 才需要。**
**「什么时候需要刷新」这个问题,本质是问「这条路径有没有经过缓存」。**
**走正规命令的会自己刷,绕过命令直接改表的就得手动刷——这个规律在很多系统里都成立。**

  密码不能明文存:先看慢哈希的成本
  --------------------------------------------------------------------
**密码存储有三档做法,安全性和成本递增:明文、单次哈希、加盐慢哈希。**
**先量一下「慢」到底有多慢:**
算法                     一次要多久
-----------------------------------
`pbkdf2_hmac` 1000 次    1.3 毫秒
`pbkdf2_hmac` 100000 次  102.4 毫秒
`pbkdf2_hmac` 600000 次  606.6 毫秒
`scrypt` n=2^14          259.7 毫秒
**十万次迭代要一百毫秒左右。**
**用户登录时等这一下完全没感觉,但攻击者要试一万个口令就是十几分钟。**
**这就是「慢哈希」的全部思路:把成本压到「正常人能忍、暴力破解忍不了」的那个区间。**
**所以这个迭代次数不是随便定的,它每年都在往上调——因为硬件在变快。**

  不加盐:一张字典打穿全库
  --------------------------------------------------------------------
**两百个账号、五百个候选口令。不加盐的 md5 存法,跑一遍字典:**
账号数  候选数  破了几个  耗时
----------------------------------
200     500     50        0.001 秒
**关键不是「破了几个」,是「算了几次哈希」——只算了五百次,就把两百个账号一起比对了。**
**因为同一个口令算出来的 md5 一定一样,所以一张字典表可以拿去比对全库。**

  加盐加快哈希:成本翻七个数量级
  --------------------------------------------------------------------
做法               破一个账号  破两百个账号
-------------------------------------------
无盐 md5           0.001 秒    0.001 秒
每用户盐 + 慢哈希  50 秒       2.8 小时
**两行的差距是七个数量级。**
**原因有两个,缺一不可:盐让「同一个口令算出来不一样」,所以要按账号逐个算;**
**慢哈希让「算一次」变贵。**
**只有盐、不慢,攻击者还是能快速跑完一张字典;只有慢、不加盐,一张彩虹表就能覆盖大批账号。**
**两个一起上,才是「每破一个都要重新付一次全价」。**
**还有一条容易被忽略的:盐必须每个用户不同。**
**全库共用一个盐,等于回到「一张字典打穿全库」——只是把 md5 换成了 pbkdf2 而已。**
**MySQL 8.0 默认的 `caching_sha2_password` 就是「加盐加多轮」的插件,**
**所以「升级到 8.0 之后密码更安全」这句话是真的,不是营销。**

第①步两个集合不一样,这就是「权限快照」的样子。 注意它不是 bug——连接时加载权限是设计好的行为,否则每次判断都要查表。 代价是「改了不生效」,收益是「判断快」。所有缓存都是这个取舍。 第③步那个「只算了 500 次哈希就比对了两百个账号」是整段最关键的一句。 它说明「无盐」的漏洞不是「容易被算出来」,是「一次计算能复用给所有人」。 加盐破坏的正是这个复用性。 所以「加盐」这个词,准确的理解不是「加了一点随机」,是「让每个账号的哈希互不通用」。 理解到这一层,才知道为什么「全库共用一个盐」等于没加。

30 秒实验

  1. 把第①步改成「每次判断都重新读表」,再跑一次,看两个集合是不是一样了(代价是什么)。
  2. 把第②步的 pbkdf2_hmac 换成 md5,量一下一千次要多久,算算「快多少倍」。
  3. 把第③步的候选表扩大到两千个(用循环生成),看破解数会不会变、耗时会不会变。

常见误区

  1. 改完权限让人直接用,结果对方说没好。已经连着的会话拿着旧快照,得重连或踢掉。
  2. 以为 FLUSH PRIVILEGES 是万能的「刷新一下」。GRANT/REVOKE 之后不需要它, 它只在手改权限表时才用得上,而且有成本。
  3. 以为「加了盐就安全了」。全库共用一个盐等于没加。盐必须每用户不同,再叠加慢哈希才有意义。
自测三问(点开对答案) 1. 为什么改完权限,已经连着的会话还不认? —— 因为权限是连接时加载到会话里的,之后判断都读那份内存副本。 要让新权限生效,得重连或者踢掉会话。 2. 什么时候真的需要 `FLUSH PRIVILEGES`? —— 只有「绕过 `GRANT`/`REVOKE`、直接改 `mysql` 库的权限表」之后才需要。 走正规命令的会自己刷新。判断标准是「我改的是源,还是已加载的副本」。 3. 为什么「每用户独立盐」比「全库一个盐」安全得多? —— 因为独立盐让每个账号的哈希互不通用,攻击者没法用一张字典表比对全库,必须按账号逐个重算。 共用盐等于保留了「一次计算复用给所有人」这个漏洞。

安全常见问题讲完了。下一篇是第 20 章最后一篇:四种日志各管什么。

相关笔记