Web安全之CSRF實例解析

前言

文章首次發表在 我的博客html

以前寫過一篇 web安全之XSS實例解析,是經過舉的幾個簡單例子講解的,一樣經過簡單得例子來理解和學習CSRF,有小夥伴問實際開發中有沒有遇到過XSS和CSRF,答案是有遇到過,不過被測試同窗發現了,還有安全掃描發現了可能的問題,這兩篇文章就是簡化了一下當時實際遇到的問題。前端

CSRF

跨站請求僞造(Cross Site Request Forgery),是指黑客誘導用戶打開黑客的網站,在黑客的網站中,利用用戶的登錄狀態發起的跨站請求。CSRF攻擊就是利用了用戶的登錄狀態,並經過第三方的站點來作一個壞事。node

要完成一次CSRF攻擊,受害者依次完成兩個步驟:git

  1. 登陸受信任網站A,並在本地生成Cookie
  2. 在不登出A的狀況,訪問危險網站B

CSRF攻擊

a.com登錄後種下cookie, 而後有個支付的頁面,支付頁面有個誘導點擊的按鈕或者圖片,第三方網站域名爲 b.com,中的頁面請求 a.com的接口,b.com 其實拿不到cookie,請求 a.com會把Cookie自動帶上(由於Cookie種在 a.com域下)。這就是爲何在服務端要判斷請求的來源,及限制跨域(只容許信任的域名訪問),而後除了這些還有一些方法來防止 CSRF 攻擊,下面會經過幾個簡單的例子來詳細介紹 CSRF 攻擊的表現及如何防護。github

下面會經過一個例子來說解 CSRF 攻擊的表現是什麼樣子的。
實現的例子:
在先後端同域的狀況下,先後端的域名都爲 http://127.0.0.1:3200, 第三方網站的域名爲 http://127.0.0.1:3100,釣魚網站頁面爲 http://127.0.0.1:3100/bad.htmlweb

平時本身寫例子中會用到下面這兩個工具,很是方便好用:
  • http-server: 是基於node.js的HTTP 服務器,它最大的好處就是:可使用任意一個目錄成爲服務器的目錄,徹底拋開後端的沉重工程,直接運行想要的js代碼;
  • nodemon: nodemon是一種工具,經過在檢測到目錄中的文件更改時自動從新啓動節點應用程序來幫助開發基於node.js的應用程序

前端頁面: client.htmljson

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>CSRF-demo</title>
    <style>
        .wrap {
            height: 500px;
            width: 300px;
            border: 1px solid #ccc;
            padding: 20px;
            margin-bottom: 20px;
        }
        input {
            width: 300px;
        }
        .payInfo {
            display: none;
        }
        .money {
            font-size: 16px;
        }
    </style>
</head>

<body>
    <div class="wrap">
        <div class="loginInfo">
            <h3>登錄</h3>
            <input type="text" placeholder="用戶名" class="userName">
            <br>
            <input type="password" placeholder="密碼" class="password">
            <br>
            <br>
            <button class="btn">登錄</button>
        </div>
        
        
        <div class="payInfo">
            <h3>轉帳信息</h3>
            <p >當前帳戶餘額爲 <span class="money">0</span>元</p>
            <!-- <input type="text" placeholder="收款方" class="account"> -->
            <button class="pay">支付10元</button>
            <br>
            <br>
            <a href="http://127.0.0.1:3100/bad.html" target="_blank">
                據說點擊這個連接的人都賺大錢了,你還不來看一下麼
            </a>
        </div>
    </div>
</body>
<script>
    const btn = document.querySelector('.btn');
    const loginInfo = document.querySelector('.loginInfo');
    const payInfo = document.querySelector('.payInfo');
    const money = document.querySelector('.money');
    let currentName = '';
    // 第一次進入判斷是否已經登錄
    Fetch('http://127.0.0.1:3200/isLogin', 'POST', {})
    .then((res) => {
        if(res.data) {
            payInfo.style.display = "block"
            loginInfo.style.display = 'none';
            Fetch('http://127.0.0.1:3200/pay', 'POST', {userName: currentName, money: 0})
            .then((res) => {
                money.innerHTML = res.data.money;
            })
        } else {
            payInfo.style.display = "none"
            loginInfo.style.display = 'block';
        }
        
    })
    // 點擊登錄
    btn.onclick = function () {
        var userName = document.querySelector('.userName').value;
        currentName = userName;
        var password = document.querySelector('.password').value;
        Fetch('http://127.0.0.1:3200/login', 'POST', {userName, password})
        .then((res) => {
            payInfo.style.display = "block";
            loginInfo.style.display = 'none';
            money.innerHTML = res.data.money;
        })
    }
    // 點擊支付10元
    const pay = document.querySelector('.pay');
    pay.onclick = function () {
        Fetch('http://127.0.0.1:3200/pay', 'POST', {userName: currentName, money: 10})
        .then((res) => {
            console.log(res);
            money.innerHTML = res.data.money;
        })
    }
    // 封裝的請求方法
    function Fetch(url, method = 'POST', data) {
        return new Promise((resolve, reject) => {
            let options = {};
            if (method !== 'GET') {
                options = {
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(data),
                }
            }
            fetch(url, {
                mode: 'cors', // no-cors, cors, *same-origin
                method,
                ...options,
                credentials: 'include',
            }).then((res) => {
                return res.json();
            }).then(res => {
                resolve(res);
            }).catch(err => {
                reject(err);
            });
        })
    }
    
</script>

</html>

實現一個簡單的支付功能:segmentfault

  1. 會首先判斷有沒有登陸,若是已經登錄過,就直接展現轉帳信息,未登陸,展現登錄信息
  2. 登錄完成以後,會展現轉帳信息,點擊支付,能夠實現金額的扣減

後端服務: server.js後端

const Koa = require("koa");
const app = new Koa();
const route = require('koa-route');
const bodyParser = require('koa-bodyparser');
const cors = require('@koa/cors');
const KoaStatic = require('koa-static');

let currentUserName = '';

// 使用  koa-static  使得先後端都在同一個服務下
app.use(KoaStatic(__dirname));

app.use(bodyParser()); // 處理post請求的參數

// 初始金額爲 1000
let money = 1000;

// 調用登錄的接口
const login = ctx => {
    const req = ctx.request.body;
    const userName = req.userName;
    currentUserName = userName;
    // 簡單設置一個cookie
    ctx.cookies.set(
        'name', 
        userName,
        {
          domain: '127.0.0.1', // 寫cookie所在的域名
          path: '/',       // 寫cookie所在的路徑
          maxAge: 10 * 60 * 1000, // cookie有效時長
          expires: new Date('2021-02-15'),  // cookie失效時間
          overwrite: false,  // 是否容許重寫
          SameSite: 'None',
        }
      )
    ctx.response.body = {
        data: {
            money,
        },
        msg: '登錄成功'
    };
}
// 調用支付的接口
const pay = ctx => {
    if(ctx.method === 'GET') {
        money = money - Number(ctx.request.query.money);
    } else {
        money = money - Number(ctx.request.body.money);
    }
    ctx.set('Access-Control-Allow-Credentials', 'true');
    // 根據有沒有 cookie 來簡單判斷是否登陸
    if(ctx.cookies.get('name')){
        ctx.response.body = {
            data: {
                money: money,
            },
            msg: '支付成功'
        };
    }else{
        ctx.body = '未登陸';
    }
}

// 判斷是否登錄
const isLogin = ctx => {
    ctx.set('Access-Control-Allow-Credentials', 'true');

    if(ctx.cookies.get('name')){
        ctx.response.body = {
            data: true,
            msg: '登錄成功'
        };

    }else{
        ctx.response.body = {
            data: false,
            msg: '未登陸'
        };
    }
}
// 處理 options 請求
app.use((ctx, next)=> {
    const headers = ctx.request.headers;
    if(ctx.method === 'OPTIONS') {
        ctx.set('Access-Control-Allow-Origin', headers.origin);
        ctx.set('Access-Control-Allow-Headers', 'Content-Type');
        ctx.set('Access-Control-Allow-Credentials', 'true');
        ctx.status = 204;
    } else {
        next();
    }
})

app.use(cors());
app.use(route.post('/login', login));
app.use(route.post('/pay', pay));
app.use(route.get('/pay', pay));
app.use(route.post('/isLogin', isLogin));

app.listen(3200, () => {
    console.log('啓動成功');
});

執行 nodemon server.js,訪問頁面 http://127.0.0.1:3200/client.html跨域

CSRF-demo

登錄完成以後,能夠看到Cookie是種到 http://127.0.0.1:3200 這個域下面的。

第三方頁面 bad.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>第三方網站</title>
</head>
<body>
    <div>
        哈哈,小樣兒,哪有賺大錢的方法,仍是踏實努力工做吧!
        <!-- form 表單的提交會伴隨着跳轉到action中指定 的url 連接,爲了阻止這一行爲,能夠經過設置一個隱藏的iframe 頁面,並將form 的target 屬性指向這個iframe,當前頁面iframe則不會刷新頁面 -->
        <form action="http://127.0.0.1:3200/pay" method="POST" class="form" target="targetIfr" style="display: none">
            <input type="text" name="userName" value="xiaoming">
            <input type="text" name="money" value="100">
        </form>
        <iframe name="targetIfr" style="display:none"></iframe>
    </div>
</body>
<script>
    document.querySelector('.form').submit();
</script>
</html>

使用 HTTP-server 起一個 本地端口爲 3100的服務,就能夠經過 http://127.0.0.1:3100/bad.html 這個連接來訪問,CSRF攻擊須要作的就是在正常的頁面上誘導用戶點擊連接進入這個頁面
CSRF-DEMO

點擊誘導連接,跳轉到第三方的頁面,第三方頁面自動發了一個扣款的請求,因此在回到正常頁面的時候,刷新,發現錢變少了。
咱們能夠看到在第三方頁面調用 http://127.0.0.1:3200/pay 這個接口的時候,Cookie自動加在了請求頭上,這就是爲何 http://127.0.0.1:3100/bad.html 這個頁面拿不到 Cookie,可是卻能正常請求 http://127.0.0.1:3200/pay 這個接口的緣由。

CSRF攻擊大體能夠分爲三種狀況,自動發起Get請求, 自動發起POST請求,引導用戶點擊連接。下面會分別對上面例子進行簡單的改造來講明這三種狀況

自動發起Get請求

在上面的 bad.html中,咱們把代碼改爲下面這樣

<!DOCTYPE html>
<html>
  <body>
    <img src="http://127.0.0.1:3200/payMoney?money=1000">
  </body>
</html>

當用戶訪問含有這個img的頁面後,瀏覽器會自動向自動發起 img 的資源請求,若是服務器沒有對該請求作判斷的話,那麼會認爲這是一個正常的連接。

自動發起POST請求

上面例子中演示的就是這種狀況。

<body>
    <div>
        哈哈,小樣兒,哪有賺大錢的方法,仍是踏實努力工做吧!
        <!-- form 表單的提交會伴隨着跳轉到action中指定 的url 連接,爲了阻止這一行爲,能夠經過設置一個隱藏的iframe 頁面,並將form 的target 屬性指向這個iframe,當前頁面iframe則不會刷新頁面 -->
        <form action="http://127.0.0.1:3200/pay" method="POST" class="form" target="targetIfr">
            <input type="text" name="userName" value="xiaoming">
            <input type="text" name="money" value="100">
        </form>
        <iframe name="targetIfr" style="display:none"></iframe>
    </div>
</body>
<script>
    document.querySelector('.form').submit();
</script>

上面這段代碼中構建了一個隱藏的表單,表單的內容就是自動發起支付的接口請求。當用戶打開該頁面時,這個表單會被自動執行提交。當表單被提交以後,服務器就會執行轉帳操做。所以使用構建自動提交表單這種方式,就能夠自動實現跨站點 POST 數據提交。

引導用戶點擊連接

誘惑用戶點擊連接跳轉到黑客本身的網站,示例代碼如圖所示

<a href="http://127.0.0.1:3100/bad.html">據說點擊這個連接的人都賺大錢了,你還不來看一下麼</a>

用戶點擊這個地址就會跳到黑客的網站,黑客的網站可能會自動發送一些請求,好比上面提到的自動發起Get或Post請求。

如何防護CSRF

利用cookie的SameSite

SameSite有3個值: Strict, Lax和None

  1. Strict。瀏覽器會徹底禁止第三方cookie。好比a.com的頁面中訪問 b.com 的資源,那麼a.com中的cookie不會被髮送到 b.com服務器,只有從b.com的站點去請求b.com的資源,纔會帶上這些Cookie
  2. Lax。相對寬鬆一些,在跨站點的狀況下,從第三方站點連接打開和從第三方站點提交 Get方式的表單這兩種方式都會攜帶Cookie。但若是在第三方站點中使用POST方法或者經過 img、Iframe等標籤加載的URL,這些場景都不會攜帶Cookie。
  3. None。任何狀況下都會發送 Cookie數據

咱們能夠根據實際狀況將一些關鍵的Cookie設置 Stirct或者 Lax模式,這樣在跨站點請求的時候,這些關鍵的Cookie就不會被髮送到服務器,從而使得CSRF攻擊失敗。

驗證請求的來源點

因爲CSRF攻擊大多來自第三方站點,能夠在服務器端驗證請求來源的站點,禁止第三方站點的請求。
能夠經過HTTP請求頭中的 Referer和Origin屬性。

HTTP請求頭

可是這種 Referer和Origin屬性是能夠被僞造的,碰上黑客高手,這種判斷就是不安全的了。

CSRF Token

  1. 最開始瀏覽器向服務器發起請求時,服務器生成一個CSRF Token。CSRF Token其實就是服務器生成的字符串,而後將該字符串種植到返回的頁面中(能夠經過Cookie)
  2. 瀏覽器以後再發起請求的時候,須要帶上頁面中的 CSRF Token(在request中要帶上以前獲取到的Token,好比 x-csrf-token:xxxx), 而後服務器會驗證該Token是否合法。第三方網站發出去的請求是沒法獲取到 CSRF Token的值的。

其餘知識點補充

1. 第三方cookie

Cookie是種在服務端的域名下的,好比客戶端域名是 a.com,服務端的域名是 b.com, Cookie是種在 b.com域名下的,在 Chrome的 Application下是看到的是 a.com下面的Cookie,是沒有的,以後,在a.com下發送b.com的接口請求會自動帶上Cookie(由於Cookie是種在b.com下的)

2. 簡單請求和複雜請求

複雜請求須要處理option請求。

以前寫過一篇特別詳細的文章 CORS原理及@koa/cors源碼解析,有空能夠看一下。

3. Fetch的 credentials 參數

若是沒有配置credential 這個參數,fetch是不會發送Cookie的

credential的參數以下

  • include:不管是不是跨域的請求,老是發送請求資源域在本地的Cookies、HTTP Basic anthentication等驗證信息
  • same-origin:只有當URL與響應腳本同源才發送 cookies、 HTTP Basic authentication 等驗證信息
  • omit: 從不發送cookies.

日常寫一些簡單的例子,從不少細節問題上也能補充本身的一些知識盲點。

參考

相關文章
相關標籤/搜索