ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL安全控制全链路:账号权限、认证加密与审计加固

MySQL安全控制全链路:账号权限、认证加密与审计加固 上周帮朋友做一次数据库巡检交接文档里只写了三个账号root、admin、app。root 和 admin 都是%通配主机密码是同一位运维的生日加123。说实话这个场景我见过太多次很多团队对 MySQL 安全性控制的理解就停在设个复杂密码、别开 3306 到公网这两条可真出事的时候这两条几乎拦不住任何东西。数据库安全不是一个开关它是一整套从连接建立、身份认证、权限授予、数据可见性一直到事后追溯的链条任何一环松了前面做得再漂亮都白搭。这篇内容我想聊的就是这条链条本身MySQL 里到底有哪些安全控制点每个控制点背后的机制是什么实际配置时容易被忽略的坑在哪以及怎么把一堆零散的加固动作整理成一套能落地的流程。不管你手上是刚装的 MySQL 8.0、公司给的生产库还是准备面试时想把权限体系讲清楚看完都能直接对应到自己那台机器上操作。下面所有命令和参数我都尽量给出可复制的写法你照着改域名和库名就能用。1. 从权限给多了说起MySQL 安全控制的真实边界1.1 一个账号盘点引出的问题那次巡检我先跑了一条查询把所有账号和它们的认证插件捞出来SELECT user, host, plugin, account_locked, password_expired, password_last_changed FROM mysql.user;看到的结果是root 有两个条目一个rootlocalhost一个root%admin 是admin%app 是app%。三个账号里有两个能在任意 IP 上登录这就是典型的权限给多了。更麻烦的是app的权限清单里有ALL PRIVILEGES ON *.*也就是说这个应用账号能 drop 库、能建用户、能读其他业务库的数据。我问了一句为什么这么给得到的回答是当初建库的时候报权限错误图省事就 ALL 了。这种做法在项目早期特别常见因为排查到底缺哪个权限很烦直接 ALL 一把梭最省事。但它的代价是应用的连接池一旦泄露比如配置文件被误传到代码仓库攻击面就是整个实例而不是某一个库。所以谈 MySQL 安全性控制第一步不是去翻配置文件而是先把账号和权限盘一遍。这件事的收益比装任何插件都高成本又只是几条 SQL。1.2 三层边界连接、库、对象理解 MySQL 权限模型我习惯把它拆成三层来看这样出问题的时候能快速定位到底卡在哪一层。第一层是连接层决定你能不能连进来。它关心的是 host 匹配、认证插件、密码策略、TLS 要求、bind-address这些。很多拒绝访问的报错其实卡在这一层跟权限表里的 Grant 没关系。第二层是库表层决定你连进来之后能看见哪些库、能做哪些全局动作。对应的是ON *.*和ON db.*这两类授权还有PROCESS、SUPER、FILE这类不绑定具体库的全局权限。第三层是对象层决定具体某张表、某个列、某个存储过程你能不能碰。对应ON db.table、列级授权以及视图和 DEFINER 带来的间接访问。这三层是逐层放行的关系连接层不通后面全免谈库表权限没给就算连进来了也只能报SELECT command denied。实际排查时先看错误码——ER_ACCESS_DENIED_ERROR1045是认证失败ER_DBACCESS_DENIED_ERROR1044是库级没权限ER_TABLEACCESS_DENIED_ERROR1142是表级没权限。知道错误码属于哪一层能省掉一半的瞎猜时间。1.3 为什么设了强密码远远不够密码只是连接层的一个因素。真正的问题在于一个强密码如果配的是%主机加ALL PRIVILEGES那它保护的东西约等于零。反过来一个中等强度的密码如果只能在特定网段登录、只对一个库有 SELECT风险就小得多。我个人的判断标准是账号的风险 可达主机范围 × 权限范围 × 凭据泄露概率。这三项里优先压缩前两项因为它们完全由你控制不依赖运气。凭据泄露概率只能靠密码策略和轮换去降低是第三顺位的事。沿着这个思路后面几节我会按连接层 → 权限层 → 数据层 → 追溯层的顺序展开每一层给出具体的配置和坑。2. host 匹配、匿名用户与同名账号账号体系里最容易漏的三个坑2.1 host 字段的匹配顺序比你想的更重要MySQL 在决定用哪条账号记录认证时不是简单地找一个匹配的就行它有一套排序规则。简单说host 字段越具体的优先级越高具体主机名或 IP 高于带通配的带通配的里前缀通配10.0.1.%又比%优先。用户名相同的情况下MySQL 会挑那条最精确的记录来比对密码。听起来合理但坑在于如果你有app%和app10.0.1.%两条记录密码却设得不一样那么从 10.0.1.5 连进来时会匹配到后一条用另一个密码。运维改密码的时候只改了其中一条就会出现我明明改过密码了为什么还登不上或者更糟——我明明改过密码了为什么旧密码还能登。规避办法很直接同一个用户名尽量只保留一条主机规则需要区分来源就用不同的用户名比如app_ro、app_rw而不是同一个名字挂多条 host。2.2 匿名用户会让认证短路MySQL 里理论上存在用户名为空字符串的匿名账号历史上某些安装方式会自动创建。匿名账号的匹配优先级在某些排序规则下很高可能导致一个你以为需要密码的账号实际上被匿名条目接走了。检查方法SELECT user, host FROM mysql.user WHERE user ;有输出就删掉用DROP USER host名;。这件事在 MySQL 8.0 上基本不会遇到但如果你在维护一些老旧的 5.x 实例或者从旧环境迁移过来的库这条务必看一眼。我在一个迁移项目里就因为这个卡了半天报错信息指向权限实际是匿名条目在捣乱。2.3 同名不同 host 的账号带来的混乱再讲一个相关的问题rootlocalhost和root%同时存在看起来很安全对吧其实这里有个认知偏差——localhost在 MySQL 里走的是 Unix socket而%走 TCP。很多运维以为我只允许 root 本地登录但只要root%还在任何人从任何 IP 用 TCP 都能尝试 root 登录区别只是密码对不对。我的建议是生产实例上root只保留rootlocalhost并且把root%直接删掉。真需要远程管理就单独建一个命名清晰的运维账号绑定固定跳板机 IP加上REQUIRE SSL而不是让 root 到处跑。提示删除账号要用DROP USER不要DELETE FROM mysql.user。后者不会清理mysql.db、mysql.tables_priv等权限表里的残留记录会留下幽灵权限。3. 密码从能登录到扛得住撞库3.1 组件形式的 validate_password 与参数MySQL 8.0 里密码强度校验是以组件形式提供的装法INSTALL COMPONENT file://component_validate_password;装完查看参数注意 8.0 的参数名是点号分隔SHOW VARIABLES LIKE validate_password.%;几个关键参数和我的常用取值参数默认值生产建议说明policyMEDIUMSTRONG校验强度等级length812 以上最小长度mixed_case_count11至少几个大小写number_count12至少几个数字special_char_count11至少几个特殊字符dictionary_file空指向字典禁止常见弱口令这里有个容易踩的坑MySQL 5.7 和 8.0 的参数前缀不一样。5.7 是validate_password_policy下划线8.0 组件形式是validate_password.policy点号。把 5.7 的配置直接搬到 8.0 的配置文件里MySQL 会忽略未知参数你还以为自己加固成功了。升级或者迁移的时候这一点要专门核对。dictionary_file这个参数值得单独说。它允许你指定一个每行一个词的字典文件密码里如果包含其中任何一个词就直接拒绝。把你公司名、产品名、password、admin、mysql这些词塞进去效果立竿见影。3.2 认证插件怎么选MySQL 8.0 默认的认证插件是caching_sha2_password比老版本的mysql_native_password安全性高。但从 8.0 升级的过程中你会发现一堆老客户端连不上因为它们的驱动不支持新插件。常见的降级方案是ALTER USER app10.0.1.% IDENTIFIED WITH mysql_native_password BY 你的密码;我要提醒的是mysql_native_password在 8.4 版本里默认已经不再启用需要显式打开才能用到了更高版本基本就退场了。所以如果新项目要选型优先升级客户端驱动而不是降级服务端认证方式。降级只是给老系统续命别当成长期方案。判断当前某账号用的是哪个插件还是那句查询SELECT user, host, plugin FROM mysql.user WHERE user app;3.3 密码轮换、锁定与失败延迟validate_password 组件激活后还会带来一组密码管理变量比较实用的有-- 记住最近 6 次密码不允许重复使用 SET GLOBAL password_history 6; -- 密码 90 天过期 SET GLOBAL default_password_lifetime 90; -- 连续失败 5 次锁定 1 天 SET GLOBAL failed_login_attempts 5; SET GLOBAL password_lock_time 1;注意password_lock_time的单位是天不是分钟也不是秒。很多人设成 1 以为锁一分钟结果账号锁了一天排查时一脸懵。另外failed_login_attempts需要账号是非锁定状态才生效而且它统计的是连续失败成功登录一次就会清零。这个机制配合应用层的连接池要小心如果应用配置文件里密码写错了连接池会高频重试几秒钟就能把账号锁死然后真正的问题——密码写错了——会被账号被锁这个新问题盖住增加排查难度。我的经验是把重试间隔调大一点或者在 CI 阶段做一次连接验证别让它带着错密码上线。4. 最小权限不是口号GRANT 的写法与角色分层4.1 权限粒度对照表MySQL 的权限粒度有好几档实际使用中我常用下面这几类粒度写法典型用途全局ON *.*只给运维专用账号库级ON shop.*应用主账号表级ON shop.orders特定报表账号列级ON shop.orders (id, amount)脱敏场景例程ON PROCEDURE shop.p_x调用权限控制ALL PRIVILEGES ON shop.*比ALL PRIVILEGES ON *.*好但依然给了 DROP、ALTER 这类 DDL 权限。对应用账号来说通常只需要SELECT, INSERT, UPDATE, DELETE加上特定场景的EXECUTE。DDL 交给单独的迁移账号用 Flyway 之类的工具在发布窗口执行。4.2 三类账号的分层设计我在项目里基本固定用三类账号这个分法可以直接抄应用读写账号app_rw10.0.1.%权限SELECT, INSERT, UPDATE, DELETE ON shop.*连接数上限设 200。应用只读账号app_ro10.0.1.%权限只有SELECT ON shop.*给报表和后台查询用。发布迁移账号deploy10.0.1.10绑定单一跳板机 IP权限SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP ON shop.*仅在发布时启用。创建时的完整写法CREATE USER app_rw10.0.1.% IDENTIFIED BY 强密码 WITH MAX_USER_CONNECTIONS 200 PASSWORD EXPIRE INTERVAL 90 DAY; GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO app_rw10.0.1.%;MAX_USER_CONNECTIONS这个资源限制很值得加它能防止某个应用实例出问题后把连接数吃满。MySQL 还提供MAX_QUERIES_PER_HOUR、MAX_UPDATES_PER_HOUR这些按小时计的限制对于定时任务类的账号特别有用——万一脚本写了个死循环疯狂更新会被自动掐断。4.3 角色ROLE与 WITH GRANT OPTIONMySQL 8.0 引入了角色管理一组权限比逐个账号授要清爽得多CREATE ROLE role_readonly; GRANT SELECT ON shop.* TO role_readonly; CREATE ROLE role_readwrite; GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO role_readwrite; GRANT role_readonly TO app_ro10.0.1.%; GRANT role_readwrite TO app_rw10.0.1.%; -- 登录后默认激活哪个角色 SET DEFAULT ROLE role_readwrite TO app_rw10.0.1.%;一个隐藏的坑角色授权后不会自动生效。要是忘了SET DEFAULT ROLE账号登录后角色处于已授予但未激活状态权限等于没有应用会直接报权限错误。排查时用SELECT CURRENT_ROLE();看当前激活了哪些角色。关于WITH GRANT OPTION我的态度是一律不给除非确实在做多租户隔离这种需要二次授权的场景。给了这个选项意味着账号能把权限再转授给别人一旦这个账号被拿下攻击者可以在你不知情的情况下创建自己的持久化入口。4.4 别直接改 mysql.user 表这是个经典陷阱有人为了方便直接UPDATE mysql.user SET authentication_string ...或者手动往mysql.db里插记录然后FLUSH PRIVILEGES。在 MySQL 8.0 上mysql.user里的密码字段存的是认证插件生成的哈希格式和算法都跟老版本不同手工构造几乎必然出错。而且直接改表会绕过权限校验逻辑、绕过密码策略组件还可能因为数据结构变化导致实例起不来。所有账号和权限操作走这三条路就对了CREATE USER / ALTER USER / DROP USER GRANT / REVOKE SET DEFAULT ROLE只有在极少数实例都起不来了、需要紧急恢复 root的场景才动--skip-grant-tables而且恢复完必须立刻重启回正常模式。5. 把入口收窄监听、TLS 与来源控制5.1 bind-address 与 skip-name-resolve 的连锁反应配置文件的这一段是连接层加固的核心[mysqld] bind-address 127.0.0.1 skip-name-resolve ON port 3307bind-address决定了 MySQL 监听哪个网卡。设成127.0.0.1意味着只有本机能连适合应用和数据库同机部署的场景如果应用在别的机器上就设成内网那块网卡的 IP而不是0.0.0.0。skip-name-resolve的作用是关闭反向 DNS 解析。开启之后性能更好、连接建立更快但它带来一个副作用账号的 host 字段只能用 IP 或者localhost写主机名一律匹配不上。我见过有团队账号配的是appdb-client-01开了这个参数之后全部连不上报的又是 1045很容易误判成密码问题。另外注意端口别用默认的 3306。改端口不是安全措施只是降低被自动化扫描器盯上的概率属于顺手做的事真正的防护还是靠权限和网络策略。5.2 强制 TLSMySQL 8.0 启动时会自动生成自签名的 SSL 证书所以 TLS 是开箱可用的。检查一下SHOW VARIABLES LIKE %ssl%; SHOW STATUS LIKE Ssl_cipher;Ssl_cipher有值说明当前这条连接走了加密。接下来可以给账号加要求ALTER USER app_rw10.0.1.% REQUIRE SSL;如果要全局强制让不加密的连接直接连不上SET GLOBAL require_secure_transport ON;注意开全局强制之前先确认所有客户端都支持 TLS否则应用会集体连不上。建议先在测试环境验证一轮再改生产配置。自签名证书的缺点是客户端默认不校验真正要做证书校验还得把 CA 分发给客户端并配置--ssl-ca。对于内网环境自签名 require_secure_transport已经能挡住绝大多数明文嗅探投入产出比是可以接受的。5.3 secure_file_priv 与 local_infile这两个参数经常被忽略但它们是数据外流的重要关口。secure_file_priv控制LOAD DATA和SELECT ... INTO OUTFILE能访问的目录SHOW VARIABLES LIKE secure_file_priv;值有三种形态空字符串表示不限目录危险指定路径表示只能在这个目录下读写NULL表示完全禁用。生产环境我倾向设成NULL或者一个专用目录权限收紧到只有 mysql 用户可读写。local_infile控制客户端能不能用LOAD DATA LOCAL把本地文件传到服务端SHOW VARIABLES LIKE local_infile;MySQL 8.0 默认是 OFF保持这个状态就行。开启它会让有权限的账号能读取客户端机器上的任意可读文件属于典型的用不上就别开的参数。6. 数据可见性视图、列级权限与 DEFINER 的坑6.1 视图做行和列的裁剪有时候业务上需要让某个账号看到部分数据比如客服只能看自己负责区域的订单。这时候直接给表权限就不合适用视图更干净CREATE VIEW v_orders_east AS SELECT id, order_no, amount, created_at FROM shop.orders WHERE region east; GRANT SELECT ON shop.v_orders_east TO cs_east10.0.2.%;这样账号只能通过视图访问看不到其他区域也看不到customer_phone这类敏感列。如果需要更细还可以用列级授权GRANT SELECT (id, order_no, amount) ON shop.orders TO report10.0.2.%;列级授权的坑在于维护成本高表结构一改加了几列要重新评估漏一次就可能暴露数据。所以列级授权适合字段稳定的表字段经常变动的场景还是用视图更好管理。6.2 SQL SECURITY DEFINER 带来的失控风险视图和存储过程有个SQL SECURITY属性默认是DEFINER意思是以定义者的权限执行。这带来一个微妙的安全问题如果一个低权限账号能访问一个由高权限账号定义的视图它实际是通过高权限在读取数据。更常见的翻车场景是DEFINER 账号被删除。视图定义里记录的 definer 用户一旦被DROP USER这个视图就会报错ERROR 1449 (HY000): The user specified as a definer (old_admin%) does not exist这个错误在数据库迁移、主从切换、或者清理离职员工账号之后特别容易冒出来而且报错信息往往只出现在运行时平时没人访问的视图一直不会被发现。我的做法是定义视图和存储过程时显式指定一个长期存在、权限受控的专用账号作为 definer定期扫描所有对象的 definer跟mysql.user做一次比对找出已经不存在的SELECT DISTINCT definer FROM information_schema.views WHERE definer NOT IN ( SELECT CONCAT(user, , host) FROM mysql.user );存储过程和函数也照这个思路查information_schema.routines。这个检查我建议写进巡检脚本跑一次成本很低能避免一次凌晨的紧急故障。7. 出事之后要查得到日志与审计线索7.1 四类日志的分工MySQL 的日志各有职责安全追溯时要清楚去哪找日志类型记录内容安全用途error log启动、崩溃、连接错误看认证失败的来源 IPgeneral log所有收到的语句事后复盘具体做了什么slow log超过阈值的慢查询发现异常的大范围扫描binlog数据变更追溯谁改了哪些数据general_log平时是关的因为开它会写海量数据、拖慢性能。但在排查可疑行为时可以临时打开SET GLOBAL log_output TABLE; SET GLOBAL general_log ON; -- 查完立刻关掉 SET GLOBAL general_log OFF;输出到表log_output TABLE比输出到文件方便过滤查起来是这样的SELECT event_time, user_host, argument FROM mysql.general_log WHERE command_type Query ORDER BY event_time DESC LIMIT 100;提示mysql.general_log表如果一直开着会疯狂膨胀排查完记得关日志并清理表数据别让它把磁盘吃满。7.2 审计方案的选择严格意义上的审计需求谁在什么时候执行了什么、结果如何、能不能防篡改需要专门的审计插件。MySQL 企业版有 audit_logPercona 发行版带审计插件社区版一般用 general_log 或者结合 binlog 来做近似效果。选型时我的判断维度是三个性能开销、日志完整性、查询便利性。全量记录所有语句的方案开销最大通常需要配合过滤规则只记特定用户或特定类型的语句。如果团队规模不大、合规压力一般我会建议先用 general_log 按需开启加上 binlog 长期保留够用且不引入额外组件。有个细节容易被忽略binlog的记录策略。默认的格式在某些场景下不会记录原始 SQL 文本追溯起来只能看到行级变更。如果审计是硬需求配置成binlog_format ROW配合binlog_rows_query_log_events ON既保留行的精确变更又能看到原始语句的注释信息。7.3 从 performance_schema 和 sys 库看异常连接排除谁在连我的时候performance_schema和sys库很好用-- 当前所有活动会话 SELECT * FROM sys.session; -- 按用户和主机统计连接 SELECT user, host, current_connections, total_connections FROM performance_schema.accounts ORDER BY current_connections DESC; -- 主机缓存里记录的来源 SELECT host, current_connections, total_connections FROM performance_schema.host_cache ORDER BY total_connections DESC;异常信号的典型特征是某个从没见过的 host 出现了大量连接、某个账号的total_connections突增、或者出现了Current connections远高于应用配置连接池上限的情况。这些线索配合 error log 里的认证失败记录基本能拼出一次扫描或者撞库的轮廓。另外sys.session是实时视图它是SHOW PROCESSLIST的增强版会多出当前执行的语句、等待事件、锁信息。排查某个账号是不是在做全表扫描这种问题时比原生命令好用不少。8. 一套能直接抄的加固流程与权限自查 SQL8.1 上线前的最小加固清单我把这套动作固化成了一张检查表新库上线或者接手存量库的时候按顺序过一遍能覆盖八成常见问题删除匿名账号删除root%root 只保留本地应用账号绑定内网网段不用%应用账号只授SELECT, INSERT, UPDATE, DELETEDDL 交给独立发布账号密码长度 12 位以上开启字典校验90 天过期bind-address指向内网 IP端口避开 3306require_secure_transport打开secure_file_priv设为受限目录或 NULLlocal_infile保持关闭视图和存储过程的 definer 指向专用长期账号general_log 平时关闭排查时按需开启所有权限变更走后用下面的自查 SQL 复核一遍。这十条里前三条的收益最大也最容易被跳过。如果你的时间有限先把账号和权限这件事做完再考虑其他。8.2 权限审查 SQL我常备的几条查询建议存成脚本每周跑一次-- 1. 列出所有非本地账号 SELECT user, host, plugin FROM mysql.user WHERE host NOT IN (localhost, 127.0.0.1, ::1); -- 2. 找出权限过大的账号 SELECT grantee, table_schema, privilege_type FROM information_schema.schema_privileges WHERE privilege_type IN (ALL, DROP, ALTER) ORDER BY grantee; -- 3. 找出还在用旧认证插件的账号 SELECT user, host FROM mysql.user WHERE plugin mysql_native_password; -- 4. 找出未设密码的账号 SELECT user, host FROM mysql.user WHERE authentication_string OR authentication_string IS NULL; -- 5. 检查孤儿 definer SELECT DISTINCT definer FROM information_schema.views WHERE definer NOT IN (SELECT CONCAT(user, , host) FROM mysql.user);第二条查询里information_schema.schema_privileges只覆盖库级授权全局权限要看user_privileges表级授权在table_privileges。三个视图一起看才能拼出完整的授权图景。我一般会把这三个视图 union 起来在一个脚本里输出方便一眼扫完。8.3 变更后怎么验证权限改完之后最忌讳的做法是改完就算。验证环节有两个动作一定要做。第一用目标账号实际连一次执行一次典型查询。很多权限问题不会在GRANT那一刻报错而是等到应用跑起来才暴露。手动验证一遍能提前发现问题mysql -u app_rw -p -h 10.0.1.20 -P 3307 -e SELECT COUNT(*) FROM shop.orders LIMIT 1;第二确认没给多。假设你本意是给SELECT结果手滑写了SELECT, DROP这是不会报错的只会默默多一个权限。用SHOW GRANTS复核SHOW GRANTS FOR app_rw10.0.1.%;输出里如果有GRANT OPTION或者出现你不记得授过的权限就要追查来源。我在实际项目里遇到过一次某个账号的权限每次都自己变多最后发现是一个旧的管理脚本还在定时跑自动补权限。这种看不见的变更来源是最麻烦的定期复核SHOW GRANTS是唯一能提前发现它的办法。我个人做完这些之后习惯再做一件事把当前所有账号的权限导出成一份快照跟上次的快照做 diff。权限的变化比权限的现状更值得关注因为绝大多数安全隐患都是从一次临时授权没回收开始的。这个做法成本很低一条导出命令加一个 diff但能让你在下一次巡检时清楚地知道这段时间里权限经历了什么。踩过几次坑之后我的体会是MySQL 安全性控制这件事没有一劳永逸的配置它更像是一次次小决定的累积这条授权要不要给、这个账号的主机范围写多宽、这个视图的 definer 有没有人会删。每一个决定单看都很小加起来就是你的实例到底是挡住了一次扫描还是被人一锅端的区别。
返回列表