關於 Web 應用程序安全性,必須認識到的第一件事是不應該信任外部數據。外部數據(outside data) 包括不是由程序員在 PHP 代碼中直接輸入的任何數據。在采取措施確保安全之前,來自任何其他來源(比如 GET 變量、表單 POST、數據庫、配置文件、會話變量或 cookie)的任何數據都是不可信任的。
例如,下面的數據元素可以被認為是安全的,因為它們是在 PHP 中設置的。
<?php $myUsername = 'tmyer'; $arrayUsers = array('tmyer', 'tom', 'tommy'); define("GREETING", 'hello there' . $myUsername); ?>
但是,下面的數據元素都是有瑕疵的。
<?php $myUsername = $_POST['username']; //tainted! $arrayUsers = array($myUsername, 'tom', 'tommy'); //tainted! define("GREETING", 'hello there' . $myUsername); //tainted! ?>
為什麼第一個變量 $myUsername 是有瑕疵的?因為它直接來自表單 POST。用戶可以在這個輸入域中輸入任何字符串,包括用來清除文件或運行以前上傳的文件的惡意命令。您可能會問,“難道不能使用只接受字母 A-Z 的客戶端(JavaScript)表單檢驗腳本來避免這種危險嗎?”是的,這總是一個有好處的步驟,但是正如在後面會看到的,任何人都可以將任何表單下載到自己的機器上,修改它,然後重新提交他們需要的任何內容。
解決方案很簡單:必須對 $_POST['username'] 運行清理代碼。如果不這麼做,那麼在使用 $myUsername 的任何其他時候(比如在數組或常量中),就可能污染這些對象。
對用戶輸入進行清理的一個簡單方法是,使用正則表達式來處理它。在這個示例中,只希望接受字母。將字符串限制為特定數量的字符,或者要求所有字母都是小寫的,這可能也是個好主意。
<?php $myUsername = cleanInput($_POST['username']); //clean! $arrayUsers = array($myUsername, 'tom', 'tommy'); //clean! define("GREETING", 'hello there' . $myUsername); //clean! function cleanInput($input){ $clean = strtolower($input); $clean = preg_replace("/[^a-z]/", "", $clean); $clean = substr($clean,0,12); return $clean; } ?>
已經知道了不能信任用戶輸入,還應該知道不應該信任機器上配置 PHP 的方式。例如,要確保禁用 register_globals。如果啟用了 register_globals,就可能做一些粗心的事情,比如使用 $variable 替換同名的 GET 或 POST 字符串。通過禁用這個設置,PHP 強迫您在正確的名稱空間中引用正確的變量。要使用來自表單 POST 的變量,應該引用 $_POST['variable']。這樣就不會將這個特定變量誤會成 cookie、會話或 GET 變量。
一些開發人員使用奇怪的語法,或者將語句組織得很緊湊,形成簡短但是含義模糊的代碼。這種方式可能效率高,但是如果您不理解代碼正在做什麼,那麼就無法決定如何保護它。
例如,您喜歡下面兩段代碼中的哪一段?
<?php //obfuscated code $input = (isset($_POST['username']) ? $_POST['username']:''); //unobfuscated code $input = ''; if (isset($_POST['username'])){ $input = $_POST['username']; }else{ $input = ''; } ?>
在第二個比較清晰的代碼段中,很容易看出 $input 是有瑕疵的,需要進行清理,然後才能安全地處理。
本教程將用示例來說明如何保護在線表單,同時在處理表單的 PHP 代碼中采用必要的措施。同樣,即使使用 PHP regex 來確保 GET 變量完全是數字的,仍然可以采取措施確保 SQL 查詢使用轉義的用戶輸入。
縱深防御不只是一種好思想,它可以確保您不會陷入嚴重的麻煩。
既然已經討論了基本規則,現在就來研究第一種威脅:SQL 注入攻擊。
在 SQL 注入攻擊 中,用戶通過操縱表單或 GET 查詢字符串,將信息添加到數據庫查詢中。例如,假設有一個簡單的登錄數據庫。這個數據庫中的每個記錄都有一個用戶名字段和一個密碼字段。構建一個登錄表單,讓用戶能夠登錄。
下面是一個簡單的登錄表單:
<html> <head> <title>Login</title> </head> <body> <form action="verify.php" method="post"> <p><label for='user'>Username</label> <input type='text' name='user' id='user'/> </p> <p><label for='pw'>Password</label> <input type='password' name='pw' id='pw'/> </p> <p><input type='submit' value='login'/></p> </form> </body> </html>
這個表單接受用戶輸入的用戶名和密碼,並將用戶輸入提交給名為 verify.php 的文件。在這個文件中,PHP 處理來自登錄表單的數據,如下所示:
<?php $okay = 0; $username = $_POST['user']; $pw = $_POST['pw']; $sql = "select count(*) as ctr from users where username='".$username."' and password='". $pw."' limit 1"; $result = mysql_query($sql); while ($data = mysql_fetch_object($result)){ if ($data->ctr == 1){ //they're okay to enter the application! $okay = 1; } } if ($okay){ $_SESSION['loginokay'] = true; header("index.php"); }else{ header("login.php"); } ?>
這段代碼看起來沒問題,對嗎?世界各地成百(甚至成千)的 PHP/MySQL 站點都在使用這樣的代碼。它錯在哪裡?好,記住 “不能信任用戶輸入”。這裡沒有對來自用戶的任何信息進行轉義,因此使應用程序容易受到攻擊。具體來說,可能會出現任何類型的 SQL 注入攻擊。例如,如果用戶輸入 foo 作為用戶名,輸入 ' or '1'='1 作為密碼,那麼實際上會將以下字符串傳遞給 PHP,然後將查詢傳遞給 MySQL:
<?php $sql = "select count(*) as ctr from users where username='foo' and password='' or '1'='1' limit 1"; ?>
這個查詢總是返回計數值 1,因此 PHP 會允許進行訪問。通過在密碼字符串的末尾注入某些惡意 SQL,黑客就能裝扮成合法的用戶。
解決這個問題的辦法是,將 PHP 的內置 mysql_real_escape_string() 函數用作任何用戶輸入的包裝器。這個函數對字符串中的字符進行轉義,使字符串不可能傳遞撇號等特殊字符並讓 MySQL 根據特殊字符進行操作。下面展示了帶轉義處理的代碼。
<?php $okay = 0; $username = $_POST['user']; $pw = $_POST['pw']; $sql = "select count(*) as ctr from users where username='".mysql_real_escape_string($username)."' and password='". mysql_real_escape_string($pw)."' limit 1"; $result = mysql_query($sql); while ($data = mysql_fetch_object($result)){ if ($data->ctr == 1){ //they're okay to enter the application! $okay = 1; } } if ($okay){ $_SESSION['loginokay'] = true; header("index.php"); }else{ header("login.php"); } ?>
使用 mysql_real_escape_string() 作為用戶輸入的包裝器,就可以避免用戶輸入中的任何惡意 SQL 注入。如果用戶嘗試通過 SQL 注入傳遞畸形的密碼,那麼會將以下查詢傳遞給數據庫:
select count(*) as ctr from users where username='foo' and password='\' or \'1\'=\'1' limit 1"
數據庫中沒有任何東西與這樣的密碼匹配。僅僅采用一個簡單的步驟,就堵住了 Web 應用程序中的一個大漏洞。這裡得出的經驗是,總是應該對 SQL 查詢的用戶輸入進行轉義。
但是,還有幾個安全漏洞需要堵住。下一項是操縱 GET 變量。
防止用戶操縱 GET 變量
上面我們探討了,防止了用戶使用畸形的密碼進行登錄。如果您很聰明,應該應用您學到的方法,確保對 SQL 語句的所有用戶輸入進行轉義。但是,用戶現在已經安全地登錄了。用戶擁有有效的密碼,並不意味著他將按照規則行事 —— 他有很多機會能夠造成損害。例如,應用程序可能允許用戶查看特殊的內容。所有鏈接指向 template.php?pid=33 或 template.php?pid=321 這樣的位置。URL 中問號後面的部分稱為查詢字符串。因為查詢字符串直接放在 URL 中,所以也稱為 GET 查詢字符串。
在 PHP 中,如果禁用了 register_globals,那麼可以用 $_GET['pid'] 訪問這個字符串。
<?php $pid = $_GET['pid']; //we create an object of a fictional class Page $obj = new Page; $content = $obj->fetchPage($pid); //and now we have a bunch of PHP that displays the page ?>
這裡有什麼錯嗎?首先,這裡隱含地相信來自浏覽器的 GET 變量 pid 是安全的。這會怎麼樣呢?大多數用戶沒那麼聰明,無法構造出語義攻擊。但是,如果他們注意到浏覽器的 URL 位置域中的 pid=33,就可能開始搗亂。如果他們輸入另一個數字,那麼可能沒問題;但是如果輸入別的東西,比如輸入 SQL 命令或某個文件的名稱(比如 /etc/passwd),或者搞別的惡作劇,比如輸入長達 3,000 個字符的數值,那麼會發生什麼呢?
在這種情況下,要記住基本規則,不要信任用戶輸入。應用程序開發人員知道 template.php 接受的個人標識符(PID)應該是數字,所以可以使用 PHP 的 is_numeric() 函數確保不接受非數字的 PID,如下所示:
<?php $pid = $_GET['pid']; if (is_numeric($pid)){ //we create an object of a fictional class Page $obj = new Page; $content = $obj->fetchPage($pid); //and now we have a bunch of PHP that displays the page }else{ //didn't pass the is_numeric() test, do something else! } ?>
這個方法似乎是有效的,但是以下這些輸入都能夠輕松地通過 is_numeric() 的檢查:
那麼,有安全意識的 PHP 開發人員應該怎麼做呢?多年的經驗表明,最好的做法是使用正則表達式來確保整個 GET 變量由數字組成,如下所示:
使用正則表達式限制 GET 變量:
<?php $pid = $_GET['pid']; if (strlen($pid)){ if (!ereg("^[0-9]+$",$pid)){ //do something appropriate, like maybe logging them out or sending them back to home page } }else{ //empty $pid, so send them back to the home page } //we create an object of a fictional class Page, which is now //moderately protected from evil user input $obj = new Page; $content = $obj->fetchPage($pid); //and now we have a bunch of PHP that displays the page ?>
需要做的只是使用 strlen() 檢查變量的長度是否非零;如果是,就使用一個全數字正則表達式來確保數據元素是有效的。如果 PID 包含字母、斜線、點號或任何與十六進制相似的內容,那麼這個例程捕獲它並將頁面從用戶活動中屏蔽。如果看一下 Page 類幕後的情況,就會看到有安全意識的 PHP 開發人員已經對用戶輸入 $pid 進行了轉義,從而保護了 fetchPage() 方法,如下所示:
對 fetchPage() 方法進行轉義:
<?php class Page{ function fetchPage($pid){ $sql = "select pid,title,desc,kw,content,status from page where pid='".mysql_real_escape_string($pid)."'"; } } ?>
您可能會問,“既然已經確保 PID 是數字,那麼為什麼還要進行轉義?” 因為不知道在多少不同的上下文和情況中會使用 fetchPage() 方法。必須在調用這個方法的所有地方進行保護,而方法中的轉義體現了縱深防御的意義。
如果用戶嘗試輸入非常長的數值,比如長達 1000 個字符,試圖發起緩沖區溢出攻擊,那麼會發生什麼呢?下一節更詳細地討論這個問題,但是目前可以添加另一個檢查,確保輸入的 PID 具有正確的長度。您知道數據庫的 pid 字段的最大長度是 5 位,所以可以添加下面的檢查。
使用正則表達式和長度檢查來限制 GET 變量:
<?php $pid = $_GET['pid']; if (strlen($pid)){ if (!ereg("^[0-9]+$",$pid) && strlen($pid) > 5){ //do something appropriate, like maybe logging them out or sending them back to home page } } else { //empty $pid, so send them back to the home page } //we create an object of a fictional class Page, which is now //even more protected from evil user input $obj = new Page; $content = $obj->fetchPage($pid); //and now we have a bunch of PHP that displays the page ?>
現在,任何人都無法在數據庫應用程序中塞進一個 5,000 位的數值 —— 至少在涉及 GET 字符串的地方不會有這種情況。想像一下黑客在試圖突破您的應用程序而遭到挫折時咬牙切齒的樣子吧!而且因為關閉了錯誤報告,黑客更難進行偵察。
緩沖區溢出攻擊
緩沖區溢出攻擊 試圖使 PHP 應用程序中(或者更精確地說,在 Apache 或底層操作系統中)的內存分配緩沖區發生溢出。請記住,您可能是使用 PHP 這樣的高級語言來編寫 Web 應用程序,但是最終還是要調用 C(在 Apache 的情況下)。與大多數低級語言一樣,C 對於內存分配有嚴格的規則。
緩沖區溢出攻擊向緩沖區發送大量數據,使部分數據溢出到相鄰的內存緩沖區,從而破壞緩沖區或者重寫邏輯。這樣就能夠造成拒絕服務、破壞數據或者在遠程服務器上執行惡意代碼。
防止緩沖區溢出攻擊的惟一方法是檢查所有用戶輸入的長度。例如,如果有一個表單元素要求輸入用戶的名字,那麼在這個域上添加值為 40 的 maxlength 屬性,並在後端使用 substr() 進行檢查。下面給出表單和 PHP 代碼的簡短示例。
<?php if ($_POST['submit'] == "go"){ $name = substr($_POST['name'],0,40); } ?> <form action="<?php echo $_SERVER['PHP_SELF'];?>" method="post"> <p><label for="name">Name</label> <input type="text" name="name" id="name" size="20" maxlength="40"/></p> <p><input type="submit" name="submit" value="go"/></p> </form>
為什麼既提供 maxlength 屬性,又在後端進行 substr() 檢查?因為縱深防御總是好的。浏覽器防止用戶輸入 PHP 或 MySQL 不能安全地處理的超長字符串(想像一下有人試圖輸入長達 1,000 個字符的名稱),而後端 PHP 檢查會確保沒有人遠程地或者在浏覽器中操縱表單數據。
正如您看到的,這種方式與前面使用 strlen() 檢查 GET 變量 pid 的長度相似。在這個示例中,忽略長度超過 5 位的任何輸入值,但是也可以很容易地將值截短到適當的長度,如下改變輸入的 GET 變量的長度所示:
<?php $pid = $_GET['pid']; if (strlen($pid)){ if (!ereg("^[0-9]+$",$pid)){ //if non numeric $pid, send them back to home page } }else{ //empty $pid, so send them back to the home page } //we have a numeric pid, but it may be too long, so let's check if (strlen($pid)>5){ $pid = substr($pid,0,5); } //we create an object of a fictional class Page, which is now //even more protected from evil user input $obj = new Page; $content = $obj->fetchPage($pid); //and now we have a bunch of PHP that displays the page ?>
注意,緩沖區溢出攻擊並不限於長的數字串或字母串。也可能會看到長的十六進制字符串(往往看起來像 \xA3 或 \xFF)。記住,任何緩沖區溢出攻擊的目的都是淹沒特定的緩沖區,並將惡意代碼或指令放到下一個緩沖區中,從而破壞數據或執行惡意代碼。對付十六進制緩沖區溢出最簡單的方法也是不允許輸入超過特定的長度。
如果您處理的是允許在數據庫中輸入較長條目的表單文本區,那麼無法在客戶端輕松地限制數據的長度。在數據到達 PHP 之後,可以使用正則表達式清除任何像十六進制的字符串。
防止十六進制字符串:
<?php if ($_POST['submit'] == "go"){ $name = substr($_POST['name'],0,40); //clean out any potential hexadecimal characters $name = cleanHex($name); //continue processing.... } function cleanHex($input){ $clean = preg_replace("![\][xX]([A-Fa-f0-9]{1,3})!", "",$input); return $clean; } ?> <form action="<?php echo $_SERVER['PHP_SELF'];?>" method="post"> <p><label for="name">Name</label> <input type="text" name="name" id="name" size="20" maxlength="40"/></p> <p><input type="submit" name="submit" value="go"/></p> </form>
您可能會發現這一系列操作有點兒太嚴格了。畢竟,十六進制串有合法的用途,比如輸出外語中的字符。如何部署十六進制 regex 由您自己決定。比較好的策略是,只有在一行中包含過多十六進制串時,或者字符串的字符超過特定數量(比如 128 或 255)時,才刪除十六進制串。
跨站點腳本攻擊
在跨站點腳本(XSS)攻擊中,往往有一個惡意用戶在表單中(或通過其他用戶輸入方式)輸入信息,這些輸入將惡 意的客戶端標記插入過程或數據庫中。例如,假設站點上有一個簡單的來客登記簿程序,讓訪問者能夠留下姓名、電子郵件地址和簡短的消息。惡意用戶可以利用這 個機會插入簡短消息之外的東西,比如對於其他用戶不合適的圖片或將用戶重定向到另一個站點的 Javascrīpt,或者竊取 cookie 信息。幸運的是,PHP 提供了 strip_tags() 函數,這個函數可以清除任何包圍在 HTML 標記中的內容。strip_tags() 函數還允許提供允許標記的列表,比如 <b> 或 <i>。
浏覽器內的數據操縱
有一類浏覽器插件允許用戶篡改頁面上的頭部元素和表單元素。使用 Tamper Data(一個 Mozilla 插件),可以很容易地操縱包含許多隱藏文本字段的簡單表單,從而向 PHP 和 MySQL 發送指令。
用戶在點擊表單上的 Submit 之前,他可以啟動 Tamper Data。在提交表單時,他會看到表單數據字段的列表。Tamper Data 允許用戶篡改這些數據,然後浏覽器完成表單提交。
讓我們回到前面建立的示例。已經檢查了字符串長度、清除了 HTML 標記並刪除了十六進制字符。但是,添加了一些隱藏的文本字段,如下所示:
<?php if ($_POST['submit'] == "go"){ //strip_tags $name = strip_tags($_POST['name']); $name = substr($name,0,40); //clean out any potential hexadecimal characters $name = cleanHex($name); //continue processing.... } function cleanHex($input){ $clean = preg_replace("![\][xX]([A-Fa-f0-9]{1,3})!", "",$input); return $clean; } ?> <form action="<?php echo $_SERVER['PHP_SELF'];?>" method="post"> <p><label for="name">Name</label> <input type="text" name="name" id="name" size="20" maxlength="40"/></p> <input type="hidden" name="table" value="users"/> <input type="hidden" name="action" value="create"/> <input type="hidden" name="status" value="live\"/> <p><input type="submit" name="submit" value="go"/></p> </form>
注意,隱藏變量之一暴露了表名:users。還會看到一個值為 create 的 action 字段。只要有基本的 SQL 經驗,就能夠看出這些命令可能控制著中間件中的一個 SQL 引擎。想搞大破壞的人只需改變表名或提供另一個選項,比如 delete。
現在還剩下什麼問題呢?遠程表單提交。
遠程表單提交
Web 的好處是可以分享信息和服務。壞處也是可以分享信息和服務,因為有些人做事毫無顧忌。
以表單為例。任何人都能夠訪問一個 Web 站點,並使用浏覽器上的 File > Save As 建立表單的本地副本。然後,他可以修改 action 參數來指向一個完全限定的 URL(不指向 formHandler.php,而是指向 http://www.bkjia.com/formHandler.php,因為表單在這個站點上),做他希望的任何修改,點擊 Submit,服務器會把這個表單數據作為合法通信流接收。
首先可能考慮檢查 $_SERVER['HTTP_REFERER'],從而判斷請求是否來自自己的服務器,這種方法可以擋住大多數惡意用戶,但是擋不住最高明的黑客。這些人足夠聰明,能夠篡改頭部中的引用者信息,使表單的遠程副本看起來像是從您的服務器提交的。
處理遠程表單提交更好的方式是,根據一個惟一的字符串或時間戳生成一個令牌,並將這個令牌放在會話變量和表單中。提交表單之後,檢查兩個令牌是否匹配。如果不匹配,就知道有人試圖從表單的遠程副本發送數據。
要創建隨機的令牌,可以使用 PHP 內置的 md5()、uniqid() 和 rand() 函數,如下所示:
<?php session_start(); if ($_POST['submit'] == "go"){ //check token if ($_POST['token'] == $_SESSION['token']){ //strip_tags $name = strip_tags($_POST['name']); $name = substr($name,0,40); //clean out any potential hexadecimal characters $name = cleanHex($name); //continue processing.... }else{ //stop all processing! remote form posting attempt! } } $token = md5(uniqid(rand(), true)); $_SESSION['token']= $token; function cleanHex($input){ $clean = preg_replace("![\][xX]([A-Fa-f0-9]{1,3})!", "",$input); return $clean; } ?> <form action="<?php echo $_SERVER['PHP_SELF'];?>" method="post"> <p><label for="name">Name</label> <input type="text" name="name" id="name" size="20" maxlength="40"/></p> <input type="hidden" name="token" value="<?php echo $token;?>"/> <p><input type="submit" name="submit" value="go"/></p> </form>
這種技術是有效的,這是因為在 PHP 中會話數據無法在服務器之間遷移。即使有人獲得了您的 PHP 源代碼,將它轉移到自己的服務器上,並向您的服務器提交信息,您的服務器接收的也只是空的或畸形的會話令牌和原來提供的表單令牌。它們不匹配,遠程表單提交就失敗了。