這篇談談 ASP.NET MVC + AJAX POST 程式如何防止 CSRF 攻擊。延伸閱讀:讓我們來談談 CSRF by huli

我寫了一個簡單的活動報名模擬頁面,Index.cshtml 如下:

@{
    Layout = null;
}
<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>登記模擬</title>
    <script src="https://unpkg.com/vue@next"></script>
    <style>
        html,body { font-size: 10pt; }
        table { border-collapse: collapse; margin-bottom: 12px; }
        td { padding: 3px 6px; border: 1px solid gray; }
        td.hdr { background-color: #eee; width: 50px; text-align: right; }
        .list { padding: 10px; margin-top: 10px; border: 1px solid gray;  }
        .msg { margin-left: 12px; color: cadetblue; }
    </style>
</head>
<body>
    <div id="app">
        <table class="op">
            <tr>
                <td class="hdr">活動</td>
                <td>
                    <select v-model="EventName">
                        <option value="上刀山">刀山歷險</option>
                        <option value="下油鍋">油鍋體驗</option>
                        <option value="寫程式">程式開發</option>
                    </select>
                    <label>
                        <input type="checkbox" v-model="Confirmed"/>
                        我已閱讀活動規則
                    </label>
                </td>
                <td>
                    <button :disabled="!Confirmed" v-on:click="Submit">
                        送出申請
                    </button>
                </td>
            </tr>
        </table>
        <button v-on:click="Query">重新整理</button>
        <span class="msg">{{Msg}}</span>
        <div class="list">
            <div v-for="e in Entries">{{e}}</div>
        </div>
    </div>
    <script>
    var app = Vue.createApp({
        data() {
            return {
                EventName: '上刀山',
                Confirmed: false,
                Entries: [],
                Msg: ''
            };
        },
        methods: {
            ShowMsg(msg) {
                this.Msg = msg;
                const self = this;
                setTimeout(() => self.Msg = '', 3000);
            },
            Submit() {
                const self = this;
                self.Msg = "傳送中";
                fetch('@Url.Action("Register")', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({ EventName: this.EventName })
                }).then(response => response.text())
                    .then(msg => { self.ShowMsg(msg); self.Query(); })
                    .catch(err => self.ShowMsg('發生錯誤'));
            },
            Query() {
                fetch('@Url.Action("Query")', { method: 'POST' })
                    .then(response => response.json())
                    .then(data => this.Entries = data);
            }
        },
        mounted() {
            this.Query();
        }
    });
    var vm = app.mount('#app');
    </script>
</body>
</html>

頁面很簡單,一個下拉選單選活動,勾選確認後發出 POST 呼叫 /Home/Register 登記,另外呼叫 /Home/Query 可取回目前報名資料。這裡用 Vue 3 實現 MVVM,簡單讓它動起來:

MVC 後端簡單搭一下,能動就好。Controller 加上 [Authorize] 要求登入,登記資料用靜態欄位 ConcurrentQueue<RegistrationEntry> 存放,Query() 將登記內容轉為 List<string> 傳回,Register() 方法則接收 eventName,由 User.Identity.Name 取登入者帳號當成登記資料。

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace MvcWeb.Controllers
{
    [Authorize]
    public class HomeController : Controller
    {
        public class RegistrationEntry
        {
            public DateTime RegTime { get; set; }
            public string EventName { get; set; }
            public string UserId { get; set; }
        }
        static ConcurrentQueue<RegistrationEntry> _registrations = new ConcurrentQueue<RegistrationEntry>();

        public ActionResult Index()
        {
            return View();
        }

        [HttpPost]
        public ActionResult Query()
        {
            return Json(
                _registrations.Select(o =>
                    $"{o.RegTime:MM-dd HH:mm:ss} {o.UserId} - {o.EventName}"
                ).ToList());
        }

        [HttpPost]
        public ActionResult Register(string eventName)
        {
            _registrations.Enqueue(new RegistrationEntry
            {
                RegTime = DateTime.Now,
                EventName = eventName,
                UserId = User.Identity.Name.Split('\\').Last()
            });
            return Content("Registered");
        }
    }
}

這樣的程式寫法存在 CSRF 安全漏洞(延伸閱讀:迷思:只要限定 POST 呼叫就不會有跨站台存取風險?),有心人士可製作惡意網頁,甚至做成本機 .html 檔用瀏覽器開啟也成,設法誘騙使用者參加報名。

以下是個簡單示範,用一個 form,將 action 指向報名 Action,活動名稱用隱藏欄位填入「下油鍋」,放果按鈕顯示「參加抽獎」,引誘使用者按鈕送出表單:(我在底下多放個 iframe 觀察結果,真正要攻擊會隱藏並顯示"祝你抽中大獎")

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
</head>
<body>
<form action="http://localhost:52752/Home/Register" method="post" target="res">
    <input type="hidden" name="EventName" value="下油鍋" />
    <button>參加抽獎</button>
</form>
<iframe name="res" style="height: 30px;margin-top: 6px"></iframe>
</body>

</html>

成功! 使用者以為自己是參加抽獎,其實是報名下油鍋:

要如何防範呢?本草綱目有記載,在送出 POST 請求時附上 AntiForgery.GetTokens() 產生的 Token,接收端用 AntiForgery.Validate() 檢查就能實現基本的保護了。

小改 .cshtml,Submit() 時在 HTTP Header 加上由 AntiForgery.GetTokens() 產生的 cookieToken 及 formToken:
註:本篇文章介紹的做法偏向 ASP.NET MVC 或 WebForm 環境,若是 ASP.NET Core 已有內建機制,參考:Razor Pages 實作 Ajax 呼叫
2023-06-16 更新:感謝程凱大提醒,Token 應透過 HTTP Header 傳送較安全,讓攻擊者無法捏一個 HTML Form 在欄位值夾帶 Token 闖關。

<script>
    @functions{
        public string TokenValue()
        {
            string cookieToken, formToken;
            AntiForgery.GetTokens(null, out cookieToken, out formToken);
            return cookieToken + ":" + formToken;
        }
    }
    var app = Vue.createApp({
        data() {
            return {
                EventName: '上刀山',
                Confirmed: false,
                Entries: [],
                Msg: ''
            };
        },
        methods: {
            ShowMsg(msg) {
                this.Msg = msg;
                const self = this;
                setTimeout(() => self.Msg = '', 3000);
            },
            Submit() {
                const self = this;
                self.Msg = "傳送中";
                fetch('@Url.Action("Register")', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': '@TokenValue()'
                    },
                    body: JSON.stringify({
                        EventName: this.EventName
                    })
                }).then(response => response.text())
                    .then(msg => { self.ShowMsg(msg); self.Query(); })
                    .catch(err => self.ShowMsg('發生錯誤'));
            },
            Query() {
                fetch('@Url.Action("Query")', { method: 'POST' })
                    .then(response => response.json())
                    .then(data => this.Entries = data);
            }
        },
        mounted() {
            this.Query();
        }
    });
    var vm = app.mount('#app');
</script>

cookieToken 及 formToken 是由 ASP.NET Machine Key 加密產生,除非 Machine Key 外流,攻擊者難以仿冒過關。(延伸閱讀:MachineKey 外流有多可怕? 淺談 ASP.NET Form 驗證之破解與防護)
註:若為 WebFarm 環境且要做法 Sessionless (處理 GET 與 POST 的伺服器不必同一台),則需在 web.config system.web/machineKey 指定相同 Machine Key。
註:如要更嚴謹,cookieToken 可寫成 Cookie 交由瀏覽器傳送,讓攻擊者無法在跨站台網頁模擬包含 Cookie 之請求,增加破解難度。

Action 端加上 AntiForgery.Validate() 檢查:

public ActionResult Register(string eventName)
{
    try
    {
        // TODO: 可改寫成 IAuthorizationFilter 方便套用
        var token = Request.Headers["X-CSRF-TOKEN"];
        var p = token.Split(':');
        AntiForgery.Validate(p[0], p[1]);
    }
    catch
    {
        return Content("Invalid Token");
    }
    _registrations.Enqueue(new RegistrationEntry
    {
        RegTime = DateTime.Now,
        EventName = eventName,
        UserId = User.Identity.Name.Split('\\').Last()
    });
    return Content("Registered");
}

加上 AntiForgery 檢核後,就能成功守住防線囉~

範例專案

ASP.NET example to use AntiForgery to prevent CSRF attacking.


Comments

# by Toolman

黑大好 如果是 .net core 的話,後端驗證 CSRF Token 似乎也可以用內建的 attribute [ValidateAntiForgeryToken] 來做驗證喔 https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery?view=aspnetcore-7.0#require-antiforgery-validation 另外想推個 "讓我們來談談 CSRF by huli" 當中提到的 "Double Submit Cookie" 用 Double Submit Cookie,前端 csrf token 可以由前端產,就不用靠後端去產了(適合前後端分離架構) 只不過後端要記得設定檢查 http header 中的 token 跟 cookie 中的 token 有無一致

# by 小黑

請教黑大,若後端使用是 asp.net web api 設計,然後給任意前端呼叫,這樣AntiForgery.Validate() 的驗證方式還能使用嗎?

# by Jeffrey

to 小黑,不適用。這種應該要發 API Key 或用 JWT 管控存取。

# by Jeffrey

to Toolman,謝分享。ASP.NET GetTokens 有支援 "Double Submit Cookie",只是官方範例跟我的程式簡化,沒有把 cookieToken 做成 Cookie 傳送。但依我的理解,Cookie Token 也必須由伺服器產生埋入 Cookie,不該由前端產生,除非加上公私鑰機制,否則很難防偽。

# by Toolman

黑大好 其實如 "讓我們來談談 CSRF by huli" 當中提到的 "client side Double Submit Cookie" 他防 CSRF 的邏輯,就是偽造的網站沒辦法存取原生網站的 cookie 因此在這個方案中,原生網站 cookie 中之 CSRF Token 由哪一端產的不重要 只要 cookie 中之 CSRF Token 猜不到就好(夠隨機就行,像是 guid) 這樣偽造的網站發動攻擊時,即便他在 request 中之 http header 中塞了一個偽造的 CSRF Token 該 request 的 cookie csrf token 會因為瀏覽器機制 自動從原生網站 domain 中之 cookie 將 cookie 之 CSRF Token 帶在 request 中 而該 cookie 之 CSRF Token 因為 domain 不同,所以偽造的網站無法存取 所以後端只要比對 http header 中之 CSRF Token 跟 cookie 中之 CSRF Token 有沒有一致就好 而這個方案最大的好處之一是,很好應用在 SPA 類型的網站 畢竟後端產之 CSRF Token,不是太容易設置到 SPA 類型網站的 html 上

# by Jeffrey

to Toolman, 我想到一種情境是該使用者曾正常訪問過站台,瀏覽器殘留當時產生的 Cookie,而在跨站台發出請求時可能夾帶上次的 Cookie (這點我不太確定),若伺服器端看到 Cookie 有值就放行而不檢核內容,感覺會失效。 另一方面,會這麼想主要是因為 ASP.NET 的 cookieToken 及 formToken 需成對產生、成對檢核,所以我理解成二者都需由伺服器產生。

# by Toolman

黑大好 不能夠看到 cookie 內容後不檢查 "client side Double Submit Cookie" 只是把 CSRF 改成在前端產 其他機制都跟您的範例差不多 而以您講的例子來說,cookie 是可能殘留,但 1. cookie 有設置 timeout 的話,過指定時間,正規瀏覽器會自動刪除 2. "client side Double Submit Cookie" 的 csrf token 也是一對的 只是一個放 http header 中自己加的欄位裡,一個放在 cookie 裡 您可以想像成,網站初始化時,要先自己設置一個 csrf token 到 cookie 中 每次發 request 時,前端都要去 cookie 拿 csrf token 設到 request 的 http header 中 以及 request 原生機制會自動帶上 cookie 中之 csrf token 因此正常狀況下,原生網站發 request 時,可以拿到 cookie 的 csrf token並設到 http header 裡 而駭客無法偽造此 token,因為他存取不到原生網站的 cookie 他代發的 request,cookie 中之 csrf token 會自動從 request 的網址中,對應 domain 的 cookie 拿 並且由於駭客不知道 csrf token 的值是什麼,因此他的 http header 中的 csrf token 一定會是錯的

# by Jeffrey

to Toolman, 明白了,前端產生的 Cookie Token 透過安全可識別的 Session 交給伺服器端保存供後續比對,這樣就安全了。

Post a comment