实践理解 Web 安全 03 CSRF 跨站请求伪造01

Cross-site request forgery 跨站请求伪造,也被称为 “One Click Attach” 或者 Session Riding,缩写为 CSRF 或者 XSRF,是一种冒充受信任的用户,向服务器发送非预期请求的攻击方式。

CSRF 原理

攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证(Cookie),绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。

Cookie 往往用来存储用户的身份信息,恶意网站设法伪造带有正确 Cookie 的 HTTP 请求,这就是 CSRF 攻击。

一个典型的 CSRF 攻击有着如下流程:

  1. 受害者登录信任的网站A(a.com)
  2. 客户端获取登录凭证,并保存在浏览器(Cookie)
  3. 受害者在没有登出网站A的情况下,被攻击者诱导访问危险的网站B(b.com)
  4. 网站B要求向网站A发送一个请求:a.com/act=xx(一般是需要登录凭证的请求),浏览器默认携带网站A的 Cookie
  5. 网站A接收到请求后,对请求进行验证,确认是受害者的凭证,误以为是受害者自己发送的请求
  6. 网站A以受害者的名义执行了 act=xx
  7. 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让网站A执行了自己定义的操作

实际案例参考:前端安全系列(二):如何防止CSRF攻击? - 小明的悲惨遭遇

Session 和 Cookie

  • Session 是服务端用来存储临时数据的机制。
  • Cookie 是客户端用来存储临时数据的机制。

Session 是为了区分 HTTP 请求是否来自同一个客户端发出的,用来识别用户身份,例如记录用户登录凭证。

它的原理是:

  1. 用户第一次发送请求到服务器
  2. 服务器会创建一个 session 对象,生成一个类似 key=>value的键值对,存储当前用户信息
  3. 然后为这个对象分配一个唯一 Session ID,并以 Cookie 的形式返回给浏览器:{key: Session ID}
  4. 浏览器存储这个 Cookie
  5. 用户在后续发送请求访问服务器时,浏览器会默认携带这个 Cookie
  6. 服务器通过这个 Session ID 查找对应的 Session 对象,获取用户身份

过期与其他用户:

  1. 如果浏览器的 Cookie 过期,再次请求服务器,由于没有 Session ID,服务器会认为这是一个新的用户,会分配新的 Session ID
  2. 如果用户长时间没有向服务器发送请求,Session 也会过期失效,即使下次用户再次携带 Session ID 发送请求,服务器也会认为这是一个新的用户,会分配新的 Session ID
  3. 如果是另一个用户访问服务器,服务器分配的 Session ID 必然与其他用户的不一样,以此来区分并记录信息。

更多参考:廖雪峰 - 使用Session和Cookie

Cookie 携带

CSRF 起源

Cookie 在最初,被设计成了允许在第三方网站发起的请求中携带,CSRF 攻击就是利用了 Cookie 的这一“弱点”。

这些请求包括:

  • 标签发送的请求:<script><link><img><iframe>
  • 发送HTTP请求的 DOM API:xhrfetch
  • 页面跳转:点击<a>链接、<form>表单自动提交、location.href跳转、window.open()等产生的请求

最初,浏览器并没有对 Cookie 的携带权限进行限制。

而现在的浏览器几乎都制定了策略预防这种情况。

Cookie 的行走记录

  1. 用户通过客户端(浏览器)访问网站A
  2. 执行登录操作,浏览器向网站A的服务器发送请求
  3. 服务器通过 Set-Cookie 响应头将 Cookie 信息返回给浏览器
  4. 浏览器按照服务器的要求,将 Cookie 存储在本地磁盘下
  5. 服务器的要求决定了哪些网站(HTTP协议、主机、路径)可以通过哪些方式(JS脚本)访问它提供的Cookie,以及什么请求可以携带这个 Cookie
  6. 然后用户去浏览网站B的页面,页面中有向网站A发送请求的操作
  7. 如果网站B携带网站A存储到本地的Cookie,此时就会在请求的过程中带上它。

Cookie 携带权限

默认情况下,一个站点向另一个站点发送请求时是否允许携带后者的 Cookie 受下面几个属性的影响:

  • Secure:是否和 Cookie 同一个协议(HTTP 或 HTTPS)
    • 从 Chrome 52 和 Firefox 52 开始,不安全的站点(http:)无法使用Cookie的 Secure 标记。
  • SameSite:是否允许跨站请求中携带 Cookie
    • None:默认值,允许跨站请求携带 Cookie
    • Strict:严格禁止跨站请求发送 Cookie,包括禁止从其它站点跳转过来,或从地址栏输入网址打开的页面请求
    • Lax:相比 Strict 宽松些,一般也不允许跨站请求发送 Cookie,但是导航到目标网址的 Get 请求除外。

导航到目标网址的 GET 请求,只包括三种情况:链接、预加载请求、GET表单:

请求类型示例正常情况 NoneLax
地址栏打开页面-发送 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)。

随着安全机制的提高,浏览器也启动了更多的策略进行预防:

实践演示 CSRF

本地启动 HTTPS Web 服务的原因

  1. 模拟跨站请求
    1. CSRF 一般都是跨站发起恶意请求,在本地开启多个 Web 服务(本地网站),Domain 相同(localhost),本身就允许携带 Cookie。
    2. 可以通过访问第三方网站,修改页面中的图片地址,实现向本地网站发送跨站请求。
  2. 浏览器策略
    1. 本来可以通过设置 Cookie 属性SameSite=None,允许跨站请求携带 Cookie
    2. 但是浏览器策略默认拒绝非安全(HTTP)的 SameSite=None 的 Cookie,必须要配合 HTTPS 协议
    3. 使用 Nodejs 开启本地 web 服务,默认是 HTTP 协议的,所以需要特殊准备。

总结下来,要模拟 CSRF 攻击就要进行一些准备:

  1. 启动一个 HTTPS 协议的 web 服务
  2. 支持 Session 和 Cookie
  3. 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:是否允许跨站请求携带 Cookie

      • true or strict:将 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 通常是跨域的,因为外域通常更容易被攻击者掌控。但是如果本域下有容易被利用的功能,比如可以发图和链接的论坛和评论区,攻击可以直接在本域下进行,而且这种攻击更加危险

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值