一台服务器上跑着好几个站,图省事全用 root 连库——这是最常见的做法,也是出事时代价最大的做法。本文讲怎么把数据库账号按站拆开、怎么安全地改密码和授权、怎么把账号锁到指定来源 IP,以及几个容易被忽略的配套问题。
文中场景与配置均已脱敏。
一、为什么要按站分离
假设一台机器上跑两个站:一个商城前台,一个用 WordPress 做的收单页。两个站各有各的库。
如果两个站都用 root 连库,那么任何一个站被打穿,攻击者拿到的就是整台机器上所有库的完全控制权——包括另一个站的订单、用户、支付记录。一个不起眼的插件漏洞,赔上的是全部数据。
正确做法是一站一账号,每个账号只授权自己那个库:
CREATE USER 'shop_app'@'%' IDENTIFIED BY '强密码';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
CREATE TEMPORARY TABLES, LOCK TABLES
ON `shop`.* TO 'shop_app'@'%';
CREATE USER 'wp_app'@'%' IDENTIFIED BY '另一个强密码';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER
ON `wordpress`.* TO 'wp_app'@'%';
FLUSH PRIVILEGES;
这样 WordPress 那个站即使被完全控制,攻击者在数据库层面也只能看到 wordpress 库,商城的数据碰不到。
权限给到什么程度
Web 应用需要的其实就两类:
- 数据操作:
SELECT INSERT UPDATE DELETE——日常读写 - 结构操作:
CREATE DROP INDEX ALTER——迁移(migration)要用
CREATE TEMPORARY TABLES 和 LOCK TABLES 按框架需要给。剩下的一律不给,尤其这几个:
| 权限 | 作用 | 为什么不给 |
|---|---|---|
EVENT |
数据库定时任务 | 应用调度一般在系统层(cron / systemd timer),用不上;给了等于让攻击者能植入定时后门 |
TRIGGER |
触发器 | 业务逻辑写在应用里的项目用不到;给了能悄悄篡改写入的数据 |
EXECUTE |
存储过程 | 同上 |
FILE |
读写服务器文件 | 绝对不能给,能直接读 /etc/passwd、写 webshell |
GRANT OPTION |
给别人授权 | 给了等于账号能自我提权 |
二、账号分离不等于安全
账号按上面的方式拆开之后,看起来隔离已经做到位了。但如果 WordPress 那个站被一个免登录 RCE 漏洞打穿,情况未必如预期。
关键在于一个容易被忽略的细节:两个站共用同一个 php-fpm 进程池。攻击者拿到 WordPress 的执行权限后,进程身份和商城站是同一个,于是:
cat /var/www/shop/.env # 直接读到了商城的数据库账号密码
数据库账号分离,在这种情况下形同虚设。 拦住的是「通过 SQL 越权访问」,没拦住的是「直接读另一个站的配置文件」。
这件事的教训是:
账号分离的前提,是被攻破的那个站拿不到另一个站的凭据。
配套要做的至少有:
- php-fpm 进程池按站分开,每个池跑在不同的系统用户下
- 配置文件权限收紧,
.env之类设成640,属主是各自站点的用户 open_basedir限制,让每个站的 PHP 只能访问自己的目录
只做数据库层面的分离,是把门锁了但窗户开着。
三、改密码
标准做法
MySQL 5.7.6 及以后统一用 ALTER USER:
ALTER USER 'shop_app'@'%' IDENTIFIED BY '新密码';
FLUSH PRIVILEGES;
老教程里的 SET PASSWORD = PASSWORD('...') 和直接 UPDATE mysql.user 都不要再用了,前者已废弃,后者会绕过密码加密逻辑把账号改坏。
关键:先数清楚有几个地方用了这个账号
改密码本身一条命令,难的是同步。一个账号往往不止一处在用:
- 应用的配置文件(
.env、config.php) - 后台脚本 / 定时任务的独立配置
- 别的机器上的脚本(备份机、数据分析机远程连过来)
- 连接池、中间件、监控探针
漏掉任何一个,那个服务就在下次连库时静默失败。而「静默」是最麻烦的——备份脚本连不上库不会有人立刻发现。
稳妥的做法是改之前先全局搜一遍:
# 在所有相关机器上搜账号名
grep -rn "shop_app" /var/www /etc /root/scripts 2>/dev/null
让脚本自己去读配置,而不是把密码抄进脚本
这是个很值回票价的习惯。比如备份脚本,不要写死密码:
# 不好:密码写死在脚本里,改密码要改两个地方,还容易漏
mysqldump -u shop_app -p'密码写死在这' shop | gzip > backup.sql.gz
而是每次运行时从应用的配置文件里现读:
# 好:单一事实来源,改了 .env 备份自动跟上
env_file=/var/www/shop/.env
user=$(grep -m1 '^DB_USERNAME=' "$env_file" | cut -d= -f2- | tr -d '"'"'"' ')
pass=$(grep -m1 '^DB_PASSWORD=' "$env_file" | cut -d= -f2- | tr -d '"'"'"' ')
MYSQL_PWD="$pass" mysqldump -u"$user" shop | gzip > backup.sql.gz
这么写之后,改密码只需要改 .env 一处,所有周边脚本自动跟上,不必逐个同步。
密码不要出现在命令行里
mysql -u shop_app -p'密码' # 危险
同机器上任何用户 ps aux 都能看到这个密码,它还会进 shell 历史。用环境变量代替:
MYSQL_PWD='密码' mysql -u shop_app
或者写进 ~/.my.cnf 并 chmod 600。
四、改授权
查看现有授权
改之前先看清楚现在有什么:
SHOW GRANTS FOR 'shop_app'@'%';
输出大概是这样:
GRANT USAGE ON *.* TO `shop_app`@`%`
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
CREATE TEMPORARY TABLES, LOCK TABLES ON `shop`.* TO `shop_app`@`%`
第一行的 USAGE ON *.* 意思是「这个账号存在,但在全局层面没有任何权限」——这是正常的,不代表有问题。
收回权限
REVOKE EVENT, TRIGGER ON `shop`.* FROM 'shop_app'@'%';
FLUSH PRIVILEGES;
收窄权限的代价:一个静默失败的备份
这是一个很典型的场景。
假设把应用账号的权限收窄,去掉 EVENT、TRIGGER、EXECUTE——理由很充分:这套系统的定时任务在 systemd 层,业务逻辑全在应用代码里,数据库里根本没有事件、触发器、存储过程。
结果第二天,每日备份失败了:
mysqldump: Couldn't execute 'show events':
Access denied for user 'shop_app'@'%' to database 'shop' (1044)
原因是备份脚本用的是 mysqldump --routines --triggers --events——这几乎是所有教程里的标准写法,用来把存储过程、触发器、定时事件一并导出。而 --events 会先执行 SHOW EVENTS 探测一下,这一步被拒了,整个 dump 退出码非零,备份判定失败。
最讽刺的地方是:库里根本一个 event 都没有。 为了导出一个不存在的东西,因为没权限探测,整份备份失败了。而且它是在最后一步失败的——数据已经导了 36 分钟,全部白费。
这里有两种修法,选哪种取决于你怎么看:
| 方案 | 做法 | 评价 |
|---|---|---|
| 把权限加回去 | GRANT EVENT ON shop.* TO ... |
为了一个用不到的功能,给长期在线的应用账号留一个能植入定时后门的权限。不划算 |
| 改备份命令 | 去掉 --events(连 --routines --triggers 一起去掉) |
库里本来就没这些对象,导出内容完全一样。推荐 |
# 改后
mysqldump --single-transaction --quick --no-tablespaces \
--databases shop | gzip > backup.sql.gz
这件事的普遍教训:收窄权限之后,要把所有用这个账号的运维工具跑一遍。权限问题不会在收窄的当下暴露,而是等到某个定时任务下次运行时才炸,中间可能已经过去好几天。
这类失败还牵出另一个问题——没人盯着的自动备份等于没有备份。定时任务失败往往要过好几天才被发现,因为没有失败告警。备份脚本至少要做到:失败时发个通知,或者记录一个 .last_success 时间戳文件供巡检。
五、限制 IP
'user'@'host' 里的 host 是账号的一部分
这是最容易搞错的地方:MySQL 的账号是 用户名 + 来源 两部分共同构成的。'shop_app'@'%' 和 'shop_app'@'10.0.0.5' 是两个完全不同的账号,各自有各自的密码和授权。
所以「限制 IP」不是「修改一个属性」,而是建一个新账号、把授权重做一遍、再删掉旧的:
-- 1. 建限定来源的新账号
CREATE USER 'shop_app'@'10.0.0.5' IDENTIFIED BY '密码';
-- 2. 授权要重新给一遍,不会从旧账号继承
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
CREATE TEMPORARY TABLES, LOCK TABLES
ON `shop`.* TO 'shop_app'@'10.0.0.5';
-- 3. 确认新账号能连、应用跑正常之后,再删旧的
DROP USER 'shop_app'@'%';
FLUSH PRIVILEGES;
顺序很重要:先建新的、验证通过,再删旧的。反过来做会有一段时间应用连不上库。
host 的几种写法
| 写法 | 含义 |
|---|---|
'user'@'localhost' |
只能通过 unix socket 本机连接 |
'user'@'127.0.0.1' |
只能通过 TCP 本机连接(和上面不是一回事) |
'user'@'10.0.0.5' |
只有这个 IP 能连 |
'user'@'10.0.0.%' |
整个网段能连 |
'user'@'%' |
任何地方都能连——公网暴露时非常危险 |
localhost 和 127.0.0.1 的区别经常坑人:MySQL 客户端默认对 localhost 走 socket,对 127.0.0.1 走 TCP,两者匹配的是不同的账号记录。只建了 localhost 的账号,用 -h 127.0.0.1 连会报 access denied。
Docker 场景要注意
如果 MySQL 跑在容器里、应用也在容器里,那么应用连过来的源 IP 是容器网络的 IP(比如 172.18.0.x),不是宿主机 IP。这时候可以按容器网段授权:
CREATE USER 'shop_app'@'172.18.%' IDENTIFIED BY '密码';
但容器 IP 是会变的,更稳妥的做法是不依赖 IP,而是在网络层就不让外面连进来——见下一节。
比账号限制更彻底的:端口别对外开
不少机器的 MySQL 直接监听 0.0.0.0:3306,全公网可连,只靠密码挡着。这意味着:
- 任何人都能对你的库发起密码爆破
- MySQL 本身的漏洞直接暴露在公网
用 docker port 或 ss -tlnp 检查一下:
ss -tlnp | grep 3306
# 危险:0.0.0.0:3306
# 安全:127.0.0.1:3306
Docker Compose 里端口映射写法的区别:
ports:
- "3306:3306" # 危险:绑到 0.0.0.0,全公网可连
- "127.0.0.1:3306:3306" # 安全:只有宿主机能连
确实需要远程连(比如备份机在另一台机器上),正确做法是:
- 端口只绑内网地址,或者干脆不映射
- 远程访问走 SSH 隧道,或者用防火墙只放行特定源 IP
# 防火墙只放行备份机
ufw allow from 10.0.0.5 to any port 3306
# 或者走 SSH 隧道,数据库端口完全不对外
ssh -fN -L 13306:127.0.0.1:3306 user@db-server
mysql -h 127.0.0.1 -P 13306 -u shop_app -p
隧道的三种转发方式、参数怎么记、以及「中间那个地址到底从谁的视角看」这类高频坑, 展开讲在这篇:SSH 隧道详解:本地转发、远程转发与动态转发。
要提醒的是:隧道适合人用,不适合机器长期用。备份机上的定时任务靠一条挂着的隧道 很脆弱——断了没人知道,任务就静默失败。那种场景还是用上面的防火墙白名单更稳。
六、一份可执行的检查清单
给现有系统做体检,按这个顺序过一遍:
账号层面
- 每个站是不是有自己的库账号,没有共用
root -
SELECT user, host FROM mysql.user;看看有没有遗留的、不知道谁在用的账号 - 有没有账号是
@'%'且授权了*.* - 有没有账号带
FILE、GRANT OPTION、SUPER
SELECT user, host FROM mysql.user;
SHOW GRANTS FOR 'some_user'@'%';
网络层面
-
ss -tlnp | grep 3306是不是绑在0.0.0.0 - 如果必须远程访问,是不是有防火墙规则限制源 IP
配套层面(最容易被忽略)
- 各站的 php-fpm 池是不是分开的,跑在不同系统用户下
-
.env之类的配置文件权限是不是640,属主正确 - 一个站被打穿,能不能读到另一个站的配置文件(实际试一下)
运维层面
- 备份脚本是从配置文件读凭据,还是把密码写死了
- 收窄权限之后,所有定时任务都手动跑过一遍了吗
- 备份失败有没有告警,或者有没有一个可巡检的成功时间戳
小结
数据库账号这件事,容易的部分是那几条 SQL,难的部分全在周边:
- 分离账号只是第一步,配置文件读得到就等于没分离
- 改密码的成本在同步,让脚本从单一配置源读凭据能省掉大部分麻烦
- 收窄权限会打断运维工具,而且是延迟几天才炸,收窄后要主动把定时任务跑一遍
- 限制 IP 是换账号不是改属性,先建后删,顺序错了就是停机
- 最有效的一招其实是别把 3306 对外开,比任何账号策略都管用