Flask 防御 CSRF 攻击

在上一个小节中讲解了 CSRF 攻击与防御的原理,本小节首先讲解了基于校验 Token 检测 CSRF 攻击的基本思想和步骤,然后通过一个银行转账的实例演示了 CSRF 的攻击与防御。

1. 基于校验 Token 检测 CSRF 攻击

1.1 基本思想

在 Flask 中防御 CSRF 攻击的最常见方法是基于校验 Token 检测 CSRF 攻击,它的基本思想如下:

  • 在服务端生成一个随机的、不可预测的字符串,该字符串称为 CSRF Token;
  • 客户获取安全相关的页面时,服务端将 CSRF Token 作为隐藏字段发送给客户端;
  • 客户端提出请求时,将页面的中的隐藏字段一并发送给服务端;
  • 服务端处理请求时,提取请求中的 CSRF Token,与服务端生成的 CSRF Token 进行比对,如果相同则请求是合法的,如果不相同则请求是 CSRF 攻击。

因为 CSRF Token 的值是随机的、不可预测的,攻击者无法构造一个带有合法 CSRF Token 的请求实施 CSRF 攻击,从而阻断了 CSRF 攻击。

1.2 具体的步骤

以一个银行转账的例子说明防御 CSRF 攻击的具体步骤,如下图所示:

1. 登录

用户输入账户名和密码登录。

2. 验证通过

账户名和密码匹配,则验证通过。

3. 生成 CSRF Token

用户验证通过后,服务端生成一个随机的、不可预测的字符串 CSRF Token,并将它存储在 Session 中。

4. 访问转账页面

用户访问转账页面。

5. 返回转账页面

服务端返回转账页面的 HTML,将存储在 Session 中的 CSRF Token 作为隐藏字段返回给客户端。例如,服务端返回的转账页面可能如下:

<form action="/transfer" method="POST">
  <input type="hidden" name="csrfToken" value="IZiglWi1k2e3z!m@z$">
  <input type="text" name="name" placeholder="接收用户" />
  <input type="text" name="amount" placeholder="转账数量" />
  <input type="submit" name="submit" value="转账">
</form>

在第 2 行,名称为 csrfToken 的隐藏字段包含了 CSRF Token 的值,字符串 “IZiglWi1k2e3z!m@z$” 是随机的、无法被猜测的,即攻击者无法获取 CSRF Token、无法构造一个合法的转账请求。

6. 转账请求

客户端发出转账请求,将隐藏字段 CSRF Token 一并发送给服务端。

7. 比对 Token

服务端收到转账请求后,提取请求中的 CSRF Token,与服务端生成的 CSRF Token 进行比对,如果相同则请求是合法的,如果不相同则请求是 CSRF 攻击。

2. 演示 CSRF 的攻击与防御

2.1 程序简介

本节通过具体的案例演示 CSRF 的攻击与防御,案例中的假设如下:

  • 银行网站,提供在线转账功能;
  • 银行网站中有两个用户:受害者 victim 和攻击者 hacker,他们的账户中各有 100 元;
  • 恶意网站,由攻击者 hacker 创办,当受害者 victim 没有退出银行网站的情况下,去访问恶意网站,会在不知情的情况下,向 hacker 转账 50 元。

为了演示 CSRF 攻击与防御,本节包括了 2 个程序:

程序 源代码目录 首页 URL
银行网站 bank http://localhost:8888
恶意网站 malicious http://localhost:4444

银行网站和恶意网站运行在同一台机器上,通过端口号区分:银行网站监听端口 8888,恶意网站监听端口 4444。

2.2 演示 CSRF 攻击

下面的视频演示了 CSRF 攻击的操作过程:

2.3 演示防御 CSRF 攻击

下面的视频演示了防御 CSRF 攻击的操作过程:

2. 实现 bank 程序

2.1 程序下载

bank 程序实现了银行网站的功能,包括 2 个源文件,点击下载例子代码 bank。:

源文件 功能
bank/app.py 后端服务程序
bank/templates/index.html 首页模板文件

2.2 首页模板 templates/index.html

2.2.1 登录后的界面

用户访问网站首页时,根据是否登录显示不同的内容,如果用户已经登录,则显示如下:

<html>
<head>
<meta charset='utf-8'>
<title>中国银行</title>
</head>

<body>
<h1>中国银行</h1>

{% if hasLogin %}
<h2>1. 基本信息</h2>
<h3>你好, {{user.name}},你的账户剩余 {{user.amount}} 元</a></h3>
<h2>2. 转账</h2>
<form action="/transfer" method="POST">
  <input type="hidden" name="csrfToken" value="{{ csrfToken }}">
  <input type="text" name="name" placeholder="接收用户" />
  <input type="text" name="amount" placeholder="转账数量" />
  <input type="submit" name="submit" value="转账">
</form>
<h2>3. 退出</h2>
<form action="/logout" method="POST">
  <input type="submit" name="submit" value="退出">
</form>

在第 10 行,如果参数 hasLogin 为真,表示用户已经登录,则显示在第 11 行到第 23 行的内容。

在第 11 行到第 12 行,显示用户的基本信息:姓名和账户余额。

在第 13 行到第 19 行,显示用于转账的表单,使用 POST 方法向服务端的 /transfer 页面提出转账请求;字段 csrfToken 存储了服务端发送的 CSRF Token,提交表单时,会将该字段一并提交;字段 name 是转账的接收账户名;字段 amount 是转账的数量。

在第 20 行到第 23 行,使用 POST 方法向服务端的 /logout 页面退出登录。

2.2.2 没有登录的界面

用户访问网站首页时,根据是否登录显示不同的内容,如果用户还没有登录,则显示如下:

{% else %}
<h2>登录</h2>
<form action="/login" method="POST">
  <input type="text" name="name" placeholder="用户" />
  <input type="password" name="password" placeholder="密码" />
  <input type="submit" name="submit" value="登录">
</form>
{% endif %}

</body>
</html>

在第 2 行到第 7 行,显示用于登录的表单,使用 POST 方法向服务端的 /login 页面登录。

2.3 后端服务 app.py

2.3.1 引入相关模块

#!/usr/bin/python3
from flask import Flask, request, session, render_template, redirect
import os, base64
import sys

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(24)

在第 2 行,引入 os 模块和 base64 模块,需要使用 os.urandom 和 b64encode 用于生成 CSRF Token。

在第 7 行,Flask 程序在使用 Session 时,需要配置 SECRET_KEY。

2.3.2 用户数据库

class User:
    def __init__(self, name, password, amount):
        self.name = name
        self.password = password
        self.amount = amount

users = [
    User('victim', '123', 100),
    User('hacker', '123', 100)
]

def findUser(name):
    for user in users:
        if user.name == name:
            return user
    return None

def checkUser(name, password):
    for user in users:
        if user.name == name and user.password == password:
            return user
    return None

在第 1 行,定义类 User 用于描述银行账户信息,包括:姓名、密码、账户余额等属性。

在第 7 行,预定义了两个用户:victim 和 hacker,将它们存储在全局变量 users 中。

在第 12 行,定义函数 findUser,在 users 中根据姓名查找 user。

在第 18 行,定义函数 checkUser,在 users 中根据姓名和密码查找 user。

2.3.3 首页面

@app.route('/')
def index():
    hasLogin = session.get('hasLogin')
    name = session.get('name')
    user = findUser(name)
    csrfToken = getCsrfToken()
    session['csrfToken'] = csrfToken
    return render_template('index.html', hasLogin = hasLogin, user = user, csrfToken = csrfToken)

设置首页面 / 的处理函数为 index,函数在 session 中查找 hasLogin、name、csrfToken 变量,将它们传递给页面模板 index.html。

在第 6 行,调用函数 getCsrfToken() 生成一个随机的、不可预测的 CSRF Token,并将其存储在 Session 中。

2.3.4 登录页面

@app.route('/login', methods = ['POST'])
def login():
    name = request.form['name']
    password = request.form['password']
    user = checkUser(name, password)
    if user != None:
        session['hasLogin'] = True
        session['name'] = name
        return redirect('/')
    else:
        return '登录失败'

设置页面 /login 的处理函数为 login,该函数首先提取请求中的 name 和 password,然后调用 checkUser 在所有的 users 中查找匹配的 User。

如果找到了匹配的 User,则设置 Session 中的 hasLogin 为真,调用 redirect(’/’) 让客户端浏览器重定向到首页面。

2.3.5 退出页面

@app.route('/logout', methods = ['POST'])
def logout():
    session['hasLogin'] = False
    session['name'] = None
    return redirect('/')

设置页面 /logout 的处理函数为 logout,该函数设置 Session 中的 hasLogin 为假,调用 redirect(’/’) 让客户端浏览器重定向到首页面。

2.3.5 检查 CSRF 攻击

def getCsrfToken():
    return bytes.decode(base64.b64encode(os.urandom(16)))

def checkCsrfAttack():
    csrfTokenFromRequest = request.form.get('csrfToken')
    csrfTokenFromSession = session.get('csrfToken')
    return csrfTokenFromRequest != csrfTokenFromSession

函数 getCsrfToken 返回一个随机的字符串,os.urandom(16) 产生一个包含 16 个字节的 bytes,base64.b64encode 将 bytes 转换为 base64 编码的字符串。

函数 checkCsrfAttack 检测 CSRF 攻击,在第 5 行,从请求的表单中获取参数 csrfToken,在第 6 行,从 Session 中获取变量 csrfToken。对两者进行比较,如果相等,表示此次请求合法;如果不相等,表示此次请求是 CSRF 攻击。

2.3.6 转账页面

@app.route('/transfer', methods = ['POST'])
def transfer():
    if not session.get('hasLogin'):
        return '请先登录'

    if checkFlag and checkCsrfAttack():
        print('警告:检测到 CSRF 攻击!')
        return '转账失败'

    sourceName = session['name']
    sourceUser = findUser(sourceName)

    targetName = request.form['name']
    amount = int(request.form['amount'])
    targetUser = findUser(targetName)

    if targetUser != None:
        sourceUser.amount -= amount
        targetUser.amount += amount
        return redirect('/')
    else:
        return '转账失败'

设置页面 /transfer 的处理函数为 transfer。在第 3 行,如果 Session 中的 hasLogin 变量未假,表示请求来自于未登录的用户,返回 ‘转账失败’。

在第 6 行,如果 checkFlag 为真并且 checkCsrfAttack 函数检测到了 CSRF 攻击,在控制台打印 CSRF 的警告,返回 ‘转账失败’。如果 checkFlag 为假,程序不检测 CSRF 攻击。

在第 10 行到第 15 行,获取来源账户,并从转账请求中获取参数:转账数量、接受账户。

在第 18 行和第 19 行,进行转账操作,最后调用 redirect(’/’) 让客户端浏览器重定向到首页面。

2.3.6 设置选项

checkFlag = False
if len(sys.argv) == 2 and sys.argv[1] == 'check':
    checkFlag = True

app.run(debug = True, port = 8888)

设置全局变量 checkFlag,如果 checkFlag 为真,程序检测 CSRF 攻击;如果 checkFlag 为假,程序不检测 CSRF 攻击。

3. 实现 malicious 程序

3.1 程序下载

malicious 程序实现了恶意网站的功能,包括 2 个源文件,点击下载例子代码 malicious。:

源文件 功能
bank/app.py 后端服务程序
bank/templates/index.html 首页模板文件

3.2 首页模板 templates/index.html

恶意网站的页面包括两部分:

  • 正常显示的部分
  • 实施 CSRF 攻击的代码

3.2.1 正常显示的部分

<html>
<head>
<meta charset='utf-8'>
<title>恶意网站</title>
</head>

<body>
<h1>恶意网站</h1>
<ul>
  <li>在网站中放置吸引人的内容,例如赌博、色情、盗版小说等,吸引人来访问
  <li>如果用户已经登录了某银行网站,访问恶意网站首页时,自动向银行网站发起转账请求
</ul>

通常恶意网站会放置吸引人的内容,例如赌博、色情、盗版小说等,诱导受害者来访问。

3.2.2 隐藏 iframe 和 表单

<style>
iframe {
    display: none;
}

form {
    display: none;   
}
</style>

CSRF 攻击需要使用 HTML 中的 iframe 和 表单元素,因此在恶意网站中设置 CSS 属性,让 iframe 和表单隐藏不可见。

3.2.3 实施 CSRF 攻击的代码

<iframe name="iframe"></iframe>

<form action="http://localhost:8888/transfer" method="POST" target='iframe'>
  <input type="text" name="name" value="hacker" placeholder="接收用户"/>
  <input type="text" name="amount" value="50" placeholder="转账数量"/>
  <input type="submit" id="submit" value="转账">
</form>

<script>
var submit = document.getElementById('submit');
submit.click();
</script>

</body>
</html>

在第 3 行,定义了一个提交转账请求的表单,相关属性如下:

  • action 是银行转账的页面;
  • target 指向一个 iframe,向银行网站提交表单请求后,在指定的 iframe 中显示银行网站的返回的内容,因为 iframe 被设置为不可见,因此访问者察觉不到访问银行转账的操作;
  • 名称为 ‘name’ 的文本字段是转账的接收账户,值为 hacker,表示向 hacker 转账;
  • 名称为 ‘amount’ 的文本字段是转账的数量,值为 50,表示转账 50 元。

在第 10 行,获取表单中的提交按钮,在第 11 行,模拟点击提交按钮,向银行发起转账请求。

3.3 后端服务 app.py

#!/usr/bin/python3
from flask import Flask, render_template
app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(debug = True, port = 4444)

在第 5 行,访问页面 / 时,服务端返回页面模板 index.html;在第 10 行,在端口号 4444 上进行监听。

4. 小结

本小节了基于校验 Token 检测 CSRF 攻击的方法,然后通过一个银行转账的实例演示了 CSRF 的攻击与防御,使用思维导图概括如下:

图片描述