實(shí)戰(zhàn):Express 模擬 CSRF 攻擊
CSRF攻擊 是前端領(lǐng)域常見的安全問題,概念方面不再贅述,可以參考維基百科。對(duì)于這些概念,包括名詞定義、攻擊方式、解決方案等估計(jì)大家都看過不少,但留下印象總是很模糊,要?jiǎng)邮植僮饕环拍芗由钣∠蟛⒛苷嬲斫?,所以我決定動(dòng)手實(shí)現(xiàn)一個(gè) CSRF 的攻擊場(chǎng)景,并通過演示的方式講解 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ā)送請(qǐng)求,A拿到請(qǐng)求后,只通過 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)賬頁面:
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 提供的惡意代碼為:
html>
<html?lang="en">
<head>
??<meta?charset="UTF-8">
??<meta?name="viewport"?content="width=device-width,?initial-scale=1.0">
??<title>Documenttitle>
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ā)送請(qǐng)求 http://bank.com/transfer?number=15000&to=Bob 進(jìn)行轉(zhuǎn)賬操作:

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

可以看到請(qǐng)求攜帶 cookie,并成功轉(zhuǎn)賬,這樣一次 CSRF 攻擊就完成了。當(dāng)然這是一次簡單的 GET 請(qǐng)求的攻擊,POST 請(qǐng)求攻擊可以通過自動(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。
針對(duì)以上特點(diǎn),我們就能進(jìn)行對(duì)應(yīng)的防范了。
CSRF 防范方法
CSRF 防范方法通常有以下幾種:
阻止不同域的訪問 同源檢測(cè)。 Samesite Cookie。 提交時(shí)要求附加本域才能獲取的信息。 添加 CSRF Token。 雙重 Cookie驗(yàn)證。
同源檢測(cè) - 通過 Origin 和 Referer 確定來源域名
針對(duì)第一個(gè)特點(diǎn)進(jìn)行域名檢查,HTTP 請(qǐng)求時(shí)會(huì)攜帶這兩個(gè) Header,用于標(biāo)記來源域名,如果請(qǐng)求來源不是本域,直接進(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,傳給用戶,用戶再次提交或者請(qǐng)求敏感操作時(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
??<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 所以攻擊失敗:

以上為加深理解而寫的代碼,而在生產(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è)方法也是很有效的,比如請(qǐng)求庫 axios 就是用的這種方式。
總結(jié)
到這里大家是不是已經(jīng)明白了 CSRF 攻擊的原因所在,并可以提出針對(duì)性的解決方案了呢,防范關(guān)鍵其實(shí)就是防止其他人冒充你去做只有你能做的敏感操作,與此同時(shí)希望大家對(duì)于這類抽象性的問題可以自己動(dòng)手敲一下,模擬一遍,用造重復(fù)輪子的方法去理解,動(dòng)手比動(dòng)眼管用的多。
以上過程和代碼僅僅為幫助學(xué)習(xí)并做演示使用,如果用于生產(chǎn)力還是需要更成熟的解決方案。
