SQL注入攻击的防御通常被放在应用层,比如参数化查询、输入过滤等,但如果攻击者绕过了应用层的检查,直接把恶意SQL送到了数据库,DBA往往毫无察觉。其实数据库本身也具备一定的自我监控能力,通过触发器配合关键字匹配,可以在数据发生变更时捕捉到可疑的注入特征,及时记录日志甚至阻断操作。这种方式不是替代应用层防护的方案,而是一种纵深防御思路下的补充手段,特别适合没法立即改造老旧代码、又想快速增加感知能力的场景。

一、为什么触发器能监控注入风险,它的原理和局限是什么
触发器是数据库中一段与表绑定的存储程序,当表上发生INSERT、UPDATE、DELETE事件时会被自动执行。注入攻击一旦得手,绝大多数情况下会伴随着对数据的篡改,比如向用户表写入恶意账号、修改管理员密码、批量篡改文章内容挂马等。这些动作都会触发我们预设的监控逻辑,从而留下攻击痕迹。
关键字匹配的思路是:在触发器内部检查与本次操作相关的上下文信息,包括新数据的内容、当前会话的连接信息等,判断其中是否包含典型的注入载荷特征。常见的特征包括or 1=1、union select、' --注释截断、xp_cmdshell等数据库扩展调用、以及十六进制编码的可疑字符串。一旦匹配命中,就把时间、用户、主机、原始数据全部写入专门的审计日志表。
不过必须清醒认识到触发器方案的局限:第一,触发器只能感知到已经发生或正在发生的数据变更,属于事后告警,不能阻止纯查询型的盲注;第二,触发器无法直接拿到完整的原始SQL语句(部分数据库可以通过内置函数获取),所以关键字匹配主要针对写入的数据值和会话上下文;第三,触发器会给每次写操作增加额外开销,高频写入的表需要评估性能影响。理解这些边界,才能把它用在正确的位置。
二、设计关键字字典与审计日志表结构
关键字字典是整个方案的核心。字典不宜过大,要聚焦那些几乎不会在正常业务数据中出现、但注入时高频出现的特征串。建议把关键字分为三类:布尔恒真类(如1=1、'or'='or')、堆叠与联合查询类(如union select、; drop table)、系统函数与注释类(如xp_cmdshell、load_file、--、/*)。分类的好处是命中时可以输出风险等级,方便后续告警分级处理。
审计日志表的设计要尽量保留现场。下面是一个适用于MySQL的日志表结构,包含风险等级、命中关键字、被写入的脏数据、会话账号和来源IP等信息。注意把IP存成字符串形式,并加上索引以便查询统计。
CREATE TABLE sql_inject_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
hit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
db_user VARCHAR(64) NOT NULL COMMENT '触发器执行时的会话账号',
client_ip VARCHAR(64) NOT NULL COMMENT '来源IP,由应用层写入或代理层记录',
table_name VARCHAR(128) NOT NULL COMMENT '被操作的表',
risk_level TINYINT NOT NULL COMMENT '1低 2中 3高',
hit_keyword VARCHAR(255) NOT NULL COMMENT '命中的关键字',
suspect_value TEXT NULL COMMENT '触发命中的可疑数据',
action_taken VARCHAR(20) NOT NULL COMMENT 'LOG或BLOCK'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_hit_time ON sql_inject_audit(hit_time);
CREATE INDEX idx_risk ON sql_inject_audit(risk_level);
这里有一点经验值得分享:不要在触发器里直接对每个关键字做硬编码判断,而是建一张关键字配置表,触发器读取配置表进行匹配。这样后续增加关键字不需要修改触发器,运维上灵活很多。配置表只需两列:关键字本身和风险等级。
三、编写触发器实现关键字匹配拦截
下面以MySQL为例,给一张模拟的用户表t_user配置INSERT和UPDATE两个监控触发器。触发器会把新写入的字段值拼接成一个待检查字符串,逐个关键字进行定位匹配,命中后写入审计表。如果风险等级为高,可以直接抛出错误让本次写入失败,实现真正意义上的拦截。
DELIMITER $$
CREATE TRIGGER trg_user_insert_check
BEFORE INSERT ON t_user
FOR EACH ROW
BEGIN
DECLARE check_text TEXT;
DECLARE hit_kw VARCHAR(255) DEFAULT '';
DECLARE risk TINYINT DEFAULT 0;
-- 拼接所有写入字段,统一小写后匹配
SET check_text = CONCAT_WS('|',
IFNULL(NEW.username,''),
IFNULL(NEW.password,''),
IFNULL(NEW.email,''),
IFNULL(NEW.remark,''));
SELECT kw.keyword, kw.risk_level INTO hit_kw, risk
FROM inject_keyword_dict kw
WHERE LOWER(check_text) LIKE CONCAT('%', LOWER(kw.keyword), '%')
ORDER BY kw.risk_level DESC
LIMIT 1;
IF risk > 0 THEN
INSERT INTO sql_inject_audit
(db_user, client_ip, table_name, risk_level,
hit_keyword, suspect_value, action_taken)
VALUES
(CURRENT_USER(), 'unknown', 't_user', risk,
hit_kw, check_text, IF(risk >= 3, 'BLOCK', 'LOG'));
-- 高危直接回滚本次写入
IF risk >= 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Blocked by injection monitor: suspect payload detected';
END IF;
END IF;
END$$
CREATE TRIGGER trg_user_update_check
BEFORE UPDATE ON t_user
FOR EACH ROW
BEGIN
DECLARE check_text TEXT;
DECLARE hit_kw VARCHAR(255) DEFAULT '';
DECLARE risk TINYINT DEFAULT 0;
SET check_text = CONCAT_WS('|',
IFNULL(NEW.username,''),
IFNULL(NEW.password,''),
IFNULL(NEW.email,''),
IFNULL(NEW.remark,''));
SELECT kw.keyword, kw.risk_level INTO hit_kw, risk
FROM inject_keyword_dict kw
WHERE LOWER(check_text) LIKE CONCAT('%', LOWER(kw.keyword), '%')
ORDER BY kw.risk_level DESC
LIMIT 1;
IF risk > 0 THEN
INSERT INTO sql_inject_audit
(db_user, client_ip, table_name, risk_level,
hit_keyword, suspect_value, action_taken)
VALUES
(CURRENT_USER(), 'unknown', 't_user', risk,
hit_kw, check_text, IF(risk >= 3, 'BLOCK', 'LOG'));
IF risk >= 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Blocked by injection monitor';
END IF;
END IF;
END$$
DELIMITER ;
代码中有几个关键点需要解释。首先是BEFORE时机的选择:在数据真正落库前执行检查,配合SIGNAL语句抛出异常,可以中止本次操作,达到拦截效果;如果用AFTER触发器就只能记录,拦不住。其次是CONCAT_WS拼接字段的做法简单粗暴,但覆盖面好,任何字段被塞入恶意载荷都能检测到。第三,通过ORDER BY risk_level DESC LIMIT 1保证取到风险最高的那个关键字,避免多关键字命中时信息混乱。
关于client_ip字段,MySQL的触发器内无法直接拿到客户端IP,常见做法有两个:一是让应用层在连接后先执行SET @client_ip = 'xxx',触发器里用@client_ip读取;二是结合连接层中间件(如ProxySQL)记录来源。对于SQL Server用户,可以直接查询sys.dm_exec_connections获取连接IP,实现上更方便。
四、测试验证与注意事项
触发器创建完成后,必须做正向和反向两方面的测试。反向测试是模拟攻击者写入注入载荷,验证能否命中关键字并成功拦截:
-- 模拟注入载荷写入,应被拦截
INSERT INTO t_user(username, password, email, remark)
VALUES('admin', '123456', 'a@ipipp.com',
"test' OR '1'='1");
-- 模拟union注入特征,应被拦截
INSERT INTO t_user(username, password, email, remark)
VALUES('user1', '123456', 'b@ipipp.com',
"x' UNION SELECT password FROM t_user--");
-- 正常数据写入,不应受影响
INSERT INTO t_user(username, password, email, remark)
VALUES('zhangsan', 'abc123', 'zhangsan@ipipp.com', '普通备注信息');
-- 查看审计结果
SELECT * FROM sql_inject_audit ORDER BY hit_time DESC;
测试时常见的坑是误报问题。比如业务备注里合法包含单引号、或者教育类网站的文章正文里本来就含有union、select这类单词。应对办法有三条:一是调整字典,把过泛的关键字改成更精确的组合形式,例如用union select替代单独的select;二是给特定字段加白名单豁免;三是把中低风险的命中只记日志不拦截,由人工或脚本二次分析审计表,确认模式后再升级为阻断规则。
性能方面,触发器在每次写入都会执行一次模糊匹配查询,对写入频率极高的表(如日志表、流水表)会产生明显开销。建议只对核心资产表启用,比如账号表、权限表、支付配置表。同时给关键字字典表控制在几十条以内,并定期归档审计日志,防止日志表本身膨胀。配合Zabbix或Prometheus对审计表做监控告警,命中高危关键字时立即通知DBA,这套轻量级的注入感知体系就完整了。
五、总结
触发器加关键字匹配的方案,本质上是利用数据库自身的能力构建的一道最后防线。它的价值不在于百分百拦截攻击,而在于当注入真的发生时,你能第一时间知道哪张表、哪个字段、被写入了什么内容,为应急响应争取宝贵时间。在老旧系统无法立即修复代码漏洞的过渡期,这套方案尤其实用。需要牢记的是,参数化查询和输入校验永远是防注入的根本手段,触发器监控只是纵深防御体系中的一环,两者结合使用才能形成完整闭环。