Cross-site request forgery 跨站请求伪造,也被称为 “One Click Attach” 或者 Session Riding,缩写为 CSRF 或者 XSRF,是一种冒充受信任的用户,向服务器发送非预期请求的攻击方式。
CSRF 原理
攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证(Cookie),绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。
Cookie 往往用来存储用户的身份信息,恶意网站设法伪造带有正确 Cookie 的 HTTP 请求,这就是 CSRF 攻击。
一个典型的 CSRF 攻击有着如下流程:
- 受害者登录信任的网站A(a.com)
- 客户端获取登录凭证,并保存在浏览器(Cookie)
- 受害者在没有登出网站A的情况下,被攻击者诱导访问危险的网站B(b.com)
- 网站B要求向网站A发送一个请求:a.com/act=xx(一般是需要登录凭证的请求),浏览器默认携带网站A的 Cookie
- 网站A接收到请求后,对请求进行验证,确认是受害者的凭证,误以为是受害者自己发送的请求
- 网站A以受害者的名义执行了 act=xx
- 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让网站A执行了自己定义的操作
实际案例参考:前端安全系列(二):如何防止CSRF攻击? - 小明的悲惨遭遇
Session 和 Cookie
- Session 是服务端用来存储临时数据的机制。
- Cookie 是客户端用来存储临时数据的机制。
Session 是为了区分 HTTP 请求是否来自同一个客户端发出的,用来识别用户身份,例如记录用户登录凭证。
它的原理是:
- 用户第一次发送请求到服务器
- 服务器会创建一个 session 对象,生成一个类似
key=>value
的键值对,存储当前用户信息 - 然后为这个对象分配一个唯一 Session ID,并以 Cookie 的形式返回给浏览器:
{key: Session ID}
- 浏览器存储这个 Cookie
- 用户在后续发送请求访问服务器时,浏览器会默认携带这个 Cookie
- 服务器通过这个 Session ID 查找对应的 Session 对象,获取用户身份
过期与其他用户:
- 如果浏览器的 Cookie 过期,再次请求服务器,由于没有 Session ID,服务器会认为这是一个新的用户,会分配新的 Session ID
- 如果用户长时间没有向服务器发送请求,Session 也会过期失效,即使下次用户再次携带 Session ID 发送请求,服务器也会认为这是一个新的用户,会分配新的 Session ID
- 如果是另一个用户访问服务器,服务器分配的 Session ID 必然与其他用户的不一样,以此来区分并记录信息。
Cookie 携带
CSRF 起源
Cookie 在最初,被设计成了允许在第三方网站发起的请求中携带,CSRF 攻击就是利用了 Cookie 的这一“弱点”。
这些请求包括:
- 标签发送的请求:
<script>
,<link>
,<img>
,<iframe>
等 - 发送HTTP请求的 DOM API:
xhr
,fetch
等 - 页面跳转:点击
<a>
链接、<form>
表单自动提交、location.href
跳转、window.open()
等产生的请求
最初,浏览器并没有对 Cookie 的携带权限进行限制。
而现在的浏览器几乎都制定了策略预防这种情况。
Cookie 的行走记录
- 用户通过客户端(浏览器)访问网站A
- 执行登录操作,浏览器向网站A的服务器发送请求
- 服务器通过 Set-Cookie 响应头将 Cookie 信息返回给浏览器
- 浏览器按照服务器的要求,将 Cookie 存储在本地磁盘下
- 服务器的要求决定了哪些网站(HTTP协议、主机、路径)可以通过哪些方式(JS脚本)访问它提供的Cookie,以及什么请求可以携带这个 Cookie
- 然后用户去浏览网站B的页面,页面中有向网站A发送请求的操作
- 如果网站B携带网站A存储到本地的Cookie,此时就会在请求的过程中带上它。
Cookie 携带权限
默认情况下,一个站点向另一个站点发送请求时是否允许携带后者的 Cookie 受下面几个属性的影响:
- Secure:是否和 Cookie 同一个协议(HTTP 或 HTTPS)
- 从 Chrome 52 和 Firefox 52 开始,不安全的站点(
http:
)无法使用Cookie的Secure
标记。
- 从 Chrome 52 和 Firefox 52 开始,不安全的站点(
- SameSite:是否允许跨站请求中携带 Cookie
None
:默认值,允许跨站请求携带 CookieStrict
:严格禁止跨站请求发送 Cookie,包括禁止从其它站点跳转过来,或从地址栏输入网址打开的页面请求Lax
:相比Strict
宽松些,一般也不允许跨站请求发送 Cookie,但是导航到目标网址的 Get 请求除外。
导航到目标网址的 GET 请求,只包括三种情况:链接、预加载请求、GET表单:
请求类型 | 示例 | 正常情况 None | Lax |
---|---|---|---|
地址栏打开页面 | - | 发送 Cookie | 发送 Cookie |
链接 | <a href="..."></a> | 发送 Cookie | 发送 Cookie |
预加载 | <link ref="prerender" href="..." /> | 发送 Cookie | 发送 Cookie |
GET 表单 | <form method="GET" action="..."> | 发送 Cookie | 发送 Cookie |
POST 表单 | <form method="POST" action="..."> | 发送 Cookie | 不发送 |
iframe | <iframe src="..."></iframe> | 发送 Cookie | 不发送 |
AJAX | $.get("...") | 发送 Cookie | 不发送 |
Image | <img src="..." /> | 发送 Cookie | 不发送 |
以前,如果 SameSite 属性没有设置,或者没有得到运行浏览器的支持,那么它的行为等同于 None,Cookies 会被包含在任何请求中——包括跨站请求。
大多数主流浏览器正在将 SameSite 的默认值迁移至 Lax。如果想要指定 Cookies 在同站、跨站请求都被发送,现在需要明确指定 SameSite 为 None。
浏览器策略
早期的浏览器没有对跨站请求携带 Cookie 进行限制,这也是最初出现 CSRF 的原因。
Chrome 51 开始,浏览器的 Cookie 新增加了一个 SameSite 属性,用来防止 CSRF 攻击和用户追踪,默认值和最初 Cookie 的设计一样是 None
(允许跨站请求携带Cookie)。
随着安全机制的提高,浏览器也启动了更多的策略进行预防:
-
新版浏览器
sameSite
默认值改为Lax
,如需允许跨站,需明确指定为None
- chrome 85版本之后,Cookies default to SameSite=Lax
-
默认拒绝非安全的
SameSite=None
的 Cookie,也就是说 Cookie 只能通过 HTTPS 协议发送(必须同时设置Secure=true
)
实践演示 CSRF
本地启动 HTTPS Web 服务的原因
- 模拟跨站请求
- CSRF 一般都是跨站发起恶意请求,在本地开启多个 Web 服务(本地网站),Domain 相同(
localhost
),本身就允许携带 Cookie。 - 可以通过访问第三方网站,修改页面中的图片地址,实现向本地网站发送跨站请求。
- CSRF 一般都是跨站发起恶意请求,在本地开启多个 Web 服务(本地网站),Domain 相同(
- 浏览器策略
- 本来可以通过设置 Cookie 属性
SameSite=None
,允许跨站请求携带 Cookie - 但是浏览器策略默认拒绝非安全(HTTP)的
SameSite=None
的 Cookie,必须要配合 HTTPS 协议 - 使用 Nodejs 开启本地 web 服务,默认是 HTTP 协议的,所以需要特殊准备。
- 本来可以通过设置 Cookie 属性
总结下来,要模拟 CSRF 攻击就要进行一些准备:
- 启动一个 HTTPS 协议的 web 服务
- 支持 Session 和 Cookie
- Cookie 设置
Secure=true; SameSite=None
生成 HTTPS 证书和私钥
使用 openSSL 生成 HTTPS 证书和私钥,安装参考 关于HTTPS网页不能发起HTTP协议请求 - OpenSSL 创建 SSL 密钥和证书工具
# 命令行执行,在当前目录下会生成两个文件 私钥(rsa_private.key)、证书(cert.crt)
openssl req -newkey rsa:2048 -nodes -keyout rsa_private.key -x509 -days 365 -out cert.crt
搭建项目
mkdir csrf
cd ./csrf
npm init -y
npm i art-template express express-art-template express-session
工具介绍
art-template
模板引擎,通过 express-art-template 中间件使用
express-session
Github - express-session,提供 session 功能的中间件。
使用方式:
var session = require('express-session')
var express = require('express')
var app = express()
app.use(session({
name: 'connect.sid',
secret: 'keyboard cat',
resave: false,
saveUninitialized: true,
cookie: { secure: true }
}))
-
name
:返回客户端的 SessionID 的 key,默认为connect.sid
-
secret
:一个 string 类型的字符串,作为服务器生成 session 的签名 -
resave
:是否强制在请求结束时修改覆盖 session- 设置为
true
,在请求结束时,会根据您的存储,重新覆盖修改 session 存储,即使请求期间从未修改过 session - 设置为
true
会创建竞争条件:当客户端向服务器发送多个并行请求时,在一个请求中对 session 做过的更改,会在另一个请求结束时被覆盖(即使它没有做过任何更改) - 默认为
true
,但建议手动设置,因为未来版本可能会修改默认值
- 设置为
-
saveUninitialized
:是否强制将“未初始化”的 session 保存到存储中- 当一个 session 是新的但未修改时,它将被取消初始化
- 设置为
false
有助于实现登录sessions、减少服务器存储使用量或遵守在设置cookie之前需要权限的协议 - 选择
false
也有助于解决客户端在没有 session 的情况下发出多个并行请求的竞争条件 - 默认为
true
,但建议手动设置,因为未来版本可能会修改默认值
-
cookie
:设置返回给客户端的存储 SessionID 的 Cookie 的属性。-
默认:
{ path: '/', httpOnly: true, secure: false, maxAge: null }
-
httpOnly
:设为true
,无法用 JS(document.cookie
) 获取 cookie -
secure
:设为true
,Cookie 只能在 HTTPS协议下才会发送给服务器 -
sameSite
:是否允许跨站请求携带 Cookietrue
orstrict
:将SameSite
属性设置为Strict
-
false
:不会设置SameSite
属性lax
:将SameSite
属性设置为Lax
-
none
:将SameSite
属性设置为None
-
初始化目录结构
├─ ssl # 存放 openSSL 生成的文件,用于开启 HTTPS 服务
│ ├─ cert.crt
│ └─ rsa_private.key
├─ views
│ ├─ index.html # 受信任的网站首页
│ └─ login.html # 受信任的网站登录页
├─ app.js
├─ package-lock.json
└─ package.json
文件内容
app.js
const express = require('express')
const session = require('express-session')
const fs = require('fs')
const path = require('path')
const https = require('https')
// 获取HTTPS私钥和证书
const cert = fs.readFileSync(path.join('ssl/cert.crt'), 'utf8')
const key = fs.readFileSync(path.join('ssl/rsa_private.key'), 'utf8')
const httpsOption = {
cert,
key
}
const app = express()
const httpsServer = https.createServer(httpsOption, app)
// express.urlencoded 基于 body-parser 中间件
// 用于解析 post 请求的请求体数据
app.use(
express.urlencoded({
extended: true
})
)
// 配置 session 中间件
app.use(
session({
secret: 'session test',
resave: false,
saveUninitialized: true,
cookie: {
path: '/',
httpOnly: true,
secure: true,
maxAge: null,
sameSite: 'none'
}
})
)
app.engine('html', require('express-art-template'))
let total = 999999 // 账户余额
// 首页
app.get('/', (req, res) => {
res.render('index.html', {
user: req.session.user,
account: {
total
}
})
})
// 登录页
app.get('/login', (req, res) => {
res.render('login.html')
})
// API:登录
app.post('/login', (req, res) => {
const user = req.body
// 模拟校验用户登录
if (user.username === 'admin' && user.password === '123456') {
// 这里使用 Session 记录用户登录状态
req.session.user = user
return res.redirect('/')
}
res.render('login.html')
})
// API:转账,支持 GET 和 POST 方式请求
app.use('/transfer', (req, res) => {
// 校验登录状态
if (!req.session.user) {
if (req.method === 'GET') {
// 如果是 GET 请求返回首页
return res.redirect('/')
} else {
// 如果是 POST 请求,返回提示文字
return res.status(401).send('未授权')
}
}
// 执行转账操作
// 转账金额,默认100
const money = req.query.money || req.body.money || 100
// 收款账户
const account = req.query.account || req.body.account
total -= money
res.send(`操作成功!向账户(${account})转账:${money}元,余额:${total}元`)
})
httpsServer.listen(3000, () => {
console.log('running on https://localhost:3000')
})
views/login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<form action="/login" method="post">
<label>用户名:</label><input name="username" type="text" />
<label>密码:</label><input name="password" type="text" />
<button>登录</button>
</form>
</body>
</html>
views/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
{{if user}}
<div>{{ user.username }}您好,欢迎登录!</div>
<div>您的账户余额:{{ account.total }} 元</div>
{{else}}
<div>请<a href="/login">登录</a></div>
{{/if}}
</body>
</html>
演示效果
node ./app.js
- 访问:
https://localhost:3000
,服务器将 Session ID 发送给客户端保存
- 进入登录页
- 输入用户名
admin
,密码123456
,点击登录 - 服务端校验通过,跳转到首页,显示欢迎登陆信息
发起攻击
现在任意站点向 https://localhost:3000
发送请求,都会携带它的 Cookie。
我们提供了一个不安全的接口 https://localhost:3000/transfer
,可以操作转账,它支持 GET 和 POST 请求。
首先在本站测试一下,登录后直接在地址栏访问 https://localhost:3000/transfer
:
操作成功,回到首页查看余额:
现在模拟在攻击者的网站发起 CSRF 攻击,如通过修改百度的 Logo 图片,向本地发送请求:
将图片地址修改为:https://localhost:3000/transfer?money=1000&account=xxxxxx
回到首页查看余额,少了1000元,CSRF 攻击成功:
如果需要以 POST 方式发送请求,攻击者可以在页面中隐藏一个自动提交的 Form 表单:
<form action="https://localhost:3000/transfer" method="POST" style="display:none">
<input type="text" name="money" value="1000" />
<input type="text" name="account" value="xxxxxxxx" />
</form>
<script> document.forms[0].submit(); </script>
而诱导受害者进入攻击者的网站的方式,可能是一个 在线美女聊天、澳门赌场上线 的广告链接。
CSRF 的攻击类型
CSRF 就是利用各种方式向被攻击网站发送 GET 或 POST 请求,我将其分为被动型和主动型:
- 被动型:通过发文、留言等功能,发布带有 CSRF 攻击地址的图片的内容、或以 XSS 方式嵌入恶意代码,当其他用户访问到这个内容时,自动发起请求
- 例如:发布文章中的图片地址发起恶意攻击请求,当其他用户浏览文章时发起攻击
- 主动型:诱导用户手动操作发起的请求
- 例如;诱导用户点击一个 在线美女聊天、澳门赌场上线 的链接
- 这种方式一般也是在网上发布带有恶意链接的图片,或者以广告的形式诱导用户中招。
- 不同于被动性攻击用户打开页面就中招,主动型需要用户进行一次操作才会触发,所以这种方式并不常见。
CSRF 的特点
- 攻击者一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
- 攻击者利用受害者在被攻击网站的登陆凭证,冒充受害者提交操作,而不是直接窃取数据。
- 整个过程攻击者并不能获取受害者的登陆凭证,仅仅是“冒用”
- 跨站请求可以用各种方式:图片URL、超链接、CORS、Form提交等,部分请求方式可以直接嵌入第三方论坛、文章中,难以追踪。
CSRF 通常是跨域的,因为外域通常更容易被攻击者掌控。但是如果本域下有容易被利用的功能,比如可以发图和链接的论坛和评论区,攻击可以直接在本域下进行,而且这种攻击更加危险。