SQL注入入门实战:从引号原理到联合查询完整过程

发布时间:2026/10/11 18:32:14
SQL注入入门实战:从引号原理到联合查询完整过程 我最近在内部带几个新人做安全基础练习发现一个特别常见的误解大家一提到SQL注入就觉得水深上来就研究自动化工具和长长短短的payload。可实际我把一条被注入的请求原文放到屏幕上的时候他们几乎都能自己看出问题在哪——说到底出入就藏在一对引号里。所以我想把这套最基础的逻辑整理成一份通俗的实战入门手册起因就是这么简单。考虑到内容量不小我把它分成上下两集。上集聚焦四件事漏洞的本质成因、靶场环境怎么准备、注入类型怎么区分、联合查询注入的完整链路下集再补盲注、报错注入和更多绕过思路。文章里所有操作演示都基于我自己搭的本地靶场也就是“模拟项目X”这个只存在于虚拟机里的迷你商城。第一次接触Web安全的朋友、正在准备安全方向面试的同学以及想搞清楚漏洞原理的后端开发这篇都是为你准备的。读完之后你应该能独立完成一次从判断注入点到查出数据的完整过程也能明白为什么参数化查询是根治手段而不是靠黑名单堆砌。1. 先搞清楚原理为什么一对引号就能改变SQL的走向1.1 一条登录SQL是怎么被拼出来的在讲漏洞之前先看一段最常见的“裸奔”代码。很多早期教程、内部原型甚至线上老项目都这么写$username $_POST[username]; $password $_POST[password]; $sql SELECT * FROM users WHERE username $username AND password $password; $result mysql_query($sql);开发者的本意是把用户名和密码当成两个普通字符串放进去做一次精确匹配。问题在于这段代码不是用参数传递的方式把用户输入交给数据库而是把输入直接“拼接”进了SQL文本。当普通用户输入admin和123456时生成的SQL是SELECT * FROM users WHERE username admin AND password 123456语句干净、语义明确。可如果用户在用户名框里输入的是 OR 11密码随便填x拼接出来就是这样SELECT * FROM users WHERE username OR 11 AND password x逐段解读这条语句的语义username 用户名为空这种条件下一般是假OR 11一个恒真的表达式OR只要有一边为真整体就为真后面的AND password x虽然密码不一定匹配但整条WHERE已经是“真 OR ... ”的结构AND的优先级被OR隔离最终条件整体成立。于是数据库会把第一条匹配的记录返回给程序。程序往往只关心“有没有查到数据”一旦查到就认为登录成功。这就是最经典的登录绕过靠的是输入把一个本该是“数据”的位置撑成了“代码”。这类漏洞之所以至今还在除了历史代码没人愿意动更重要的是很多人仍然没理解根因——他们总觉得是“过滤得不够多”于是不断给黑名单加词却始终没有从语法层面切断“数据”和“代码”的混淆。1.2 从“数据”到“代码”注入的本质到底是什么这里我想强调一个特别重要的认知它会贯穿你之后所有的注入学习SQL注入的本质不是“过滤不严”而是程序和数据库之间没有区分“数据”和“指令代码”的边界。用一句话描述漏洞的成因就是用户输入本应被当作字符串数据使用却因为引号和拼接的存在进入到了SQL解析层被数据库当成了SQL语法的一部分去执行。我平时给新人做类比时经常说SQL语句里的单引号就像是“字符串的院墙”。程序原本希望用户输入只是院子里站着的一个人一串普通文本于是在SQL里用引号把这块区域围好然后让用户进去站着。但如果这个院墙本身开着口子用户自己又拿了一张写着引号的“通行证”那他就可以走出院子、走到马路上指挥交通甚至把整条街的护栏都改一遍。在SQL的世界里这个“整条街的护栏”就是其他表和字段。一旦输入进入语法层攻击者理论上就可以调动数据库里他本来没权限操作的部分。这也是SQL注入的危害被称为“直接打到数据层门口”的原因因为它绕过了应用层的大部分安全检查直奔存储层。1.3 为什么“过滤了关键词”还是会被绕很多朋友会问我在代码里把select、union、and这些词都替换成空字符串或者加个黑名单是不是就安全了我在审计里见过太多试图用黑名单解决问题的代码结论往往是能挡住一部分人的随手尝试但挡不住认真研究的人。原因有几个大小写绕过SeLeCt在MySQL里默认不区分大小写内联注释绕过MySQL支持/*!50000union*/这类特殊形式某些过滤正则会被骗过等价写法绕过AND可以写成OR可以写成||空格可以用%0a、%09、/**/代替编码绕过URL解码、双重URL编码在某些框架场景下也能造成漏过滤。黑名单永远要面对“有没有没考虑全的写法”这个问题而攻击者只需要找到一个漏网之鱼。所以真正的修复思路不是把黑名单加得越来越长而是从语法层面让用户输入永远只能当“数据”出现。这就是第6章要讲参数化查询的原因在那之前我们得先把靶场准备好。2. 动手前先把靶场搭对才能放心练手2.1 一套最小可复现的靶场环境清单学习SQL注入最忌讳的就是拿别人的线上系统去练习。这不仅不礼貌也完全没有意义你连目标环境长什么样都不知道练习结果很难沉淀成有效经验。正确做法永远是在自己完全控制的虚拟机或容器里建一个靶场。我常用的环境组合是一台Linux虚拟机系统镜像采用最小化安装Nginx PHP 做Web服务MySQL 8 做数据库一个自己写的迷你商城页面也就是前面说的模拟项目X。如果你机器性能不够配置方案可以降级直接带图形界面的桌面环境一键安装集成工具包或者用现成的容器镜像拉起整套环境都可以。关键记住“自建、自控、可恢复”这三点最好每次练习前都能快照出问题随时回滚。下面是Linux环境下的基础安装命令供参考apt update apt install -y nginx php-fpm php-mysql mysql-server systemctl enable nginx systemctl start nginx装完确认服务起来后建一个测试目录比如/var/www/sqli-lab把虚拟主机指过去。后面所有的页面代码和请求都从这里来。2.2 靶场数据库与接口代码接下来初始化数据库。我用一个非常简单的商品表结构模拟商城详情页的场景CREATE DATABASE IF NOT EXISTS shop_demo DEFAULT CHARSET utf8; USE shop_demo; CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, detail TEXT ); INSERT INTO products (id, name, price, detail) VALUES (1, 普通鼠标, 39.90, 入门款办公鼠标), (2, 机械键盘, 199.00, 青轴机械键盘), (3, 显示器, 899.00, 27寸IPS屏); CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL ); INSERT INTO users (username, password) VALUES (admin, 25f9e794323b453885f5181f1b624d0b);说明一下这里特意没有对password做任何哈希处理实际系统绝不能这么存。但作为入门靶场我们要先能看到明文或固定哈希才好在联合查询时直观对照结果。数据库建好后写一个没有任何防护的商品查询接口/var/www/sqli-lab/product.php?php $conn new mysqli(127.0.0.1, sqli_user, pass123, shop_demo); if ($conn-connect_error) { die(连接失败: . $conn-connect_error); } $id $_GET[id]; $sql SELECT id, name, price, detail FROM products WHERE id $id; $result $conn-query($sql); if ($result $row $result-fetch_assoc()) { echo 商品ID: . $row[id] . br; echo 名称: . $row[name] . br; echo 价格: . $row[price] . br; echo 描述: . $row[detail] . br; } else { echo 未找到该商品; } $conn-close(); ?一眼就能看出后端直接把$_GET[id]拼进SQL没有做任何过滤也没有参数化。这就是标准的数字型注入点。练习时建议把PHP的display_errors打开否则有些数据库错误被吞掉不利于观察现象。2.3 养成记录请求与响应的习惯在正式开始测试前我建议你先建立一套“测试记录”的习惯。不要凭肉眼和记忆判断“刚才好像报错了”而要真正把每次请求和响应记下来。我现在每次练习都会用浏览器开发者工具配合终端工具操作发完一个请求后记录三样东西完整请求URL含参数响应中的关键差异报错、内容变化、是否为空、返回行数此时的假想SQL——我猜后端拼出来的语句长什么样。实际操作中可以做一张简单的表比如请求URL响应特征假想SQL判断/product.php?id1显示商品详情... WHERE id 1正常基线/product.php?id1数据库报错... WHERE id 1疑似注入这个习惯的价值在于逼着你把每一步的“证据”串起来而不是瞎试。很多老手调试速度块不是因为他们背了大量payload而是因为每一个判断都有依据排查起来完全不慌。3. 注入类型辨识数字型、字符串型和注释符是怎么配合的3.1 数字型与字符串型的分水岭拿到一个疑似注入点第一步先判断它是数字型还是字符串型。这决定了后面你要不要闭合引号以及如何构造测试语句。类型后端模板注入时注意点常见场景数字型WHERE id $id不需要闭合引号直接拼接逻辑商品ID、用户ID等数字参数字符串型WHERE name $name需要先闭合前引号并可注释掉后文搜索词、登录名、公告标题前面靶场里的product.php就是标准的数字型注入点。而字符串型的典型场景是登录框WHERE username $username你输入admin--时如果后端什么都不处理就会变成SELECT * FROM users WHERE username admin-- AND password ...其中--在SQL里表示注释后面的内容全部作废密码校验被整段“跳过”。这也是为什么很多早期漏洞案例里只要知道一个用户名就能直接登录。3.2 注释符不同数据库的“方言”注释符在注入里的角色很关键把原本SQL语句中你不想让它生效的尾部内容废掉。常见的注释方式有这么几种--标准SQL注释注意后面通常需要空格或紧跟换行#MySQL独有的行内注释/* ... */多行注释MySQL中还支持/*! ... */这种特殊执行式注释。以登录场景为例你要闭合用户名前的引号并注释掉后面的密码条件SELECT * FROM users WHERE username admin-- 在URL里提交的时候--后面的空格往往需要用代替因为URL中空格会被编码掉所以你在很多payload里看到的是--。如果你发现--在目标环境上无效先别急着怀疑注入不存在很可能是注释符类型或空格处理的问题。3.3 报错信息是最好的“方向标”但不是唯一证据当请求参数里的引号触发数据库报错时页面会返回类似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 ...这样的信息。能出现这类报错说明输入确实进入了SQL解析层这是很强的信号。除了确认注入存在报错信息还能帮你判断数据库类型。比如MySQL的报错格式和PostgreSQL、微软系数据库都不一样看到关键词“MySQL server version”基本能确认后端。靶场里为了方便入门打开了详细报错真实业务环境往往会关掉详细报错只给一个通用500页面。但报错只是第一层证据。有些报错来自程序内部逻辑比如数值参数经过强类型转换后变成0导致查询本身正常但结果为空页面返回的是业务错误这不代表可注入。所以报错负责告诉你“有异常”能不能确定是注入还得靠下一章的布尔逻辑判断。4. 注入点确认从试探到坐实每一步都有依据4.1 单引号和双引号试探先在商品ID后面加一个单引号发请求/product.php?id1。如果后端直接把1拼进SQL那么SELECT id, name, price, detail FROM products WHERE id 1MySQL会直接报语法错误。靶场里看到的响应确实是一段SQL语法报错信息证明参数没有经过类型转换且真的进入了SQL解析层。接着用/product.php?id1试一下注意两个单引号在SQL里表示一个转义的字符串字面量这里会因为1后面没有干净闭合而产生另一种报错。不同情况下的报错细节能帮你推断后端拼接模板到底长什么样。除了单引号双引号也要试一试。有些后端模板用双引号包裹字符串有些数据库方言对双引号的处理方式不同试探一下能节省排查时间。建议你把这个自动化或者至少记录成固定测试序列1、1、1、1、1每次拿到新目标都按顺序跑一遍。4.2 用“恒真”与“恒假”把注入坐实只靠报错还不够稳妥尤其在实际测试中可能遇到各种异常页。我更喜欢用布尔逻辑来做确认。所谓恒真恒假就是构造两个条件一个永远为真一个永远为假然后对比响应/product.php?id1 AND 11因为11恒真所以如果参数真的能被拼进SQL这条查询会返回与id1完全一样的数据/product.php?id1 AND 12因为12恒假这条查询应该返回“未找到该商品”。如果“恒真”有数据、“恒假”无数据差异明显基本可以确定这个参数就是注入点而且没有对关键字做拦截。对应地如果目标是字符串型需要先闭合引号再拼接逻辑比如登录名场景WHERE username admin AND 11这个判断逻辑之所以可靠是因为它不依赖报错页面是否存在只依赖页面正常回显与否。即便应用把错误全部吞掉只要“恒真”和“恒假”两个响应可区分你仍然能确认注入。这同时也是后面盲注思路的前奏盲注本质上就是把这种判断自动化、逐位提取数据。我把判断流程整理成一个矩阵练习时直接照着做测试输入预期“真”响应预期“假”响应结论id1 AND 11显示商品详情不显示参数可拼入id1 AND 12不显示不显示参数未拼入或已过滤id1 AND 11 --显示商品详情不显示字符型注入成立4.3 别把“假注入”当成真发现顺着这个话题我多泼一盆冷水。很多人第一次在测试环境里看到报错就兴奋地喊着“找到了”结果仔细一查只是代码逻辑里一个普通的空指针或者类型转换错误攻击者根本没法利用。常见的假信号有参数经过强类型转换比如intval($id)之后1变成了1页面恢复正常并没有注入程序已使用参数化查询报错来自框架的异常页面攻击载荷没有进入SQL语法层参数同时被多个地方使用其中一处做了过滤另一处没有但整体响应看起来一致。识别假注入的最好办法还是回到4.2的恒真恒假判断只要真、假两种请求的响应能被稳定区隔基本就是可用的注入点如果区分不出来即使看到报错也只能算“疑似”要继续挖下去。5. 联合查询注入的完整链路在靶场里跑通一次“查数据”演练5.1 用ORDER BY数清字段数确认注入点之后下一步要搞清楚当前主查询返回了几个字段。因为UNION要求两边结果集列数必须一致字段数是第一个要确认的参数。我用ORDER BY来做因为它的报错特征非常明确/product.php?id1 ORDER BY 1 -- 正常 /product.php?id1 ORDER BY 2 -- 正常 /product.php?id1 ORDER BY 3 -- 正常 /product.php?id1 ORDER BY 4 -- 正常 /product.php?id1 ORDER BY 5 -- 报错字段数小于5当ORDER BY 5报错时说明原查询只有4列。这个数字就是后面UNION SELECT里要写的占位个数。你可能会问为什么不用UNION SELECT 1,2,3...直接试也能试但ORDER BY的好处是它不改变结果集内容报错标准且不容易漏判。5.2 定位回显位置现在构造一条UNION SELECT。关键点要让原始查询不返回数据从而让UNION部分成为唯一的数据来源。常见做法是加一个AND 12/product.php?id1 AND 12 UNION SELECT 1,2,3,4把1,2,3,4填进去是为了在页面里“探位置”。我的靶场响应如下商品ID: 1 名称: 2 价格: 3 描述: 44个字段都能被页面展示出来就能直接使用。如果某些字段没有被展示比如价格字段被程序拿去计算页面不会显示对应数字你就需要优先使用会回显的位置去放查询函数。需要补充的一点是如果查询结果有多条但页面只展示第一条你可以在UNION子句里加上LIMIT 1来控制输出这也算是联合查询里的一个常用小技巧。5.3 从当前库名到最终数据一条标准的information_schema路径在MySQL里information_schema是一个自动存在的元数据库它记录了所有数据库、表、列的信息。对联合查询注入来说它就是一本“目录册”不需要猜表名查它就行。第一步获取当前数据库名/product.php?id1 AND 12 UNION SELECT 1,database(),3,4database()是一个不需要参数的内置函数返回当前连接的默认库名。此时页面“名称”位置会显示shop_demo。第二步列出当前库里所有的表/product.php?id1 AND 12 UNION SELECT 1,table_name,3,4 FROM information_schema.tables WHERE table_schemadatabase()这里我故意没有限制返回条数所以结果可能有多行但页面只展示第一条。如果想依次查看可以用LIMIT 0,1、LIMIT 1,1这样的方式翻页。靶场响应里能看到products、users两张表看到users不难猜到里面存的是账号数据。第三步列出users表的字段/product.php?id1 AND 12 UNION SELECT 1,column_name,3,4 FROM information_schema.columns WHERE table_nameusers注意这里table_nameusers是字符串字符串里的单引号在UNION SELECT的SQL环境下很容易和拼接逻辑冲突。实际发送时要注意URL编码或者用--把末尾条件注释掉。靶场里我看到的字段有id、username、password。第四步取数据/product.php?id1 AND 12 UNION SELECT 1,username,password,4 FROM users这一步之后页面上会直接打印出admin和对应的密码哈希值。到这里一次完整的“查库、查表、查字段、查数据”联合查询注入就闭环了。我建议你在靶场里把上面四步完整跑一遍并把每个步骤的请求、响应都放进记录表。不要直接跳到自动化工具的按钮上否则你会错过很多判断细节。联合查询是所有注入类型里最“直观”的一种因为它直接把结果打在页面上一旦你跑通了后面理解盲注和报错注入都会轻松很多。6. 绕一圈回来为什么防御端比攻击演示更值得你花时间6.1 参数化查询从语法层面釜底抽薪只要理解了注入的根源修复方案其实顺理成章让用户输入永远只作为“值”出现而不是作为“SQL语法片段”出现。在PHP里用预处理语句就能做到$stmt $conn-prepare(SELECT id, name, price, detail FROM products WHERE id ?); $stmt-bind_param(i, $id); $stmt-execute(); $result $stmt-get_result();核心在于?占位符。数据库收到这条SQL时结构是固定的用户输入的1 OR 11只会被当成字符串字面量跟SQL语法没有任何交集。这就好比你把口令放进保险柜的密码盒里而不是直接把保险柜锁舌换成用户给的样子无论输入多奇怪都只能待在“值”的那一格。同样的思路在其他技术栈里到处可见Java有预处理语句Python的数据库接口用%s占位符Go的标准库也是问号占位。凡是成熟的数据库访问层几乎都提供了参数化能力没有理由不用。6.2 转义函数不能当唯一解宽字节注入就是前车之鉴在参数化能力普及之前开发者常用字符串转义函数把单引号变成\让用户输入失去闭合能力。这个思路本身没错但它不是万无一失的。最广为人知的反例就是宽字节注入在GBK编码下某个多字节字符的最后一个字节如果恰好是反斜杠的十六进制编码0x5c而转义函数又在单引号前面加了\那么攻击者可以用一个特殊字节“吃掉”这个反斜杠让单引号依然裸露出来闭合依旧成立。不同数据库、不同编码、不同连接方式的组合都可能产生新的绕过方式。所以我的建议很简单如果你的技术栈支持参数化查询就直接用参数化转义函数可以用作辅助加固但永远不要把它当成唯一防线。外部防护设备同样只能作为补充它没法替代代码层面的修复。6.3 一次典型修复记录了哪些兜底手段最后分享一个典型的修复记录也是很多团队最终落地的方案问题商品详情接口 id 参数直接拼接 SQL存在联合查询注入。 修复 1. 主查询改为参数化预处理根除注入 2. id 参数增加整数白名单校验非法输入直接返回400 3. 数据库账号降权应用账号仅授予 SELECT 指定表的权限 4. 开启错误日志与访问日志采集异常 SQL 留痕 5. 回归验证使用历史 payload 复测注入均失效正常商品详情可访问。第3条值得单独强调即使注入没修干净如果应用连库的账号只有SELECT指定表的权限没有权限读information_schema以外的内容攻击者也拿不到多少有价值的数据。最小权限原则是最后一道保险很多人嫌麻烦就忽略了但它往往能在关键时刻止损。防护的另一个重点是日志。把SQL异常、慢查询、非常规请求比如大量、UNION、information_schema关键字记录下来哪怕当天没有攻击事后复盘也能找到线索。我个人的体会是学SQL注入入门最容易被细节绕晕但真正吃透之后回看一切又非常简单引号、拼接、没做参数化这三个词就是90%注入问题的共同画像。先把这一层打牢比背一百个payload都有用。下集我会接着写盲注和报错注入尤其是那种页面没有任何回显的情况下怎么靠“真和假”把数据库里的数据一点一点抠出来。建议你现在就去把靶场搭起来把联合查询这条链路跑通到时候看下集就不会觉得悬空了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询