某物流企业MySQL数据库被拖库损失惨重 安全工程师总结的10步加固指南 从关闭默认端口到启用SSL加密 手把手教你守护数据资产
那天凌晨三点,我手机响了。
是物流公司的运维老张,声音都在抖——”数据库被人拖了,三十万条运单信息全没了。”
赶到机房的时候,屏幕还闪着红光照得人脸发青。攻击者从3306端口直接连进来的,用的还是两年前的默认密码”root123”。等我们反应过来,人家早就删库跑路了,留下的只有一地鸡毛和监管部门的通知函。
后来复盘,整条链路漏洞百出:默认端口没改、弱口令、没有SSL加密、权限配置得像筛子一样…每一处单独看都不致命,合在一起就是致命伤。
今天把这次事故的复盘和后续做的十步加固,原原本本写出来。不是写给领导看的汇报材料,是写给真正在数据库旁边熬夜的你们。
第一步:改掉那个”大家都知道但没人改”的默认端口
很多人觉得改端口是”security by obscurity”,没用。确实,端口扫描器能扫出来。但现实是,绝大多数自动化攻击脚本只会盯着3306打,改个端口能过滤掉90%的无差别扫描。
# 修改MySQL端口配置
# 编辑 /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
port = 3312 # 改一个非标准端口,避免被自动化扫描工具命中
# 重启MySQL
systemctl restart mysql
改完之后,连接方式也要跟着变:
# 原来的连接方式
mysql -h 192.168.1.100 -P 3306 -u admin -p
# 改端口后的连接方式
mysql -h 192.168.1.100 -P 3312 -u admin -p
防火墙规则也要同步更新,不然白改:
# iptables 示例
iptables -A INPUT -p tcp --dport 3312 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 3312 -j DROP
# 只允许内网特定网段访问
firewall-cmd --add-port=3312/tcp --permanent
firewall-cmd --reload
老张那天跟我说,他们公司的MySQL端口三年来一直没改过,连外包开发的笔记本都能直接连上生产库。这种”方便”的代价,我们刚付完。
第二步:密码策略,别再用”root123”这种侮辱智商的密码
拖库事故里,百分之七八十能用弱口令解释。企业环境里密码复杂度要求经常形同虚设——有人为了”好记”,把密码改成”Company2023!“,半年换一个。
-- 启用密码强度验证插件
INSTALL PLUGIN validate_password SONAME 'validate_password.so';
-- 配置密码策略(最严格级别)
SET GLOBAL validate_password.policy = STRONG;
SET GLOBAL validate_password.length = 16;
SET GLOBAL validate_password.mixed_case_count = 2;
SET GLOBAL validate_password.number_count = 2;
SET GLOBAL validate_password.special_char_count = 2;
STRONG级别的要求:
- 至少16个字符
- 包含大小写字母各至少2个
- 数字至少2个
- 特殊字符至少2个
- 不能包含用户名或字典词
-- 创建符合策略的新管理员账户
CREATE USER 'db_admin'@'10.0.0.%' IDENTIFIED BY 'K#9mP$2xL@7nQ&4w';
-- 验证密码策略是否生效
SHOW VARIABLES LIKE 'validate_password%';
顺便说一句,密码不要写在配置文件里。有人把密码写在my.cnf的[client]段里,结果服务器被拿下,配置文件直接泄露。用环境变量或者专门的密钥管理服务。
第三步:最小权限原则,给每个账户划定明确边界
这是老张他们公司最大的问题——一个运维账号,权限比DBA还大。
-- 创建只读报表账户(只允许查,不允许改)
CREATE USER 'report_user'@'10.0.1.%' IDENTIFIED BY 'Rp#7kL9mN2pQ4xZ8';
GRANT SELECT ON logistics_db.* TO 'report_user'@'10.0.1.%';
FLUSH PRIVILEGES;
-- 创建应用账户(只允许特定表的增删改查)
CREATE USER 'app_user'@'10.0.2.%' IDENTIFIED BY 'Ap$5nM8kP3qR7wY2';
GRANT SELECT, INSERT, UPDATE ON logistics_db.orders TO 'app_user'@'10.0.2.%';
GRANT SELECT ON logistics_db.customers TO 'app_user'@'10.0.2.%';
-- 禁止访问敏感表
-- 不授予FILE、SUPER、PROCESS等危险权限
-- 查看某用户的实际权限
SHOW GRANTS FOR 'app_user'@'10.0.2.%';
-- 定期审计所有账户的权限
SELECT
user,
host,
COUNT(*) as total_privileges,
SUM(if(Priv = 'Select',1,0)) as select_count,
SUM(if(Priv = 'Update',1,0)) as update_count,
SUM(if(Priv = 'Delete',1,0)) as delete_count,
SUM(if(Priv = 'Create',1,0)) as create_count,
SUM(if(Priv = 'Drop',1,0)) as drop_count,
SUM(if(Priv = 'Grant',1,0)) as grant_count,
SUM(if(Priv = 'Alter',1,0)) as alter_count,
SUM(if(Priv = 'Index',1,0)) as index_count,
SUM(if(Priv = 'References',1,0)) as references_count,
SUM(if(Priv = 'Create_tmp_table',1,0)) as create_tmp_table_count,
SUM(if(Priv = 'Lock_tables',1,0)) as lock_tables_count,
SUM(if(Priv = 'Create_view',1,0)) as create_view_count,
SUM(if(Priv = 'Show_view',1,0)) as show_view_count,
SUM(if(Priv = 'Create_routine',1,0)) as create_routine_count,
SUM(if(Priv = 'Alter_routine',1,0)) as alter_routine_count,
SUM(if(Priv = 'Execute',1,0)) as execute_count,
SUM(if(Priv = 'Event',1,0)) as event_count,
SUM(if(Priv = 'Trigger',1,0)) as trigger_count
FROM mysql.global_priv
WHERE user != 'mysql.sys'
GROUP BY user, host
ORDER BY total_privileges DESC;
物流行业有个特点:运单号、收件人电话、地址这些是敏感数据。但很多系统直接把整张表对应用账号开放。改成分字段授权,至少能把损失面控住。
第四步:关闭不必要的功能和存储过程
MySQL默认装了很多功能,很多企业根本用不上。这些”功能”往往是攻击者的跳板。
-- 查看当前启用的功能
SHOW VARIABLES LIKE '%secure%';
-- 关键参数:禁止从本地读取文件
-- 在 my.cnf 中配置
[mysqld]
secure_file_priv = /var/lib/mysql-files/
-- 或者更严格
secure_file_priv = ''
-- 禁用本地数据加载(防止攻击者通过LOAD DATA LOCAL读取服务器文件)
SET GLOBAL local_infile = 0;
-- 查看当前状态
SHOW VARIABLES LIKE 'local_infile';
-- 禁用不必要的存储过程执行权限
-- 很多攻击者会通过存储过程执行系统命令
REVOKE EXECUTE ON *.* FROM 'some_user'@'host';
-- 检查是否有危险的存储过程
SELECT
db,
name,
type,
security_type,
definer
FROM mysql.proc
WHERE security_type = 'DEFINER'
AND definer NOT LIKE '%localhost';
# my.cnf 完整安全配置片段
[mysqld]
# 安全相关文件目录
secure_file_priv = /var/lib/mysql-files/
local_infile = 0
# 禁用本地文件系统访问
show_compatibility_56 = ON
# 只接受TCP连接,禁用socket文件(可选)
# skip-networking 慎用,会影响本地连接
第五步:SSL/TLS加密,别让数据在传输中”裸奔”
这是老张他们完全没做的。数据库和应用之间的连接明文传输,中间随便加个ARP欺骗或者流量镜像,数据全透明。
# 生成自签名证书(生产环境建议用正规CA)
mkdir -p /etc/mysql/ssl
cd /etc/mysql/ssl
# 生成CA证书
openssl req -newkey rsa:2048 -days 3650 -nodes -keyout ca-key.pem -out ca-cert.pem
# 生成服务器证书
openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -out server-cert.pem
openssl x509 -req -in server-cert.pem -days 3650 -CA ca-cert.pem -CAkey ca-key.pem -set_serial 01 -out server-cert.pem
# 生成客户端证书
openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client-cert.pem
openssl x509 -req -in client-cert.pem -days 3650 -CA ca-cert.pem -CAkey ca-key.pem -set_serial 02 -out client-cert.pem
# 设置权限
chmod 600 *.pem
chown mysql:mysql *.pem
# my.cnf 配置SSL
[mysqld]
ssl-ca=/etc/mysql/ssl/ca-cert.pem
ssl-cert=/etc/mysql/ssl/server-cert.pem
ssl-key=/etc/mysql/ssl/server-key.pem
# 要求特定账户使用SSL
[mysqld]
require_secure_transport = ON
-- 创建强制使用SSL的账户
CREATE USER 'secure_app'@'10.0.2.%' IDENTIFIED BY 'Sd#4mN7pQ9xZ2kL5' REQUIRE SSL;
GRANT SELECT, INSERT ON logistics_db.orders TO 'secure_app'@'10.0.2.%';
-- 更严格:指定CA和证书
CREATE USER 'strict_app'@'10.0.2.%' IDENTIFIED BY 'St$8nM2pQ5xZ9kL3'
REQUIRE X509;
GRANT SELECT ON logistics_db.customers TO 'strict_app'@'10.0.2.%';
-- 验证SSL连接状态
SHOW GLOBAL STATUS LIKE 'Ssl_cipher';
SHOW GLOBAL STATUS LIKE 'Ssl_verify_mode';
SHOW VARIABLES LIKE 'have_openssl';
# 客户端连接测试(指定证书)
mysql -h 192.168.1.100 -P 3312 -u secure_app -p \
--ssl-ca=/etc/mysql/ssl/ca-cert.pem \
--ssl-cert=/etc/mysql/ssl/client-cert.pem \
--ssl-key=/etc/mysql/ssl/client-key.pem
物流系统的数据库和应用服务器通常在不同机房,网络层不可控。SSL不是可选项,是必选项。
第六步:网络访问控制,谁可以连你的数据库
这一步很多人会忽略——数据库只应该被特定的IP访问。
# 防火墙规则示例(只允许应用服务器访问)
# 允许应用网段
iptables -A INPUT -p tcp --dport 3312 -s 10.0.2.0/24 -j ACCEPT
# 允许运维堡垒机
iptables -A INPUT -p tcp --dport 3312 -s 10.0.100.5/32 -j ACCEPT
# 拒绝其他所有
iptables -A INPUT -p tcp --dport 3312 -j DROP
# 如果用firewalld
firewall-cmd --add-rich-rule='rule family="ipv4" source address="10.0.2.0/24" port port="3312" protocol="tcp" accept' --permanent
firewall-cmd --add-rich-rule='rule family="ipv4" source address="10.0.100.5/32" port port="3312" protocol="tcp" accept' --permanent
firewall-cmd --add-rich-rule='rule family="ipv4" port port="3312" protocol="tcp" reject' --permanent
firewall-cmd --reload
-- MySQL层面的账户主机限制
-- 不要用 '%' 通配符,精确指定IP或网段
CREATE USER 'app_user'@'10.0.2.15' IDENTIFIED BY 'Ap$5nM8kP3qR7wY2';
CREATE USER 'app_user'@'10.0.2.16' IDENTIFIED BY 'Ap$5nM8kP3qR7wY2';
-- 或者网段
CREATE USER 'report_user'@'10.0.1.%' IDENTIFIED BY 'Rp#7kL9mN2pQ4xZ8';
# 检查当前监听状态
ss -tlnp | grep mysql
netstat -tlnp | grep 3312
# 确保只监听在内网IP上,不要监听0.0.0.0
# my.cnf中配置
[mysqld]
bind-address = 10.0.2.100
第七步:日志审计,出事之后有迹可查
老张他们公司的MySQL只开了错误日志,二进制日志也没开。等发现被拖库的时候,根本不知道攻击者做了什么、拿了什么数据。
# my.cnf 开启完整日志
[mysqld]
# 错误日志
log_error = /var/log/mysql/error.log
# 二进制日志(用于恢复和审计)
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
expire_logs_days = 30
max_binlog_size = 100M
# 通用查询日志(生产环境谨慎开启,有性能影响)
# general_log = 1
# general_log_file = /var/log/mysql/general.log
# 慢查询日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
# 登录失败记录
log_error_verbosity = 3
-- 创建审计账户(独立的审计库)
CREATE DATABASE audit_log;
CREATE TABLE audit_log.query_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_time DATETIME NOT NULL,
user_host VARCHAR(255) NOT NULL,
thread_id INT NOT NULL,
query_time TIME NOT NULL,
rows_sent INT DEFAULT NULL,
rows_examined INT DEFAULT NULL,
sql_text TEXT NOT NULL,
INDEX idx_time (event_time),
INDEX idx_user (user_host)
) ENGINE=InnoDB;
-- 用事件调度器定期归档(如果开启general_log)
-- 或者用审计插件
# 安装MySQL审计插件(MariaDB审计插件或Percona审计插件)
# MariaDB审计插件示例
INSTALL PLUGIN audit_log SONAME 'audit_log.so';
-- 配置审计规则
SET GLOBAL audit_log_filter = '{"filter":{"log":1,"command":"all","user":"*","host":"*"}}';
# 定期清理日志,避免磁盘爆满
# 保留最近7天的慢查询日志用于分析
mysql -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY);"
# 检查日志状态
SHOW BINARY LOGS;
SHOW MASTER STATUS;
第八步:补丁管理,别让已知漏洞成为突破口
物流行业很多系统还在用MySQL 5.6甚至5.5,这些版本早就不支持了,还有一堆CVE漏洞。
# 检查当前版本和已知漏洞
mysql --version
# 查看安全更新历史
# MySQL官网安全公告:https://dev.mysql.com/news/
# 定期检查:https://mysqlcore.com/cve/
# 升级流程(示例:从5.7升级到8.0)
# 1. 备份
mysqldump -u root -p --all-databases --single-transaction > backup_$(date +%Y%m%d).sql
# 2. 检查兼容性
# 使用mysql_upgrade(5.7及以下版本)
mysql_upgrade -u root -p
# 3. 升级(以yum为例)
yum update mysql-server
# 4. 验证
mysql -e "SELECT VERSION();"
-- 检查是否有高危漏洞相关的配置问题
SELECT @@sql_mode;
SELECT @@secure_file_priv;
SELECT @@local_infile;
SELECT @@require_secure_transport;
-- 确保使用最新的安全补丁版本
-- MySQL 5.7最低支持到2023年10月
-- MySQL 8.0是当前LTS版本
# 设置自动安全更新(推荐)
# Ubuntu/Debian
apt-get install unattended-upgrades
# 编辑 /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Package-Whitelist {
"mysql-server";
"mysql-client";
"mysql-common";
};
# CentOS/RHEL
yum-automatic 或 dnf-automatic
第九步:数据加密,最后一道防线
网络层加密了,但如果数据库文件被拷贝走呢?磁盘被偷了呢?这个时候需要透明数据加密(TDE)或应用层加密。
# MySQL 8.0+ 透明数据加密(TDE)
# 创建keyring插件目录
mkdir -p /var/lib/mysql-keyring
chown mysql:mysql /var/lib/mysql-keyring
# 配置keyring插件
[mysqld]
plugin-load-add = keyring_file.so
keyring_file_data = /var/lib/mysql-keyring/keyring
-- 启用TDE
ALTER INSTANCE RELOAD TLS CONFIGURATION;
-- 加密表空间
ALTER TABLE logistics_db.orders ENCRYPTED='Y';
-- 检查加密状态
SELECT
SCHEMA_NAME,
TABLE_NAME,
ENCRYPTED,
CREATE_TIME
FROM information_schema.TABLES
WHERE ENCRYPTED = 'Y';
-- 加密敏感字段(应用层示例)
-- 使用AES_ENCRYPT存储敏感数据
ALTER TABLE customers ADD COLUMN phone_enc VARBINARY(256);
-- 插入时加密
INSERT INTO customers (name, phone_enc)
VALUES ('张三', AES_ENCRYPT('13800138000', 'your-secret-key-here'));
-- 查询时解密
SELECT name, AES_DECRYPT(phone_enc, 'your-secret-key-here') AS phone
FROM customers;
# Python应用层加密示例(更安全的做法)
from cryptography.fernet import Fernet
import base64
import os
# 生成密钥(应存储在密钥管理服务中,不要硬编码)
def generate_key():
return Fernet.generate_key()
# 加密敏感数据
def encrypt_data(plain_text, key):
fernet = Fernet(key)
token = fernet.encrypt(plain_text.encode())
return base64.b64encode(token).decode()
# 解密
def decrypt_data(encrypted_token, key):
fernet = Fernet(key)
plain_text = fernet.decrypt(base64.b64decode(encrypted_token))
return plain_text.decode()
# 使用示例
key = generate_key()
encrypted_phone = encrypt_data('13800138000', key)
print(f"加密后: {encrypted_phone}")
print(f"解密后: {decrypt_data(encrypted_phone, key)}")
第十步:监控告警,别等出事才想起来
老张他们事故后补了一个监控,但只能看到CPU和内存,看不到谁在查询什么、有没有异常登录。这种监控等于没有。
# 使用Prometheus + mysqld_exporter监控MySQL
# docker-compose方式部署
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
mysql_exporter:
image: prom/mysqld-exporter
environment:
DATA_SOURCE_NAME: 'exporter:Export123@tcp(127.0.0.1:3312)/'
ports:
- "9104:9104"
networks:
- monitoring
prometheus:
image: prom/prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
networks:
- monitoring
grafana:
image: grafana/grafana
ports:
- "3000:3000"
networks:
- monitoring
networks:
monitoring:
EOF
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'mysql'
static_configs:
- targets: ['mysql_exporter:9104']
-- 创建监控账户
CREATE USER 'monitor'@'10.0.100.%' IDENTIFIED BY 'Mn#9kL2pQ5xZ8mN4';
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'monitor'@'10.0.100.%';
FLUSH PRIVILEGES;
# 异常登录告警脚本
cat > /usr/local/bin/mysql_alert.sh << 'EOF'
#!/bin/bash
# 检测异常MySQL登录
LOG_FILE="/var/log/mysql/error.log"
ALERT_THRESHOLD=5
CHECK_INTERVAL=300
while true; do
# 检查最近5分钟内的登录失败次数
FAILED_COUNT=$(grep -c "Access denied" "$LOG_FILE" | tail -1)
if [ "$FAILED_COUNT" -gt "$ALERT_THRESHOLD" ]; then
echo "警告:检测到异常登录尝试,失败次数: $FAILED_COUNT" | \
tee /var/log/mysql/alert.log
# 发送告警(邮件/钉钉/企业微信)
curl -X POST "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"msgtype":"text","text":{"content":"MySQL异常登录告警:最近5分钟失败次数超过阈值"}}'
fi
sleep $CHECK_INTERVAL
done
EOF
chmod +x /usr/local/bin/mysql_alert.sh
-- 实时监控查询
-- 查看当前连接数和来源
SHOW PROCESSLIST;
-- 统计各IP连接数
SELECT
SUBSTRING_INDEX(host, ':', 1) AS client_ip,
COUNT(*) AS connections,
SUM(Command) AS command_count
FROM information_schema.processlist
GROUP BY client_ip
ORDER BY connections DESC;
-- 检查最近登录失败的账户
SELECT
user,
host,
COUNT(*) AS failed_attempts
FROM mysql.error_log
WHERE message LIKE '%Access denied%'
GROUP BY user, host
HAVING failed_attempts > 10;
# 使用MySQL Enterprise Audit或Percona Audit Log Plugin进行详细审计
# 记录所有DML操作
audit_log_filter = ' {"filter": {"log": 1, "command": "all", "user": "*"}}'
老张他们公司拖库之后,用了两周时间按这十步全部加固了一遍。上个月我回访,运维团队已经养成了每周审查权限、每月更新补丁的习惯。
数据泄露这个事,没有”万一”。你觉得不重要的疏忽,攻击者眼里就是突破口。数据库是物流企业的命根子——运单号、客户信息、结算数据,全在那儿。守着它,就是守着自己的饭碗。
希望这篇文章能帮到你。有任何问题,随时问。
