别人收到秋天奶茶, 我收到了安全工单 - CSRF

1,871 阅读7分钟

之前的文章, 触发了掘金的 bug 停止了分发和检索, 这里重发下~

本文有一丁点儿门槛, 对于刚入行技术同学可能会有点难度, 如果阅读过程中有任何问题请在评论区与我讨论, 所有的评论都会回复哈~

ps 号外: 从今年年初发布的 chrome 80 版本开始, 谷歌浏览器修改了 Cookie 的默认值策略: SameSite 属性的默认值修改为 Lax 了, 线下模拟有些难度, 建议大家先下载 稍早版本的 chrome 进行测试.

金九银十, 妥妥的收获的季节. 有的人拿到了 offer, 有的人获得了鲜花, 再不济的也收到了秋天的奶茶. 眼前欣欣向荣的景象让我这万年单身狗也开始心猿意马, 手机突然颤抖, 扣动了我的心弦. 难道是...

2020-10-23-20-11-04

万万没想到, WTM 收到了这个...

亲爱的打工人:

    经安全组扫描, 您负责的 XXX 业务线存在 CSRF 漏洞, 现责令限期修复.修复时间 2020.09.25 号.
请及时修复并联系安全组复验....


                                                 江南皮革厂安全组

U1S1 虽然安全相关面试题背的很 6 但是动真格的玩儿实战...

2020-10-25-16-07-11

但是转念一想, 作为一个有开发经验的开发攻城狮. 在困难面前, 还是要装出了一如既往的沉稳和冷静. 正所谓我不下地狱谁下地狱...

问题不大

看到领导狐疑的表情, 我的内心其实是这样式儿的...

2020-10-25-16-19-51

老规矩, 遇到问题肯定是先学习这个问题, 于是我打开了百度

什么是 csrf

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

--- 摘自 百度百科

根据百度百科中的总结, 跨站请求伪造的意思就是三个: 跨站(Cross-site) 请求(request) 伪造(forgery). 为了实现跨站的前提, 我们先创建一个站...

创建被攻击网站(脂腹饱)

  • 首先, 我们创建一个目录, 并添加相关文件, 目录结构如下
    ├── frond-end               前端代码目录
    │   ├── index.html          用户主页
    │   └── login.html          登录页面
    ├── package-lock.json       npm
    ├── package.json            npm
    └── server                  后端代码目录
        ├── server.js           后端主程序
        └── utils               后端工具库
            ├── db.js           数据库主文件
            ├── getReqData.js   获取接口请求参数封装方法
            └── index.js        后端工具库入口文件, 工具库中的方法都从这里导出
    
  • 其次, 编写项目代码
    • 第一步, 实现一个最简单的登录页面 login.html
      <!DOCTYPE html>
      <html>
          <head>
              <title>登录 - 脂腹饱</title>
          </head>
          <body>
              <h1>用户登录, 脂腹饱~</h1>
              <form id="form" method="POST" action="/api/login">
                  <input required name="id" placeholder="请输入用户 id" />
                  <button type="submit">登录</button>
              </form>
          </body>
      </html>
      
    • 第二步, 实现用户主页前端页面 index.html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
          <title>欢迎使用 - 脂腹饱</title>
      </head>
      <body>
          <h1>脂腹饱, 脂托腹~</h1>
          <h3>欢迎 <span id="user"></span>. 钱包余额: <span id="money"></span></h3>
          <form>
              <label>转账给: </label>
              <input name="toUser" placeholder="收款的宝宝是?">
              <input name="money" placeholder="转账金额">
              <button type="submit">转账</button>
          </form>
          <script>
              const refresMyMoney = () => {
                  $.get('/api/appinfo').done(({ errno, data }) => {
                      if (errno === 0) {
                          const { userName, money } = data
                          $('#money').html(money)
                          $('#user').html(userName)
                      } else {
                          location.href = '/login.html'
                      }
                  })
              }
      
              $('form').on('submit', e => {
                  e.preventDefault()
                  const formData =
                      $(e.target).serializeArray()
                          .map(({ name, value }) => ({ [name]: value }))
                          .reduce((memo, curItem) => ({ ...memo, ...curItem }), {})
      
                  $.get('/api/transfer', formData).done(resp => {
                      if (resp.errno === 0) {
                          refresMyMoney()
                      } else {
                          location.href = '/login.html'
                      }
                  })
              })
              // 页面加载完成, 请求当前页面数据
              window.onload = refresMyMoney
          </script>
      </body>
      </html>
      
    • 第三步, 实现后端逻辑 server.js
      const Koa = require('koa')
      const KoaRouter = require('koa-router')
      const path = require('path')
      const koaBody = require('koa-body')
      const koaStatic = require('koa-static')
      
      const { db, getReqData } = require('./utils')
      
      const PORT = 2333
      const app = new Koa()
      const router = new KoaRouter()
      
      router.all('/api/login', ctx => {
          const { body: { id } } = getReqData(ctx)
          if (db[id]) {
              ctx.cookies.set('userid', id)
              ctx.redirect('/')
          } else {
              ctx.redirect('/login.html')
          }
      })
      
      router.all('/api/appinfo', ctx => {
          const { id } = getReqData(ctx)
          if (db[id]) {
              ctx.body = {
                  errno: 0,
                  data: {
                      money: db[id],
                      userName: id
                  }
              }
          } else {
              ctx.body = { errno: 666 }
          }
      })
      
      router.all('/api/transfer', ctx => {
          const { query: { toUser, money }, id } = getReqData(ctx)
          if (!id) {
              ctx.body = { errno: 666, errmsg: '您尚未登录请登录' }
              return
          }
          db[toUser] += (+money)
          db[id] -= (+money)
          ctx.body = { errno: 0 }
      })
      
      app.use(koaBody())
      app.use(router.routes(), router.allowedMethods())
      app.use(koaStatic(path.resolve(__dirname, '../frond-end')))
      
      app.listen(PORT, () => {
          console.log(`the server is running in ${PORT}`)
      })
      
    • 最后, 实现后端的工具库
      // db.js
      module.exports = {
          quanquan: 100,
          zhangsan: 100,
          huangguan: 100
      }
      
      // getReqData.js
      module.exports = (ctx) => {
          const {
              query,
              request: {
              body
              }
          } = ctx
          const id = ctx.cookies.get('userid')
          const token = ctx.headers.token
          return { query, body, id, token }
      }
      
      // index.js
      exports.getReqData = require('./getReqData')
      exports.db = require('./db')
      
  • 然后, 安装依赖并修改 package.json 具体步骤为:
    • 执行 npm init -y 快速初始化 node 项目
    • 执行 npm i koa koa-body koa-router koa-static 安装项目依赖
    • 执行 npm i nodemon -D 安装开发依赖
    • package.json 文件的 script 字段下添加 "start": "nodemon server/server.js"
    • well done, 干的漂亮伙计...
  • 最后 npm start, 浏览器访问 localhost:2333, 看到如下页面说明成功啦~ 2020-11-01-15-46-07

至此, 我们已经拥有了(目前为止)全球最大 IPO 公司网站的核心功能. 马上就要去敲钟, 还有点小点激动了~

2020-11-01-16-21-34

为了让我们的网站看上去高大上一点, 我们为它添加一个 NB 的域名, 修改 hosts 文件, 添加以下两行内容

127.0.0.1 zhifubao.com
127.0.0.1 huangguan.com

添加完成后我们就可以通过 zhifubao.com 访问网站啦, 大家可以体验下两个浏览器分别登陆 zhangsanquanquan 两个用户, 体验两者之间相互转账功能, 如下图~

2020-11-01-16-31-01

当前步骤代码地址

发起第一波攻击

经过一顿操作, 我们拥有了被攻击网站 --- 脂腹饱, 下面开始攻击操作. 正如百度百科中的总结. 作为跨站脚本攻击, 首先要做的就是跨站. 接下来我们创建一个攻击的网站, 创建一个目录 hacker 并创建 index.html 文件, 写入以下内容:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>----</title>
    <style>
        html, body, iframe { margin: 0; padding: 0; width: 100%; height: 100%;}
    </style>
</head>
<body>
    <iframe src="/" frameborder="0"></iframe>
    <img src="http://zhifubao.com:2333/api/transfer?toUser=quanquan&money=10">
</body>
</html>

然后, 全局安装 npm i -g live-server 并在 hacker 目录下执行 live-server . 启动静态服务, 浏览器会自动启动 127.0.0.1:8080 就是我们的攻击也面主站啦~

现在, 勤劳好学的张三同学, 通宵达旦学习 Linux 内核基础原理和架构思想 的时候. 突然就收到了一条伊妹儿.

2020-11-01-16-44-08

这样是放白天, 遇到这种邮件那肯定是一下划过关闭弹窗. 但是苦学一晚的张三同学就在想.

2020-11-01-17-08-15

于是, 浏览器的布局变成了这样.

2020-11-03-23-35-55

拘谨腼腆的张三同学看到这个页面以后感觉好羞耻, 抓紧关闭了页签. 刷新了浏览器. 额...少了 10 块钱, 隔壁的圈圈账户多了 10 块.

2020-11-01-17-18-18

吃了哑巴亏的非常不爽, 又不敢伸张.

2020-11-01-17-22-28

于是打开控制台, 回顾一下刚刚的过程.

2020-11-03-23-36-58

hacker 网站 的控制台里边居然有 zhibubao.com 的请求, 而且还携带了 Cookie, 可怜的张三同学, 尽管迅速关了页面还是遭到了最简单的 CSRF 攻击, 我们总结下攻击流程:

  • 首先, 引导 脂腹饱用户 访问 hacker 网站
  • 其次, 通过页面内的 img 标签向 脂腹饱 发送 GET 请求, 并携带 脂腹饱 网站下的所有 Cookie
  • 最后, 请求通过, 转账完成

到目前步骤的代码地址

防范 img 标签 GET 攻击

聪明的小伙伴马上就会想到, 既然 hacker 网站 中写的 img 标签说死也就只能发出来一个 GET 请求, 那我把转账接口改成 POST 不就妥了嘛...

talk is cheap, 代码搞起来...

前端代码

// 请求方式改成 post
$.post('/api/transfer', formData).done(resp => {

后端代码

router.post('/api/transfer', ctx => {
    const { body: { toUser, money }, id } = getReqData(ctx)
    if (!id) {
        ctx.body = { errno: 666, errmsg: '您尚未登录请登录' }
        return
    }
    db[toUser] += (+money)
    db[id] -= (+money)
    ctx.body = { errno: 0 }
})

刷新页面再次测试一把转账功能, 居然还能用 😂

2020-11-03-15-23-52

然后再打开 hacker 网站, 试一下...

2020-11-03-23-38-39

哈哈哈哈, 恶意请求接口直接 404, 鼎鼎大名的 CSRF 跨站请求伪造居然这么容易就被我们解决了?

2020-11-03-15-25-46

没有了后顾之忧, 长夜漫漫的张三同学决定...

接着奏乐接着舞

防范 form 表单攻击

漏洞轻松修复, 吃着火锅唱着歌, 要多快乐多快乐...

2020-11-03-15-41-02

通过修改请求方法这一招挡住了不少入门级别的脚本小子. 但是在 hacker 遇到 hacker 大佬, 你只会收到不屑的一撇...

2020-11-03-22-40-41

发现接口 404 hacker 简升级一下代码

<body>
  <iframe src="/" frameborder="0"></iframe>
  <form action="http://zhifubao.com:2333/api/transfer" method="post">
    <input type="hidden" name="toUser" value="quanquan">
    <input type="hidden" name="money" value="10">
  </form>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      document.forms[0].submit()
    })
  </script>
</body>

然后, 再次访问的张三同学发现页面直接跳转了而且左边的圈圈账上又多了 10 块

2020-11-03-16-03-13

张三觉得不妙, 一个美名远扬的网站居然这样胡乱跳转. 反手就到相关部门把黑客网站给举报了. 于是, 黑客再次升级了网站.

<body>
  <iframe src="/" frameborder="0"></iframe>

  <iframe src="/" frameborder="0" style="display: none;" name="hacker"></iframe>
  <form action="http://zhifubao.com:2333/api/transfer" method="post" target="hacker">
    <input type="hidden" name="toUser" value="quanquan">
    <input type="hidden" name="money" value="10">
  </form>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      document.forms[0].submit()
    })
  </script>
</body>

代码 diff 可以查看这次提交

完了, 面试题里边不是这么写的呀, POST 应该比 GET 更安全呀, 现在的 POST 居然也跪了...

2020-11-03-22-42-45

终极解决 CSRF

经过之前的步骤我们可以总结出: CSRF 攻击的精髓有两点:

  1. 用户正在访问需要 Cookie 记录登录状态的网站
  2. 使用相同浏览器访问存在构造了恶意请求的网站链接

针对这两点我们不难得出, 如果想要阻止被攻击我们可以从两个层面做工作.

作为用户

作为用户, 当我们访问一个 不可描述 的网站的时候, 可以选择用一个不常用的浏览器. 最好是用完以后把这个浏览器卸载掉...

2020-11-03-17-26-53

作为开发者

作为一个开发者, 我们知道了当网站需要 Cookie 记录登录状态的时候才存在 CSRF 漏洞, 那么有没有方法可以绕过 Cookie 记录登录状态呢?

2020-11-03-17-29-23

假设, 我们登录成功的时候服务端不再写入 Cookie 而是给浏览器下发一个 token. 前端判断登录成功的时候把返回的 token 存储到 localStorage 里(切记不是 cookie). 后续前端像后端发起请求时, 从 localStorage 中取出 token 并写入请求头. 后端通过校验请求头判断用户登录状态. 看上去就可以绕过 Cookie 存储登录状态存在的问题辽.

整个流程如下: 2020-11-03-17-52-41

talk is cheap..., 首先前端修改

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  <style>
    html, body { padding: 0; margin: 0; display: flex; align-items: center; flex-direction: column; padding-top: 100px; }
  </style>
  <title>欢迎使用 - 脂腹饱</title>
</head>
<body>

  <h1>脂腹饱, 脂托腹~</h1>
  <h3>欢迎 <span id="user"></span>. 钱包余额: <span id="money"></span></h3>
  <form>
    <label>转账给: </label>
    <input type="text" name="toUser">
    <input type="text" name="money">
    <button type="submit">转账</button>
  </form>
  <script>
    const refresMyMoney = () => {
      $.ajax({
        url: '/api/appinfo',
        headers: {
          token: localStorage.getItem('token')
        }
      }).done(({errno, data}) => {
        if (errno === 0) {
          const {userName, money} = data
          $('#money').html(money)
          $('#user').html(userName)
        } else {
          location.href = '/login.html'
        }
      })
    }

    $('form').on('submit', e => {
      e.preventDefault()
      const formData =
        $(e.target).serializeArray()
          .map(({name, value}) => ({[name]: value}))
          .reduce((memo, curItem) => ({...memo, ...curItem}) , {})

      $.ajax({
        url: '/api/transfer',
        method: 'post',
        data: formData,
        headers: {
          token: localStorage.getItem('token')
        }
      }).done(resp => {
        if (resp.errno === 0) {
          refresMyMoney()
        } else {
          location.href = '/login.html'
        }
      })
    })
    window.onload = refresMyMoney
  </script>
</body>
</html>

login.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    html, body { padding: 0; margin: 0; display: flex; align-items: center; flex-direction: column; padding-top: 100px; }
  </style>
  <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  <title>登录 - 脂腹饱</title>
</head>
<body>
  <h1>用户登录, 脂腹饱~</h1>
  <form>
    <input
      required
      name="id"
      placeholder="请输入用户 id"
      list="users"
    />
    <button type="submit">登录</button>
  </form>
  <script>
    $('form').on('submit', e => {
      e.preventDefault()
      const formData =
        $(e.target).serializeArray()
          .map(({ name, value }) => ({ [name]: value }))
          .reduce((memo, curItem) => ({ ...memo, ...curItem }), {})
      $.post('/api/login', formData).done(resp => {
        if (resp.errno === 0) {
          const {data} = resp
          localStorage.setItem('token', data);
          location.href = '/'
        } else {
          alert('登录失败~')
        }
      })
    })
  </script>
</body>
</html>

然后放上我们后端的修改

const Koa = require('koa')
const KoaRouter = require('koa-router')
const path = require('path')
const koaBody = require('koa-body')
const koaStatic = require('koa-static')
const { db, getReqData } = require('./utils')
const PORT = 2333

const app = new Koa()
const router = new KoaRouter()
router.all('/api/login', ctx => {
  const { body: { id } } = getReqData(ctx)
  if (db[id]) {
    ctx.body = {
      errno: 0,
      data: id
    }
  } else {
    ctx.body = { errno: 666 }
  }
})
router.all('/api/appinfo', ctx => {
  const { token } = getReqData(ctx)
  if (db[token]) {
    ctx.body = {
      errno: 0,
      data: {
        money: db[token],
        userName: token
      }
    }
  } else {
    ctx.body = { errno: 666 }
  }
})
router.post('/api/transfer', ctx => {
  const { body: { toUser, money }, token } = getReqData(ctx)
  if (!token) {
    ctx.body = {
      errno: 666, errmsg: '月薪 3 万, 来给爸爸上班    ---杭周马'
    }
    return
  }
  db[toUser] += (+money)
  db[token] -= (+money)
  ctx.body = { errno: 0 }
})
app.use(koaBody())
app.use(router.routes(), router.allowedMethods())
app.use(koaStatic(path.resolve(__dirname, '../frond-end')))
app.listen(PORT, () => {
  console.log(`the server is running in ${PORT}`)
})

最后, 验证一把, 依旧是左右两个浏览器分辨登录 quanquanzhangsan 两个用户. 并且成功给 quanquan 账户转钱 20 块.

2020-11-03-19-13-18

然而, 再次 "不小心" 打开 不可描述 的网站, 会不会有问题呢?

2020-11-03-23-40-29

我们看到, 尽管 hacker 网站尝试伪造转账请求, 但是并没有成功, 还收到了码爸爸的邀请, 美滋滋的当福娃了~

2020-11-03-22-47-45

到目前为止我们已经拒绝了所有的 CSRF 攻击, 当前步骤代码地址终于可以安心的吃起火锅唱起歌了...

2020-11-03-23-04-29

后记

感谢大家耐心阅读, 今年收到了一系列安全工单(XSS / CSRF 等), 由于团队内更期待 CSRF 内容所以先写了这篇作为内部分享搞, 如有疏漏望大佬不吝赐教. 若大家喜欢我可能还会总结下其他的安全工单解决过程和漏洞原理. 谢谢大家~

参考文档