如何編寫安全的PHP代碼

毫不要信任外部數據或輸入php

關於 Web 應用程序安全性,必須認識到的第一件事是不該該信任外部數據。外部數據(outside data) 包括不是由程序員在 PHP 代碼中直接輸入的任何數據。在採起措施確保安全以前,來自任何其餘來源(好比 GET 變量、表單 POST、數據庫、配置文件、會話變量或 cookie)的任何數據都是不可信任的。html

例如,下面的數據元素能夠被認爲是安全的,由於它們是在 PHP 中設置的。mysql

<?php程序員

$myUsername = 'tmyer';正則表達式

$arrayUsers = array('tmyer', 'tom', 'tommy');sql

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 設置

已經知道了不能信任用戶輸入,還應該知道不該該信任機器上配置 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 查詢的用戶輸入進行轉義。

(注:mysql_real_escape_string函數將在php7中被移除,推薦使用更加安全的PDO_MySQL

可是,還有幾個安全漏洞須要堵住。下一項是操縱 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() 的檢查:

100 (有效)

100.1 (不該該有小數位)

+0123.45e6 (科學計數法 —— 很差)

0xff33669f (十六進制 —— 危險!危險!)

那麼,有安全意識的 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.nowamagic.net/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 源代碼,將它轉移到本身的服務器上,並向您的服務器提交信息,您的服務器接收的也只是空的或畸形的會話令牌和原來提供的表單令牌。它們不匹配,遠程表單提交就失敗了。

相關文章
相關標籤/搜索