
很多人刚开始学网络安全时对数据库的印象往往是“会写几条 SELECT 就够了”。可真到了自己搭靶场、复盘漏洞日志、看一条真实 SQL 查询结果的时候第一个翻车点往往不是多表 JOIN也不是索引优化而是非常简单的一句判断字段“是否为空”。比如你很自然地在 WHERE 里面写了phone NULL结果一行都查不出来或者你想过滤掉“没填昵称”的用户只写了nickname 结果所有nickname为 NULL 的数据都被漏掉了。更麻烦的是MySQL 不会报错SQL 依然正常执行只有在你对着返回结果反复核对时才发现问题。这篇文章是网络安全“零基础入门到 SEC 挖洞实战”系列里、特地补数据库基本功的第 24 节。今天不聊高深的注入技巧就先把 MySQL 条件查询里“判断是否为空”这件事彻底讲透NULL 和空字符串到底有什么区别、不同条件该怎么写、为什么安全场景下也要关注这个细节、以及动态拼接 SQL 时应该怎么避免翻车。把这一节吃透后面分析漏洞和写“检测条件”时才不会犯低级错误。1. 为什么“是否为空”在 MySQL 里最容易翻车1.1 NULL 的准确含义在 MySQL 中NULL不是一个值它表示的是一种“未知”“缺失”“未定义”的状态。这一点非常关键。MySQL 文档里描述得很直接NULL意味着“no data”也就是这个字段当前没有数据。它不是 0不是空字符串也不是空格。你可以把它理解为“这个位置没有填写任何内容”。而空字符串是一个确定的值只不过这个值的长度是 0。它表示“我知道这个字段应该有内容但用户最终填了一个空的东西”。用一个场景对比用户注册时根本没有填写“昵称”字段数据库里通常是NULL。前端提交了一个空表单后端把空字符串写进了数据库数据库里就是。这两种状态在外观上看起来很接近但在 MySQL 的条件判断里完全是两码事。1.2 三值逻辑对 WHERE 的影响很多教材会讲 SQL 存在“三值逻辑”除了TRUE和FALSE还有UNKNOWN。NULL参与比较运算时结果通常不是TRUE也不是FALSE而是UNKNOWN。先看一个基础判断SELECT 1 NULL AS result_1, 0 NULL AS result_2, NULL NULL AS result_3;这段 SQL 等价于把三个比较表达式各算了一遍。执行后你会发现三列的结果全部是NULL而不是0或1。也就是说用普通比较运算符、、、、、去和NULL比较结果都是UNKNOWN既不是真也不是假。在WHERE条件里只有结果为TRUE的行才会被返回UNKNOWN会被当成不满足条件过滤掉。所以SELECT * FROM member WHERE phone NULL;这句话本质上是“谁的手机号和未知数相等”查询结果必然为空。这也是为什么初学者经常会问为什么我明明用了却什么都查不到答案是判断字段是否为NULL不能用或必须使用专门的操作符IS NULL和IS NOT NULL。1.3 NULL 和空字符串的区别在业务表设计里这两个值是会出现混用的。下面这张表可以把区别总结得比较清楚对比维度NULL空字符串 含义数据缺失、未知值确定但长度为 0比较方式必须用 IS NULL / IS NOT NULL可以用 / 参与 COUNT(字段)不计入计入参与字符串拼接结果通常变为 NULL不会影响其他字符串存储空间需要额外标记位占用一个空字符串的存储排序时默认位置ASC 时 NULL 一般排最前按普通字符串排序查询时是否容易漏数据容易漏也容易漏看你是否用对条件这里补充一个字符串拼接的例子SELECT CONCAT(Hello, NULL) AS s1, CONCAT(Hello, ) AS s2;结果是s1为NULLs2为Hello。如果你在程序里拿到NULL做拼接很容易把整个字符串变成NULL这也是很多后端“数据莫名丢了”的原因之一。2. MySQL 判断空值的标准写法2.1 判断 NULLIS NULL / IS NOT NULL判断一个字段是否为NULL标准写法是-- 查询昵称没有填写的用户 SELECT * FROM member WHERE nickname IS NULL; -- 查询昵称已经填写的用户 SELECT * FROM member WHERE nickname IS NOT NULL;注意IS NULL和IS NOT NULL是专门判断NULL的操作符它们的返回值一定是TRUE或FALSE不会出现UNKNOWN的问题。2.2 判断空字符串 / 判断一个字段是否为空字符串仍然可以用普通比较运算符-- 查询昵称是空字符串的用户 SELECT * FROM member WHERE nickname ; -- 查询昵称不是空字符串的用户 SELECT * FROM member WHERE nickname ;这里真正的坑在于如果你只想查“空字符串”用 没问题但如果你想查“还没填写的用户”只用IS NULL或只用 都会漏掉另一部分数据。现实业务中NULL和空字符串是可能同时存在的。最稳妥的写法是-- 查询 nickname 字段完全没有内容的数据包括 NULL 和空字符串两种状态 SELECT * FROM member WHERE nickname IS NULL OR nickname ;2.3 NULL 安全等于 MySQL 里还有一个比较少见的操作符它叫做“NULL 安全等于”。普通在遇到NULL时返回UNKNOWN而在两边都是NULL时返回TRUE在一边是NULL另一边不是NULL时返回FALSE。SELECT NULL NULL AS r1, NULL 1 AS r2, NULL AS r3;结果分别是1、0、0。在项目中使用频率并不高但如果你看到同事写的代码里出现它至少要知道它是在做“NULL 安全比较”。更推荐的方式仍然是显式写IS NULL或IS NOT NULL语义更清晰可读性也更好。3. 环境准备与测试数据这一节开始进入实际操作。为了让你能照着自己练我会搭建一个最小的 MySQL 环境并准备一张带各种空值状态的会员表。介绍版本时说明一点下面的示例使用 MySQL 5.7/8.0 均可运行语法上不依赖某个特定小版本。3.1 准备一个本地 MySQL如果你本地还没有 MySQL可以用 Docker 快速拉一个容器来学习。这个方式适合测试环境不适合生产环境直接照搬。docker run --name mysql-sec \ -e MYSQL_ROOT_PASSWORDyour_password \ -p 3306:3306 \ -d mysql:8.0等容器启动后进入容器或者从宿主机连接mysql -h127.0.0.1 -uroot -p如果你使用的是本地已安装的 MySQL直接打开命令行客户端即可。注意以下所有建表、插入、删除操作都只演示用。请勿把这些命令直接执行到有线上数据的业务库中尤其是带有DROP TABLE IF EXISTS的语句必须慎之又慎。3.2 建库建表和插入演示数据CREATE DATABASE IF NOT EXISTS sec_learn DEFAULT CHARACTER SET utf8mb4; USE sec_learn; DROP TABLE IF EXISTS member; CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, nickname VARCHAR(50) NULL COMMENT 昵称未填写则为NULL, email VARCHAR(100) NULL COMMENT 邮箱可能为NULL, phone VARCHAR(20) DEFAULT COMMENT 手机号空字符串表示未绑定, points INT DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常0禁用, last_login_at DATETIME NULL COMMENT 最后登录时间可能为NULL ) ENGINEInnoDB;插入一批有代表性的数据INSERT INTO member (username, nickname, email, phone, points, status, last_login_at) VALUES (alice, 爱丽丝, aliceexample.com, 13800000001, 100, 1, 2025-03-01 10:00:00), (bob, NULL, NULL, 13800000002, 200, 1, 2025-02-03 09:30:00), (carol, , carolexample.com, , 50, 0, NULL), (dave, Dave, NULL, NULL, 200, 1, 2025-03-08 08:12:00), (eve, NULL, eveexample.com, , 0, 0, NULL);可以看到member表里的空值状态很不一致bob的nickname和email是NULL但phone有值。carol的nickname是空字符串email有值phone是空字符串last_login_at是NULL。dave的email和phone都是NULL。eve的nickname是NULLphone是空字符串。这种“既有NULL又有空字符串”的表在真实业务里非常多见尤其是老系统改造后。4. 不同条件查询判断是否为空的完整示例4.1 查询“昵称未填写”的用户如果业务方认为“未填写”只可能是NULL那么直接这样写SELECT id, username, nickname FROM member WHERE nickname IS NULL;预期返回bobeve但如果前端曾经提交过空表单把空字符串也写进了nickname那么carol也会算作“未填写”。为了严谨业务查询应该同时兼容两种情况SELECT id, username, nickname FROM member WHERE nickname IS NULL OR nickname ;这次返回的是bob、carol、eve三条数据。这里就体现出空值判断的第一个关键结论不要默认数据库只有一种空状态。4.2 查询“手机号为空”时要考虑两种状态phone字段的表结构默认是DEFAULT 也就是说老系统可能存空字符串。如果再用WHERE phone IS NULL去查“没有手机号的用户”结果会漏掉carol和eve。完整写法SELECT id, username, phone FROM member WHERE phone IS NULL OR phone ;查询结果是carolphone为空字符串davephone为NULLevephone为空字符串这个例子想说明的问题是当字段允许NULL且默认值又是空字符串时查询条件必须同时处理两种情况否则统计结果一定不准确。4.3 把空值翻译成业务文案实际开发中我们经常需要把NULL显示成“未填写”这样的文案。使用IFNULL()SELECT username, IFNULL(nickname, 未填写昵称) AS display_nickname FROM member;使用COALESCE()可以在多个字段之间取第一个非NULL的值SELECT username, COALESCE(nickname, email, phone, 没有任何联系方式) AS contact_info FROM member;COALESCE()在真实业务里很有用但注意它只能处理NULL不能把空字符串自动当作“无”。如果nickname是空字符串而你想显示“未填写”需要写成SELECT username, IF(nickname IS NULL OR nickname , 未填写昵称, nickname) AS display_nickname FROM member;4.4 COUNT、GROUP BY 对空值的统计差异COUNT(*)会统计所有行而COUNT(字段)不会统计值为NULL的行这是很常见的一个统计口径坑。SELECT COUNT(*) AS total_rows, COUNT(nickname) AS has_nickname, COUNT(phone) AS has_phone, COUNT(DISTINCT nickname) AS distinct_nickname FROM member;预期结果total_rows 5has_nickname 2因为只有alice和carol的nickname不为NULL其中carol是空字符串但空字符串也会被统计进去has_phone 3因为dave的phone是NULLbob的 phone 不是NULL等等distinct_nickname 3如果按值区分分别是爱丽丝、空字符串、Dave其中多个NULL不会被去重成一组这里要特别注意COUNT(phone)不统计NULL但是会统计空字符串。如果你想知道“多少人有手机号”应该先明确业务口径有手机号指的是“非NULL且非空”而不是简单用COUNT(phone)。4.5 NOT IN 子查询遇到 NULL 的坑这是一个难度更高但实际很常见的坑。假设另一张表存在一些“不再允许展示的用户”我们把其中一个用户在名单里的username设为NULL然后执行SELECT username, nickname FROM member WHERE username NOT IN ( SELECT username FROM deny_user WHERE username IS NOT NULL );如果deny_user里有一行的username是NULL事情就变得微妙了。NOT IN底层会展开成多个条件。为了简化理解可以用一个直接例子来说明SELECT * FROM member WHERE username NOT IN (alice, NULL);很多人以为这句话的意思是“排除alice其他都返回”但实际上它什么都不会返回。原因是username alice对某些用户是TRUE但username NULL的结果是UNKNOWN。多个OR条件中只要有一个是UNKNOWN且没有TRUE兜底整条判断结果就是UNKNOWN。所以当NOT IN后面的列表或子查询结果里出现NULL时最终结果可能为空集合。这也是为什么许多团队的 SQL 规范里会明确要求外层使用NOT IN时内层子查询必须显式排除NULL。正确的改法是SELECT username, nickname FROM member WHERE username NOT IN ( SELECT username FROM deny_user WHERE username IS NOT NULL );这个写法在真实业务和日志分析场景里都很实用。5. 动态拼 WHERE 场景空值判断与传参搜索热词里有一个高频需求叫“根据某个字段的值动态拼 where 的查询条件”。在 Java 后端项目里这个概念通常落到 MyBatis 的动态 SQL 上。5.1 MyBatis 动态 SQL 里如何区分两种空在 MyBatis 中我们如果提供一个searchMembers方法根据传入条件搜索会员最容易犯的错误是if testnickname ! null只判断了 Java 传参是否为null但传入空字符串时条件仍然会拼接查询结果可能把昵称等于空字符串的用户精确匹配出来。更合理的判断是if testnickname ! null and nickname ! AND nickname #{nickname} /if这个表达式表示只有用户真的输入了内容才把昵称作为筛选条件。完整的 Mapper XML 可以这样写?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.MemberMapper select idsearchMembers resultTypemap SELECT id, username, nickname, email, phone FROM member where if testusername ! null and username ! AND username #{username} /if if testnickname ! null and nickname ! AND nickname #{nickname} /if if testonlyNoNickname ! null and onlyNoNickname AND (nickname IS NULL OR nickname ) /if /where /select /mapper对应的 Java Mapper 接口package com.example.mapper; import org.apache.ibatis.annotations.Param; import java.util.List; import java.util.Map; public interface MemberMapper { ListMapString, Object searchMembers( Param(username) String username, Param(nickname) String nickname, Param(onlyNoNickname) Boolean onlyNoNickname ); }这个例子里最重要的是where标签会自动去掉多余的AND而if条件同时处理了 Java 传参里的null和空字符串这是很常见的工程实践。如果把if的判断写成nickname ! null那么在接口层传入nickname时动态 SQL 会变成SELECT * FROM member WHERE nickname ;这会导致用户没有输入昵称查询结果却只返回昵称为空字符串的人看起来非常奇怪。结合上一节说的两种空状态这个问题就很容易理解了。5.2 参数化查询与安全边界刚才 MyBatis 示例中用到了#{nickname}这是参数绑定不是${nickname}两者有本质区别。#{}在 MyBatis 中会生成预编译的占位符?再由 JDBC 做参数绑定。即使传入等特殊字符它也只是当作一个值处理不会被拼进 SQL 结构里。${}则直接把内容拼进 SQL 字符串。这种方式适合动态表名或排序字段这种不能被预编译的场景但绝不能用在前端传入的用户名、昵称、手机号等字段上。如果必须使用${}你要对输入做严格白名单校验否则很容易把条件查询变成 SQL 注入点。对网络安全初学者来说这一节是很好的提醒动态拼接 SQL 是最常见的漏洞来源之一。想判断一个字符串是否为空、想根据字段动态拼条件都应建立在参数化查询的基础上而不是先拼一段 SQL 再考虑过滤。6. 网络安全入门者的空值视角这一节专门说说为什么“MySQL 条件查询是否为空”会在网络安全基础教程里反复出现。6.1 条件表达式返回 NULL 为什么会导致逻辑绕过很多安全漏洞的本质是程序对输入数据的假设和数据库实际数据不一致。比如登录功能如果写成了拼接 SQLString sql SELECT COUNT(*) FROM member WHERE username username AND password password ;如果password传入一个包含单引号的参数就可能打破原来的 SQL 结构让原本应该返回 0 的条件判断产生非预期结果。这种问题的根源不是“空值判断”而是没有使用参数化查询。空值的特殊性会在中间层造成很多隐蔽问题应用层判断参数为空后可能向 SQL 中传递null字符串。ID 为NULL时某些查询会过滤掉正常数据导致越权判断失效。对不同状态使用还是IS NULL可能让数据范围判断出现偏差。NOT IN搭配空值导致整体查询结果为空时业务可能误认为“没有数据”从而跳过某些校验。因此在做安全测试或代码审计时看到操作者手写动态 SQL第一反应就应该是这个查询条件里有没有依赖外部输入外部输入会不会破坏 SQL 结构有没有使用参数绑定6.2 合法漏洞挖掘必须遵守的边界进入网络安全行业很多人会关注“漏洞挖掘”“SRC 平台”“挖洞实战”这类关键词。但请注意一点只能在你拥有合法授权的位置进行测试。企业 SRC 平台有自己的测试范围和规则必须遵守。CTF 比赛、授权靶场、本地漏洞环境都是练习条件查询与注入判断的好地方。绝对不要对一个你没有授权的线上系统发起探测或利用。一个合格的安全工程师不是只能背出几条漏洞 Payload而是能说清楚漏洞成因、修复方案和边界条件。数据库知识越扎实越能理解为什么修复建议是“使用预编译”“参数化查询”“最小权限”“过滤空值”而不是“对用户输入做简单替换”。6.3 日志与数据取证中空值的含义安全工作和后端开发还有一个交集日志分析和数据取证。当你从数据库里导出登录日志、操作日志、异常请求记录时经常需要筛选“某个字段为空”的请求。比如查询所有没有 User-Agent 的请求。查询某个回调地址为空但是触发了重定向的记录。查询手机号为空却成功完成某一步操作的账号。如果筛选时只用WHERE field 就会漏掉field IS NULL的记录导致分析结论出现明显偏差。审计报告里的“1 条异常”和“37 条异常”差别往往就在这里。7. 常见问题排查与避坑清单为了便于收藏和快速查阅我把空值相关的高频问题整理成一张排查表问题现象可能原因排查方式解决方案使用WHERE phone NULL查询不到数据用普通比较运算符判断 NULL检查 WHERE 条件是否使用改为phone IS NULL使用WHERE nickname 仍然漏数据数据库中还存了NULL查看原始数据中字段是否允许 NULL改成nickname IS NULL OR nickname COUNT(phone)比预期少NULL不会被COUNT(字段)统计对比COUNT(*)和COUNT(字段)结果明确业务口径需要统计 NULL 时用COUNT(*)或SUM(phone IS NOT NULL)NOT IN查询结果为空子查询结果中包含NULL单独执行子查询并观察是否有 NULL内层加WHERE 字段 IS NOT NULL空字符串在页面上显示为空后端存储了而不是NULL直接查询数据库字段内容插入数据前统一转化空字符串为 NULL或展示层用 IFNULL/COALESCE 兜底字符串拼接后整个字段不见了CONCAT拼接到了 NULL检查字段是否包含 NULL使用CONCAT_WS或先IFNULL处理MyBatis 动态 SQL 拼入了空条件if只判断了! null查看 XML 的 test 条件是否包含! 改为! null and ! 两个查询条件组合时结果数量异常多个条件之间对空值的处理互相影响把条件拆开单独执行并对比对每个可空字段统一制定空值判断规则这张表里每一行都来自真实的开发和学习场景。建议在实际写 SQL 之前先想一想这个字段可能是 NULL 还是空字符串我准备用哪个条件去覆盖它8. 最佳实践与工程建议8.1 表结构设计在设计表结构时与其到查询阶段为NULL和的混用头疼不如在建表阶段就定好规则。建议是如果一个字段表示“未填写”优先使用NOT NULL DEFAULT 这样查询规则统一为 。如果一个字段表示“可选值且有业务层面的未知态”则用NULL DEFAULT NULL查询规则统一为IS NULL。少在同一张表里既允许NULL又给默认空字符串。对重要字段比如用户手机号、昵称、邮箱建议增加CHECK约束或应用层校验避免脏数据写入。很多老业务宁可让字段“该为 NULL 的存成空字符串该为空的字符串存成 NULL”也不要随便允许两种语义同时出现。保持数据语义单一是降低后期维护成本最有效的手段。8.2 SQL 写法查询时遵循几条简单原则判断 NULL用IS NULL/IS NOT NULL。判断空字符串用 / 。判断“业务层面为空”写成(col IS NULL OR col )并统一通过视图、Mapper 封装或常量复用。尽量少用因为它会让不熟悉的人困惑。统计时永远先定义口径空字符串是否算空NULL 是否纳入统计在子查询中使用NOT IN内层必须排除NULL或者改用NOT EXISTS。8.3 程序侧处理后端代码里最常见的处理方式是在写入数据库之前把空字符串统一转成NULL。比如 Java 工具类public static String emptyToNull(String value) { return (value null || value.isEmpty()) ? null : value; }如果是普通 Web 接口前端提交的可选字段为时入库前统一转为NULL后续查询就只需要IS NULL减少条件分支。反过来如果既有系统已经存了大量空字符串也可以使用 MySQL 的一把梭更新-- 在测试环境验证过备份之后再执行 UPDATE member SET nickname NULL WHERE nickname ;生产环境执行这种 UPDATE 前必须先备份并在测试库验证影响行数。8.4 安全与权限文章最后再强调一条工程和安全结合的建议所有涉及外部输入的 SQL 查询优先使用参数化查询。数据库账号使用最小权限业务账号原则上不授予DROP、ALTER等危险权限。日志中不要记录完整的手机号、密码、token 等敏感信息。在安全测试和漏洞挖掘时遵守授权边界只在靶场或允许测试的环境里操作。9. 收尾实践建议这一节如果要做成练习你不必急于写很复杂的 SQL。建议按下面的顺序操作创建一张含NULL和空字符串的测试表。分别用IS NULL、 、OR 组合去查询观察返回结果差异。统计COUNT(*)、COUNT(字段)的结果确认统计口径。模拟一个NOT IN列表里有NULL的场景看查询结果会不会变空。在 MyBatis 动态 SQL 里用if分别判断null和空字符串观察拼接出的 SQL。把这些动作做完你对 MySQL 条件查询中“判断是否为空”的理解基本上能超过很多只背 CRUD 的开发者。今天的核心不是背语法而是建立一个潜意识看到“空”的时候先问是 NULL还是空字符串还是业务意义上的无值。这个判断习惯会贯穿你后续写查询 SQL、做数据统计、看漏洞代码的每一步。它是零基础入门阶段最不起眼、但最能拉开差距的细节之一。