Flask 防御 CSRF 攻擊
在上一個小節(jié)中講解了 CSRF 攻擊與防御的原理,本小節(jié)首先講解了基于校驗 Token 檢測 CSRF 攻擊的基本思想和步驟,然后通過一個銀行轉賬的實例演示了 CSRF 的攻擊與防御。
1. 基于校驗 Token 檢測 CSRF 攻擊
1.1 基本思想
在 Flask 中防御 CSRF 攻擊的最常見方法是基于校驗 Token 檢測 CSRF 攻擊,它的基本思想如下:
- 在服務端生成一個隨機的、不可預測的字符串,該字符串稱為 CSRF Token;
- 客戶獲取安全相關的頁面時,服務端將 CSRF Token 作為隱藏字段發(fā)送給客戶端;
- 客戶端提出請求時,將頁面的中的隱藏字段一并發(fā)送給服務端;
- 服務端處理請求時,提取請求中的 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="轉賬數(shù)量" />
<input type="submit" name="submit" value="轉賬">
</form>
在第 2 行,名稱為 csrfToken 的隱藏字段包含了 CSRF Token 的值,字符串 “IZiglWi1k2e3z!m@z$” 是隨機的、無法被猜測的,即攻擊者無法獲取 CSRF Token、無法構造一個合法的轉賬請求。
6. 轉賬請求
客戶端發(fā)出轉賬請求,將隱藏字段 CSRF Token 一并發(fā)送給服務端。
7. 比對 Token
服務端收到轉賬請求后,提取請求中的 CSRF Token,與服務端生成的 CSRF Token 進行比對,如果相同則請求是合法的,如果不相同則請求是 CSRF 攻擊。
2. 演示 CSRF 的攻擊與防御
2.1 程序簡介
本節(jié)通過具體的案例演示 CSRF 的攻擊與防御,案例中的假設如下:
- 銀行網(wǎng)站,提供在線轉賬功能;
- 銀行網(wǎng)站中有兩個用戶:受害者 victim 和攻擊者 hacker,他們的賬戶中各有 100 元;
- 惡意網(wǎng)站,由攻擊者 hacker 創(chuàng)辦,當受害者 victim 沒有退出銀行網(wǎng)站的情況下,去訪問惡意網(wǎng)站,會在不知情的情況下,向 hacker 轉賬 50 元。
為了演示 CSRF 攻擊與防御,本節(jié)包括了 2 個程序:
程序 | 源代碼目錄 | 首頁 URL |
---|---|---|
銀行網(wǎng)站 | bank | http://localhost:8888 |
惡意網(wǎng)站 | malicious | http://localhost:4444 |
銀行網(wǎng)站和惡意網(wǎng)站運行在同一臺機器上,通過端口號區(qū)分:銀行網(wǎng)站監(jiān)聽端口 8888,惡意網(wǎng)站監(jiān)聽端口 4444。
2.2 演示 CSRF 攻擊
下面的視頻演示了 CSRF 攻擊的操作過程:
2.3 演示防御 CSRF 攻擊
下面的視頻演示了防御 CSRF 攻擊的操作過程:
2. 實現(xiàn) bank 程序
2.1 程序下載
bank 程序實現(xiàn)了銀行網(wǎng)站的功能,包括 2 個源文件,點擊下載例子代碼 bank。:
源文件 | 功能 |
---|---|
bank/app.py | 后端服務程序 |
bank/templates/index.html | 首頁模板文件 |
2.2 首頁模板 templates/index.html
2.2.1 登錄后的界面
用戶訪問網(wǎng)站首頁時,根據(jù)是否登錄顯示不同的內容,如果用戶已經(jīng)登錄,則顯示如下:
<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="轉賬數(shù)量" />
<input type="submit" name="submit" value="轉賬">
</form>
<h2>3. 退出</h2>
<form action="/logout" method="POST">
<input type="submit" name="submit" value="退出">
</form>
在第 10 行,如果參數(shù) hasLogin 為真,表示用戶已經(jīng)登錄,則顯示在第 11 行到第 23 行的內容。
在第 11 行到第 12 行,顯示用戶的基本信息:姓名和賬戶余額。
在第 13 行到第 19 行,顯示用于轉賬的表單,使用 POST 方法向服務端的 /transfer 頁面提出轉賬請求;字段 csrfToken 存儲了服務端發(fā)送的 CSRF Token,提交表單時,會將該字段一并提交;字段 name 是轉賬的接收賬戶名;字段 amount 是轉賬的數(shù)量。
在第 20 行到第 23 行,使用 POST 方法向服務端的 /logout 頁面退出登錄。
2.2.2 沒有登錄的界面
用戶訪問網(wǎng)站首頁時,根據(jù)是否登錄顯示不同的內容,如果用戶還沒有登錄,則顯示如下:
{% 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 行,F(xiàn)lask 程序在使用 Session 時,需要配置 SECRET_KEY。
2.3.2 用戶數(shù)據(jù)庫
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 行,定義函數(shù) findUser,在 users 中根據(jù)姓名查找 user。
在第 18 行,定義函數(shù) checkUser,在 users 中根據(jù)姓名和密碼查找 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)
設置首頁面 / 的處理函數(shù)為 index,函數(shù)在 session 中查找 hasLogin、name、csrfToken 變量,將它們傳遞給頁面模板 index.html。
在第 6 行,調用函數(shù) 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 的處理函數(shù)為 login,該函數(shù)首先提取請求中的 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 的處理函數(shù)為 logout,該函數(shù)設置 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
函數(shù) getCsrfToken 返回一個隨機的字符串,os.urandom(16) 產生一個包含 16 個字節(jié)的 bytes,base64.b64encode 將 bytes 轉換為 base64 編碼的字符串。
函數(shù) checkCsrfAttack 檢測 CSRF 攻擊,在第 5 行,從請求的表單中獲取參數(shù) 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 的處理函數(shù)為 transfer。在第 3 行,如果 Session 中的 hasLogin 變量未假,表示請求來自于未登錄的用戶,返回 ‘轉賬失敗’。
在第 6 行,如果 checkFlag 為真并且 checkCsrfAttack 函數(shù)檢測到了 CSRF 攻擊,在控制臺打印 CSRF 的警告,返回 ‘轉賬失敗’。如果 checkFlag 為假,程序不檢測 CSRF 攻擊。
在第 10 行到第 15 行,獲取來源賬戶,并從轉賬請求中獲取參數(shù):轉賬數(shù)量、接受賬戶。
在第 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. 實現(xiàn) malicious 程序
3.1 程序下載
malicious 程序實現(xiàn)了惡意網(wǎng)站的功能,包括 2 個源文件,點擊下載例子代碼 malicious。:
源文件 | 功能 |
---|---|
bank/app.py | 后端服務程序 |
bank/templates/index.html | 首頁模板文件 |
3.2 首頁模板 templates/index.html
惡意網(wǎng)站的頁面包括兩部分:
- 正常顯示的部分
- 實施 CSRF 攻擊的代碼
3.2.1 正常顯示的部分
<html>
<head>
<meta charset='utf-8'>
<title>惡意網(wǎng)站</title>
</head>
<body>
<h1>惡意網(wǎng)站</h1>
<ul>
<li>在網(wǎng)站中放置吸引人的內容,例如賭博、色情、盜版小說等,吸引人來訪問
<li>如果用戶已經(jīng)登錄了某銀行網(wǎng)站,訪問惡意網(wǎng)站首頁時,自動向銀行網(wǎng)站發(fā)起轉賬請求
</ul>
通常惡意網(wǎng)站會放置吸引人的內容,例如賭博、色情、盜版小說等,誘導受害者來訪問。
3.2.2 隱藏 iframe 和 表單
<style>
iframe {
display: none;
}
form {
display: none;
}
</style>
CSRF 攻擊需要使用 HTML 中的 iframe 和 表單元素,因此在惡意網(wǎng)站中設置 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="轉賬數(shù)量"/>
<input type="submit" id="submit" value="轉賬">
</form>
<script>
var submit = document.getElementById('submit');
submit.click();
</script>
</body>
</html>
在第 3 行,定義了一個提交轉賬請求的表單,相關屬性如下:
- action 是銀行轉賬的頁面;
- target 指向一個 iframe,向銀行網(wǎng)站提交表單請求后,在指定的 iframe 中顯示銀行網(wǎng)站的返回的內容,因為 iframe 被設置為不可見,因此訪問者察覺不到訪問銀行轉賬的操作;
- 名稱為 ‘name’ 的文本字段是轉賬的接收賬戶,值為 hacker,表示向 hacker 轉賬;
- 名稱為 ‘amount’ 的文本字段是轉賬的數(shù)量,值為 50,表示轉賬 50 元。
在第 10 行,獲取表單中的提交按鈕,在第 11 行,模擬點擊提交按鈕,向銀行發(fā)起轉賬請求。
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 上進行監(jiān)聽。
4. 小結
本小節(jié)了基于校驗 Token 檢測 CSRF 攻擊的方法,然后通過一個銀行轉賬的實例演示了 CSRF 的攻擊與防御,使用思維導圖概括如下: