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

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

为什么接着学这两个

上一篇写的是 SQL 注入和 XSS,这两个漏洞的共同点是:攻击者往目标里塞东西。

CSRF 和 SSRF 不一样,它们的关键不是"塞进去什么",而是"让谁去干活":

  • CSRF 是让受害者自己的浏览器替攻击者发请求
  • SSRF 是让服务器自己替攻击者发请求

一个是借用人的身份,一个是借用机器的位置。搞清楚这个区别,这两个漏洞就通了。

靶场与实验环境

靶场特点适合阶段
皮卡丘(Pikachu)CSRF、SSRF 各有一关,中文,一个页面一类漏洞入门,先把流程跑通
DVWACSRF 分 low / medium / high / impossible 四档进阶,想看防御怎么被绕过时必刷
自己写的最小 demoFlask 或 Express 十分钟搭一个,能看服务端日志最推荐——理解本质靠这个

我一开始贪快只刷靶场,结果卡了两天没搞懂 CSRF 为什么"能成立"。后来自己写了个最小 demo,把服务端收到的每个请求打印出来,五分钟就想通了。能打印日志的环境比任何靶场都值钱。

一、CSRF

1.1 一句话原理

浏览器会自动带上目标站点的 Cookie,而服务端没法区分这个请求是用户自己点的、还是第三方页面偷偷发的。

所以 CSRF 成立需要三个条件同时满足:

条件说明缺了会怎样
凭据自动携带鉴权靠 Cookie(或 Basic Auth),浏览器自动带上用 Bearer Token 存 localStorage 就天然免疫
参数可预测请求的参数能被攻击者提前构造出来有随机 nonce 且强校验就挡掉
无二次校验服务端不做 CSRF Token、Origin、验证码等检查任意一道加上就大幅缓解

三个条件拆开看都很朴素,但同时满足的场景非常多——几乎所有传统表单站点都符合。

1.2 为什么说它"打的是人"

这是理解 CSRF 最关键的一步。

SQL 注入打的是数据库,XSS 打的是其他用户的浏览器,而 CSRF 打的是受害者本人的身份和权限。

服务器视角看到的请求是这样的:

1
2
3
4
5
6
POST /user/password HTTP/1.1
Host: victim.com
Cookie: PHPSESSID=abc123...
Content-Type: application/x-www-form-urlencoded

newpass=hacked123

这个请求在协议层面完全合法——Cookie 是真的,Session 是有效的,格式没问题。服务器没有任何理由拒绝它。

唯一的问题是:这个请求不是用户想发的,是用户访问了攻击者页面后,被那段页面"替"他发的。

1.3 同源策略到底限制了啥

很多人以为"有同源策略所以跨站发不了请求",这是个经典误解。

同源策略限制的是读取响应,不是发送请求:

行为是否受限说明
跨站发 GET 请求❌ 不受限<img src>、<script src>、<link> 随便发
跨站发 POST 表单❌ 不受限<form action="跨站"> + JS 自动 submit 就行
跨站读响应内容✅ 受限拿不到响应体,只能通过时序/侧信道猜
跨站发 JSON(带自定义头)✅ 触发预检Content-Type: application/json 会先 OPTIONS

关键结论:浏览器允许跨站"写",不允许跨站"读"。

而 CSRF 只需要"写"——改密码、发帖、转账、加管理员,全是写操作,根本不需要读响应。所以同源策略对 CSRF 几乎没有防护作用。

1.4 GET 型 CSRF:最容易理解的起点

最原始的形态,一个 img 标签就够了:

1
2
<!-- 攻击者页面 evil.html -->
<img src="http://victim.com/user/delete?id=1" width="0" height="0">

页面一加载,浏览器就去请求那个 URL,并且自动带上 victim.com 的 Cookie。用户看不到任何东西(图片尺寸为 0),删除就已经执行完了。

这种漏洞现在少见了,因为:

  1. 现代框架默认要求 POST
  2. SameSite=Lax 会阻止跨站的非顶级导航请求带 Cookie

但它仍然是理解 CSRF 最直观的例子——"一个 src 属性就是一次攻击"。

1.5 POST 型 CSRF:最常见的实战形态

POST 表单同样可以跨站提交,这是 CSRF 最主要的形态:

1
2
3
4
5
6
<form action="http://victim.com/user/password" method="POST" id="f">
<input name="newpass" value="hacked123" type="hidden">
</form>
<script>
document.getElementById('f').submit();
</script>

要点:

  • 表单的 action 可以是任意域名,浏览器不会拦
  • submit() 会自动跳转,页面一变用户就发现了——所以实战中攻击者会提交到 iframe 里,页面看起来毫无变化
  • 更隐蔽的做法:target 指向一个隐藏 iframe
1
2
3
<iframe name="sink" style="display:none"></iframe>
<form action="http://victim.com/user/password" method="POST" target="sink" id="f">...</form>
<script>document.getElementById('f').submit()</script>

这样用户完全感知不到,密码就被改了。

1.6 JSON 型:一个流传很广的错误认知

网上常见说法:"我的接口是 JSON 的,不是表单,所以不怕 CSRF"。

这个说法是错的。

它的依据是:Content-Type: application/json 属于非简单请求,会触发 CORS 预检(OPTIONS),而预检通不过就没法发。

但这里有两个漏洞:

漏洞一:服务端不校验 Content-Type

很多后端的写法是"拿到 body 就 json_decode",根本不管请求头。这时候攻击者可以用表单发过去:

1
2
3
<form action="http://victim.com/api/update" method="POST" enctype="text/plain">
<input name='{"username":"admin","role":"1","x":"' value='"}'>
</form>

enctype="text/plain" 发出去的内容长这样:

1
{"username":"admin","role":"1","x":"="}

后端如果宽松解析,或者用正则提取字段,就中招了。

漏洞二:某些接口接受 text/plain / form 格式

只要服务端对多种 Content-Type 都做了兼容(很常见,为了对接老客户端),CORS 预检就被绕过了。

判断方法:抓包看接口是否严格校验 Content-Type,以及是否对不合法的类型直接返回 415。

1.7 防御:按可靠程度排序

① 不用 Cookie 鉴权(最根本)

这条最彻底:如果鉴权凭据不由浏览器自动携带,CSRF 从原理上就不成立。

方案凭据位置CSRF 风险代价
Session CookieCookie有传统方案,简单
Bearer TokenlocalStorage / 内存无要防 XSS(XSS 能偷 token)
Cookie + CSRF TokenCookie + 请求体低需要额外实现

所以现代 SPA + API 架构天然免疫 CSRF——不是因为他们做对了什么,而是他们根本没给浏览器"自动带凭据"的机会。

代价是:XSS 变成了致命的(能直接偷 token),所以这条路是"防了 CSRF,把风险集中到 XSS"。

② CSRF Token

原理:服务端给每个会话(或每个表单)生成一个随机串,表单提交时必须带上,服务端校验。

实现正确的三个要求:

  1. 不可预测:用 CSPRNG,不能用时间戳、不能自增
  2. 绑定用户:token 必须和当前会话关联,不能全局共用
  3. 至少校验所有"写"操作:包括 GET、包括 AJAX、包括上传接口

常见实现错误:

错误写法为什么没用
token 存在 Cookie 里,且校验逻辑是"Cookie 里的值 == 表单里的值"攻击者无法读 Cookie,但可以通过别的漏洞(XSS/子域)写——而且这种写法本身容易被绕过
全局共享一个 token一个用户泄露就全站沦陷
只在"改密码"接口校验攻击者改打"改邮箱""绑定手机"接口
token 校验不严(只查存在性不查值)形同虚设

③ SameSite Cookie

给 Cookie 加 SameSite 属性,浏览器就不再对跨站请求携带它:

值行为防护效果
Strict完全不带最强,但用户从外链点进来会"掉登录"
Lax顶级导航的 GET 带,其他不带推荐默认值,挡住绝大多数跨站 POST
None全都带等于没防(且必须配 Secure)

Lax 是现在浏览器的默认值,这是为什么很多老的 CSRF 漏洞"自己就好了"。

但它不是万能的:

  • 旧的浏览器/App 内嵌 WebView 可能不支持
  • Lax 仍然允许 top-level 的 GET 导航带 Cookie——所以用 GET 做写操作依然是漏洞

④ 校验 Origin / Referer

1
2
3
4
5
// 现代浏览器对跨域请求一定会带 Origin 头,而且 JS 改不了它
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && $origin !== 'https://mysite.com') {
http_response_code(403); exit;
}
头可靠性坑
Origin高——不可伪造,现代浏览器都发同源 GET 导航可能不带
Referer中——可被 Referrer-Policy 清空必须处理"空值",否则正常用户被误杀或攻击者用空值绕过

⑤ 二次验证

改密码要输原密码、转账要短信验证码、高危操作要邮箱确认。

这是性价比最高的一道——因为它防的不只是 CSRF,还有账号被盗后的横向操作。

1.8 几个流传很广的误区

  • "上了 HTTPS 就安全了" —— 错。CSRF 不关心加密,HTTPS 只防中间人。
  • "我有 CSRF Token 就安全" —— token 本身可能通过 XSS、日志、Referer 泄露,实现错误等于没有。
  • "我的接口是 JSON 的" —— 见 1.6,不成立。
  • "只有后台才需要防" —— 前台用户的"发帖""改资料""绑定"一样中招。
  • "攻击者读不到响应就没危害" —— CSRF 全部是写操作,根本不需要读。

二、SSRF

2.1 一句话原理

服务端接收了一个 URL,然后"替你去请求"它。

典型场景:图片抓取、URL 预览、远程下载、Webhook 回调、代理接口、XML 解析器加载外部实体。

这些功能的共同点是:它们都需要服务器主动发起一个网络请求,而请求的地址来自用户输入。

如果服务端不校验这个地址,攻击者就能把请求指向任何它够得着的地方——包括 127.0.0.1、内网段、云厂商的元数据服务。

2.2 为什么 SSRF 通常比 CSRF 更严重

维度CSRFSSRF
攻击的是用户的身份服务器的网络位置
视野范围受害者在目标站的权限服务器能访问的所有内网资源
能否横向一般不能能——内网穿透、打内网应用
能否 RCE不能能,通过 gopher 打 Redis/FastCGI 等

核心差别在于:服务器往往站在内网的门口。

一台 Web 服务器能看到的东西,比你在外网能看到的多得多——内网的数据库、Redis、管理后台、K8s API、云元数据,全都只有它够得着。SSRF 就是把这个"位置优势"借给攻击者。

2.3 皮卡丘 SSRF 实操

皮卡丘的 SSRF 关卡长这样:

1
http://靶场地址/vul/ssrf/ssrf_curl.php?url=http://127.0.0.1/pikachu/vul/sqli/sqli_str.php

它用 curl 请求你传入的 url,然后把结果返回。第一关连任何过滤都没有,直接把 url 换成:

1
?url=http://127.0.0.1/

就能看到内网的首页。换成 file:///etc/passwd(如果 curl 支持)就能读文件。

这一步的意义:你亲眼看到"我人在外网,却拿到了 127.0.0.1 的内容"。这个瞬间是理解 SSRF 的核心。

2.4 危害链:从"读个文件"到"拿下服务器"

SSRF 的价值不是"能读一个文件",而是它是一条通往内网的跳板。递进关系:

阶段动作典型目标
1探活内网段扫描:http://192.168.1.1~254,看响应时间/状态码差异
2读文件file:///etc/passwd、file:///proc/self/environ
3打内网应用无认证的 Jenkins、K8s API、Consul、Elasticsearch
4读云元数据拿到临时凭据 → 接管云资源
5协议走私 RCEgopher:// 打 Redis 写 SSH key / 写 crontab、打 MySQL、打 FastCGI

第 4 和第 5 是 SSRF 从"信息泄露"升级为"严重"的分水岭。

2.5 云元数据:SSRF 的最高价值目标

云服务器上有个特殊的本地地址,能拿到实例的临时凭据。这是 SSRF 的"金矿":

云厂商元数据地址
阿里云http://100.100.100.200/latest/meta-data/
AWShttp://169.254.169.254/latest/meta-data/
腾讯云http://metadata.tencentyun.com/latest/meta-data/
GCPhttp://metadata.google.internal/computeMetadata/v1/(需 Metadata-Flavor 头)

阿里云的信息泄露路径通常是:

1
2
/latest/meta-data/ram/security-credentials/     -> 角色名
/latest/meta-data/ram/security-credentials/角色名 -> 临时 AK/SK/Token

拿到这组凭据,就等于拿到了绑定在该实例上的 RAM 角色权限——如果角色权限给大了(比如能操作 OSS、能读 RDS),后果很严重。

加固演进:AWS 的 IMDSv2 要求先拿一个 token(PUT /latest/api/token 带 X-aws-ec2-metadata-token-ttl-seconds 头),再带 token 去读。因为 PUT 请求无法用简单的 GET/表单构造出来,所以能挡住大部分基础 SSRF。阿里云也对部分实例做了类似加固。

2.6 绕过技巧:这才是 SSRF 的精髓

防御方常见的做法是"过滤掉 127.0.0.1、过滤掉 192.168."。而绕过方式有一大把:

① IP 地址的多种写法

同一个 127.0.0.1,可以用这些形式表示:

写法说明
127.0.0.1常规
2130706433十进制整数
0x7f000001十六进制
0177.0.0.1八进制
127.1省略中间段(等价 127.0.0.1)
0在多数系统里等价于 127.0.0.1
[::1]IPv6 回环
[::ffff:127.0.0.1]IPv4 映射的 IPv6

为什么有效:URL 解析器和后续的 socket 连接用的可能是两套 IP 解析逻辑。过滤器看到的是字符串,而真正连接时用的是解析后的 IP。

② 利用 DNS 指向内网

比变形更稳的办法——用一个域名,指向内网地址:

1
2
http://127.0.0.1.nip.io/
http://127.0.0.1.sslip.io/

这类服务会把子域名里的 IP 直接解析出来。过滤器只看到"域名",但实际连的是 127.0.0.1。

更彻底的是用自己的域名做 DNS Rebinding:第一次解析返回外网 IP(通过校验),TTL 设为 0,第二次解析返回内网 IP(发起真实请求)。这能绕过"先解析后校验"的防御。

③ URL 解析差异

1
2
3
http://evil.com@127.0.0.1/          # @ 前面的被当作用户名
http://127.0.0.1#@evil.com/ # # 后面的被当锚点
http://127.0.0.1:80@evil.com/ # 端口与 @ 的歧义

不同的语言/库对 URL 的解析规则不一致,防御方校验的和实际请求的可能是两个不同的主机。

④ 302 跳转绕过

防御方校验了 URL 是 http://evil.com/safe(外网,合法),但这个地址返回 302 Location: http://127.0.0.1/。如果客户端跟随重定向,第二跳就打到内网了。

关键:只校验第一跳是无效的,必须校验每一跳,或者干脆禁止跟随重定向。

⑤ 协议走私

除了 http,很多 HTTP 客户端支持(或可被诱导支持)其他协议:

协议用途
file://读本地文件
gopher://任意 TCP 流量——打 Redis / MySQL / FastCGI
dict://打 Redis(构造简单命令)
ftp://反弹、绕过
http+unix://打 unix socket

gopher:// 是 SSRF 的终极武器,因为它能发任意字节到任意 TCP 端口。用它打 Redis 的典型思路是:发一条 CONFIG SET dir + CONFIG SET dbfilename + SET payload + BGSAVE,把恶意内容写成 authorized_keys 或计划任务。

(这里不展开完整 payload——靶场里 Gopherus 这个工具能一键生成,自己起 Redis 玩就够了。)

2.7 盲 SSRF:没有回显怎么办

如果目标只返回"请求成功/失败",看不到内容,就是盲 SSRF。

判断方法:

  1. 时间差异:让目标请求一个"会挂起很久"的地址,对比响应时间
  2. DNSLog 外带:让目标请求 http://你的dnslog子域名/,看 DNS 记录里有没有出现

    • 国内可用 dnslog.cn、ceye.io
    • 最稳的是自己起一台 VPS 看 access log——我用的就是这个笨办法,但最可靠
  3. 错误信息差异:不同内网端口返回的错误不一样(Connection refused vs timeout)

我最开始用公共 DNSLog,发现经常收不到——后来才明白是目标服务器可能有出网限制或者 DNS 被劫持。自建 VPS 加 tcpdump 看包,比什么工具都直接。

2.8 防御:白名单是唯一可靠的

SSRF 防御的困难在于:黑名单永远有绕过方式。所以正确顺序是:

① 白名单(首选)

如果业务允许,直接限定目标域名/IP 白名单——只有已知的安全地址才允许请求。

适用于:固定的第三方 API 对接、固定的图片源。

② 协议白名单

只允许 http:// 和 https://,显式拒绝 file://、gopher://、dict://、ftp://。

③ 解析后校验(关键细节)

错误做法:先校验 URL 字符串里的主机名是不是内网 IP,再去请求。

正确做法:

  1. 用 DNS 解析出 IP
  2. 校验这个 IP 是否是内网地址(127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16、::1、fc00::/7)
  3. 用这个已经解析好的 IP 去请求(而不是再次用域名解析)

第 3 步是防 DNS Rebinding 的核心——解析和请求必须用同一个 IP。

④ 禁止跟随重定向,或对每一跳都校验

如果必须跟随,那每一跳都要重新走一遍上面的校验流程。

⑤ 网络层兜底

  • 出口防火墙限制服务器能访问的内网段
  • 使用独立的出网代理,代理上做策略
  • 容器化 + 网络隔离

网络层是最可靠的——因为代码里的校验总有写漏的一天。

⑥ 云上加固

  • 开启 IMDSv2(AWS)/ 元数据加固(阿里云)
  • RAM 角色最小权限
  • 元数据服务限制为只有特定进程可访问

2.9 一个真实的防御翻车点

见过这样的代码:

1
2
3
if (strpos($url, '127.0.0.1') !== false || strpos($url, '192.168.') !== false) {
die('禁止访问内网');
}

绕过方式:http://127.0.0.1.nip.io/。

字符串里没有 127.0.0.1(虽然子域名里有——等等,其实有)。换个更狠的:http://2130706433/,字符串里既没有 127.0.0.1 也没有 192.168.,但它就是 127.0.0.1。

教训:字符串过滤不是防御。 必须解析成 IP 再判断。

三、两者的本质对比

CSRFSSRF
一句话借用户的浏览器发请求借服务器的网络位置发请求
攻击目标用户的身份/权限服务器的内网可达范围
需要受害者访问恶意页面(或点链接)触发一次服务端请求(传个 URL)
需要浏览器配合需要(自动带 Cookie)不需要
典型入口改密码、转账、发帖、绑定图片抓取、URL 预览、远程下载、Webhook
同源策略有用吗基本没用(只防读不防写)无关
危害等级中 ~ 高高 ~ 严重
防御核心CSRF Token / SameSite / Origin白名单 / 解析后校验 / 禁协议
黑名单有效吗Token 混淆有效无效,必然被绕过

一个统一的认知

写了这么多,我觉得这两个漏洞背后其实是同一个错误:

混淆了信任边界。
  • CSRF:服务端把「浏览器自动带来的凭据」当成了「用户本人的真实意愿」。
    凭据能证明"这个人是谁",但证明不了"这个操作是他想做的"。
  • SSRF:服务端把「用户提供的地址」当成了「可信的内部目标」。
    地址是个字符串,但字符串背后可能是内网。

两者都是在"接受了一个不该直接信任的输入"之后,没有做二次确认。

这也是为什么所有防御都指向同一个原则:

不要相信任何来自外部的输入,哪怕它看起来"格式正确"。

四、工具

工具用途
Burp Suite抓包改包;Engagement tools → Generate CSRF PoC 能直接生成 PoC
dnslog.cn / ceye.io盲 SSRF 的 DNS 外带
Gopherus一键生成 gopher 协议 payload(Redis / MySQL / FastCGI)
SSRFmapSSRF 利用自动化
自建 VPS + tcpdump盲打回显最可靠的方案
浏览器 DevTools → Network观察 SameSite Cookie 实际有没有被带上

五、我的心得

5.1 CSRF 的认知转折

开始时一直想不通:"攻击者又不知道我的密码,怎么能改我东西?"

后来才明白:CSRF 根本不需要知道密码。 它利用的是"你已经登录了"这个状态。浏览器每次请求都会自动带上 Cookie——这个设计本来是为了方便,结果成了攻击面。

想通这一点之后,看所有"自动携带凭据"的设计都会本能地问一句:"这个请求怎么证明是用户主动发起的?"

5.2 SSRF 的认知转折

第一眼看 SSRF:?url=http://127.0.0.1/ 能读到内网首页——"哦,能看个页面而已"。

直到看到 gopher 打 Redis 写 SSH key 的链子,才意识到:SSRF 的真正价值是把 GET 变成了任意协议的 POST,把"外网访问"变成了"服务器视角的访问"。

服务器站在内网门口,你借了它的眼睛和手。

5.3 学习方法的反思

这两个漏洞我都试过"先看文章再打靶场",效率很低——因为文章里的 payload 是"结果",不是"过程"。

真正让我开窍的是自己搭最小 demo:

  • 写一个 Flask 站点,登录用 Cookie,提供一个"改昵称"的 POST 接口
  • 写一个攻击页面,用 form 自动提交
  • 在服务端把每个请求的 Cookie / Origin / Referer 都 print 出来

当我在日志里看到那个"带着合法 Cookie、但不是用户发起的请求"时,CSRF 就彻底通了。

SSRF 同理——写个接收 URL 的接口,把 curl 的返回打印出来,然后传各种变形地址,看哪一个能绕过自己写的过滤器。自己写过滤器再自己绕,比刷十个靶场有用。

下一步

Web 这块还欠着几个大头:XXE(和 SSRF 有大量交叉,因为外部实体也能发请求)、反序列化、以及越权——尤其是水平越权,实际项目里比 SQL 注入常见得多。

另外,把这篇的防御部分做成一份自查清单,以后写接口时对着过一遍。