實(shí)戰(zhàn):Express 模擬 CSRF 攻擊
CSRF攻擊 是前端領(lǐng)域常見的安全問題,概念方面不再贅述,可以參考維基百科。對于這些概念,包括名詞定義、攻擊方式、解決方案等估計(jì)大家都看過不少,但留下印象總是很模糊,要?jiǎng)邮植僮饕环拍芗由钣∠蟛⒛苷嬲斫猓晕覜Q定動(dòng)手實(shí)現(xiàn)一個(gè) CSRF 的攻擊場景,并通過演示的方式講解 CSRF 的防范手段。
CSRF 攻擊流程 CSRF 模擬攻擊 CSRF 防范方法
CSRF 攻擊流程
假設(shè)用戶先通過 bank.com/auth 訪問銀行網(wǎng)站A的授權(quán)接口,通過認(rèn)證后拿到A返回的 cookie: userId=ce032b305a9bc1ce0b0dd2a,接著攜帶 cookie 訪問 bank.com/transfer?number=15000&to=Bob 銀行A的轉(zhuǎn)賬接口轉(zhuǎn)給Bob 15000元,然后A返回 success 表示轉(zhuǎn)賬成功。
釣魚網(wǎng)站B(hack.com)通過郵件或者廣告等方式引誘小明訪問,并返回給小明惡意的 HTML 攻擊代碼,HTML 中會(huì)包含發(fā)往銀行A的敏感操作:bank.com/transfer?number=150000&to=Jack ,此時(shí)瀏覽器會(huì)攜帶A的 cookie 發(fā)送請求,A拿到請求后,只通過 cookie 判斷是個(gè)合法操作,于是在小明不知情的情況下,賬戶里150000元被轉(zhuǎn)給了Jack,即惡意攻擊者。
這樣就完成了一次基本的 CSRF 攻擊。
CSRF 攻擊流程圖如下:

如果現(xiàn)在看不懂沒關(guān)系,可以看完演示再回頭看此圖就會(huì)恍然大悟了。
CSRF 模擬攻擊
首先通過 express 搭建后端,以模擬 CSRF 攻擊。
啟動(dòng)銀行 A 的服務(wù)器,端口 3001,包含 3 個(gè)接口:
app.use('/', indexRouter);
app.use('/auth', authRouter);
app.use('/transfer', transferRouter);
authRouter:
router.get('/', function(req, res, next) {
res.cookie('userId', 'ce032b305a9bc1ce0b0dd2a', { expires: new Date(Date.now() + 900000) })
res.end('ok')
});
transferRouter:
router.get('/', function(req, res, next) {
const { query } = req;
const { userId } = req.cookies;
if(userId){
res.send({
status: 'transfer success',
transfer: query.number
})
}else{
res.send({
status: 'error',
transfer: ''
})
}
});
使用 ejs 提供銀行轉(zhuǎn)賬頁面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
<%= title %>
</title>
</head>
<body>
<h2>
轉(zhuǎn)賬
</h2>
<script>
const h2 = document.querySelector('h2');
h2.addEventListener('click', () => {
fetch('/transfer?number=15000&to=Bob').then(res => {
console.log(res.json());
})
})
</script>
</body>
</html>
假設(shè)釣魚網(wǎng)站 B 提供的惡意代碼為:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div class="wrapper">
<iframe src="http://bank.com/transfer?number=150000&to=Jack" frameborder="0"></iframe>
</div>
<script>
</script>
</body>
</html>
并將其啟動(dòng)在3002端口,再通過 Whistle 進(jìn)行域名映射,因?yàn)閮烧叨际?Localhost 域名,而 Cookie 不區(qū)分端口,所以需要區(qū)分域名。

首先打開 Firefox 瀏覽器(暫時(shí)不用 Chrome ),訪問銀行 A 的 /auth獲得授權(quán):

然后通過點(diǎn)擊轉(zhuǎn)賬按鈕發(fā)送請求 http://bank.com/transfer?number=15000&to=Bob 進(jìn)行轉(zhuǎn)賬操作:

用戶收到誘惑進(jìn)入了 hack 網(wǎng)站,hack 網(wǎng)站首頁有一個(gè)發(fā)往銀行A的請求 http://bank.com/transfer?number=150000&to=Jack ,這個(gè)請求可以放在 iframe、img、script 等的 src 里面。

可以看到請求攜帶 cookie,并成功轉(zhuǎn)賬,這樣一次 CSRF 攻擊就完成了。當(dāng)然這是一次簡單的 GET 請求的攻擊,POST 請求攻擊可以通過自動(dòng)提交表單實(shí)現(xiàn),比如:
<form action="bank.com/transfer" method=POST>
<input type="hidden" name="number" value="150000" />
<input type="hidden" name="to" value="Jack" />
</form>
<script> document.forms[0].submit(); </script>
從上面可以看出,CSRF 攻擊主要特點(diǎn)是:
發(fā)生在第三方域名(hack.com)。 攻擊者只能使用 cookie 而拿不到具體的 cookie。
針對以上特點(diǎn),我們就能進(jìn)行對應(yīng)的防范了。
CSRF 防范方法
CSRF 防范方法通常有以下幾種:
阻止不同域的訪問 同源檢測。 Samesite Cookie。 提交時(shí)要求附加本域才能獲取的信息。 添加 CSRF Token。 雙重 Cookie驗(yàn)證。
同源檢測 - 通過 Origin 和 Referer 確定來源域名
針對第一個(gè)特點(diǎn)進(jìn)行域名檢查,HTTP 請求時(shí)會(huì)攜帶這兩個(gè) Header,用于標(biāo)記來源域名,如果請求來源不是本域,直接進(jìn)行攔截。

但是這兩個(gè) Header 也是可以不攜帶的,所以我們的策略是校驗(yàn)如果兩個(gè) Header 不存在或者存在但不是本域則阻攔。
修改 transferRouter 代碼如下:
const csrfGuard = require('../middleware/csrfGuard')
/* GET users listing. */
router.get('/', csrfGuard, function(req, res, next) {
const { query } = req;
const { userId } = req.cookies;
if(userId){
res.send({
status: 'transfer success',
transfer: query.number
})
}else{
next()
}
});
router.get('/', function(req, res, next) {
res.send({
status: 'error',
transfer: ''
})
});
csrfGuard.js:
module.exports = function(req, res, next){
const [Referer, Origin] = [req.get('Referer'), req.get('Origin')]
if(Referer && Referer.indexOf('bank.com') > 0){
next();
}
else if(Origin && Origin.indexOf('bank.com') > 0){
next();
}else{
next('route')
}
}
驗(yàn)證:

Samesite Cookie
在敏感 cookie 上攜帶屬性 Samesite:Strict 或 Lax,可以避免在第三方不同域網(wǎng)站上攜帶 cookie,具體原因可以參考阮一峰老師的Cookie 的 SameSite 屬性。
// authRouter.js
router.get('/', function(req, res, next) {
res
.cookie('userId', 'ce032b305a9bc1ce0b0dd2a', { expires: new Date(Date.now() + 900000), sameSite: 'lax' })
res.end('ok')
});
查看 bank.com cookie:

再次訪問 hack.com,發(fā)現(xiàn)轉(zhuǎn)賬鏈接并未攜帶 cookie:

這樣就達(dá)到了防范的目的,兼容性 目前來看還可以,雖然沒有達(dá)到完美覆蓋,但大部分瀏覽器也都支持了
PS: 前面之所以沒有使用 Chrome 瀏覽器做實(shí)驗(yàn),是因?yàn)閺?Chrome 80 版本起,Samesite 被默認(rèn)設(shè)置為了 Lax,而 Firefox 仍然為 None。
添加 CSRF Token
首先服務(wù)器生成一個(gè)動(dòng)態(tài)的 token,傳給用戶,用戶再次提交或者請求敏感操作時(shí),攜帶此 token,服務(wù)端校驗(yàn)通過才返回正確結(jié)果。
改寫 indexRouter,使其返回 token 給頁面:
var express = require("express");
var router = express.Router();
const jwt = require("jsonwebtoken");
router.get("/", function (req, res, next) {
res.render("index", { title: "Express", token: jwt.sign({
username: 'ming'
}, 'key', {
expiresIn: '1d'
}) });
});
module.exports = router;
前端頁面:
// index.ejs
<body>
<h2>
轉(zhuǎn)賬
</h2>
<span id='token' data-token=<%= token %>></span>
<script>
const h2 = document.querySelector('h2');
const tokenElem = document.querySelector('#token');
const token = tokenElem.dataset.token;
h2.addEventListener('click', () => {
fetch('/transfer?number=15000&to=Bob&token=' + token).then(res=>{
console.log(res.json());
})
})
</script>
</body>
將 transferRouter 的驗(yàn)證中間件改成 token 驗(yàn)證:
const tokenVerify = require('../middleware/tokenVerify')
router.get('/', tokenVerify, function(req, res, next) {
const { query } = req;
const { userId } = req.cookies;
if(userId){
res.send({
status: 'transfer success',
transfer: query.number
})
}else{
next()
}
});
JWT 驗(yàn)證:
const jwt = require("jsonwebtoken");
module.exports = function(req, res, next){
const { token } = req.query;
jwt.verify(token,'key', (err, decode)=> {
if(err){
next('route')
}else{
console.log(decode);
next()
}
})
}
攜帶 token 正常訪問成功:

釣魚網(wǎng)站拿不到 token 所以攻擊失?。?/p>
以上為加深理解而寫的代碼,而在生產(chǎn)環(huán)境中,node 可以使用 csurf中間件來防御 csrf 攻擊
雙重Cookie驗(yàn)證
設(shè)置一個(gè)專用 cookie,因?yàn)楣粽吣貌坏?cookie,所以將 cookie 種到域名的同時(shí),訪問敏感操作也需要攜帶,攻擊者帶不上 cookie,就達(dá)到了防范的目的。
// authRouter.js
const randomString = require('random-string');
/* GET users listing. */
router.get('/', function(req, res, next) {
res
.cookie('userId', 'ce032b305a9bc1ce0b0dd2a', { expires: new Date(Date.now() + 900000) })
.cookie('csrfcookie', randomString(), { expires: new Date(Date.now() + 900000) })
res.end('ok')
});
bank.com 銀行轉(zhuǎn)賬頁面:
<script>
const h2 = document.querySelector('h2');
const csrfcookie = getCookie('csrfcookie')
h2.addEventListener('click', () => {
fetch('/transfer?number=15000&to=Bob&csrfcookie=' + csrfcookie).then(res => {
console.log(res.json());
})
})
</script>
驗(yàn)證中間件:
// doubleCookie.js
module.exports = function(req, res, next){
const queryCsrfCookie = req.query.csrfcookie
const realCsrfCookie = req.cookies.csrfcookie;
console.log(queryCsrfCookie, realCsrfCookie);
if(queryCsrfCookie === realCsrfCookie){
next()
}else{
next('route')
}
}
銀行 bank.com:

而 hack.com 拿不到 csrfcookie 所以驗(yàn)證不通過。
這個(gè)方法也是很有效的,比如請求庫 axios 就是用的這種方式。
總結(jié)
到這里大家是不是已經(jīng)明白了 CSRF 攻擊的原因所在,并可以提出針對性的解決方案了呢,防范關(guān)鍵其實(shí)就是防止其他人冒充你去做只有你能做的敏感操作,與此同時(shí)希望大家對于這類抽象性的問題可以自己動(dòng)手敲一下,模擬一遍,用造重復(fù)輪子的方法去理解,動(dòng)手比動(dòng)眼管用的多。
以上過程和代碼僅僅為幫助學(xué)習(xí)并做演示使用,如果用于生產(chǎn)力還是需要更成熟的解決方案。
最后
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個(gè)小忙:
點(diǎn)個(gè)「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點(diǎn)在看,都是耍流氓 -_-)
歡迎加我微信「 sherlocked_93 」拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
關(guān)注公眾號(hào)「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時(shí)聊騷。

