Web後端學習筆記 Flask(10)CSRF攻擊原理

CSRF(Cross Site Request Forgery,跨站域請求僞造)是一種網絡的攻擊方式,它在2007年曾被列爲互聯網20大安全隱患之一。

CSRF攻擊的原理:

網站是通過cookie實現登錄功能的,而cookie只要存在瀏覽器中,那麼瀏覽器在訪問這個cookie所對應的網站的時候,就會自動的攜帶cookie信息到服務器上去。那麼這時候就存在一個漏洞,如果你在訪問網站未退出的情況下,又訪問了一個病毒網站,那麼這個網站可以在網頁代碼中插入JS代碼,使用JS代碼給其他服務器(未退出的網站)發送請求(例如ICBC轉賬請求)。因爲在發送請求的時候,請求也是由瀏覽器發送出去的,所以瀏覽器會自動把cookie信息發送給對應的服務器(ICBC),所以服務器就不知道這個請求是僞造的,就被欺騙過去了。從而達到在用戶不知情的情況下,給服務器發送了轉賬請求。

原理圖如下所示:

CSRF攻擊防禦:

CSRF攻擊的要點就是在向服務器發送請求的時候,相應的cookie會自動地發送給對應的服務器。不知道這個請求是用戶發起的還是僞造的。這時候,可以在用戶每次訪問有表單的頁面的時候,在網頁源代碼中添加一個隨機的字符串,叫csrf_token,在cookie中也加入一個相同值的csrf_token字符串。以後在給服務器發送請求的時候,必須在body以及cookie中都攜帶csrf_tooken,服務器只有檢測到cookie中的csrf_token和body中的csrf_token相同,纔會認爲這個請求是正常的,否則就是僞造的。

下面通過一個實例實現CSRF攻擊:

1. 首先編寫一個類似於ICBC轉賬網站:
主要是簡單地實現的是註冊,登陸,轉賬功能

首先實現數據庫映射,採用的是flask-migrate,以及flask-script

實現數據庫db

配置文件: config.py

# -*- coding: utf-8 -*-

import os
from datetime import timedelta

HOST_NAME = "127.0.0.1"
PORT = "3306"
DATABASE = "icbc"
USERNAME = "root"
PASSWORD = "root1234"
# dialect+driver://username:password@host:port/database
DB_URI = "mysql+pymysql://{username}:{password}@{host}:{port}/{database}".format(
    username=USERNAME, password=PASSWORD, host=HOST_NAME, port=PORT, database=DATABASE
)

SQLALCHEMY_DATABASE_URI = DB_URI
SQLALCHEMY_TRACK_MODIFICATIONS = None
TEMPLATE_AUTO_RELOAD = True
DEBUG = True

SECRET_KEY = os.urandom(24)       # 設置flask中session加密的字符串 24長度的加密字符串
PERMANENT_SESSION_LIFETIME = timedelta(days=1)

實現db:

# -*- coding: utf-8 -*-
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

models.py中,定義數據庫ORM

# -*- coding: utf-8 -*-
from exts import db


class User(db.Model):
    __tablename__ = "user"
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    email = db.Column(db.String(50), nullable=False)
    username = db.Column(db.String(50), nullable=False)
    password = db.Column(db.String(50), nullable=False)
    deposit = db.Column(db.Float, default=0)

manager.py中,進行數據庫遷移,這裏要用到flask-migrate和flask-script

# -*- coding: utf-8 -*-
from flask_script import Manager
from app import app
from flask_migrate import MigrateCommand, Migrate
from exts import db
from models import User
# 只需要導入模型即可,flask會自動進行檢測

manager = Manager(app)
Migrate(app=app, db=db)
manager.add_command("db", MigrateCommand)


if __name__ == "__main__":
    manager.run()

通過flask-migrate中的命令行實現數據庫遷移:
python mamanger.py db init      初始化alembic倉庫

python manager.py db migrate      生成遷移腳本

python manager.py db upgrade     完成數據庫遷移

定義前端頁面:
index.html   首頁

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ICBC首頁</title>
</head>
<body>
    <h1>ICBC歡迎你</h1>
    <ul>
        <li><a href="{{ url_for("register") }}">立即註冊</a></li>
        <li><a href="{{ url_for("login") }}">立即登陸</a></li>
        <li><a href="{{ url_for("transfer") }}">立即轉賬</a></li>
    </ul>
</body>
</html>

login.html 登陸頁面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ICBC登陸</title>
</head>
<body>
    <form action="" method="post">
        <table>
            <tbody>
                <tr>
                    <td>郵箱: </td>
                    <td><input type="email" name="email"></td>
                </tr>
                <tr>
                    <td>密碼: </td>
                    <td><input type="password" name="password"></td>
                </tr>
                <tr>
                    <td></td>
                    <td><input type="submit" value="登錄"></td>
                </tr>
            </tbody>
        </table>
    </form>
</body>
</html>

register.html 註冊頁面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ICBC用戶註冊</title>
</head>
<body>
    <form action="" method="post">
        <table>
            <tbody>
                <tr>
                    <td>郵箱: </td>
                    <td><input type="email" name="email"></td>
                </tr>
                <tr>
                    <td>用戶名: </td>
                    <td><input type="text" name="username"></td>
                </tr>
                <tr>
                    <td>餘額: </td>
                    <td><input type="text" name="deposit"></td>
                </tr>
                <tr>
                    <td>密碼: </td>
                    <td><input type="password" name="password"></td>
                </tr>
                <tr>
                    <td>重複密碼: </td>
                    <td><input type="password" name="repeat_password"></td>
                </tr>
                <tr>
                    <td></td>
                    <td><input type="submit" value="註冊"></td>
                </tr>
            </tbody>
        </table>
    </form>
</body>
</html>

transfer.html  轉賬頁面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ICBC轉賬頁面</title>
</head>
<body>
    <form action="" method="post">
        <table>
            <tbody>
                <tr>
                    <td>轉到賬號: </td>
                    <td><input type="email" name="transfer_account" placeholder="郵箱"></td>
                </tr>
                <tr>
                    <td>轉賬金額: </td>
                    <td><input type="text" name="transfer_money"></td>
                </tr>
                <tr>
                    <td></td>
                    <td><input type="submit" value="轉賬"></td>
                </tr>
            </tbody>
        </table>
    </form>
</body>
</html>

在完成頁面後,需要定義表單驗證模塊,對註冊,登陸,以及轉賬的數據進行驗證,forms.py

# -*- coding: utf-8 -*-
from wtforms import Form, StringField, FloatField
from wtforms.validators import Email, Length, EqualTo, InputRequired
from models import User
from exts import db


class Registry(Form):
    email = StringField(validators=[Email()])
    username = StringField(validators=[Length(min=4, max=10)])
    password = StringField(validators=[Length(min=5, max=12)])
    repeat_password = StringField(validators=[EqualTo("password")])
    deposit = FloatField(validators=[InputRequired()])


class Login(Form):
    email = StringField(validators=[Email()])
    password = StringField(validators=[Length(min=5, max=12)])

    # 可以先在表單這裏進行驗證, 自定義驗證器

    # def validate(self):
    #     result = super(Login, self).validate()  # 先調用父類的validator,看能否通過驗證
    #     if not result:
    #         return False
    #     # 通過查詢數據庫驗證用戶
    #     email = self.email.data
    #     password = self.password.data
    #     user = db.session.query(User).filter(User.email == email,
    #                                          User.password == password).first()
    #     if user:
    #         return True
    #     else:
    #         self.email.errors.append("郵箱或密碼錯誤")
    #         return False


class Transfer(Form):
    transfer_account = StringField(validators=[Email()])
    transfer_money = FloatField(validators=[InputRequired()])

因爲表單登錄涉及到get和post方法,所以這裏推薦使用類視圖實現:app.py定義視圖函數

from flask import Flask, render_template, views, request, session
import config
from forms import Registry, Login, Transfer
from exts import db
from models import User
from auth import login_required

app = Flask(__name__)
app.config.from_object(config)
db.init_app(app=app)


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


class RegisterView(views.MethodView):
    def get(self):
        """
        定義get方法執行的操作
        :return:
        """
        return render_template("html/register.html")

    def post(self):
        """
        定義post方法執行的操作
        :return:
        """
        form = Registry(request.form)
        if form.validate():
            email = form.email.data
            username = form.username.data
            password = form.password.data
            deposit = form.deposit.data
            # 註冊信息保存到數據庫
            user = User(email=email, username=username, password=password,
                        deposit=deposit)
            db.session.add(user)
            db.session.commit()
            return "註冊成功"
        else:
            print(form.errors)
            return "註冊失敗"


class LoginView(views.MethodView):
    def get(self):
        """
        定義get方法下的操作
        :return:
        """
        return render_template("html/login.html")

    def post(self):
        """
        定義post方法下的操作
        :return:
        """
        form = Login(request.form)
        if form.validate():
            email = form.email.data
            password = form.password.data
            user = db.session.query(User).filter(User.email == email,
                                                 User.password == password).first()
            if user:
                # 通過session來完成
                session["user_id"] = user.id
                session.permanent = True
                return "登陸成功"
            else:
                return "郵箱或密碼錯誤"
        else:
            print(form.errors)
            return "登陸失敗"


class TransferView(views.MethodView):

    decorators = [login_required]

    def get(self):
        return render_template("html/transfer.html")

    def post(self):
        form = Transfer(request.form)
        if form.validate():
            transfer_account = form.transfer_account.data
            transfer_money = form.transfer_money.data    # 這裏已經通過表單驗證轉換爲float類型
            user = db.session.query(User).filter(User.email == transfer_account).first()
            if user:
                current_account_id = session.get("user_id")   # 獲取當前登陸用戶的id
                current_user = db.session.query(User).filter(User.id == current_account_id).first()
                if current_user.deposit > transfer_money:
                    # 可以進行轉賬
                    user.deposit = user.deposit + transfer_money
                    current_user.deposit = current_user.deposit - transfer_money
                    db.session.add_all([user, current_user])
                    db.session.commit()
                    return "轉賬成功"
                else:
                    return "餘額不足"
            else:
                return "用戶不存在"
        else:
            return "數據填寫不正確"


app.add_url_rule("/register/", view_func=RegisterView.as_view("register"))
app.add_url_rule("/login/", view_func=LoginView.as_view("login"))
app.add_url_rule("/transfer/", view_func=TransferView.as_view("transfer"))


if __name__ == '__main__':
    app.run()

還有一點需要注意的,在正常情況下,只有登錄狀態下,纔可以訪問轉賬頁面,所以這裏可以通過定義裝飾器,來實現這一功能。auth.py

# -*- coding: utf-8 -*-
# 做登錄限制,有些頁面只有登錄之後才能訪問
# 通過定義裝飾器實現
from functools import wraps
from flask import session, redirect, url_for


def login_required(func):
    @wraps(func)   # 防止傳入的函數的一些簽名丟失
    def wrapper(*args, **kwargs):
        if session.get("user_id"):
            # 當前處於登陸狀態
            return func(*args, **kwargs)
        else:
            return redirect(url_for("login"))
    return wrapper


這樣,就實現了一個轉賬網站的簡易功能。

給網站添加CSRF防禦:

csrf_token的原理是:

以用戶轉賬頁面爲例:在用戶請求轉賬的時候,服務器準備返回轉賬頁面,但是在返回轉賬頁面之前,服務器會做兩件事情:
1. 在cookie中添加csrf_token,  一個唯一的字符串,然後將在返回請求頁面的同時,會將cookie存儲到瀏覽器。

2. 在轉賬頁面當中也添加一個相同的csrf_token, 然後在將頁面返回到瀏覽器。

在用戶輸入完相關的轉賬信息,在點擊提交按鈕之後,就會將請求發送給服務器。同時瀏覽器也會將cookie發送給服務器。所以服務器會將表單當中的csrf_token和cookie中的csrf_token進行對比,如果兩者相同,則表示通過驗證。否則,這個請求就是一個僞造的請求。

此時惡意網站是無法去僞造頁面中csrf_token的,因爲每次請求頁面的時候,csrf_token都是不同的。

在flask框架中,已有CSRF防禦的相應機制。使用非常簡單。

1. 直接導入CSRFProtect

2. CSRFProtect綁定APP

3. 在相應的表單頁面,需要添加一個input標籤,因爲這個標籤只是存儲後端返回的csrf_token,不會顯示在返回的頁面上,所以需要設置type="hidden"

【注】服務器返回給頁面的csrf_token和返回給瀏覽器中cookie的csrf_token雖然是同一個值,但是分別經過了不同的轉換方法,所以這兩者看起不是相同的字符串(但實質上在後端經過轉換後還是同一個字符串)

AJAX處理CSRF漏洞

通過ajax提交表單,定義login.js文件,這裏獲取表單元素使用了jQuery

// 整個文檔加載完畢後纔會執行這個函數 window.onload = function() {}
$(function () {
   $('#submit').click(function (event) {
       event.preventDefault();  // 點擊按鈕後此時不會再提交,而是執行後面的代碼
       let email = $('input[name=email]').val();
        let password = $('input[name=password]').val();
        let csrf_token = $('input[name=csrf_token]').val();
        // 通過ajax的post方法提交數據
        $.post(          
            {
                "url": "/login/",    // 同一域名下,前面的部分可以省略
                'data': {
                    "email": email,
                    "password": password,
                    "csrf_token": csrf_token
                },
                'success': function (data) {
                    console.log(data)
                },
                'fail': function (error) {
                    console.log(error)
                }
            }
        )
   })
});

在flask中,一般推薦將存儲csrf_token的input標籤放到head中的meta標籤中,這樣做的好處是,例如表單需要繼承模板,則只需要在父模板中寫好csrf_token即可,子模版無論是否用到csrf_token,都會擁有csrf_token。

則在login.js中獲取csrf_token:

在用ajax提交數據的時候,也可以不把csrf_token放在提交的數據中,而是放在請求頭中:

// 整個文檔加載完畢後纔會執行這個函數 window.onload = function() {}
$(function () {
   $('#submit').click(function (event) {
       event.preventDefault();  // 點擊按鈕後此時不會再提交,而是執行後面的代碼
       let email = $('input[name=email]').val();
       let password = $('input[name=password]').val();
       // let csrf_token = $('input[name=csrf_token]').val();
       let csrf_token = $('meta[name=csrf_token]').attr('content');
       // 在進行Ajax請求之前,將csrf_token放到請求頭中
       $.ajaxSetup(
           {
                "beforeSend": function(xhr, settings)
                {
                   if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain)
                   {
                       xhr.setRequestHeader("X-CSRFToken", csrf_token)
                   }
                }
            }
       );

        $.post(
            {
                "url": "/login/",    // 同一域名下,前面的部分可以省略
                'data': {
                    "email": email,
                    "password": password
                    //"csrf_token": csrf_token
                },
                'success': function (data) {
                    console.log(data)
                },
                'fail': function (error) {
                    console.log(error)
                }
            }
        )
   })
});

-----------------------------------------------------------------------------------------------------------------------------------

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章