讲个真实经历:我第一次打开 PortSwigger 的 Web Security Academy,看到 SQL 注入第一题“WHERE 子句注入漏洞利用——检索隐藏数据”时,内心其实是有点不屑的。因为网上到处是“万能密码”的传说,貌似把' or 1=1--塞进登录框就完事了。结果真到了这个靶场里,我愣是卡了快二十分钟,不是因为题目难,而是因为我没搞清楚“WHERE 子句注入”和“登录框注入”在查询构造上根本不是一回事。
这道题非常适合作为 SQL 注入学习的起点,它只干一件事:让你通过修改 URL 里的category参数,把后台原本隐藏的商品数据全部查出来。整个过程不依赖 Burp Suite,浏览器地址栏就够了。对刚入门 Web 安全的人来说,这一关的正确打开方式不是背 payload,而是把“查询是怎么被拼出来的”这件事想明白;对已经工作多年、做业务开发的朋友来说,这道题也是一个很好的自查案例——你在项目里写的每一条 SQL,是否也藏着同样的拼接问题。
1. 为什么我要反复重做这一关:关于“检索隐藏数据”这个考点
1.1 新手入门 SQL 注入后的最大误区
大部分初学者第一次接触 SQL 注入,都是从某段“万能密码”视频开始的:' OR '1'='1打进用户名框,密码乱输,然后登录成功。这确实是 SQL 注入,但它属于典型的“认证绕过”场景,核心逻辑是把登录查询的 WHERE 条件永远置为真。
问题在于,很多人记住了这个 payload,却没理解它为什么会生效。换到 PortSwigger 第一题这种场景,你就发现不对劲了:这里没有登录框,只有一个商品分类筛选页面,URL 长这样:
filter?category=Gifts页面会展示 Gifts 分类下所有“已发布”的商品。如果没有一点查询构造的基础,你大概率会习惯性输入' or '1'='1,然后发现页面要么报错,要么还是原样,完全不知道下一步该干什么。原因很简单:' or '1'='1这类 payload 通常是为了绕过与密码相关的条件,而这道题的查询条件是“分类 + 发布状态”,两条查询语句的结构完全不同,注入点也不同。
1.2 这道题到底能让你学到什么
这一关的官方名称叫“SQL injection vulnerability in WHERE clause allowing retrieval of hidden data”,翻译过来就是在 WHERE 子句里有注入漏洞,可以检索隐藏数据。它针对的是商品展示场景下的典型查询:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1;这里released = 1就表示商品已经发布。被隐藏起来的数据,通常就是released = 0的那些商品,它们在平台上存在,但前台不展示。
攻击者的目标,是通过修改category参数,让整条查询的AND released = 1条件失效。最终页面上不仅会出现分类下的商品,还会把原本“未发布、不可见”的商品全部暴露出来。
理解这个目标非常关键,因为“隐藏数据”四个字不是营销话术,而是指业务系统里真实存在、却被查询条件挡住的那部分数据。在现实世界里,这类数据可能是还没上架的商品、还没审批的评论、还没公开的文章草稿,甚至是被软删除的记录。你通过注入把它们检索出来,看到的本不该出现在前台的东西,就是信息泄露的第一步。
1.3 我反复刷这道题的三个原因
第一次做这题,我靠的是网上现成的 payload,一分钟就过了。当时没什么成就感,反而心虚:我知道怎么操作,但不知道每一步在干嘛。后来转行做安全测试,回头看这一关,才发现它值得反复做的原因有三点。
第一,它逼你建立“查询语句可视化”的习惯。每次看到一个参数,先问自己:后端大概会写一条什么样的 SQL?参数落在哪个位置?是字符串单引号里,还是数字不加引号?这个习惯,能帮你判断后续所有 SQL 注入题目的走向。
第二,它是理解“注释符”和“URL 编码”的最佳入门场景。--注释符在这里不是可有可无的装饰,而是让查询变成合法语句的关键。很多人第一次做实验时卡在这一步,原因不是不知道注入,而是不知道怎么让 SQL 语句不报错。
第三,它是通向 UNION 注入、盲注等高阶题型的根基。WHERE 子句是 SQL 查询里最常见的过滤点,你掌握了这里,后面学习“检索其他表的数据”“绕过过滤”“堆叠注入”都会顺手很多。
2. 漏洞原理拆解:一条筛选查询是如何被输入带偏的
2.1 先看后端最可能出现的 SQL 语句
PortSwigger 这道题,你光看页面可能觉得就是一个普通电商网站的目录页。左侧有分类,点一下“Gifts”,URL 就变成filter?category=Gifts。在后端,最常见的实现代码大概是:
$category = $_GET['category']; $sql = "SELECT * FROM products WHERE category = '$category' AND released = 1";关键就在$category被直接拼接进 SQL 字符串。对于水平一般的开发者来说,这不是段多冷门的代码,但它却恰恰是 SQL 注入的温床。
我们用正常参数走一遍:category=Gifts,得到的查询是:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1;数据库会先去 products 表里找category等于Gifts的商品,再从中筛掉未发布的,最终返回一组可见商品。
2.2 单引号试探:这一步为什么值得亲自动手
我见过很多人做这道题,上来直接丢OR 1=1,然后页面没反应就懵了。正确的第一步,应该是先用一个单引号试探。
把category=Gifts改成category=Gifts',后端拼出来的语句会变成:
SELECT * FROM products WHERE category = 'Gifts'' AND released = 1;SQL 语法里,字符串应该由一对单引号包裹。现在出现了连续三个单引号,数据库读到这里就找不到匹配的闭合引号,直接报语法错误。在页面上的表现通常有两种:要么返回 500 错误,要么干脆显示一个异常页面。
这一步极其重要,它确认了两件事:第一,参数确实被拼接进了 SQL 语句;第二,我们能够通过输入改变这条语句的语法结构。只有确认了这一点,后面再考虑怎么构造合法且有效的攻击语句才有意义。很多人跳过试探直接上最终 payload,导致出现“payload 凑巧能跑通但原理完全不懂”的情况,后面换一个场景就歇菜。
2.3 拼入 OR 1=1 之后,查询变成了什么
试探确认了注入点,接下来就是构造合法语句。我们要让整条 WHERE 条件永久为真,并想办法让后面的AND released = 1失效。
在 URL 里输入:
category=Gifts' OR 1=1--经过 URL 解码后,后端拼成的 SQL 是:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1--' AND released = 1;注意这里发生了两件事。
第一,'Gifts' OR 1=1是一个永真的条件。因为1=1恒为真,OR 运算符只要有一边为真,整个条件就成立。于是 WHERE 子句不再关心category是什么,所有商品都符合条件。
第二,--是 SQL 的注释符。它把后面原本是' AND released = 1的部分全部注释掉了。注意,--后面通常还需要一个空格,URL 中可以用+或%20来表示。这样一来,数据库真正执行的查询就变成了:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1;released = 1这个筛选条件直接消失了。查询结果从“Gifts 分类下的已发布商品”变成了“全部商品”,包括那些从未被发布、被刻意隐藏的数据。
2.4 “隐藏数据”的命名逻辑与业务背景
为什么题目叫“检索隐藏数据”?因为商品表里有条字段,通常叫released,表示发布状态。值为 1 代表已经发布,值为 0 代表未发布。
正常用户的查询无论如何都会被AND released = 1挡住,所以未发布商品在前台不可见。但对数据库来说,它们只是普通的行记录,依然在表里面安安稳稳躺着。注入攻击做的事情,本质上不是“凭空变出数据”,而是通过改变查询逻辑,把原本不可见的记录暴露出来。
这个点理解之后,你会意识到“隐藏”并不是真正隐藏,只是应用层用查询条件挡住了它们。防护做得不好,数据库里的每一条记录都可能被一条精心构造的 WHERE 子句翻出来。
3. 一步步拿下实验:从第一次手工试错到看到全部商品
3.1 进入实验环境后的初始确认
这道题在 PortSwigger 上的入口很直白。打开实验室页面后,你会看到一个类似电商网站的界面,顶部有访问地址,还有一个“Access the lab”按钮。点击进去后,浏览器会打开一个独立的靶机实例 URL,每个人的地址都不一样,但路径结构一致:
https://目标实例.web-security-academy.net/实验首页有几个商品分类链接,比如 Accessories、Gifts、Lifestyle 等。点击任意一个分类,地址栏会出现:
/filter?category=Gifts在动手之前,我建议你按 F12 打开开发者工具,切到 Network 面板,再刷新一次页面。观察请求,你会看到浏览器向/filter路径发起了一次 GET 请求,参数category=Gifts被明文附在 URL 上。
这一步不是为了炫技,而是为了确认一件事:参数是 GET 参数,直接体现在地址栏里。这就意味着所有实验操作都可以靠单纯编辑 URL 完成,不需要额外的抓包工具。
3.2 第一次试错:制造一个有意的语法错误
实验的第一步,我的习惯是在参数值后面加一个单引号:
/filter?category=Gifts'把地址栏里的 URL 改完,按回车,页面很可能直接变成一个错误页。在 PortSwigger 这个靶场里,常见的表现是页面提示“Internal Server Error”或者干脆一片空白。
这说明什么?说明后端没有任何参数校验,你的单引号被原样传到了数据库,SQL 解析直接失败。不要把这个当成失败,这恰恰是整个实验最关键的成功信号。
试错之后的思考路径是:既然是category='Gifts'这种结构,单引号破坏了字符串闭合,那我需要把这个 SQL 句子“修好”,让数据库认为这是一条合法查询。怎么修?要么用注释符把后面的内容丢掉,要么再补一个引号让结构重新闭合。这道题选注释符最干净。
3.3 提交核心 Payload:OR 1=1 加上注释符
接下来在地址栏输入:
/filter?category=Gifts'+OR+1=1--注意这里把空格写成了+,这是 URL 编码的惯用做法。地址栏里如果你直接输入空格,浏览器也会自动帮你转成%20,但手动用+更直观。URL 解码后,实际传给后端的参数值是:
Gifts' OR 1=1--拼接到 SQL 里,就是我们前面分析的:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1--' AND released = 1;按下回车后,页面会刷新,商品列表会明显变长。除了原本可见的 Gifts 分类商品,你会看到一些从未在前台出现过的商品,它们的介绍文字里透露着“尚未发布”的气息,比如某些新品预告、内部测试商品等。至此,实验目标达成:隐藏数据被检出来了。
完整链路我用一个表格总结一下,方便你在练习时对照检查:
| 操作 | URL 参数写法 | 后端 SQL 结果 | 页面表现 |
|---|---|---|---|
| 正常访问 | category=Gifts | WHERE category='Gifts' AND released=1 | 只显示已发布商品 |
| 单引号试探 | category=Gifts' | WHERE category='Gifts'' AND released=1 | 500 错误或空白页 |
| 核心注入 | category=Gifts'+OR+1=1-- | WHERE category='Gifts' OR 1=1 | 所有商品,包括未发布的隐藏商品 |
3.4 提交答案与验证
PortSwigger 实验室有一个“正文下方”的提交区域,里面有一句话,要求你描述“检索到的隐藏商品的名称”。实际上,当你把隐藏商品全部展示出来后,页面里会有一个明显的、名称特殊的商品,比如某款只在后台存在的新品。把它填进去,点“Submit”,这一关就过了。
这里有个细节值得注意:很多人做实验时一看列表变长就急着提交,却不清楚自己提交的商品到底是不是“隐藏数据”。我的建议是,提交前对比一下原始页面。原始 Gifts 分类下没有这款商品,但注入后列表里出现了,那它就是被隐藏的数据。这个“对比”的思维,在后续所有漏洞实验中都会反复用到。
4. 让我踩坑最多的地方:注释写法、URL 编码和绕过直觉
4.1 别把“万能密码”的肌肉记忆硬套到这道题
我第一次做这个实验,直接复制了登录框的经典 payload:
' or '1'='1结果页面空空如也。因为登录场景的查询是:
SELECT * FROM users WHERE username = '' or '1'='1' AND password = 'xxx'而商品分类场景是:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1两者结构不一样。在这道题里,你如果只构造' or '1'='1,拼出来是:
WHERE category = '' or '1'='1' AND released = 1确实能让前面的 category 条件失效,但因为AND released = 1依然存在,未发布商品还是不会显示。虽然页面可能正常,但你的实验提交阶段会一直失败。
所以,做题前先想明白:当前查询长什么样?我想让哪个条件失效?在 WHERE 子句注入里,通常不是构造一个复杂的恒真表达式,而是更直接的OR 1=1--。
4.2 注释符的三种写法,以及为什么我推荐--+
这道题的显著难点是注释符。--在 URL 里是个很微妙的东西,它本身在 SQL 中表示注释,但在 URL 的 GET 参数里,它只是普通字符。问题出在“注释符后必须跟一个空格”这个要求上。
不同的数据库对注释符的要求不太一样。在 MySQL 里,--后面必须至少有一个空格才能当作注释符;而在 Oracle、PostgreSQL 等数据库中,--本身就是注释符,不强制要求后面有空格。PortSwigger 的靶场环境用的是 Oracle 数据库,所以使用--通常能成功。
但为了保证兼容性和成功率,我在练习时习惯写--+。这里的+在 URL 编码中等价于空格,所以后端收到的就是:
Gifts' OR 1=1--也就是注释符后面带了一个空格。这样无论目标数据库对空格是否敏感,都能正常注释掉后面的内容。
还有一个容易踩的坑:如果你在地址栏输入category=Gifts' OR 1=1--,浏览器有时候会把空格原样传到地址栏,但某些前端框架或浏览器插件会自动把空格转成%20,甚至可能破坏--的语义。所以我一般不在地址栏直接输入原始空格,而是显式写成+,减少被浏览器“篡改”的概率。
4.3 注入后页面没变化?先按这三步检查
做实验时最难受的,不是不知道 payload,而是 payload 看起来没问题,页面却毫无反应。按照我的经验,按照以下顺序排查基本都能解决。
第一步,检查单引号试探是否成功。如果category=Gifts'没有引起 500 错误或者页面变化,说明参数可能没走 SQL 拼接,或者目标页面压根不展示报错信息。这时候就要考虑是不是数字型注入、POST 参数注入,需要换一种探测方式。
第二步,检查 URL 编码是否被破坏。+在某些后端框架里会被当作空格,但如果你用的是%27表示单引号,%20表示空格,理论上更稳妥。写 URL 时尤其注意,不要把 payload 里的空格变成null或%00,那会导致 SQL 直接报错。
第三步,检查注释符是否真的生效。把返回的页面源码拿来看,如果查询语句被完整回显在错误信息里,你会看到--后面的部分是否被注释掉。如果AND released = 1还在,说明注释符没生效,换用--+或#试试。
这三个排查步骤,我在后续很多 SQL 注入题目里都在用。它不是这道题的专属技巧,而是一套通用的“注入探测-构造-排错”方法论。
5. 从实验靶场走出来的思考:这一点在真实业务中如何发生与收口
5.1 真实世界里的“隐藏数据”通常藏在哪里
很多人打完这个实验室后,心里可能会冒出一个疑问:现实中真有电商网站会这样裸奔吗?说实话,直接拼接用户输入并拼进 SQL 的情况,在老旧系统里还很常见;就算现在的框架普遍用 ORM,碰到动态排序、复杂报表、搜索等场景,手写 SQL 的概率仍然存在。
“隐藏数据”在真实业务里的形态也比题目里更丰富。比如:
- 电商后台的未上架商品,字段
status = 0; - 内容管理系统里的草稿文章,字段
published_at IS NULL; - 用户体系里的内部测试账号,字段
is_test = 1; - 金融系统里的历史记录,被
deleted = 1软删除。
攻击者使用OR 1=1--这类注入,本质就是把“必须满足全部条件”的 WHERE 子句,改写成“只需要满足其中一个条件”的逻辑。原本的过滤条件全部失效,未发布、未审核、软删除的数据就会全部流到前端。
这里我必须多说一句,包括 PortSwigger 靶场在内的所有实验环境,都是经过授权的漏洞研究环境。现实中任何针对非授权目标的注入尝试都是违法的,所以这篇文章里的技术,请只放在你自己的靶场和授权测试范围内实践。
5.2 防御端最有效的三件套
这一关教会我们的核心千防御:别把用户输入当成 SQL 代码。
第一根防线是参数化查询。把用户输入当作数据传入,而不是拼接进语句。在 Python 里,正确和错误的写法差别非常直观:
# 错误示范:直接拼接 query = f"SELECT * FROM products WHERE category = '{category}' AND released = 1" cursor.execute(query) # 推荐做法:参数绑定 cursor.execute( "SELECT * FROM products WHERE category = ? AND released = 1", (category,) )用参数绑定之后,即使用户输入再诡异的' OR 1=1--,数据库也只会把它当成一个普通的字符串值,不会介入 SQL 语法解析。
第二根防线是对输入做白名单校验。尤其是 category 这类业务枚举值,后端完全可以维护一个合法分类列表,只有命中的才允许进入查询。这样就算攻击者构造出再刁钻的 payload,也到不了 SQL 层。
第三根防线是数据库账号权限最小化。如果查询只读商品表的账号本身就不具备访问其他表的权限,攻击者想通过 UNION 注入拖取用户表、订单表,就会因为权限不足而失败。权限收得越紧,注入的破坏力就越小。
我建议做开发或运维的朋友,把这三层都当成默认配置。第一层防住大多数注入,第二层防住粗糙的参数污染,第三层防住注入成功后的横向影响。
5.3 做完这一题之后,下一步该练什么
PortSwigger 官方把 SQL 注入系列排成了好几关,第一题只是热身的开始。打完这一关,我建议按照下面的顺序继续:
- 第二题:还是 WHERE 子句注入,但要先确认数据库列数,使用 UNION 检出其他表的数据。这能帮你理解注入不是只能绕过条件,还能“代查”其他数据。
- 第三题:同样用 UNION 检索,但结果需要展示为登录账号密码。这离真实利用更近一步。
- 登录绕过题:终于轮到“万能密码”场景,但你经历过这一关后,看到那个
' OR '1'='1的感受会和最初完全不同。
把基础查询、注释符、URL 编码、条件构造这四个点吃透之后,你会发现后面几十道 SQL 注入题,本质都是在这些基础逻辑上加不同的“壳”。
写在小实验之外
我个人最深的体会是,这一关之所以被官方排在第一道,不是因为它的利用逻辑最简单,而是因为它把 SQL 注入最底层的那根弦拨响了:你对一个参数输入的内容,会如何改变一条 SQL 语句的结构。理解了这点之后,再去记各种 payload,就只是像背字典一样轻松了。
做完这道题,我建议你也像我一样,把它当成一个“最低标准”来审视自己写过的代码。随便打开一个旧项目,搜索一下有没有把请求参数直接拼到 SQL 字符串里的地方,如果有,趁早改成参数化查询。这道实验题救不了真实业务,但如果你愿意用这道题的角度去回看自己的代码,它会很值。