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("#", "#", $string);
$string = str_replace("%", "%", $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() 函數調用,它會將 <、>、和 & 轉換為 <、>、和 &。儘管這會處理大部分的普通攻擊,但有經驗的 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:
- 到 http://pixel-apes.com/safehtml/?page=safehtml 下載最新版本的 SafeHTML。
- 把文件放到你伺服器的類文件夾。該文件夾包括 SafeHTML 和 HTMLSax 功能所需的所有東西。
- 在腳本中
include
SafeHTML 類文件(safehtml.php)。 - 創建一個名為 $safehtml 的新 SafeHTML 對象。
- 用 $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 多年來的密碼安全模型。單向密碼安全按照下面的方式工作:
- 當一個用戶或管理員創建或更改一個賬戶密碼時,系統對密碼進行哈希並保存結果。主機系統會丟棄明文密碼。
- 當用戶通過任何方式登錄到系統時,再次對輸入的密碼進行哈希。
- 主機系統丟棄輸入的明文密碼。
- 當前新哈希的密碼和之前保存的哈希相比較。
- 如果哈希的密碼相匹配,系統就會授予訪問許可權。
主機系統完成這些並不需要知道原始密碼;事實上,原始密碼完全無所謂。一個副作用是,如果某人侵入系統並盜取了密碼資料庫,入侵者會獲得很多哈希後的密碼,但無法把它們反向轉換為原始密碼。當然,給足夠時間、計算能力,以及弱用戶密碼,一個攻擊者還是有可能採用字典攻擊找出密碼。因此,別輕易讓人碰你的密碼資料庫,如果確實有人這樣做了,讓每個用戶更改他們的密碼。
加密 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_TWOFISH192、MCRYPT_RC2、MCRYPT_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) 協議下發布。
via: http://www.codeproject.com/Articles/363897/PHP-Security
作者:SamarRizvi 譯者:ictlyh 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive