PHP 安全程式設計建議

linux.cn發表於2016-09-16

簡介

要提供網際網路服務,當你在開發程式碼的時候必須時刻保持安全意識。可能大部分 PHP 指令碼都對安全問題都不在意,這很大程度上是因為有大量的無經驗程式設計師在使用這門語言。但是,沒有理由讓你因為對你的程式碼的不確定性而導致不一致的安全策略。當你在伺服器上放任何涉及到錢的東西時,就有可能會有人嘗試破解它。建立一個論壇程式或者任何形式的購物車,被攻擊的可能性就上升到了無窮大。

PHP 安全程式設計建議

背景

為了確保你的 web 內容安全,這裡有一些常規的安全準則:

別相信表單

攻擊表單很簡單。通過使用一個簡單的 JavaScript 技巧,你可以限制你的表單只允許在評分域中填寫 1 到 5 的數字。如果有人關閉了他們瀏覽器的 JavaScript 功能或者提交自定義的表單資料,你客戶端的驗證就失敗了。

使用者主要通過表單引數和你的指令碼互動,因此他們是最大的安全風險。你應該學到什麼呢?在 PHP 指令碼中,總是要驗證 傳遞給任何 PHP 指令碼的資料。在本文中,我們向你演示瞭如何分析和防範跨站指令碼(XSS)攻擊,它可能會劫持使用者憑據(甚至更嚴重)。你也會看到如何防止會玷汙或毀壞你資料的 MySQL 注入攻擊。

別相信使用者

假定你網站獲取的每一份資料都充滿了有害的程式碼。清理每一部分,即便你相信沒有人會嘗試攻擊你的站點。

關閉全域性變數

你可能會有的最大安全漏洞是啟用了 register_globals 配置引數。幸運的是,PHP 4.2 及以後版本預設關閉了這個配置。如果開啟了 register_globals,你可以在你的 php.ini 檔案中通過改變 register_globals 變數為 Off 關閉該功能:

register_globals = Off

新手程式設計師覺得註冊全域性變數很方便,但他們不會意識到這個設定有多麼危險。一個啟用了全域性變數的伺服器會自動為全域性變數賦任何形式的引數。為了瞭解它如何工作以及為什麼有危險,讓我們來看一個例子。

假設你有一個稱為 process.php 的指令碼,它會向你的資料庫插入表單資料。初始的表單像下面這樣:

<input name="username" type="text" size="15" maxlength="64">

執行 process.php 的時候,啟用了註冊全域性變數的 PHP 會將該引數賦值到 $username 變數。這會比通過 $_POST['username']$_GET['username'] 訪問它節省擊鍵次數。不幸的是,這也會給你留下安全問題,因為 PHP 會設定該變數的值為通過 GET 或 POST 的引數傳送到指令碼的任何值,如果你沒有顯示地初始化該變數並且你不希望任何人去操作它,這就會有一個大問題。

看下面的指令碼,假如 $authorized 變數的值為 true,它會給使用者顯示通過驗證的資料。正常情況下,只有當使用者正確通過了這個假想的 authenticated_user() 函式驗證,$authorized 變數的值才會被設定為真。但是如果你啟用了 register_globals,任何人都可以傳送一個 GET 引數,例如 authorized=1 去覆蓋它:

<?php
// Define $authorized = true only if user is authenticated
if (authenticated_user()) {
    $authorized = true;
}
?>

這個故事的寓意是,你應該從預定義的伺服器變數中獲取表單資料。所有通過 post 表單傳遞到你 web 頁面的資料都會自動儲存到一個稱為 $_POST 的大陣列中,所有的 GET 資料都儲存在 $_GET 大陣列中。檔案上傳資訊儲存在一個稱為 $_FILES 的特殊資料中。另外,還有一個稱為 $_REQUEST 的複合變數。

要從一個 POST 方法表單中訪問 username 欄位,可以使用 $_POST['username']。如果 username 在 URL 中就使用 $_GET['username']。如果你不確定值來自哪裡,用 $_REQUEST['username']

<?php
$post_value = $_POST['post_value'];
$get_value = $_GET['get_value'];
$some_variable = $_REQUEST['some_value']; 
?>

$_REQUEST 是 $_GET、$_POST、和 $_COOKIE 陣列的結合。如果你有兩個或多個值有相同的引數名稱,注意 PHP 會使用哪個。預設的順序是 cookie、POST、然後是 GET。

推薦安全配置選項

這裡有幾個會影響安全功能的 PHP 配置設定。下面是一些顯然應該用於生產伺服器的:

  • register_globals 設定為 off
  • safe_mode 設定為 off
  • error_reporting 設定為 off。如果出現錯誤了,這會向使用者瀏覽器傳送可見的錯誤報告資訊。對於生產伺服器,使用錯誤日誌代替。開發伺服器如果在防火牆後面就可以啟用錯誤日誌。(LCTT 譯註:此處據原文邏輯和常識,應該是“開發伺服器如果在防火牆後面就可以啟用錯誤報告,即 on。”)
  • 停用這些函式:system()、exec()、passthru()、shell_exec()、proc_open()、和 popen()。
  • open_basedir 為 /tmp(以便儲存會話資訊)目錄和 web 根目錄,以便指令碼不能訪問這些選定區域外的檔案。
  • expose_php 設定為 off。該功能會向 Apache 頭新增包含版本號的 PHP 簽名。
  • allow_url_fopen 設定為 off。如果你能夠注意你程式碼中訪問檔案的方式-也就是你驗證所有輸入引數,這並不嚴格需要。
  • allow_url_include 設定為 off。對於任何人來說,實在沒有明智的理由會想要訪問通過 HTTP 包含的檔案。

一般來說,如果你發現想要使用這些功能的程式碼,你就不應該相信它。尤其要小心會使用類似 system() 函式的程式碼-它幾乎肯定有缺陷。

啟用了這些設定後,讓我們來看看一些特定的攻擊以及能幫助你保護你伺服器的方法。

SQL 注入攻擊

由於 PHP 傳遞到 MySQL 資料庫的查詢語句是用強大的 SQL 程式語言編寫的,就有了某些人通過在 web 查詢引數中使用 MySQL 語句嘗試 SQL 注入攻擊的風險。通過在引數中插入有害的 SQL 程式碼片段,攻擊者會嘗試進入(或破壞)你的伺服器。

假如說你有一個最終會放入變數 $product 的表單引數,你使用了類似下面的 SQL 語句:

$sql = "select * from pinfo where product = '$product'";

如果引數是直接從表單中獲得的,應該使用 PHP 自帶的資料庫特定轉義函式,類似:

$sql = 'Select * from pinfo where product = '"' 
       mysql_real_escape_string($product) . '"';

如果不這樣做的話,有人也許會把下面的程式碼段放到表單引數中:

39'; DROP pinfo; SELECT 'FOO

那麼 $sql 的結果就是:

select product from pinfo where product = '39'; DROP pinfo; SELECT 'FOO'

由於分號是 MySQL 的語句分隔符,資料庫會執行下面三條語句:

select * from pinfo where product = '39'
DROP pinfo
SELECT 'FOO'

好了,你丟失了你的表。

注意實際上 PHP 和 MySQL 不會執行這種特殊語法,因為 mysql_query() 函式只允許每個請求處理一個語句。但是,一個子查詢仍然會生效。

要防止 SQL 注入攻擊,做這兩件事:

  • 總是驗證所有引數。例如,如果需要一個數字,就要確保它是一個數字。
  • 總是對資料使用 mysql_real_escape_string() 函式轉義資料中的任何引號和雙引號。

注意:要自動轉義任何表單資料,可以啟用魔術引號(Magic Quotes)。

一些 MySQL 破壞可以通過限制 MySQL 使用者許可權避免。任何 MySQL 賬戶可以限制為只允許對選定的表進行特定型別的查詢。例如,你可以建立只能選擇行的 MySQL 使用者。但是,這對於動態資料並不十分有用,另外,如果你有敏感的使用者資訊,可能某些人能訪問其中一些資料,但你並不希望如此。例如,一個訪問賬戶資料的使用者可能會嘗試注入訪問另一個人的賬戶號碼的程式碼,而不是為當前會話指定的號碼。

防止基本的 XSS 攻擊

XSS 表示跨站指令碼。不像大部分攻擊,該漏洞發生在客戶端。XSS 最常見的基本形式是在使用者提交的內容中放入 JavaScript 以便偷取使用者 cookie 中的資料。由於大部分站點使用 cookie 和 session 驗證訪客,偷取的資料可用於模擬該使用者-如果是一個常見的使用者賬戶就會深受麻煩,如果是管理員賬戶甚至是徹底的慘敗。如果你不在站點中使用 cookie 和 session ID,你的使用者就不容易被攻擊,但你仍然應該明白這種攻擊是如何工作的。

不像 MySQL 注入攻擊,XSS 攻擊很難預防。Yahoo、eBay、Apple、以及 Microsoft 都曾經受 XSS 影響。儘管攻擊不包含 PHP,但你可以使用 PHP 來剝離使用者資料以防止攻擊。為了防止 XSS 攻擊,你應該限制和過濾使用者提交給你站點的資料。正是因為這個原因,大部分線上公告板都不允許在提交的資料中使用 HTML 標籤,而是用自定義的標籤格式代替,例如 [b][linkto]

讓我們來看一個如何防止這類攻擊的簡單指令碼。對於更完善的解決辦法,可以使用 SafeHTML,本文的後面部分會討論到。

function transform_HTML($string, $length = null) {
// Helps prevent XSS attacks
    // Remove dead space.
    $string = trim($string);
    // Prevent potential Unicode codec problems.
    $string = utf8_decode($string);
    // HTMLize HTML-specific characters.
    $string = htmlentities($string, ENT_NOQUOTES);
    $string = str_replace("#", "&#35;", $string);
    $string = str_replace("%", "&#37;", $string);
    $length = intval($length);
    if ($length > 0) {
        $string = substr($string, 0, $length);
    }
    return $string;
}

這個函式將 HTML 特定的字元轉換為 HTML 字面字元。一個瀏覽器對任何通過這個指令碼的 HTML 以非標記的文字呈現。例如,考慮下面的 HTML 字串:

<STRONG>Bold Text</STRONG>

一般情況下,HTML 會顯示為:Bold Text

但是,通過 transform_HTML() 後,它就像原始輸入一樣呈現。原因是處理的字串中的標籤字串轉換為 HTML 實體。transform_HTML() 的結果字串的純文字看起來像下面這樣:

<STRONG>Bold Text</STRONG>

該函式的實質是 htmlentities() 函式呼叫,它會將 <、>、和 & 轉換為 &lt;&gt;、和 &amp;。儘管這會處理大部分的普通攻擊,但有經驗的 XSS 攻擊者有另一種把戲:用十六進位制或 UTF-8 編碼惡意指令碼,而不是採用普通的 ASCII 文字,從而希望能繞過你的過濾器。他們可以在 URL 的 GET 變數中傳送程式碼,告訴瀏覽器,“這是十六進位制程式碼,你能幫我執行嗎?” 一個十六進位制例子看起來像這樣:

<a href="http://host/a.php?variable=%22%3e %3c%53%43%52%49%50%54%3e%44%6f%73%6f%6d%65%74%68%69%6e%67%6d%61%6c%69%63%69%6f%75%73%3c%2f%53%43%52%49%50%54%3e">

瀏覽器渲染這個資訊的時候,結果就是:

<a href="http://host/a.php?variable="> <SCRIPT>Dosomethingmalicious</SCRIPT>

為了防止這種情況,transform_HTML() 採用額外的步驟把 # 和 % 符號轉換為它們的實體,從而避免十六進位制攻擊,並轉換 UTF-8 編碼的資料。

最後,為了防止某些人用很長的輸入超載字串從而導致某些東西崩潰,你可以新增一個可選的 $length 引數來擷取你指定最大長度的字串。

使用 SafeHTML

之前指令碼的問題比較簡單,它不允許任何型別的使用者標記。不幸的是,這裡有上百種方法能使 JavaScript 跳過使用者的過濾器,並且要從使用者輸入中剝離全部 HTML,還沒有方法可以防止這種情況。

當前,沒有任何一個指令碼能保證無法被破解,儘管有一些確實比大部分要好。有白名單和黑名單兩種方法加固安全,白名單比較簡單而且更加有效。

一個白名單解決方案是 PixelApes 的 SafeHTML 反跨站指令碼解析器。

SafeHTML 能識別有效 HTML,能追蹤並剝離任何危險標籤。它用另一個稱為 HTMLSax 的軟體包進行解析。

按照下面步驟安裝和使用 SafeHTML:

  1. http://pixel-apes.com/safehtml/?page=safehtml 下載最新版本的 SafeHTML。
  2. 把檔案放到你伺服器的類資料夾。該資料夾包括 SafeHTML 和 HTMLSax 功能所需的所有東西。
  3. 在指令碼中 include SafeHTML 類檔案(safehtml.php)。
  4. 建立一個名為 $safehtml 的新 SafeHTML 物件。
  5. 用 $safehtml->parse() 方法清理你的資料。

這是一個完整的例子:

<?php
/* If you're storing the HTMLSax3.php in the /classes directory, along
   with the safehtml.php script, define XML_HTMLSAX3 as a null string. */
define(XML_HTMLSAX3, '');
// Include the class file.
require_once('classes/safehtml.php');
// Define some sample bad code.
$data = "This data would raise an alert <script>alert('XSS Attack')</script>";
// Create a safehtml object.
$safehtml = new safehtml();
// Parse and sanitize the data.
$safe_data = $safehtml->parse($data);
// Display result.
echo 'The sanitized data is <br />' . $safe_data;
?>

如果你想清理指令碼中的任何其它資料,你不需要建立一個新的物件;在你的整個指令碼中只需要使用 $safehtml->parse() 方法。

什麼可能會出現問題?

你可能犯的最大錯誤是假設這個類能完全避免 XSS 攻擊。SafeHTML 是一個相當複雜的指令碼,幾乎能檢查所有事情,但沒有什麼是能保證的。你仍然需要對你的站點做引數驗證。例如,該類不能檢查給定變數的長度以確保能適應資料庫的欄位。它也不檢查緩衝溢位問題。

XSS 攻擊者很有創造力,他們使用各種各樣的方法來嘗試達到他們的目標。可以閱讀 RSnake 的 XSS 教程http://ha.ckers.org/xss.html ,看一下這裡有多少種方法嘗試使程式碼跳過過濾器。SafeHTML 專案有很好的程式設計師一直在嘗試阻止 XSS 攻擊,但無法保證某些人不會想起一些奇怪和新奇的方法來跳過過濾器。

注意:XSS 攻擊嚴重影響的一個例子 http://namb.la/popular/tech.html,其中顯示瞭如何一步一步建立一個讓 MySpace 伺服器過載的 JavaScript XSS 蠕蟲。

用單向雜湊保護資料

該指令碼對輸入的資料進行單向轉換,換句話說,它能對某人的密碼產生雜湊簽名,但不能解碼獲得原始密碼。為什麼你希望這樣呢?應用程式會儲存密碼。一個管理員不需要知道使用者的密碼,事實上,只有使用者知道他/她自己的密碼是個好主意。系統(也僅有系統)應該能識別一個正確的密碼;這是 Unix 多年來的密碼安全模型。單向密碼安全按照下面的方式工作:

  1. 當一個使用者或管理員建立或更改一個賬戶密碼時,系統對密碼進行雜湊並儲存結果。主機系統會丟棄明文密碼。
  2. 當使用者通過任何方式登入到系統時,再次對輸入的密碼進行雜湊。
  3. 主機系統丟棄輸入的明文密碼。
  4. 當前新雜湊的密碼和之前儲存的雜湊相比較。
  5. 如果雜湊的密碼相匹配,系統就會授予訪問許可權。

主機系統完成這些並不需要知道原始密碼;事實上,原始密碼完全無所謂。一個副作用是,如果某人侵入系統並盜取了密碼資料庫,入侵者會獲得很多雜湊後的密碼,但無法把它們反向轉換為原始密碼。當然,給足夠時間、計算能力,以及弱使用者密碼,一個攻擊者還是有可能採用字典攻擊找出密碼。因此,別輕易讓人碰你的密碼資料庫,如果確實有人這樣做了,讓每個使用者更改他們的密碼。

加密 Vs 雜湊

技術上來來說,雜湊過程並不是加密。雜湊和加密是不同的,這有兩個理由:

不像加密,雜湊資料不能被解密。

是有可能(但非常罕見)兩個不同的字串會產生相同的雜湊。並不能保證雜湊是唯一的,因此別像資料庫中的唯一鍵那樣使用雜湊。

function hash_ish($string) {
    return md5($string);
}

上面的 md5() 函式基於 RSA 資料安全公司的訊息摘要演算法(即 MD5)返回一個由 32 個字元組成的十六進位制串。然後你可以將那個 32 位字串插入到資料庫中和另一個 md5 字串相比較,或者直接用這 32 個字元。

破解指令碼

幾乎不可能解密 MD5 資料。或者說很難。但是,你仍然需要好的密碼,因為用一整個字典生成雜湊資料庫仍然很簡單。有一些線上 MD5 字典,當你輸入 06d80eb0c50b49a509b49f2424e8c805 後會得到結果 “dog”。因此,儘管技術上 MD5 不能被解密,這裡仍然有漏洞,如果某人獲得了你的密碼資料庫,你可以肯定他們肯定會使用 MD5 字典破譯。因此,當你建立基於密碼的系統的時候尤其要注意密碼長度(最小 6 個字元,8 個或許會更好)和包括字母和數字。並確保這個密碼不在字典中。

用 Mcrypt 加密資料

如果你不需要以可閱讀形式檢視密碼,採用 MD5 就足夠了。不幸的是,這裡並不總是有可選項,如果你提供以加密形式儲存某人的信用卡資訊,你可能需要在後面的某個地方進行解密。

最早的一個解決方案是 Mcrypt 模組,這是一個用於允許 PHP 高速加密的外掛。Mcrypt 庫提供了超過 30 種用於加密的計算方法,並且提供口令確保只有你(或者你的使用者)可以解密資料。

讓我們來看看使用方法。下面的指令碼包含了使用 Mcrypt 加密和解密資料的函式:

<?php
$data = "Stuff you want encrypted";
$key = "Secret passphrase used to encrypt your data";
$cipher = "MCRYPT_SERPENT_256";
$mode = "MCRYPT_MODE_CBC";
function encrypt($data, $key, $cipher, $mode) {
// Encrypt data
return (string)
            base64_encode
                (
                mcrypt_encrypt
                    (
                    $cipher,
                    substr(md5($key),0,mcrypt_get_key_size($cipher, $mode)),
                    $data,
                    $mode,
                    substr(md5($key),0,mcrypt_get_block_size($cipher, $mode))
                    )
                );
}
function decrypt($data, $key, $cipher, $mode) {
// Decrypt data
    return (string)
            mcrypt_decrypt
                (
                $cipher,
                substr(md5($key),0,mcrypt_get_key_size($cipher, $mode)),
                base64_decode($data),
                $mode,
                substr(md5($key),0,mcrypt_get_block_size($cipher, $mode))
                );
}
?>

mcrypt() 函式需要幾個資訊:

  • 需要加密的資料
  • 用於加密和解鎖資料的口令,也稱為鍵。
  • 用於加密資料的計算方法,也就是用於加密資料的演算法。該指令碼使用了 MCRYPT_SERPENT_256,但你可以從很多演算法中選擇,包括 MCRYPT_TWOFISH192MCRYPT_RC2MCRYPT_DES、和 MCRYPT_LOKI97
  • 加密資料的模式。這裡有幾個你可以使用的模式,包括電子密碼本(Electronic Codebook) 和加密反饋(Cipher Feedback)。該指令碼使用 MCRYPT_MODE_CBC 密碼塊連結。
  • 一個 初始化向量-也稱為 IV 或者種子,用於為加密演算法設定種子的額外二進位制位。也就是使演算法更難於破解的額外資訊。
  • 鍵和 IV 字串的長度,這可能隨著加密和塊而不同。使用 mcrypt_get_key_size()mcrypt_get_block_size() 函式獲取合適的長度;然後用 substr() 函式將鍵的值擷取為合適的長度。(如果鍵的長度比要求的短,別擔心,Mcrypt 會用 0 填充。)

如果有人竊取了你的資料和短語,他們只能一個個嘗試加密演算法直到找到正確的那一個。因此,在使用它之前我們通過對鍵使用 md5() 函式增加安全,就算他們獲取了資料和短語,入侵者也不能獲得想要的東西。

入侵者同時需要函式,資料和口令,如果真是如此,他們可能獲得了對你伺服器的完整訪問,你只能大清洗了。

這裡還有一個資料儲存格式的小問題。Mcrypt 以難懂的二進位制形式返回加密後的資料,這使得當你將其儲存到 MySQL 欄位的時候可能出現可怕錯誤。因此,我們使用 base64encode()base64decode() 函式轉換為和 SQL 相容的字母格式和可檢索行。

破解指令碼

除了實驗多種加密方法,你還可以在指令碼中新增一些便利。例如,不用每次都提供鍵和模式,而是在包含的檔案中宣告為全域性常量。

生成隨機密碼

隨機(但難以猜測)字串在使用者安全中很重要。例如,如果某人丟失了密碼並且你使用 MD5 雜湊,你不可能,也不希望查詢回來。而是應該生成一個安全的隨機密碼併傳送給使用者。為了訪問你站點的服務,另外一個用於生成隨機數字的應用程式會建立有效連結。下面是建立密碼的一個函式:

<?php
 function make_password($num_chars) {
    if ((is_numeric($num_chars)) &&
        ($num_chars > 0) &&
        (! is_null($num_chars))) {
        $password = '';
        $accepted_chars = 'abcdefghijklmnopqrstuvwxyz1234567890';
        // Seed the generator if necessary.
        srand(((int)((double)microtime()*1000003)) );
        for ($i=0; $i<=$num_chars; $i++) {
            $random_number = rand(0, (strlen($accepted_chars) -1));
            $password .= $accepted_chars[$random_number] ;
        }
        return $password;
     }
}
?>

使用指令碼

make_password() 函式返回一個字串,因此你需要做的就是提供字串的長度作為引數:

<?php
$fifteen_character_password = make_password(15);
?>

函式按照下面步驟工作:

  • 函式確保 $num_chars 是非零的正整數。
  • 函式初始化 $accepted_chars 變數為密碼可能包含的字元列表。該指令碼使用所有小寫字母和數字 0 到 9,但你可以使用你喜歡的任何字符集合。(LCTT 譯註:有時候為了便於肉眼識別,你可以將其中的 0 和 O,1 和 l 之類的都去掉。)
  • 隨機數生成器需要一個種子,從而獲得一系列類隨機值(PHP 4.2 及之後版本中並不需要,會自動播種)。
  • 函式迴圈 $num_chars 次,每次迭代生成密碼中的一個字元。
  • 對於每個新字元,指令碼檢視 $accepted_chars 的長度,選擇 0 和長度之間的一個數字,然後新增 $accepted_chars 中該數字為索引值的字元到 $password。
  • 迴圈結束後,函式返回 $password

許可證

本篇文章,包括相關的原始碼和檔案,都是在 The Code Project Open License (CPOL) 協議下發布。

相關文章