之前带学员做 SRC 漏洞挖掘练习时,很多刚接触网络安全的朋友都会问同一个问题:“我连 MySQL 查询都不熟练,真的能去做挖洞实战吗?”我的回答通常是:恰恰因为 SQL 基础不牢,很多人在测试注入点和判断业务逻辑时才会毫无头绪。SQL 注入只是攻击手法,而读数据、过滤条件、判断空值,才是你手里真正的基础武器。
本篇文章是“零基础入门到 SEC 挖洞实战”系列的第 24 篇。我会聚焦 MySQL 条件查询中一个既基础又容易踩坑的话题:判断字段是否为空。内容会围绕 IS NULL、空字符串、IFNULL、COALESCE、动态 WHERE 条件拼接展开,同时结合网络安全从业者的视角,分析这些查询写法在漏洞挖掘、绕过防护和代码审计中可能遇到的问题。
如果你是非科班转行网络安全,或者刚开始接触 SRC挖洞平台,想系统补一遍 MySQL 基础,那么这篇文章非常适合你。本文会给出大量可直接执行的 SQL 示例,也会提醒你哪些写法在“挖洞视角”下特别危险。
1. 为什么学渗透测试还要熟练 MySQL 条件查询
1.1 从挖洞实战看 SQL 基础的价值
很多刚开始接触网络安全的人会有一个误区:觉得“挖洞”就是拿到一个 URL,然后用扫描器跑一遍,看到漏洞直接提交报告。但真实场景中,无论是手工验证 SQL 注入,还是分析一个业务接口是否存在越权,都要求你能够理解后端的查询逻辑。
举个例子,一个商城网站的搜索框,本质可能执行了类似下面的 SQL:
SELECT * FROM products WHERE name LIKE '%手机%' AND status = 1;如果你在搜索框输入一个单引号,页面报出数据库错误,你能否判断它拼接 SQL 的方式?如果后端代码用WHERE name = '用户输入' AND delete_flag IS NULL这样的逻辑,你又能否通过参数改变判断条件?这些都依赖你对 MySQL 条件查询有足够深的理解。
另外,在 SRC 漏洞挖掘中,很多逻辑漏洞来自开发者没有正确处理“空值”与“空字符串”。同一个字段,可能在某些情况下是 NULL,另一些情况下是空字符串。如果查询条件判断不严谨,就可能出现数据越权访问或者业务逻辑绕过。
1.2 MySQL 条件查询在整个学习路线中处于什么位置
对于零基础入门的读者,MySQL 的学习路径通常建议按照下面这个顺序展开:
- 安装 MySQL 并掌握基本连接命令。
- 学会建库、建表、插入基础数据。
- 掌握 SELECT 查询和 WHERE 条件过滤。
- 掌握聚合、排序、分组。
- 掌握多表 JOIN 连接查询。
- 理解事务、索引、权限。
- 结合 Web 应用学习如何安全地拼接 SQL。
本文讨论的“判断是否为空”属于第 3 阶段中一个比较细的分支。它不复杂,但坑极多。很多人在这个点上出了问题,不是因为不知道IS NULL这个语法,而是因为在真实业务中,运行了几年、存了几百万条数据的表里,NULL和空字符串几乎总是混合存在的。
1.3 为什么要单独用一篇文章讲“为空”判断
MySQL 中的“空值”并不真的是“什么都没有”。从底层存储来看,NULL是一个特殊标记,它表示“未知”或“不存在”;而空字符串''是一个真实的字符串值,长度为 0。这两种状态的判断方式完全不同,这也是最容易出问题的地方。
如果不加区分地使用= NULL或者!= NULL,查询结果会直接让你怀疑人生。后面会专门演示这些错误写法,并给出正确写法。
2. 环境准备与基础表设计
2.1 环境说明
本文的 SQL 示例以 MySQL 8.x 为例,同时也兼容 MySQL 5.7 的绝大部分写法。如果你使用的是 MariaDB,本节的 SQL 也可以直接运行。
版本需要根据你的实际项目做调整,本文重点演示 SQL 查询逻辑,不在安装和版本差异上过多展开。如果你的电脑还没有安装 MySQL,可以从官网下载社区版,也可以使用 Docker 快速拉起一个临时环境:
docker run --name mysql-study -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql:8.0启动容器后,可以使用下面的命令进入 MySQL 交互界面:
docker exec -it mysql-study mysql -uroot -p123456再次说明:这只是为了方便本地练习。生产环境中,数据库密码策略、权限管理必须按照企业的安全规范来执行,不能在公网环境随意暴露端口。
2.2 创建一张“用户表”作为演示样本
为了更贴近网络安全学习中常见的“用户中心”“订单系统”场景,我们创建一张用户扩展信息表。这张表除了常见字段外,特别加入了几个容易出现 NULL 和空字符串问题的字段。
CREATE DATABASE IF NOT EXISTS sec_demo DEFAULT CHARSET utf8mb4; USE sec_demo; DROP TABLE IF EXISTS t_user_profile; CREATE TABLE t_user_profile ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL COMMENT '用户名', nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', intro TEXT COMMENT '个人简介', delete_flag TINYINT DEFAULT 0 COMMENT '删除标记,0表示未删除,1表示已删除' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '用户扩展信息表';解释一下字段设计思路:
username使用NOT NULL,因为注册时一定有用户名,这是强约束。nickname、phone、email使用DEFAULT NULL,表示“用户可能没填”。intro是可空文本字段,适合模拟“个人简介为空”的情况。delete_flag是软删除标记,这是很多业务系统里都会出现的字段,后面在“动态条件查询”和“挖洞视角”中会重点讨论。
2.3 写入测试数据
插入下面的测试数据,方便后续验证不同查询结果:
INSERT INTO t_user_profile (username, nickname, phone, email, intro, delete_flag) VALUES ('zhangsan', '张三', '13800001111', 'zhangsan@example.com', '网络安全爱好者', 0), ('lisi', NULL, NULL, NULL, NULL, 0), ('wangwu', '', '13900002222', '', '', 0), ('zhaoliu', '赵六', NULL, 'zhaoliu@example.com', '测试账号', 1), ('sunqi', '', NULL, 'sunqi@example.com', NULL, 0);现在表里的数据状态很有趣:
lisi的 nickname、phone、email 都是 NULL。wangwu的 nickname 是空字符串,email、intro 也是空字符串。sunqi的 nickname 是空字符串,intro 却是 NULL。zhaoliu是已删除用户,delete_flag = 1。
这些混在一起的数据,就是实际项目里最常见的状态。接下来我们开始查询。
3. 判断字段是否为 NULL:IS NULL 和 IS NOT NULL
3.1 错误的“= NULL”写法
初学 MySQL 时,几乎每个人都写过这样的 SQL:
-- 错误示例:查不到任何数据 SELECT * FROM t_user_profile WHERE nickname = NULL;执行结果是什么?没有任何数据返回,也不会报错。原因是 MySQL 中NULL不是一个值,而是一个“未知”的状态。=是值之间的相等比较,当事务一方是未知时,整个表达式的结果也是未知,WHERE 子句只会留下结果为 TRUE 的行,所以查询结果为空。
同样,下面这条 SQL 也是错误的:
-- 错误示例:会返回所有非NULL行,但这里不是说“没有昵称” SELECT * FROM t_user_profile WHERE nickname != NULL;3.2 正确的 IS NULL 写法
要判断字段是否为 NULL,必须使用IS NULL:
SELECT id, username, nickname, phone, email FROM t_user_profile WHERE nickname IS NULL;执行结果预期为:
| id | username | nickname | phone | |
|---|---|---|---|---|
| 2 | lisi | NULL | NULL | NULL |
同理,判断“不为空”应该使用IS NOT NULL:
SELECT id, username, nickname FROM t_user_profile WHERE nickname IS NOT NULL;这一步很容易理解,但需要养成肌肉记忆:看见 NULL 判断,第一反应就是IS NULL/IS NOT NULL,不要用=或!=。
3.3 挖洞视角:IS NULL 和权限绕过场景
了解基础语法后,我们把它放进安全场景。假设某个网站的后台系统在列出用户时用了这样的 SQL:
SELECT * FROM users WHERE delete_flag IS NULL OR delete_flag = 0;这种写法常见的背景是:早期代码把delete_flag的默认值设计成了 NULL,后来为了统计方便才补上默认 0。如果开发者意识不统一,有的行是 NULL,有的行是 0,那么查询就必须写成IS NULL OR = 0。
从渗透测试角度看,遇到这类查询时,你在请求参数里看到delete_flag=0,但系统内部实际处理 NULL 的逻辑你无法直接看到。需要观察是否存在“参数缺省”的情况:如果不传 delete_flag,后端代码是把它当 NULL 还是当 0?这往往就是越权访问或水平权限漏洞的诞生点。
不过要特别提醒:这属于授权测试或漏洞挖掘练习时的分析思路。你不能在未授权的系统上做任何验证,必须遵守法律和平台规则。
4. 判断空字符串:'' 与 CHAR_LENGTH
4.1 空字符串是“长度为0的字符串”
空字符串''是一个真实存在的值。它既不是 NULL,也不是“没有内容”。比如用户提交表单时,如果前端把输入框里的内容清空而后端没有做拦截,保存到数据库里的往往就是''。
下面的查询可以找出“昵称为空字符串”的用户:
SELECT id, username, nickname FROM t_user_profile WHERE nickname = '';预期结果是:
| id | username | nickname |
|---|---|---|
| 3 | wangwu | |
| 5 | sunqi |
4.2 同时过滤 NULL 和空字符串:IFNULL 结合条件
但现实业务中,用户表里的“没填昵称”状态可能是 NULL,也可能是空字符串。如果只判断nickname = '',就漏掉了 NULL 的记录;如果只判断IS NULL,就漏掉了空字符串的记录。
常见的处理方式是用IFNULL把 NULL 转换成空字符串,再统一比较:
SELECT id, username, nickname FROM t_user_profile WHERE IFNULL(nickname, '') = '';这条 SQL 的执行过程是:
IFNULL(nickname, '')表示:如果 nickname 是 NULL,则返回空字符串'';否则返回 nickname 本身。- 这样一来,无论是 NULL 还是空字符串,都会在比较前被统一成
''。 - 再和
''比较时,就能把两种“为空”状态全部找出来。
结果为 lisi、wangwu、sunqi 三条记录。
也可以使用COALESCE,它是更通用的空值合并函数,可以传多个参数,返回第一个非 NULL 值:
SELECT id, username, nickname FROM t_user_profile WHERE COALESCE(nickname, '') = '';4.3 CHAR_LENGTH 判断空内容
如果文本字段里可能包含空格,比如用户填了一个空格“ ”作为昵称,那么nickname = ''也无法捕捉到。更严格的业务校验通常会使用TRIM去掉首尾空格后再判断:
SELECT id, username, nickname FROM t_user_profile WHERE CHAR_LENGTH(TRIM(nickname)) = 0;这条 SQL 的逻辑是:
TRIM(nickname)去掉首尾空格。CHAR_LENGTH返回字符串长度。- 长度等于 0,说明内容是空字符串、NULL 或纯空格。
但要注意,CHAR_LENGTH(TRIM(NULL))的结果是 NULL,而不是 0。因此如果字段本身可能是 NULL,这种方式无法直接筛出 NULL 记录。最稳妥的方式还是先IFNULL(nickname, '')再 TRIM 再计算长度:
SELECT id, username, nickname FROM t_user_profile WHERE CHAR_LENGTH(TRIM(IFNULL(nickname, ''))) = 0;这条 SQL 可以查出来“实际用户看起来没有昵称”的所有记录,不管是 NULL、空字符串还是纯空格,这是很实用的写法。
5. NOT IN 与 NULL 的隐藏陷阱
5.1 NOT IN 遇到 NULL 为什么会失效
这是很多后端开发者和安全测试人员都踩过的坑。先看一个例子,假设要查询所有“邮箱不是 zhangsan@example.com 和 lisi@example.com”的用户:
SELECT id, username, email FROM t_user_profile WHERE email NOT IN ('zhangsan@example.com', 'lisi@example.com');直觉上,你可能会觉得返回结果应该是不包含这两条记录的所有用户。但在 MySQL 中,这条 SQL 的返回结果会让人困惑,因为凡是 email 为 NULL 的行,都不会被返回。
为什么?因为 NOT IN 本质上等价于多个AND email != 'zhangsan@example.com' AND email != 'lisi@example.com'。如果 email 是 NULL,那么NULL != 'xxx'的结果是 NULL,不是 TRUE,所以整行无法通过 WHERE 过滤。
为了避免这种问题,需要显式排除 NULL,或使用IFNULL做转换:
SELECT id, username, email FROM t_user_profile WHERE IFNULL(email, '') NOT IN ('zhangsan@example.com', 'lisi@example.com');这样 email 为 NULL 的行,会被当成空字符串参与比较,从而返回在结果集中。
5.2 挖洞视角:NOT IN 导致数据漏查
在授权漏洞测试中,如果你发现某个业务接口的“黑名单”功能没有生效,可以猜一下后端是不是用了这种写法:
SELECT * FROM user_blacklist WHERE username NOT IN ('admin', 'test');假设被测试的目标系统里,用户名允许为 NULL(虽然不合理,但实际中常见),那么 NULL 用户会绕过黑名单过滤。这个场景常被用来解释为什么很多“逻辑漏洞”不是出在特别高深的地方,而是出在最基础的 SQL 语义理解上。
当然,真正做 SRC 挖掘时,你不能拿别人的线上库来做实验。理解这条规则的目的是:当你阅读目标系统的开源代码,或者在授权测试中看到类似的 SQL 片段时,能快速判断它是否存在可利用的过滤绕过点。
6. 动态拼接 WHERE 条件的实现思路与安全问题
6.1 需求场景
很多网安学习者一开始只是单纯学 SQL,但进入 SRC 挖洞实战后,会遇到一个高频关键词:动态拼 WHERE 查询条件。具体来说,前端页面上有多个筛选条件:用户名、邮箱、手机号、是否删除。用户可以填其中一个,也可以同时填多个,也可以什么都不填。后端需要根据用户实际传入的条件动态生成 SQL。
-- 伪代码:动态拼接 WHERE username = 'xxx' AND email = 'yyy' AND phone = 'zzz'如果没有填某个条件,就不要把它拼接进 WHERE 中,否则查询结果就是空。这里的关键点有两个:
- 拼接前需要判断参数是否为空。
- 判断“空”时,同样要区分 NULL、空字符串、空格等情况。
6.2 一条完整的动态查询示例
假设我们要实现的接口是:根据可选的 username、nickname、phone 查询未删除的用户。以下是 Java 后端中 MyBatis 动态 SQL 的一个常见写法,同时也代表了一种安全实践——使用#{}预编译参数,避免 SQL 注入。
<!-- 文件路径:src/main/resources/mapper/UserProfileMapper.xml --> <select id="searchUsers" resultType="com.example.demo.entity.UserProfile"> SELECT id, username, nickname, phone, email FROM t_user_profile <where> <if test="username != null and username != ''"> AND username = #{username} </if> <if test="nickname != null and nickname != ''"> AND nickname = #{nickname} </if> <if test="phone != null and phone != ''"> AND phone = #{phone} </if> AND delete_flag = 0 </where> </select>这里有几个点值得说明:
<where>标签会自动处理开头的 AND,如果所有条件都不满足,它不会生成无意义的 WHERE 子句。<if>用来做空值判断,避免把用户没填的字段拼进 SQL。delete_flag = 0直接写死,保证只查未删除数据。- 使用
#{username}是预编译参数方式,而不是${username}字符串拼接。这是防 SQL 注入的基本要求。
如果你看到的代码中使用的是${},并且把用户输入直接拼进 SQL,那么漏洞风险非常高。
6.3 空值判断在动态条件里的坑
上面 MyBatis 的<if test="nickname != null and nickname != ''">只能过滤掉 Java 中的 null 和空字符串,无法过滤全空格字符串。如果要严格处理,可以写成:
<if test="nickname != null and nickname.trim() != ''">这里的.trim()会先去掉首尾空格,再判断是否为空。你还需要在 Service 层或前端做类似处理,不能只依赖数据库层。
其实动态 SQL 不止存在于 MyBatis 中。在很多小型 Web 项目中,开发者喜欢直接在 Java 中用 StringBuilder 拼接 SQL,比如:
String sql = "SELECT * FROM users WHERE 1=1 "; if (username != null && !username.isEmpty()) { sql += "AND username = '" + username + "' "; }这段代码最大的问题不是 WHERE 1=1,而是直接拼接用户输入。username 一旦包含单引号,就可能被注入。安全做法是手写 PreparedStatement,而不是在 Java 中拼 SQL 字符串。
下面给一个 Java 原生 PreparedStatement 的示例:
// 文件路径:src/main/java/com/example/demo/UserSearchService.java public List<User> searchUsers(String username, String phone) { StringBuilder sql = new StringBuilder("SELECT * FROM t_user_profile WHERE delete_flag = 0 "); List<Object> params = new ArrayList<>(); if (username != null && !username.trim().isEmpty()) { sql.append(" AND username = ? "); params.add(username.trim()); } if (phone != null && !phone.trim().isEmpty()) { sql.append(" AND phone = ? "); params.add(phone.trim()); } try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } try (ResultSet rs = ps.executeQuery()) { // 处理结果集 } } catch (SQLException e) { // 记录日志并做异常处理,不要把异常详情返回给前端 } return null; }用?占位符配合setObject填充参数,可以避免 SQL 注入。另一种思路是使用 MyBatis 的<where>和<if>,它生成的 SQL 仍然是预编译形式,只要不误用${},安全性就有保障。
6.4 动态条件查询为什么要注意 delete_flag
在数据库实战和挖洞实战中,delete_flag(或is_deleted)非常常见。很多系统采用软删除方案,删除操作只把 delete_flag 置为 1,而不是真正 DELETE 数据。
如果后端查询时漏掉了AND delete_flag = 0,那么已被删除的数据依然会通过查询接口被返回。在越权测试、IDOR(不安全的直接对象引用)测试中,这种漏洞经常被利用。
所以,无论你是在做正常开发还是在进行网络安全测试,都建议先问一个问题:
- 这个表有没有软删除字段?
- 每次查询是否都正确携带了未删除条件?
- 是否存在某个查询接口会泄露已删除的数据?
这些问题对了解一个系统是否安全非常有帮助。
7. 基于条件判断的 SQL 注入注入点分析
7.1 什么是条件语义被改变
在 SQL 注入中,有一类常见的利用思路是“改变 WHERE 条件的判断语义”。举个典型例子,下面这条查询原本是想查到指定用户:
SELECT * FROM users WHERE username = 'admin' AND password = '123456';如果后端代码用字符串拼接:
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";当用户输入username = admin' --时,SQL 变成:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '123456';注释符把后面的密码校验条件全部注释掉,于是攻击者不需要密码就能登录。这种利用的本质是“将用户输入拼接进了原本的查询条件中,从而改变了整体判断逻辑”。
7.2 NULL 判断与盲注
再回到“判断为空”这个话题。在 SQL 盲注中,有一种常见判断是让某个条件恒真或恒假来探测数据。比如:
AND IFNULL(nickname, '') = ''如果 nickname 是 NULL,那么括号内的表达式为 TRUE;如果 nickname 不是 NULL,表达式为 FALSE。这听起来像一个普通的业务判断,但在安全测试中,攻击者可能用它做逐字符比较。比如:
AND IFNULL((SELECT SUBSTRING(database(), 1, 1)), '') = 's'这条语句的含义是:取出当前数据库名的第一个字符,如果这个字符是 s,则整条查询返回正常;否则查询结果不同。通过页面的正常/异常差异,攻击者可以一点一点猜出数据库名。这种方式属于“布尔盲注”。
如果你是一个白帽测试人员,理解这种原理能帮你更好地看清漏洞成因。但必须再次强调:不要去未授权的网站做这类尝试。应该在本地靶场或授权测试环境中练习,比如自己搭建 DVWA、SQLi-Labs 等靶场,或使用 SRC 平台授权的测试项目。
7.3 如何写出安全的“空值条件”代码
从代码审计角度,防御 SQL 注入的关键点包括:
- 永远不要手工拼接用户输入到 SQL 中。
- 要使用参数化查询或预编译语句,例如 JDBC 的 PreparedStatement、MyBatis 的
#{}。 - 在 MyBatis 中,只有少数不适合参数化的场景可以使用
${},比如动态表名、动态排序字段,但这些字段必须走白名单校验,不能直接使用用户输入。 - 在业务代码中判断空值,尽量用工具类方法统一处理,不要每一处都写不同的判断逻辑。
对 MySQL 条件查询来说,还有一个额外的安全注意点:参数化查询无法修复表名、列名层面的注入。如果你把用户传入的“排序字段”直接拼进ORDER BY ${sortField},即使用了 PreparedStatement,也很难完全防御。这种场景下,建议使用白名单映射。
8. 完整实践:一条用户筛选查询的多种正确写法
8.1 需求描述
在本地库sec_demo中,写一个查询脚本,完成以下需求:
- 支持按 nickname 是否为空筛选。
- 支持按手机号是否为空筛选。
- 支持按 delete_flag 是否为 0 筛选。
- 支持查看 NULL 状态和空字符串状态的区别。
- 最终输出一份“过滤掉无邮箱用户”的用户列表。
8.2 从最简单到综合的 SQL
先看最简单的只查“邮箱为空”的用户,这里我们把 NULL 和空字符串都算作“空”:
SELECT id, username, email FROM t_user_profile WHERE IFNULL(email, '') = '';这条 SQL 返回 wangwu 和 sunqi,因为 lisi 的 email 是 NULL,wangwu 的 email 是空字符串,sunqi 的 email 不是 NULL 也不是空字符串?等等,我们查看插入语句:
- lisi:email 为 NULL。
- wangwu:email 为 ''。
- zhaoliu:email 为 'zhaoliu@example.com'。
- sunqi:email 为 'sunqi@example.com'。
- zhangsan:email 为 'zhangsan@example.com'。
如果你把要求改为“邮箱不为空”,则可以使用:
SELECT id, username, email FROM t_user_profile WHERE email IS NOT NULL AND email != '';如果把纯空格也视为违反规则,则更严谨的写法是:
SELECT id, username, email FROM t_user_profile WHERE CHAR_LENGTH(TRIM(IFNULL(email, ''))) > 0;8.3 综合案例:筛选出需要“人工补全资料”的用户
这条综合 SQL 想要查的是:昵称为空或手机号为空或邮箱为空,并且还不是删除账号的用户。这是典型的运营后台需求。
SELECT id, username, nickname, phone, email FROM t_user_profile WHERE delete_flag = 0 AND ( CHAR_LENGTH(TRIM(IFNULL(nickname, ''))) = 0 OR CHAR_LENGTH(TRIM(IFNULL(phone, ''))) = 0 OR CHAR_LENGTH(TRIM(IFNULL(email, ''))) = 0 );预期结果会因为数据差异而不同。按我们插入的数据来看:
- zhangsan:资料完整,不满足筛选。
- lisi:delete_flag = 0,nickname、phone、email 均为 NULL,符合条件。
- wangwu:delete_flag = 0,nickname 为空字符串,phone 有值,email 为空字符串,符合条件。
- zhaoliu:delete_flag = 1,不会进入结果。
- sunqi:delete_flag = 0,nickname 为空字符串,phone 为 NULL,email 有值,符合条件。
所以结果应该有 lisi、wangwu、sunqi 三条。
8.4 使用存储过程或脚本验证
如果你使用的是命令行,直接粘贴上面的 SQL 就能看到结果。如果你希望在业务代码中复用,可以考虑编写一个查询函数。这里给出一个纯 SQL 封装视图的案例。
CREATE OR REPLACE VIEW v_user_profile_need_fill AS SELECT id, username, nickname, phone, email FROM t_user_profile WHERE delete_flag = 0 AND ( CHAR_LENGTH(TRIM(IFNULL(nickname, ''))) = 0 OR CHAR_LENGTH(TRIM(IFNULL(phone, ''))) = 0 OR CHAR_LENGTH(TRIM(IFNULL(email, ''))) = 0 );之后可以直接通过下面语句查询:
SELECT * FROM v_user_profile_need_fill;视图的好处是封装复杂逻辑,调用方不需要每次都写重复判断。但要注意:如果表数据量很大,在视图上继续过滤时很难再有效利用普通索引。实际项目中需要测试查询计划,不能盲目照搬。
9. NULL、空字符串与 DISTINCT / COUNT 的组合查询
9.1 COUNT 函数与 NULL
MySQL 中COUNT(*)和COUNT(column)的行为不一样。COUNT(*)统计行数,不管该行中某个字段是否为 NULL;COUNT(email)统计的是 email 字段非 NULL 的行数。
例如:
SELECT COUNT(*) AS total_rows, COUNT(email) AS email_not_null, COUNT(IFNULL(email, '')) AS email_not_empty FROM t_user_profile;在这个例子中,COUNT(email)会忽略 email 为 NULL 的行,但不会忽略空字符串行。所以 email 为 '' 的 wangwu 会被统计进email_not_null。如果你想统计“邮箱真正填写且非空字符串”的用户,就得先处理空字符串的问题。
9.2 为什么挖洞测试要看 COUNT 的行为差异
在信息收集阶段,通过接口返回的总数可以反推数据库里的记录情况。比如,一个用户列表接口返回的总数,如果比实际展示数多,有可能存在软删除数据被查询出来或被错误统计的情况。你甚至能通过“总数异常”去判断后端筛选条件的严谨程度。
当然,我不会建议你用这些技巧去做任何未授权行为。但在 SRC 漏洞挖掘平台里,如果某个测试目标是你拥有授权资格的,那么通过这类差异发现逻辑缺陷是常见思路。
9.3 使用 COALESCE 统一处理字段统计
COALESCE 函数可以一次处理多个字段,返回第一个非 NULL 值。你可以用它在展示层把 NULL 转换为默认值:
SELECT id, username, COALESCE(nickname, '未填写昵称') AS nickname_display, COALESCE(phone, '未绑定手机') AS phone_display FROM t_user_profile;COALESCE 与 IFNULL 的区别在于:IFNULL 只有两个参数,而 COALESCE 可以接收多个参数。SQL 标准更推荐 COALESCE,只是老代码里用得更多的是 IFNULL。两者都可以用于判断空值,但要注意它们不能把空字符串自动转换为默认值。如果想处理空字符串,需要结合 NULLIF:
SELECT id, username, COALESCE(NULLIF(TRIM(nickname), ''), '未填写昵称') AS nickname_display FROM t_user_profile;这里的NULLIF(TRIM(nickname), '')表示:如果 nickname 去掉空格后是空字符串,则返回 NULL;否则返回原值。然后外层的 COALESCE 再把 NULL 转成默认文案。这条写法的好处是同时处理了 NULL、空字符串和纯空格三种情况。
10. 常见问题与排查思路
下面整理几张排查表,先收藏,后面实际使用时可以直接对照。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
SELECT * FROM table WHERE name = NULL查不到数据 | 把 NULL 当成普通值,使用 = 比较 | 改为name IS NULL |
SELECT * FROM table WHERE name != NULL返回空或错误结果 | 错误使用 != 判断非空 | 改为name IS NOT NULL |
| 查“没有昵称”的用户,但漏掉了一部分 | 只写了nickname = '',忽略了 NULL 记录 | 使用IFNULL(nickname, '') = '' |
邮箱为 NULL 的行在NOT IN中消失 | NOT IN 遇到 NULL 返回未知,导致行被过滤 | 加OR email IS NULL或用IFNULL(email,'') NOT IN (...) |
| 动态 WHERE 条件没有生效 | 判断参数为空时逻辑不统一 | Service 层使用统一的 isBlank 判断 |
| MyBatis 动态 SQL 抛 SQL 语法错误 | <where>使用不当,或if拼出了多余的 AND | 检查<where>和<if>标签组合 |
| 页面显示数据正常,后台统计对不上 | COUNT(column) 与 COUNT(*) 混用 | 明确统计口径,区分 NULL 和空字符串 |
| 出现 SQL 注入风险 | 代码中直接拼接${}或 Java 字符串 | 改为#{}或 PreparedStatement 参数化 |
| 查询已删除数据被返回 | 忘记携带 delete_flag 过滤条件 | 所有业务查询统一加delete_flag=0 |
排查清单:
- 先看字段定义是允许 NULL 还是 NOT NULL。
- 再确认数据里是否存在空字符串。
- 然后确认业务逻辑中“空”的定义到底是什么。
- 查看 SQL 有没有使用
IS NULL、IFNULL、COALESCE。 - 查看动态 SQL 构建代码是否安全,是否使用参数化查询。
- 在测试环境插入 NULL、空字符串、纯空格三条数据做验证。
- 确认 delete_flag 等软删除字段是否参与了条件过滤。
11. 网络安全视角下的最佳实践与工程建议
11.1 数据库查询规范建议
无论你是普通后端开发,还是准备进入网络安全领域,下面这些规范都可以直接用于日常项目:
- 统一空值判断函数。项目里可以定义一个公共 SQL 片段或统一工具类,对于“是否为空”的判断,使用统一规则,避免每名开发人员写法不同。
- 建表时明确字段约束。能设置为 NOT NULL 的字段,尽量设置 NOT NULL,并给默认值。例如
delete_flag TINYINT NOT NULL DEFAULT 0,这样能从根本上减少 NULL 和 0 混用的问题。 - 字符串类型字段不要用 NULL 表示“未填写”。更推荐使用
DEFAULT '',并配合业务校验。当然这需要权衡,因为 NULL 和空字符串在索引、存储上有差别,团队要统一规范。 - 动态拼接 WHERE 条件时必须使用安全参数绑定。在实际项目中,禁止直接使用用户传入值拼接 SQL。
- 重要查询带上 delete_flag。如果系统使用软删除,一定要统一由框架层或 MyBatis 拦截器统一补充条件,不能只靠开发人员手动记。
11.2 SRC 漏洞挖掘中的自我约束
在网络安全学习过程中,需要不断建立“授权”意识。想练 SQL 注入、布尔盲注等技术,应该在本地靶场、CTF 平台、SRC 平台授权的测试项目中进行。不要因为学会了IFNULL和 NULL 绕过思路,就想去真实站点上验证,那非常危险。
SRC 平台通常有漏洞测试范围说明,你只允许在指定域名和产品下测试。提交漏洞前要脱敏截图,不能保存目标业务数据。这些既是职业道德,也是法律底线。
11.3 开发安全 Checklist
最后给出一个适合开发的“安全自检清单”:
- [ ] 代码中不存在用户输入直接拼接 SQL 的情况。
- [ ] MyBatis 只有
#{},没有用户输入进入${}。 - [ ] 排序字段 / 表名等动态部分都有白名单校验。
- [ ] 所有查询都考虑了权限范围,接口不能越权查看数据。
- [ ] 查询结果统一过滤已删除数据。
- [ ] 异常信息不会直接返回给前端,避免暴露表结构和 SQL 片段。
- [ ] 日志中不记录明文密钥、完整手机号等敏感信息。
- [ ] 数据库账号遵循最小权限原则,业务账号没有 DDL 或 DROP 权限。
12. 小结
这篇文章从 MySQL 条件查询的“空值判断”出发,重点拆解了几个内容:
NULL与空字符串的区别。IS NULL/IS NOT NULL的正确用法。IFNULL、COALESCE、NULLIF、CHAR_LENGTH在空值处理中的应用。NOT IN遇到 NULL 的隐藏陷阱。- 动态拼 WHERE 条件的两种实现方式,以及为什么安全漏洞常出现在这里。
- 从网络安全角度理解 SQL 注入、软删除绕过和代码审计中的关键点。
无论你当前处在“零基础入门”阶段,还是已经开始接触 SEC 挖洞实战,SQL 基础都会直接决定你对漏洞原理理解得深不深。很多人觉得 SQL 简单,但其实真正能把 NULL、空字符串、条件拼接等细节处理好的人并不多。建议你打开本地 MySQL,把上面每条 SQL 都执行一遍,观察返回结果,再习惯性思考一个安全相关问题:“如果这条查询接口暴露在公网,会不会有被绕过的可能?”
如果本文对你有帮助,可以收藏备用。下一篇文章我们可以继续深入 MySQL 的排序、分组与聚合查询,并结合网络安全常见的数据泄露场景,讲解 ORDER BY 注入与 GROUP BY 逻辑。觉得有用的话,欢迎在评论区留言,我也会继续更新这个系列。