<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://euriconicacio.github.io/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://euriconicacio.github.io/blog/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-08-14T01:21:12+00:00</updated><id>https://euriconicacio.github.io/blog/feed.xml</id><title type="html">h3llh0und</title><subtitle>Long-form field notes by Eurico Nicacio (h3llh0und) — offensive security, cloud forensics, and what happens when you point an agent at real work.</subtitle><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><entry><title type="html">CVE-2026-73530: reaching loopback through the IPv6 unspecified address</title><link href="https://euriconicacio.github.io/blog/cve-2026-73530-reaching-loopback-through-the-ipv6-unspecified-address/" rel="alternate" type="text/html" title="CVE-2026-73530: reaching loopback through the IPv6 unspecified address" /><published>2026-08-13T23:00:00+00:00</published><updated>2026-08-13T23:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/cve-2026-73530-reaching-loopback-through-the-ipv6-unspecified-address</id><content type="html" xml:base="https://euriconicacio.github.io/blog/cve-2026-73530-reaching-loopback-through-the-ipv6-unspecified-address/"><![CDATA[<blockquote>
  <p><strong><em>Disclaimer.</em></strong> <em>This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to a marker service on my own loopback interface. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory and the fixed release were public.</em></p>
</blockquote>

<p>The <code class="language-plaintext highlighter-rouge">flyto-core</code> project published <a href="https://github.com/flytohub/flyto-core/security/advisories/GHSA-gc4h-hj7x-gp5p">GHSA-gc4h-hj7x-gp5p</a> — a Server-Side Request Forgery (CWE-918) guard bypass, rated High at <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N</code>, 7.7. It is indexed as <a href="https://www.cve.org/CVERecord?id=CVE-2026-73530"><strong>CVE-2026-73530</strong></a>. I reported it on 9 August; the advisory and the fix in 2.28.0 went out on the 13th.</p>

<p>The two records live in different places and neither links to the other, so both are worth citing: the <a href="https://github.com/flytohub/flyto-core/security/advisories/GHSA-gc4h-hj7x-gp5p">GitHub advisory</a> for the technical detail and the affected ranges, and the <a href="https://www.cve.org/CVERecord?id=CVE-2026-73530">CVE record</a> for the identifier your scanner or ticket is keyed on.</p>

<p><strong>Upgrade to 2.28.0.</strong> Everything up to and including 2.27.0 is affected.</p>

<p>The bug is one address missing from a denylist. What makes it worth a write-up is where that denylist sits, and why three separate layers of a careful guard each had a good reason to let this particular address through.</p>

<h2 id="where-the-guard-sits">Where the guard sits</h2>

<p>Flyto2 Core is the open-source execution engine behind Flyto2 — a runtime for AI-agent and automation workflows, exposing several hundred modules that agents call as tools over MCP, or that run from YAML recipes. Three of those modules are <code class="language-plaintext highlighter-rouge">http.get</code>, <code class="language-plaintext highlighter-rouge">http.request</code> and <code class="language-plaintext highlighter-rouge">http.batch</code>: a caller hands over a URL, the engine fetches it and returns the response.</p>

<p>That is a Server-Side Request Forgery sink by construction, and the project treats it as one. Every one of those modules routes its URL through <code class="language-plaintext highlighter-rouge">validate_url_ssrf</code> in <code class="language-plaintext highlighter-rouge">src/core/utils.py</code> before a socket is opened, and the guard is the thing standing between a caller-supplied URL and whatever the host running the engine can reach on its own interfaces.</p>

<p>It matters who the caller is. In this deployment shape the URL frequently is not written by the operator: it arrives in a recipe, or it is chosen by a model calling the module as a tool, possibly influenced by a page that model just scraped. The guard exists precisely because that input is not trusted. So a bypass of it is not a step towards the impact — the bypass <em>is</em> the impact.</p>

<h2 id="the-two-lists">The two lists</h2>

<p>The predicate at the centre of the guard is <code class="language-plaintext highlighter-rouge">is_private_ip</code>, and it works off a hand-maintained list of ranges:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">PRIVATE_IP_RANGES</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'10.0.0.0/8'</span><span class="p">),</span>        <span class="c1"># RFC 1918 Class A
</span>    <span class="p">...</span>
    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'127.0.0.0/8'</span><span class="p">),</span>       <span class="c1"># Loopback
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'169.254.0.0/16'</span><span class="p">),</span>    <span class="c1"># Link-local
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'0.0.0.0/8'</span><span class="p">),</span>         <span class="c1"># Current network   &lt;-- IPv4 covered
</span>    <span class="p">...</span>
    <span class="c1"># IPv6
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'::1/128'</span><span class="p">),</span>           <span class="c1"># Loopback
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'fc00::/7'</span><span class="p">),</span>          <span class="c1"># Unique local
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'fe80::/10'</span><span class="p">),</span>         <span class="c1"># Link-local
</span>    <span class="n">ipaddress</span><span class="p">.</span><span class="n">ip_network</span><span class="p">(</span><span class="s">'ff00::/8'</span><span class="p">),</span>          <span class="c1"># Multicast
</span><span class="p">]</span>                                              <span class="c1"># &lt;-- no ::/128
</span></code></pre></div></div>

<p>A second layer denies hostnames by string:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">BLOCKED_HOSTNAMES</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">'localhost'</span><span class="p">,</span>
    <span class="s">'localhost.localdomain'</span><span class="p">,</span>
    <span class="s">'127.0.0.1'</span><span class="p">,</span>
    <span class="s">'::1'</span><span class="p">,</span>
    <span class="s">'0.0.0.0'</span><span class="p">,</span>                                 <span class="c1"># &lt;-- no '::'
</span>    <span class="s">'metadata.google.internal'</span><span class="p">,</span>
    <span class="s">'169.254.169.254'</span><span class="p">,</span>
    <span class="s">'metadata.internal'</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each list is reasonable read on its own. Put the IPv4 entries next to the IPv6 entries and the asymmetry is the whole finding: the IPv4 unspecified address, <code class="language-plaintext highlighter-rouge">0.0.0.0</code>, is blocked twice over — by name in the hostname set, and by range via <code class="language-plaintext highlighter-rouge">0.0.0.0/8</code>. Its IPv6 twin, <code class="language-plaintext highlighter-rouge">::</code>, is on neither list.</p>

<p>That is the only reading trick involved. Not “does this list look complete”, which is unanswerable, but <strong>“what is on one side of this list and not the other”</strong>, which you can answer line by line.</p>

<h2 id="why--reaches-the-local-host">Why <code class="language-plaintext highlighter-rouge">::</code> reaches the local host</h2>

<p><code class="language-plaintext highlighter-rouge">0.0.0.0</code> and <code class="language-plaintext highlighter-rouge">::</code> are <em>unspecified</em> addresses. As a bind target they mean “every interface”. As a connect target they are not routable at all, so the stack substitutes loopback and the connection lands on the local host. That is what makes <code class="language-plaintext highlighter-rouge">http://0.0.0.0:8080/</code> a well-known SSRF payload, and why <code class="language-plaintext highlighter-rouge">0.0.0.0</code> is on every denylist worth the name. <code class="language-plaintext highlighter-rouge">::</code> does exactly the same thing on IPv6. It is simply less famous.</p>

<p>The entire finding rests on that premise, so I verified it rather than deriving it. With a marker service bound to <code class="language-plaintext highlighter-rouge">::</code>, raw connects to each candidate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>::1                  -&gt; connected, resp=b'HTTP/1.0 200 OK\r\nSer'
::                   -&gt; connected, resp=b'HTTP/1.0 200 OK\r\nSer'
::ffff:0:7f00:1      -&gt; TimeoutError: timed out
</code></pre></div></div>

<h2 id="three-good-reasons-to-miss-one-address">Three good reasons to miss one address</h2>

<p>There is a third layer, and it is the reason this guard is better than most. <code class="language-plaintext highlighter-rouge">_extract_embedded_ipv4</code> unwraps IPv6 transition forms — IPv4-mapped, IPv4-compatible, 6to4, NAT64 — and range-checks whatever IPv4 address falls out. That is what makes <code class="language-plaintext highlighter-rouge">::ffff:127.0.0.1</code>, <code class="language-plaintext highlighter-rouge">2002:7f00:1::</code> and <code class="language-plaintext highlighter-rouge">64:ff9b::a9fe:a9fe</code> (NAT64 for <code class="language-plaintext highlighter-rouge">169.254.169.254</code>) all resolve to something already on the denylist. Somebody sat down and thought about IPv6 encodings properly.</p>

<p>And that layer skips <code class="language-plaintext highlighter-rouge">::</code> on purpose:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1"># IPv4-compatible ::a.b.c.d (deprecated), excluding :: and ::1
</span>    <span class="k">if</span> <span class="n">raw</span><span class="p">[:</span><span class="mi">12</span><span class="p">]</span> <span class="o">==</span> <span class="nb">bytes</span><span class="p">(</span><span class="mi">12</span><span class="p">)</span> <span class="ow">and</span> <span class="n">raw</span><span class="p">[</span><span class="mi">12</span><span class="p">:]</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="nb">bytes</span><span class="p">(</span><span class="mi">4</span><span class="p">),</span> <span class="sa">b</span><span class="s">'</span><span class="se">\x00\x00\x00\x01</span><span class="s">'</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">ipaddress</span><span class="p">.</span><span class="n">IPv4Address</span><span class="p">(</span><span class="n">raw</span><span class="p">[</span><span class="o">-</span><span class="mi">4</span><span class="p">:])</span>
    <span class="k">return</span> <span class="bp">None</span>
</code></pre></div></div>

<p>That exclusion is <em>correct in its own terms</em>. <code class="language-plaintext highlighter-rouge">::</code> carries no meaningful embedded IPv4, and unwrapping it to <code class="language-plaintext highlighter-rouge">0.0.0.0</code> would be a category error. The function’s job is translating transition forms, and <code class="language-plaintext highlighter-rouge">::</code> is not one.</p>

<p>So the address falls through all three layers for three individually defensible reasons: not in the range list, not in the hostname set, not a transition form. <code class="language-plaintext highlighter-rouge">is_private_ip('::')</code> returns <code class="language-plaintext highlighter-rouge">False</code>, and the final gate hands the URL back as valid:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="k">for</span> <span class="n">ip</span> <span class="ow">in</span> <span class="n">resolved_ips</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">is_private_ip</span><span class="p">(</span><span class="n">ip</span><span class="p">):</span>
            <span class="k">raise</span> <span class="n">SSRFError</span><span class="p">(</span><span class="sa">f</span><span class="s">"URL resolves to private IP: </span><span class="si">{</span><span class="n">hostname</span><span class="si">}</span><span class="s"> -&gt; </span><span class="si">{</span><span class="n">ip</span><span class="si">}</span><span class="s">. ..."</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">url</span>
</code></pre></div></div>

<p>This is the interesting shape of the bug, and the reason it is not a story about someone forgetting IPv6 — they plainly did not. It is <strong>a value handled by no layer because every layer has a good local reason to treat it as somebody else’s problem.</strong> Maintaining a denylist per address family invites exactly that, and the drift stays invisible while each list is read on its own terms.</p>

<h2 id="what-it-reaches">What it reaches</h2>

<p>Concretely: a workflow step, or an agent tool call, that supplies <code class="language-plaintext highlighter-rouge">http://[::]:8080/</code> gets back the body of whatever is listening on port 8080 of the machine running the engine — a service that may be bound to loopback specifically so that nothing outside the host can talk to it. Internal dashboards, dev servers, queue consoles and unauthenticated admin endpoints all live at exactly that address.</p>

<p>The honest bounds, which went into the report above the severity discussion rather than waiting for a triager to find them:</p>

<ul>
  <li><strong>Not a credential-theft path.</strong> <code class="language-plaintext highlighter-rouge">::</code> reaches the local host, not arbitrary internal hosts. Cloud metadata at <code class="language-plaintext highlighter-rouge">169.254.169.254</code> is IPv4-only and stays out of reach.</li>
  <li><strong>Not arbitrary ports.</strong> The port allowlist still applies — 80, 443, 8080 and 8443. That covers a lot of internal admin and dev surface, but it is not arbitrary.</li>
  <li><strong>Not IPv4 loopback.</strong> A service bound only to <code class="language-plaintext highlighter-rouge">127.0.0.1</code> is untouched; the target has to be listening on <code class="language-plaintext highlighter-rouge">::1</code> or <code class="language-plaintext highlighter-rouge">::</code>.</li>
</ul>

<p>Two variants also passed validation and are <em>not</em> counted as vulnerabilities, because they do not route: <code class="language-plaintext highlighter-rouge">0177.0.0.1</code> resolves to <code class="language-plaintext highlighter-rouge">177.0.0.1</code>, which is a correct classification, and <code class="language-plaintext highlighter-rouge">::ffff:0:127.0.0.1</code> times out. Writing those down costs a paragraph and saves a triager from rediscovering them.</p>

<p>Bounding the reach costs nothing, and the claim that survives is still worth 7.7: a complete bypass of the loopback half of a guard whose stated purpose is blocking loopback, with the response body returned to the caller.</p>

<h2 id="proving-it-as-a-differential">Proving it as a differential</h2>

<p>A claim that one URL works is weak on its own. A claim that one URL works <em>while the two the guard was built for do not, in the same run</em> is a result.</p>

<p>The proof-of-concept binds a marker service to <code class="language-plaintext highlighter-rouge">[::1]:8080</code>, reachable over no public route, and drives the library’s real <code class="language-plaintext highlighter-rouge">http.get</code> module — not a stub, and not a reimplementation of the predicate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[+] internal service on [::1]:8080 (IPv6 loopback only)

  http://[::1]:8080/       -&gt; blocked: [NETWORK_ERROR] Hostname blocked: ::1
  http://127.0.0.1:8080/   -&gt; blocked: [NETWORK_ERROR] Hostname blocked: 127.0.0.1
  http://[::]:8080/        -&gt; BYPASS: {'status': 200, 'body':
                              'INTERNAL-ONLY-SERVICE: flag{loopback_reached_via_ipv6_unspecified}',
                              'headers': {'Server': 'BaseHTTP/0.6 Python/3.10.4', ...}}
</code></pre></div></div>

<p>The response body comes back to the caller, so this is a read SSRF rather than a blind one.</p>

<p>At the guard level, every neighbouring encoding is rejected and only the unspecified form passes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://[::]:8080/                 PASS
http://[::]/                      PASS
http://[0:0:0:0:0:0:0:0]:8080/    PASS
http://[::ffff:127.0.0.1]/        blocked
http://0.0.0.0:8080/              blocked
http://[64:ff9b::a9fe:a9fe]/      blocked
http://169.254.169.254/           blocked
</code></pre></div></div>

<p>Those blocked rows are doing real work in the report. They say: this guard is good, this is not a review of it, here is the single input it does not classify.</p>

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

<p>The whole thing went in through the project’s private vulnerability reporting, in the shape the project’s own published advisories use, with the source references as <code class="language-plaintext highlighter-rouge"># path:line</code> comments inside the code fences so anything I asserted could be checked against the tree rather than taken on trust.</p>

<p>Three choices in it are worth repeating anywhere else:</p>

<p><strong>One CVSS vector, defended metric by metric.</strong> Not two, not a range. Offering the maintainer a choice of scores reads as uncertainty about your own finding rather than as transparency. I scored it <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N</code> — 7.7 — and justified each metric against what the proof-of-concept actually did, so the reasoning could be argued with piece by piece. It was accepted as filed.</p>

<p><strong>Say which claims are demonstrated and which are read.</strong> One consequence went in as a code path rather than a demonstration, and was labelled that way: the per-hop redirect revalidation calls the same predicate, so a <code class="language-plaintext highlighter-rouge">302 Location: http://[::]:8080/</code> is accepted at the hop check exactly as at the initial check. I tested the initial-URL path. Marking that boundary is what makes the rest of a report trustworthy.</p>

<p><strong>Propose the fix as finished code, not as a diff.</strong> The reader is deciding whether the shape is right, not applying a patch. I proposed the general form rather than adding <code class="language-plaintext highlighter-rouge">::/128</code> to the range list, because one predicate covers both address families and cannot drift the way two parallel lists drift:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1"># 0.0.0.0 and :: are both routed to loopback by the stack.
</span>    <span class="k">if</span> <span class="n">ip</span><span class="p">.</span><span class="n">is_unspecified</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">True</span>
</code></pre></div></div>

<h2 id="the-fix-that-shipped">The fix that shipped</h2>

<p>That is what went into 2.28.0, with a comment naming the reason better than mine did:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1"># Both IPv4 and IPv6 unspecified addresses can be routed to the local host.
</span>    <span class="c1"># Check the address property so every textual representation is covered.
</span>    <span class="k">if</span> <span class="n">ip</span><span class="p">.</span><span class="n">is_unspecified</span><span class="p">:</span>
</code></pre></div></div>

<p><em>Every textual representation</em> is the point. <code class="language-plaintext highlighter-rouge">::</code>, <code class="language-plaintext highlighter-rouge">[0:0:0:0:0:0:0:0]</code>, <code class="language-plaintext highlighter-rouge">0.0.0.0</code>, <code class="language-plaintext highlighter-rouge">0000::0</code> — a property on the parsed address covers the whole set for free, where a denylist only ever covers the spellings someone thought to type.</p>

<h2 id="getting-the-cve-id">Getting the CVE ID</h2>

<p>One practical note, because it is the step that turns an advisory into a citable identifier and I had not seen it written down.</p>

<p>A GitHub Security Advisory is not a CVE. GitHub is a CNA, but assignment through it is opt-in — the maintainer requests the ID, and is under no obligation to. This advisory published without one.</p>

<p>That is recoverable after the fact, and it took a single email. Third-party CNAs — VulnCheck, Snyk, and MITRE as the fallback — scope themselves to vulnerabilities <em>“not in another CNA’s scope”</em>, and GitHub’s own scope is written as <em>“CVEs requested by code owners using the GitHub Security Advisories feature”</em>, plus projects assessed by GitHub and Microsoft researchers. No request from the code owner, and I am not a GitHub or Microsoft researcher, so it was never in that scope to begin with — a scope statement rather than an escalation, and nothing asked of maintainers who were already finished.</p>

<p>What made it fast was sending the <em>finished record</em> instead of a request for someone else to write one: affected versions and the fixed release, CWE, the CVSS vector from the published advisory, root cause with file and line, the reachability path, the proof-of-concept with its controls, the bounds section, the shipped fix, and the credit line. <a href="https://www.cve.org/CVERecord?id=CVE-2026-73530"><strong>CVE-2026-73530</strong></a> came back published the same day, against a quoted window of two to three business days, crediting <code class="language-plaintext highlighter-rouge">euriconicacio</code> as finder, with the title, CWE, scoring and affected-module list carried across nearly verbatim.</p>

<p>One consequence of taking that route: the ID lives on the CVE record, not on the GitHub advisory. A repository advisory only carries a <code class="language-plaintext highlighter-rouge">cve_id</code> when the ID came through GitHub as CNA, and nothing backfills a third-party assignment into it afterwards. Both records describe the same vulnerability, so link both rather than assuming a reader who has one can find the other.</p>

<h2 id="timeline">Timeline</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>9 Aug 2026</td>
      <td>Reported through the project’s private vulnerability reporting</td>
    </tr>
    <tr>
      <td>13 Aug 2026</td>
      <td><a href="https://github.com/flytohub/flyto-core/security/advisories/GHSA-gc4h-hj7x-gp5p">GHSA-gc4h-hj7x-gp5p</a> published; 2.28.0 released with the fix</td>
    </tr>
    <tr>
      <td>13 Aug 2026</td>
      <td><a href="https://www.cve.org/CVERecord?id=CVE-2026-73530">CVE-2026-73530</a> assigned and published by VulnCheck as a third-party CNA</td>
    </tr>
  </tbody>
</table>

<p>Four days from report to published fix, with the maintainers taking the stronger of the fix shapes offered and improving the comment on it. That is a good disclosure experience by any standard.</p>

<p>If you maintain a guard like this one, the cheap check is not <em>“do we handle IPv6”</em> — the answer is usually yes, and it was yes here. It is: <strong>put your IPv4 denylist and your IPv6 denylist side by side and ask what is on one and not the other.</strong> <code class="language-plaintext highlighter-rouge">0.0.0.0</code> was on both. <code class="language-plaintext highlighter-rouge">::</code> was on neither.</p>

<p><em>Eurico Nicacio —</em> <a href="https://github.com/euriconicacio"><em>@h3llh0und</em></a></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[An SSRF guard bypass I reported in flyto-core: the denylist blocks 0.0.0.0 twice over and its IPv6 twin :: not at all — and the stack routes :: straight to the local host.]]></summary></entry><entry><title type="html">CVE-2026-64941: an open redirect in the guard that prevents open redirects</title><link href="https://euriconicacio.github.io/blog/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects/" rel="alternate" type="text/html" title="CVE-2026-64941: an open redirect in the guard that prevents open redirects" /><published>2026-08-10T15:00:00+00:00</published><updated>2026-08-10T15:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects</id><content type="html" xml:base="https://euriconicacio.github.io/blog/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects/"><![CDATA[<blockquote>
  <p><strong><em>Disclaimer.</em></strong> <em>This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every test ran against local servers standing in for two origins. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory and the fixed releases were public.</em></p>
</blockquote>

<p><img src="/blog/assets/images/posts/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects/01.png" alt="CVE-2026-64941: an open redirect in the guard that prevents open redirects" /></p>

<p>Phoenix LiveView published <a href="https://github.com/phoenixframework/phoenix_live_view/security/advisories/GHSA-36m4-rm57-3prf">GHSA-36m4-rm57-3prf</a> this morning — <strong>CVE-2026-64941</strong>, an open redirect (CWE-601) in <code class="language-plaintext highlighter-rouge">Phoenix.LiveView.redirect/2</code>. I reported it on 8 August, it was accepted the following day, and it was published today with a fix in three release branches. Severity is Low: <code class="language-plaintext highlighter-rouge">CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N</code>, 2.1.</p>

<p><strong>Upgrade to 1.0.19, 1.1.33 or 1.2.9.</strong> Everything from 0.5.0 — November 2019 — is affected.</p>

<p>What makes it worth a post is not the severity. It is that the vulnerable function is a security control whose single job is to prevent exactly this, and that the bug is not really in Elixir at all — it is in what a browser does to a string before anyone gets to parse it.</p>

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

<p><code class="language-plaintext highlighter-rouge">validate_local_url!/2</code> in <code class="language-plaintext highlighter-rouge">lib/phoenix_live_view.ex</code> had three clauses, unchanged since the day it was written:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">@invalid_local_url_chars</span> <span class="p">[</span><span class="s2">"</span><span class="se">\\</span><span class="s2">"</span><span class="p">]</span>

<span class="k">defp</span> <span class="n">validate_local_url!</span><span class="p">(</span><span class="s2">"//"</span> <span class="o">&lt;&gt;</span> <span class="n">_</span> <span class="o">=</span> <span class="n">to</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">raise_invalid_local_url!</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">defp</span> <span class="n">validate_local_url!</span><span class="p">(</span><span class="s2">"/"</span> <span class="o">&lt;&gt;</span> <span class="n">_</span> <span class="o">=</span> <span class="n">to</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span> <span class="k">do</span>
  <span class="k">if</span> <span class="no">String</span><span class="o">.</span><span class="n">contains?</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="nv">@invalid_local_url_chars</span><span class="p">)</span> <span class="k">do</span>
    <span class="k">raise</span> <span class="no">ArgumentError</span><span class="p">,</span> <span class="s2">"unsafe characters detected for </span><span class="si">#{</span><span class="n">where</span><span class="si">}</span><span class="s2"> in URL </span><span class="si">#{</span><span class="n">inspect</span><span class="p">(</span><span class="n">to</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span>
  <span class="k">else</span>
    <span class="n">to</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">defp</span> <span class="n">validate_local_url!</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">raise_invalid_local_url!</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Reject a leading <code class="language-plaintext highlighter-rouge">//</code>. Accept a leading <code class="language-plaintext highlighter-rouge">/</code> unless it contains a backslash. Reject everything else. <code class="language-plaintext highlighter-rouge">git log -S "invalid_local_url_chars"</code> returned a single commit, so that list had never been revisited.</p>

<p>Now consider <code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;/example.com</code>. It starts with <code class="language-plaintext highlighter-rouge">/</code> but not <code class="language-plaintext highlighter-rouge">//</code>, so the first clause does not match. It contains no backslash, so the second clause returns it unchanged, and as far as the server is concerned it is a path on your application.</p>

<h2 id="the-browser-is-the-entire-bug">The browser is the entire bug</h2>

<p>The <a href="https://url.spec.whatwg.org/#url-parsing">WHATWG URL Standard</a> instructs the basic URL parser to <em>remove all ASCII tab and newline</em> from its input before parsing. Not reject — remove. So the string the browser parses is not the string LiveView validated:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">:to</code> value</th>
      <th>resolved against <code class="language-plaintext highlighter-rouge">https://app.example.com/login</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;/example.com</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://example.com/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;LF&gt;/example.com</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://example.com/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;CR&gt;/example.com</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://example.com/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;example.com</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://app.example.com/example.com</code> — local, not an issue</td>
    </tr>
  </tbody>
</table>

<p>That last row is the reason this survived seven years. The second slash is mandatory: strip the tab out of <code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;example.com</code> and you still have a path. A naive test of <code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;evil.com</code> shows nothing at all, which is a good description of how a whole class of bugs stays alive — the obvious payload is the one that does not work.</p>

<p>I confirmed it in Chrome rather than deriving it from the spec, with two local servers standing in for the two origins and a page doing exactly what LiveView’s client does:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span><span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/</span><span class="se">\t</span><span class="s2">/127.0.0.1:4031/CANARY-EXTERNAL</span><span class="dl">"</span><span class="p">;</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server 4030 (origin)          GET /
server 4031 (attacker host)   GET /CANARY-EXTERNAL
</code></pre></div></div>

<h2 id="why-phoenix-core-was-not-affected">Why Phoenix core was not affected</h2>

<p>Phoenix has the same guard, under the same name, with the same constant name — and is fine, for two independent reasons:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># phoenix/lib/phoenix/controller.ex</span>
<span class="nv">@invalid_local_url_chars</span> <span class="p">[</span><span class="s2">"</span><span class="se">\\</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"/%09"</span><span class="p">,</span> <span class="s2">"/</span><span class="se">\t</span><span class="s2">"</span><span class="p">]</span>
</code></pre></div></div>

<p>First, that list carries the tab entry, added in response to <strong>CVE-2017-1000163</strong> — this same class of bypass, in the identically-named function, nine years ago. Second, <code class="language-plaintext highlighter-rouge">Phoenix.Controller.redirect/2</code> writes its result into a <code class="language-plaintext highlighter-rouge">Location</code> response header, and <code class="language-plaintext highlighter-rouge">Plug.Conn.put_resp_header/3</code> raises on any value containing LF or CR. Two layers, either of which alone would have been enough.</p>

<p>LiveView has neither, because its redirect never becomes an HTTP header. It travels over the LiveView channel as JSON and lands in <code class="language-plaintext highlighter-rouge">assets/js/phoenix_live_view/browser.ts</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">redirect</span><span class="p">(</span><span class="nx">toURL</span><span class="p">,</span> <span class="nx">flash</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span> <span class="nx">navigate</span> <span class="o">=</span> <span class="p">(</span><span class="nx">url</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">url</span><span class="p">;</span> <span class="p">})</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">flash</span><span class="p">)</span> <span class="p">{</span> <span class="k">this</span><span class="p">.</span><span class="nx">setCookie</span><span class="p">(</span><span class="dl">"</span><span class="s2">__phoenix_flash__</span><span class="dl">"</span><span class="p">,</span> <span class="nx">flash</span><span class="p">,</span> <span class="mi">60</span><span class="p">);</span> <span class="p">}</span>
  <span class="nx">navigate</span><span class="p">(</span><span class="nx">toURL</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Assignment to <code class="language-plaintext highlighter-rouge">window.location.href</code> runs the URL parser that does the stripping. Nothing between the server-side guard and that assignment ever looks at the value again.</p>

<p>Running each candidate through both implementations:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">:to</code></th>
      <th>Phoenix core</th>
      <th>LiveView</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">//evil.com</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">ArgumentError</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">ArgumentError</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/\evil.com</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">ArgumentError</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">ArgumentError</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;/evil.com</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">ArgumentError</code></td>
      <td><strong>accepted</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;LF&gt;/evil.com</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">Plug.Conn.InvalidHeaderError</code></td>
      <td><strong>accepted</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/&lt;CR&gt;/evil.com</code></td>
      <td>raises <code class="language-plaintext highlighter-rouge">Plug.Conn.InvalidHeaderError</code></td>
      <td><strong>accepted</strong></td>
    </tr>
  </tbody>
</table>

<h2 id="how-it-was-found">How it was found</h2>

<p>There was no fuzzing and no harness. It came out of reading someone else’s patch and asking what it did not cover.</p>

<p>In July 2026, LiveView published <a href="https://github.com/phoenixframework/phoenix_live_view/security/advisories/GHSA-5cgh-g58j-m9cq">CVE-2026-58228</a>, which fixed scheme validation in <code class="language-plaintext highlighter-rouge">Phoenix.LiveView.Utils</code>: a leading ASCII control character or space made <code class="language-plaintext highlighter-rouge">uri_scheme/1</code> miss a <code class="language-plaintext highlighter-rouge">javascript:</code> scheme. Its stated root cause is precisely the browser behaviour above — those characters get discarded before parsing, so what you validate is not what resolves.</p>

<p>That is an insight about a <em>browser</em>, not about one function. So the question is simply: <strong>where else does this project validate a URL?</strong> The answer was one other place, in a different module, guarding a different sink — navigation rather than a rendered <code class="language-plaintext highlighter-rouge">href</code> — and it had not been looked at when the first one was fixed. It was still there in 1.2.8, released after that fix shipped.</p>

<p>That is the whole method, and it generalises past this codebase: when a project patches a URL, path or encoding guard, the fix tells you which primitive the maintainers now know about. Then you enumerate every other place the project makes the same kind of promise, and check whether the primitive reaches it. Fixes are usually applied at the site where the bug was reported, not across the class.</p>

<p>The other half of why this converted at all is a selection rule I now apply before picking a target: <strong>is the library itself the security control?</strong> A parser, a server or a transport usually needs a third party to disagree with it before a defect has any impact, and modern front-ends are strict. A sanitizer, an authorization check or a redirect guard has intrinsic impact, because the bypass <em>is</em> the impact. No one has to be misconfigured downstream for it to matter.</p>

<h2 id="whose-bug-it-is">Whose bug it is</h2>

<p>The obvious way to close this report is “validate your own inputs”. I addressed that in the report rather than waiting to be asked, because it is a fair question.</p>

<p><code class="language-plaintext highlighter-rouge">validate_local_url!/2</code> is not a convenience helper. It rejects <code class="language-plaintext highlighter-rouge">//</code> and <code class="language-plaintext highlighter-rouge">\</code>, and when it raises it says <em>unsafe characters detected</em>. Both the behaviour and the wording commit to a security property: a <code class="language-plaintext highlighter-rouge">:to</code> that survives validation cannot leave the origin. An application author reading <code class="language-plaintext highlighter-rouge">redirect/2</code> has no reason to add a second check on top of a guard that already advertises this one, and the documentation for <code class="language-plaintext highlighter-rouge">:to</code> describes it as a path.</p>

<p>The narrow position — not that applications should be free to redirect anywhere, but that a guard which already promises same-origin should not be defeated by three characters browsers are specified to discard — is also the position Phoenix core took in 2017 on the identical class in the identically-named function.</p>

<h2 id="what-the-maintainers-changed-and-why-they-were-right">What the maintainers changed, and why they were right</h2>

<p>Two things in my report were narrowed, and both narrowings were correct. Recording them is more useful than recording the parts that stood.</p>

<p><strong>The severity came down.</strong> I suggested <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N</code> — 6.1, Medium, which is how open redirects are usually scored. The coordinating CNA rescored it in CVSS v4 to 2.1, Low, and the v4 vector says something 3.1 simply cannot: the vulnerable system is untouched, and the impact lands on a <em>subsequent</em> system — the user’s browser. In 3.1 the only way to express “the harm happens somewhere other than the thing that is broken” is <code class="language-plaintext highlighter-rouge">S:C</code>, which is where my 6.1 came from and which overstated it.</p>

<p>I had also written that OAuth authorization codes and tokens could leak. In the common <code class="language-plaintext highlighter-rouge">return_to</code> shape, that is wrong: the application redirects <em>to</em> the supplied path, it does not append a secret to it. <code class="language-plaintext highlighter-rouge">SC:L</code> — a referrer, at most — is the honest ceiling. Claim what you demonstrated, not what the vulnerability class is famous for.</p>

<p><strong>The affected surface came down.</strong> My report named all four entry points, since <code class="language-plaintext highlighter-rouge">push_opts!/2</code> shares the guard with <code class="language-plaintext highlighter-rouge">redirect/2</code>. The maintainers pointed out that this is true on the server and irrelevant on the client: live navigation goes through <code class="language-plaintext highlighter-rouge">expandURL</code>, which rewrites any value starting with <code class="language-plaintext highlighter-rouge">/</code> against <code class="language-plaintext highlighter-rouge">window.location.protocol</code> and <code class="language-plaintext highlighter-rouge">window.location.host</code> before navigating. <code class="language-plaintext highlighter-rouge">push_navigate/2</code> and <code class="language-plaintext highlighter-rouge">push_patch/2</code> therefore land on your own origin regardless of what the string contains. Only <code class="language-plaintext highlighter-rouge">redirect/2</code>, which assigns the raw value to <code class="language-plaintext highlighter-rouge">window.location.href</code>, actually leaves. (Live patch appears to have been exposed between 0.5.0 and 0.7.0, before that expansion existed.)</p>

<p>Sharing a code path is not the same as sharing a sink, and I had not read far enough down the client to know the difference.</p>

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

<p>I proposed two shapes: aligning the character list with Phoenix core, or stripping the characters the browser strips and validating the result:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">stripped</span> <span class="o">=</span> <span class="no">String</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="p">[</span><span class="s2">"</span><span class="se">\t</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"</span><span class="se">\r</span><span class="s2">"</span><span class="p">],</span> <span class="s2">""</span><span class="p">)</span>
<span class="k">if</span> <span class="no">String</span><span class="o">.</span><span class="n">starts_with?</span><span class="p">(</span><span class="n">stripped</span><span class="p">,</span> <span class="s2">"//"</span><span class="p">)</span> <span class="ow">or</span> <span class="no">String</span><span class="o">.</span><span class="n">contains?</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="nv">@invalid_local_url_chars</span><span class="p">)</span> <span class="k">do</span>
</code></pre></div></div>

<p>The maintainers proposed something better and shorter:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># We add \r and \n since those are checked on Phoenix at the header level</span>
<span class="nv">@invalid_local_url_chars</span> <span class="p">[</span><span class="s2">"</span><span class="se">\\</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"/%09"</span><span class="p">,</span> <span class="s2">"/</span><span class="se">\t</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">,</span> <span class="s2">"</span><span class="se">\r</span><span class="s2">"</span><span class="p">]</span>
</code></pre></div></div>

<p>It allocates nothing per call, it converges the two guards so LiveView and Phoenix core now say the same thing, and it is strictly stronger than mine in one place I had missed — <code class="language-plaintext highlighter-rouge">/%09</code> is on the list, and my version accepts <code class="language-plaintext highlighter-rouge">/%09/example.com</code> outright. (I could not get that one to leave the origin, since the parser does not percent-decode before stripping, but it costs nothing and Phoenix has carried it for years.) Unprefixed <code class="language-plaintext highlighter-rouge">\n</code> and <code class="language-plaintext highlighter-rouge">\r</code> also track the reasoning exactly: Phoenix rejects those anywhere via the header layer, so bare entries reproduce Phoenix’s effective behaviour rather than just the leading-<code class="language-plaintext highlighter-rouge">//</code> case.</p>

<p>I checked the fix rather than assume it. Sweeping 450 candidates of the form <code class="language-plaintext highlighter-rouge">/</code> + up to two of <code class="language-plaintext highlighter-rouge">{tab, LF, CR, \, space, %09, %0A, %0D}</code> + a host, and resolving each with a WHATWG parser against a document on another origin: 160 leave the origin, and <strong>none</strong> get past the list.</p>

<p>That is not an accident of the sample. For a stripped string to begin with <code class="language-plaintext highlighter-rouge">//</code>, the original must be <code class="language-plaintext highlighter-rouge">/</code>, then one or more characters the parser removes, then <code class="language-plaintext highlighter-rouge">/</code> — so bytes 0 and 1 are always a slash followed by a tab, LF or CR. Three slash-prefixed pairs is exhaustive, and the bare <code class="language-plaintext highlighter-rouge">\\</code> entry covers the backslash-after-stripping case such as <code class="language-plaintext highlighter-rouge">/&lt;TAB&gt;\example.com</code>.</p>

<h2 id="timeline-and-credits">Timeline and credits</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>8 Aug 2026</td>
      <td>Reported via GitHub Security Advisory</td>
    </tr>
    <tr>
      <td>9 Aug 2026</td>
      <td>Accepted; CVE-2026-64941 assigned by the Erlang Ecosystem Foundation CNA; fix written and reviewed</td>
    </tr>
    <tr>
      <td>10 Aug 2026</td>
      <td>Published; 1.0.19, 1.1.33 and 1.2.9 released</td>
    </tr>
  </tbody>
</table>

<p>Roughly forty-eight hours from report to published fix across three maintained branches. The advisory credits me as reporter, two LiveView maintainers as remediation developer and remediation reviewer, and the EEF as coordinator.</p>

<p>I want to be specific about what was good here, because “the maintainers were responsive” undersells it. They engaged with the technical substance within hours, corrected two things I had gotten wrong, produced a fix better than either I proposed, and requested a CVE when I raised that a GHSA alone reaches Dependabot and OSV but misses scanners that consume neither. The BEAM ecosystem’s disclosure path — a project advisory, coordinated by the EEF as CNA, with a <code class="language-plaintext highlighter-rouge">credits</code> field that names the finder — works, and it works quickly.</p>

<p>If you run Phoenix LiveView and pass anything user-influenced to <code class="language-plaintext highlighter-rouge">redirect/2</code>, upgrade. Then go and look at every other place your own code promises that a URL is local.</p>

<p><em>Eurico Nicacio —</em> <a href="https://github.com/euriconicacio"><em>@h3llh0und</em></a></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[The open redirect I reported in Phoenix LiveView — in validate_local_url!/2, the guard whose entire job is keeping a redirect target on your own origin. It never looked at the three characters browsers discard before they parse a URL.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/cve-2026-64941-an-open-redirect-in-the-guard-that-prevents-open-redirects/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pwnloop, one week in</title><link href="https://euriconicacio.github.io/blog/pwnloop-one-week-in/" rel="alternate" type="text/html" title="pwnloop, one week in" /><published>2026-08-08T12:00:00+00:00</published><updated>2026-08-08T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/pwnloop-one-week-in</id><content type="html" xml:base="https://euriconicacio.github.io/blog/pwnloop-one-week-in/"><![CDATA[<blockquote>
  <p><strong><em>Disclaimer.</em></strong> <em>This is a personal open-source project, built and run on my own equipment and my own accounts. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every single-host target was a Hack The Box machine and the campaign target was a Hack The Box Pro Lab, both engaged over that platform’s own VPN and within its terms of service. No flag values appear in this post or in the repository. No chain is published for any machine not confirmed retired, and no Pro Lab chain, hostname or credential appears anywhere — flag sharing is a platform violation regardless of a machine’s status, and Pro Labs never retire.</em></p>
</blockquote>

<p><img src="/blog/assets/images/posts/pwnloop-one-week-in/01.png" alt="pwnloop, one week in" /></p>

<p>Eight days ago <code class="language-plaintext highlighter-rouge">pwnloop</code> was a disposable Kali container and a 200-line skill file that I started on a Friday to see whether an agent could take a lab machine from an IP address to a root flag without me in the loop.</p>

<p>It can. That stopped being the interesting question on day two.</p>

<p>The interesting question is what happens the <em>second</em> time. Because the loop is not allowed to close a run until it has written back into its own methodology, every engagement leaves the tool different from how it found it — and the difference is always the same shape: something the run had to work out on target is now on the page, so the next run reads it instead of working it out again.</p>

<p>That is the whole mechanism, and a week is the first interval long enough to watch it compound. This post is the state of the tool after eight days, the full surface of what it does, and what each run put into it.</p>

<p>The two earlier posts cover the origin — <a href="/blog/pwnloop-an-autonomous-engagement-loop-for-lab-machines/">the single-host loop</a> and <a href="/blog/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/">lab mode against a Pro Lab</a>. This one does not repeat them.</p>

<h2 id="where-it-is">Where it is</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>day 1</th>
      <th>day 8</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Skill file</td>
      <td>~200 lines, single host</td>
      <td>loop specification + campaign layer</td>
    </tr>
    <tr>
      <td>Reference set</td>
      <td>14 files</td>
      <td>26 files, ~4,000 lines</td>
    </tr>
    <tr>
      <td>Machines</td>
      <td>0</td>
      <td>17 rooted, 17 root flags</td>
    </tr>
    <tr>
      <td>Campaigns</td>
      <td>0</td>
      <td>1 Pro Lab, 3/3 hosts, 4/4 flags</td>
    </tr>
    <tr>
      <td>Modes</td>
      <td>one machine</td>
      <td>one machine · one network</td>
    </tr>
    <tr>
      <td>Releases</td>
      <td>unversioned</td>
      <td>v1.9.0 — 18 tagged releases</td>
    </tr>
    <tr>
      <td>State model</td>
      <td>a markdown ledger</td>
      <td>ledger + campaign state CLI + checkpoints</td>
    </tr>
  </tbody>
</table>

<p>Seventeen machines, seventeen root flags, one Pro Lab. It has not walked away from a target.</p>

<p>That is the least interesting row in the table, and I want to get it out of the way early. A completion count is a claim about a week; the reference set is a claim about the next one.</p>

<h2 id="what-it-does">What it does</h2>

<h3 id="one-command-one-address">One command, one address</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/pwnloop <span class="o">&amp;&amp;</span> claude
<span class="o">&gt;</span> /pwnloop 10.129.x.x
</code></pre></div></div>

<p>The agent verifies the VPN and reachability, creates an engagement directory named after the address, and runs recon → enumeration → foothold → privilege escalation → cleanup → report without stopping between phases. It announces each flag in chat the moment it reads one, so you can submit while it keeps working.</p>

<p>You pass the address, never the machine’s name. The name is the strongest recall trigger there is: hand a model a well-known box’s name and it can return the published chain before a packet has been sent, and you will never know how much of the run was discovery and how much was retrieval. Working from an address means recognition can only happen <em>after</em> enumeration has earned the fingerprint. When it happens anyway, the run declares it in the ledger before the next command.</p>

<p>Four conditions — and only four — return control to the operator: VPN down, target unreachable for more than five minutes, a genuine scope question, or three full enumeration passes with no new leads. Everything else it decides. Generous escalation criteria are how an autonomous agent quietly degrades into a chat session.</p>

<h3 id="one-command-one-network">One command, one network</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> /pwnloop-lab 10.10.110.0/24
</code></pre></div></div>

<p>Campaign mode points the same loop at a graph instead of a box. The single-host methodology becomes the inner loop, unchanged, run per host. The outer loop picks the highest-value lead off a frontier — unowned reachable host, untried credential, new subnet — and writes everything it learns to disk through a CLI that is the state file’s only writer.</p>

<p>Its first run took HTB’s free Puppet mini Pro Lab from one entry address to full compromise: 3 of 3 hosts, 4 of 4 flags, seven sessions, about four and a half hours of wall-clock, verified by the platform’s own completion certificate rather than by a screenshot I produced.</p>

<h3 id="what-comes-out">What comes out</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>engagements/&lt;address&gt;/
  FINDINGS.md    append-only ledger, updated live — findings, evidence, status
  REPORT.md      defender-facing: chain, impact, remediation, earliest break point
  WRITEUP.md     teaching-facing: the narrative, including the leads that failed
  scans/         raw tool output, one file per run
  loot/          credentials, hashes, keys, downloaded artifacts
  www/           payloads staged for delivery to the target
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">REPORT.md</code> and <code class="language-plaintext highlighter-rouge">WRITEUP.md</code> are separate documents on purpose. The report argues to a defender: what is broken, what it costs, what to fix, and which single control would have broken the chain earliest. The write-up teaches a reader: how the box fell, and — the part most write-ups omit — which leads were dead ends and why. A defender does not want your narrative and a learner does not want your CVSS table.</p>

<h3 id="the-machinery-underneath">The machinery underneath</h3>

<p><strong>A disposable container.</strong> All offensive tooling and the lab VPN live inside it. The container gets <code class="language-plaintext highlighter-rouge">NET_ADMIN</code> and <code class="language-plaintext highlighter-rouge">/dev/net/tun</code>, so OpenVPN runs there and your host’s routing table is never touched. Tear it down and every trace of the engagement’s tooling goes with it. Native arm64 on Apple Silicon, no emulation.</p>

<p><strong>An append-only ledger.</strong> Every finding carries an evidence file and a status: <code class="language-plaintext highlighter-rouge">LEAD</code> → <code class="language-plaintext highlighter-rouge">CONFIRMED</code> / <code class="language-plaintext highlighter-rouge">PARKED</code> / <code class="language-plaintext highlighter-rouge">DEAD</code>, where <code class="language-plaintext highlighter-rouge">DEAD</code> carries the reason it was ruled out. That last part is what stops the loop re-testing it on the next pass. If you are demonstrating this to a room, project a <code class="language-plaintext highlighter-rouge">tail -f</code> of this file rather than the transcript.</p>

<p><strong>A grounding invariant.</strong> No finding without an evidence file. Before running a command the loop should be able to name the file and the line of output that motivated it. This is the single highest-value constraint in the whole document: it forces the loop to operate on <em>observed</em> state rather than <em>plausible</em> state.</p>

<p><strong>A divergence guard.</strong> Any lead that has produced no concrete artifact in about fifteen minutes is marked <code class="language-plaintext highlighter-rouge">PARKED</code> and the loop moves on. Without it, an agent spends an hour on the most <em>interesting</em> lead rather than the most <em>productive</em> one.</p>

<p><strong>A credential matrix.</strong> Every spray result is recorded, win or lose, so the loop only ever suggests untried (credential, host, service) triples. A locked result removes that credential from every future suggestion. Recording a negative is worth exactly as much as recording a positive — it is what stops the next session re-running it, and lockout is the only irreversible mistake available in a lab.</p>

<p><strong>Route canaries.</strong> Every tunnel is registered with an <code class="language-plaintext highlighter-rouge">IP:port</code> behind it known to answer, so a route can be tested rather than trusted. After a lab reset, a dead tunnel and a hardened target are indistinguishable, and one of those two readings wastes a whole session.</p>

<p><strong>Checkpoints.</strong> The state file records what is true; the checkpoint records what you were in the middle of and what you would have done next — the one thing nothing can infer for you. Written before running out of room, not after.</p>

<p><strong>A memory split.</strong> <code class="language-plaintext highlighter-rouge">memory/patterns.md</code> is upstream and curated; <code class="language-plaintext highlighter-rouge">memory/local.md</code> is yours and created at install. Both are read before every engagement, and neither ever conflicts on a pull. Same split for the container package list.</p>

<p><strong>Leak controls.</strong> <code class="language-plaintext highlighter-rouge">engagements/</code>, <code class="language-plaintext highlighter-rouge">campaigns/</code>, <code class="language-plaintext highlighter-rouge">flags.local.md</code> and <code class="language-plaintext highlighter-rouge">vpn/</code> are gitignored, and a pre-commit hook refuses any commit containing a flag-shaped string, a path under those directories, private key material or an attacker VPN address. The realistic failure is the maintainer pasting engagement output into a reference file, not an outsider — so the hook exists for me.</p>

<p><strong><code class="language-plaintext highlighter-rouge">pwnloop backup</code>.</strong> An encrypted archive of the two things git does not track and cannot recover: the local memory file and <code class="language-plaintext highlighter-rouge">campaigns/</code>. A direct consequence of campaign mode — a campaign produces nothing publishable, so it is its own only record.</p>

<h3 id="what-it-deliberately-is-not">What it deliberately is not</h3>

<p>The exclusions are decisions, not a roadmap:</p>

<ul>
  <li><strong>No C2, EDR evasion or malware development.</strong> A lab machine needs none of it, and a repository that ships it is a different kind of artifact with a different set of obligations.</li>
  <li><strong>No phishing or social-engineering infrastructure.</strong> There is no human on the other side of a lab box.</li>
  <li><strong>No log or audit tampering.</strong> Cleanup removes the operator’s artifacts and nothing else. The engagement’s footprint in the logs is the defender’s evidence and part of what makes the exercise worth anything.</li>
  <li><strong>No write-up lookup, and no acting on recall.</strong> This is the one I get asked about most. Fetching the target’s own walkthrough would raise the completion rate immediately and destroy the only thing being measured. Researching a <em>technology</em> — a CVE, a protocol, an exploit’s source — is the opposite of that and is constant.</li>
</ul>

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

<p>Eighteen releases in eight days. What makes them worth listing is that not one of them is a feature I sat down and designed — every single one is a gap a run walked into first.</p>

<p><strong>Fri 31 Jul – Sun 2 Aug — the loop, and ten machines.</strong> Container, skill file, first targets, six releases. The gaps were mundane and expensive: packages that were absent exactly when credential reuse, pcap analysis or PDF extraction needed them; a preset git identity, without which <code class="language-plaintext highlighter-rouge">git commit-tree</code> refuses and plumbing-based exploitation is impossible; the fact that <code class="language-plaintext highlighter-rouge">sed -i</code> cannot edit a bind-mounted <code class="language-plaintext highlighter-rouge">/etc/hosts</code>. Every one of those cost a run several minutes and costs nothing ever again.</p>

<p><strong>Mon 3 Aug — three more machines, four releases.</strong> The first post went out in the morning; Support, Snapped and Zero went down that afternoon and evening. Zero — the only Insane box in the set — produced two new primitive classes on its own: an attacker-controlled <code class="language-plaintext highlighter-rouge">.htaccess</code> as a file-read primitive where a served directory is writable, and a root command-injection class where a privileged config-check rebuilds its command from a process’s own command line.</p>

<p><strong>Tue 4 Aug — Lame, and a blind spot worth more than the box.</strong> A deliberately antique host, easy by any measure, which exposed something the methodology had no answer for: a correct, well-evidenced lead that <strong>stock tooling can no longer deliver</strong>. The injectable field was one a modern client negotiates <em>around</em>, so <code class="language-plaintext highlighter-rouge">smbclient</code> and impacket packed it into an NTLMSSP blob where the metacharacters never reached a shell. The symptom is indistinguishable from “not vulnerable” — a clean <code class="language-plaintext highlighter-rouge">NT_STATUS_LOGON_FAILURE</code> and no side effect — and the loop very nearly filed it as a dead end. It instead hand-built the raw SMB1 exchange and got unauthenticated RCE as root in a single packet. That diagnosis order is now a reference section: confirm the precondition on the target rather than arguing with the version, know which legacy client knobs are <em>accepted and ignored</em>, emit the raw exchange yourself, and confirm with a side-effect oracle, since these bugs run the command and <em>then</em> reject the login.</p>

<p><strong>Wed 5 Aug — campaign mode, a Pro Lab, and a two-forest AD box.</strong> The last line of the first post’s limitations was that nothing in the sample was multi-host. Campaign mode closed it in five releases, and the Puppet run immediately found two things the reference set had no page for: <strong>deployment infrastructure</strong> — configuration management is an authenticated API whose entire purpose is running code as root on every node it manages, which makes it the highest-value target on an internal network — and <strong>operating through a C2 framework</strong>, which is how a modern internal engagement actually moves, as opposed to the single shell a machine gives you. Both are pages now. That evening it took a two-forest Active Directory machine across a trust boundary, which produced the release the same day: a <em>blocked precondition</em> — an approval gate, a missing role, a patched sink — is the cue to re-enumerate the version’s other CVEs for a sibling with a different trigger, not a dead end; and multi-RPC operations drop through a SOCKS proxy as <code class="language-plaintext highlighter-rouge">INVALID_HANDLE</code>, so prefer a stable local forward and keep the receiving host in-segment.</p>

<p><strong>Fri 7 – Sat 8 Aug — two machines, and one release with a theme.</strong> A camera-management box and a monitoring-stack box, and between them the run stalled at three separate seams that turned out to be the same seam: <strong>a tool’s silence read as a negative result.</strong> A live path traversal answered <code class="language-plaintext highlighter-rouge">404</code>, because the sink was a static route rather than a parameter and <code class="language-plaintext highlighter-rouge">curl</code> had collapsed the <code class="language-plaintext highlighter-rouge">..</code> client-side before the request was ever sent — the fix is <code class="language-plaintext highlighter-rouge">--path-as-is</code>. A hash that looked like an uncrackable password was a format john would not load: a PBKDF2 digest longer than the 32 bytes the format takes, reported as the indistinguishable <code class="language-plaintext highlighter-rouge">No password hashes loaded</code>, and truncatable because the derivation is block-concatenated. And a <code class="language-plaintext highlighter-rouge">sudo</code> rule that read as restrictive ended in a trailing <code class="language-plaintext highlighter-rouge">*</code>, which grants the binary’s <em>entire flag surface</em> — including every option that changes what privilege the work runs with — so the escalation is a documented feature of the allowed binary and nothing about it looks anomalous.</p>

<p>The rule that came out of that is worth more than the three techniques: <strong>validate the pipeline with a control input you constructed before believing any negative result.</strong> A cracker that finds nothing, a request that 404s and a rule that looks locked down are all the same claim — “there is nothing here” — made by a tool that was never asked whether it could see.</p>

<p>Three of those seventeen machines have no published write-up. One is an <strong>active, unretired machine</strong> — and that one is the single most useful result in the set, for a reason I did not plan: there are no published walkthroughs of it yet, so recall is not merely controlled for, it is unavailable. The other two are simply not confirmed retired. All three chains stay in the gitignored engagement directory until they are, which is the rule, and the rule does not bend for the result I would most like to show you.</p>

<h2 id="the-second-loop">The second loop</h2>

<p>This is the part I would keep if I could keep only one.</p>

<p>Every engagement is required to change the methodology before it closes out. Not because I sit down afterwards to improve it — because <em>finishing a run requires writing back into it</em>. A pattern that generalises goes to a memory file the next run reads before it starts. A tool installed mid-run becomes a package. A technique that worked and was undocumented becomes a reference section. A wrong turn becomes a rule that removes that turn from the search space.</p>

<p>Over eight days that took the reference set from 14 files to 26 — Kubernetes, LLM and agent platforms, container escape, cloud metadata, the full AD CS ESC1–ESC16 catalog, NTLM/Kerberos coercion and relay, binary exploitation, deployment infrastructure, C2 operations, multi-hop pivoting — roughly 4,000 lines of methodology, all of it produced by a run that needed it and did not have it.</p>

<p>One rule keeps that file from rotting: <strong>write the method, never the box’s answer.</strong> A reference entry reading “product X version N → CVE-Y → run this payload” bakes one machine’s solution into the methodology and turns the next run into recall. The transferable class goes into the shared references; the box-specific recipe stays in a local memory file that is not shared at all. For campaign mode the same rule doubles as a platform-rules boundary: <code class="language-plaintext highlighter-rouge">skills/</code> and <code class="language-plaintext highlighter-rouge">references/</code> are public and a Pro Lab never retires, so the test before an entry is committed is whether a reader could use it to identify the lab or skip a step on it.</p>

<p>The change I am proudest of is still a <strong>deletion</strong>. An early run left the rule “fix Kerberos clock skew with <code class="language-plaintext highlighter-rouge">ntpdate -u &lt;dc&gt;</code>.” A later one proved that cannot work — the container has no <code class="language-plaintext highlighter-rouge">CAP_SYS_TIME</code>, so <code class="language-plaintext highlighter-rouge">ntpdate</code> measures the offset and then fails to apply it — and it would have blocked certificate authentication outright. The entry was removed and replaced with a <code class="language-plaintext highlighter-rouge">faketime</code> shim that shifts one process instead of the system clock.</p>

<p>A loop that only accumulates gets worse over time. The interesting property is that this one can also take something out.</p>

<h2 id="discovery-is-paid-once">Discovery is paid once</h2>

<p>Here is the thing I did not expect to be the headline of the week.</p>

<p>The loop does not give up. In seventeen engagements and one campaign it has not walked away from a target, and it has not stalled out cycling on the same three ideas — the ledger, the <code class="language-plaintext highlighter-rouge">DEAD</code> reasons and the fifteen-minute time-box exist precisely to make both of those impossible. What it does instead, when it meets something the methodology has no page for, is work the thing out on target: read the binary, read the script, pin the version, compose the primitive out of shell.</p>

<p>That costs time. It cost time <em>exactly once</em>.</p>

<p>Because every one of those moments ends the same way — the run cannot close until the thing it worked out is a rule, a package or a reference section. The expensive discovery happens on one machine. Every machine after it reads the answer before it starts. That is what makes a week of runs different from a week of demos, and it is why the reference set is the artifact I would point at rather than the completion count.</p>

<p>Three are worth naming, because in each one you can see the same trade: an hour spent once, a page that costs nothing forever.</p>

<p><strong>The version was pinned and the CVE hunt was not a step yet.</strong> On one Linux box the loop enumerated cleanly, identified the service and pinned the product — and then went straight to hand-rolling exploitation, because nothing in the methodology said to stop and look for published vulnerabilities in the version it had just pinned. The intended path was a recent CVE whose public proof-of-concept contained the one delivery detail that mattered: which protocol field was the actual sink, as opposed to the three plausible fields it had already tried. Pinning the precise version with a protocol-specific probe rather than an <code class="language-plaintext highlighter-rouge">nmap -sV</code> guess, then hunting CVEs and weaponised public exploits, is now a primary step in the loop rather than something it might get to.</p>

<p><strong>The right binary, and no rule for choosing between its CVEs.</strong> Root on another box was a custom daemon on localhost. The loop found a memory-corruption CVE in it, confirmed the overflow on target, defeated PIE with an address leak — real work, all of it correct — and then spent ninety minutes on an exploit whose primitive turned out to be a <em>linear-forward</em> write rather than an arbitrary one, which put the target address, at a lower offset, permanently out of reach. Public research on that CVE stops at the same wall for the same reason. A second, far cheaper CVE in the same daemon — an argument injection in a format string — was the intended path and is a one-liner. What was missing was not capability; it was a <em>selection rule</em>. The methodology now says: for a pinned version, enumerate <strong>all</strong> of its CVEs and reason about the set, ranked by cost and reliability — an auth bypass or an argument injection beats a memory-corruption bug on a hardened target — consider chaining them, and exhaust public weaponised exploits before writing your own.</p>

<p><strong>A tool that answered “nothing here”, three times in one run.</strong> The most recent release is the cleanest example of the pattern, because all three gaps have the same shape. A traversal that was live and read as patched, a hash that was crackable and read as an uncrackable password, a <code class="language-plaintext highlighter-rouge">sudo</code> rule that was a root grant and read as a restriction. In each case the loop had the finding in front of it and a tool told it there was nothing there. Nothing in the methodology said <em>that answer can be a lie, and here is how to prove it either way</em> — so the rule that went in is not any of the three techniques, it is the control input: construct a case you know the answer to, run it through the same pipeline, and only then believe a negative.</p>

<p>Note what those three have in common, because it is the actual argument of this post. In none of them was the loop incapable. What was missing each time was an <em>ordering</em> or a <em>check</em> — it already knew how to hunt a CVE, how to weigh one exploit against another, how to crack a hash. Nothing told it in what order, or when to distrust an answer. The methodology was silent at exactly one point in each run, and that silence is a thing you can fix permanently in about forty lines.</p>

<p>Which is why the write-back rule above is the one that carries the whole design. What each of those runs sent upstream was not the CVE it eventually used — that would be one box’s answer, and a reference set full of answers turns the next run into recall. What went up was the ordering that finds that CVE without being told, and the check that stops a silent tool from closing a live lead. Those two rules keep working on machines I have never seen.</p>

<h2 id="what-is-next">What is next</h2>

<p><strong>A bigger network.</strong> Campaign mode is designed for nineteen hosts and has run against three. Per-host delegation to subagents never triggered at all — it only fires at three or more live hosts on one subnet — and the credential matrix ended with ten credentials and a single recorded attempt. The machinery meant to matter most at scale is, so far, untested at scale.</p>

<p><strong>Binary exploitation still has not closed a chain.</strong> The first post listed it as a gap and it remains one. The loop has done real work at that layer — a confirmed overflow, a PIE defeat, a correct read of why a primitive was insufficient — and has never landed root through memory corruption, because on every box where it might have, a cheaper path existed and the methodology now correctly tells it to take the cheaper path. That is the right call per-run and it means the capability stays unmeasured. Fixing it needs a target where the cheap path is not there.</p>

<p><strong>Re-runs as the real instrument.</strong> The honest measurement of “the loop got better” is the same target twice with a methodology that changed in between, recording what memory short-circuited and what stayed slow anyway. The second half is the useful one: a phase still slow across two runs is the next thing to fix. The format is specified and I have barely done it. One week is not enough to read that instrument, and I would rather say so than round it up.</p>

<p><strong>A reproducible container.</strong> It builds from a rolling-release base with unpinned packages, so builds are resilient — a package that disappears is logged rather than breaking the image — but not bit-for-bit repeatable. Fine for lab work. For engagement tooling behind a report someone relies on, I would pin the base image by digest and freeze the package snapshot.</p>

<p><a href="https://github.com/euriconicacio/pwnloop"><strong>github.com/euriconicacio/pwnloop</strong></a> — MIT. Machine results are in <code class="language-plaintext highlighter-rouge">writeups/</code>; campaign results are in <code class="language-plaintext highlighter-rouge">labs.md</code>, which explains exactly what may and may not go in a row.</p>

<p>If you run it against something that makes it work for its answer, that is still the most useful thing you could send me. A target that costs the loop an hour is worth more right now than another one it already has the page for.</p>

<p><em>Eurico Nicacio —</em> <a href="https://github.com/euriconicacio"><em>@h3llh0und</em></a></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[Eight days ago it was a container and a 200-line skill file. It has since rooted seventeen machines and taken a Pro Lab end to end — and every path it had to work out on the way is now written into the skill, so it never works it out twice.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-one-week-in/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-one-week-in/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pwnloop lab mode: a Pro Lab in four and a half hours</title><link href="https://euriconicacio.github.io/blog/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/" rel="alternate" type="text/html" title="pwnloop lab mode: a Pro Lab in four and a half hours" /><published>2026-08-05T15:00:00+00:00</published><updated>2026-08-05T15:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours</id><content type="html" xml:base="https://euriconicacio.github.io/blog/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/"><![CDATA[<blockquote>
  <p><strong><em>Disclaimer.</em></strong> <em>This is a personal open-source project, built and run on my own equipment and my own accounts. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. The target was a</em> <strong><em>Hack The Box Pro Lab</em></strong>, <em>engaged over that platform’s own VPN and within its terms of service. Pro Labs never retire, so this post contains</em> <strong><em>no chain, no hostnames, no credentials and no flags</em></strong> <em>— and neither does the repository. What a lab is worth publishing is covered near the end, and it is deliberately not a walkthrough.</em></p>
</blockquote>

<p><img src="/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/01.png" alt="pwnloop lab mode: a Pro Lab in four and a half hours" /></p>

<p>Two days ago I <a href="/blog/pwnloop-an-autonomous-engagement-loop-for-lab-machines/">wrote up</a> <code class="language-plaintext highlighter-rouge">pwnloop</code>, an autonomous engagement loop that takes a single lab machine from an IP address to a root flag without checking in. The last line of that post’s limitations section was that the sample had nothing multi-host in it.</p>

<p>That gap is now closed. <strong>Campaign mode</strong> points the same loop at a <em>network</em>, and its first run took HTB’s free <strong>Puppet</strong> mini Pro Lab from one entry address to full compromise:</p>

<p><strong>3 of 3 hosts owned. 4 of 4 flags. Seven sessions. About four and a half hours of wall-clock.</strong> Verified by the platform’s own completion certificate, <code class="language-plaintext highlighter-rouge">HTBCERT-0398EDB2AC</code> — not by a screenshot I produced.</p>

<p><img src="/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/02.png" alt="Puppet campaign result" /></p>

<p>Everything below is about the <em>harness</em>. The lab’s path is not here and will not be, for a reason I will get to.</p>

<h2 id="a-pro-lab-is-not-a-long-machine">A Pro Lab is not a long machine</h2>

<p>A machine is one target and one context window. You can hold the whole thing in your head, and if you cannot, the run was probably too long anyway.</p>

<p>A Pro Lab is a <strong>graph</strong>. Most of its hosts are unreachable until you have owned another one. The work outlives your context window many times over. And the mistakes that cost you a day are not technical — they are organisational:</p>

<ul>
  <li>spraying a credential you already sprayed, three hosts and two hours ago;</li>
  <li>debugging a scan through a tunnel that died forty minutes ago, and reading the results as <em>“this host is hardened”</em>;</li>
  <li>forgetting which of nineteen hosts still has an unexplored port;</li>
  <li>locking out the one account that mattered, which is the only truly irreversible mistake available in a lab.</li>
</ul>

<p>Every one of those is a <em>state</em> problem. So campaign mode is almost entirely about state.</p>

<h2 id="the-two-level-loop">The two-level loop</h2>

<p>The single-host methodology is not replaced. It becomes the <strong>inner</strong> loop, run per host, unchanged. What lab mode adds is the outer loop that decides <em>which</em> host, keeps the network model on disk, and survives being resumed by a session that remembers nothing:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>campaign loop  ── pick the highest-value lead from the frontier
     │             (unowned reachable host · untried credential · new subnet)
     │
     ├─→ host loop   ── the pwnloop skill, scoped to one IP
     │                   recon → enum → foothold → privesc → flag
     │
     └─→ write back  ── every host, credential, route, flag and lead goes into
                        campaign.json through the CLI, never by hand
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/pwnloop <span class="o">&amp;&amp;</span> claude
<span class="o">&gt;</span> /pwnloop-lab 10.10.110.0/24     <span class="c"># an entry range</span>
<span class="o">&gt;</span> /pwnloop-lab 10.10.110.5        <span class="c"># or a single entry host</span>
</code></pre></div></div>

<p><strong>You pass the entry point, never the lab’s name</strong> — the same trade the single-host loop makes with machine names, for the same reason. A lab’s name is the strongest recall trigger there is, and the best-documented Pro Labs are the ones whose names carry the most. The campaign directory is derived from the address, <code class="language-plaintext highlighter-rouge">campaigns/.current</code> is the handle, and you are asked what the lab is called only at the end, when the search order is already on record. Asking then costs nothing. Asking at the start costs the entire measurement.</p>

<h2 id="state-is-the-design">State is the design</h2>

<p>The rule that makes the whole thing work is one line: <strong>nothing important lives in your context.</strong> If a fact matters after the current host, it goes through the state CLI the moment it is learned. Assume you will be resumed by a session that has read nothing.</p>

<p>There are four layers, and knowing which is which is the difference between a campaign that can be handed over and one that only exists in a transcript:</p>

<p><img src="/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/03.png" alt="The four state layers" /></p>

<p>The CLI is the <strong>only writer</strong> of the state file. On a twenty-host lab, free-form state edits drift within hours. Everything the agent learns goes in as a command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pwnloop host add 10.10.110.100 <span class="nv">os</span><span class="o">=</span>linux <span class="nv">subnet</span><span class="o">=</span>10.10.110.0/24 <span class="nv">ports</span><span class="o">=</span><span class="s2">"22 80 445"</span>
pwnloop cred add <span class="nv">user</span><span class="o">=</span>james <span class="nv">secret</span><span class="o">=</span><span class="s1">'S3cret!'</span> <span class="nb">source</span><span class="o">=</span>/var/www/.env <span class="nv">host</span><span class="o">=</span>10.10.110.100
pwnloop try c3 10.10.110.101 smb ok        <span class="c"># every spray result, win or lose</span>
pwnloop route add <span class="nv">subnet</span><span class="o">=</span>172.16.1.0/24 <span class="nv">via</span><span class="o">=</span>10.10.110.100 <span class="nb">type</span><span class="o">=</span>ligolo <span class="nv">canary</span><span class="o">=</span>172.16.1.5:445
pwnloop lead add <span class="nv">kind</span><span class="o">=</span>subnet <span class="nv">target</span><span class="o">=</span>172.16.2.0/24 <span class="nv">note</span><span class="o">=</span><span class="s2">"route print on .100"</span> <span class="nv">prio</span><span class="o">=</span>1
</code></pre></div></div>

<p>Three mechanisms carry most of the value.</p>

<p><strong>The credential matrix.</strong> Every spray result is recorded, win <em>or</em> lose, so <code class="language-plaintext highlighter-rouge">pwnloop try next</code> only ever suggests untried (credential, host, service) triples. A <code class="language-plaintext highlighter-rouge">locked</code> result removes that credential from every future suggestion. Recording a failure is worth as much as recording a success: it is what stops the next session re-running it.</p>

<p><strong>Route canaries.</strong> Every tunnel is registered with an IP:port behind it that is known to answer, so <code class="language-plaintext highlighter-rouge">route check</code> <em>tests</em> whether a route still carries traffic instead of trusting the state file. An unverifiable route is worse than no route — after a lab reset, a dead tunnel and a hardened target are indistinguishable, and one of those two readings wastes a whole session.</p>

<p><strong>Checkpoints.</strong> The state file records what is <em>true</em>. The checkpoint records what you were in the <em>middle of</em> and what you would have done next, which is the one thing nothing can infer for you. It is written before running out of room, not after, and <code class="language-plaintext highlighter-rouge">resume</code> prints it before anything else.</p>

<p>Then the resume protocol, in a fixed order: VPN first, because a dead VPN makes every route look dead. Then the <strong>entry host</strong>, before any route — it is the first hop of every chain, so if it was reset, everything below it is dead for one reason and testing them individually tells you nothing. Then re-establish dead routes. Then re-verify one owned host per subnet, because labs get reset. <em>Then</em> pick a lead.</p>

<h2 id="when-the-entry-point-is-one-host">When the entry point is one host</h2>

<p>The common Pro Lab shape is a single address fronting a network you cannot see yet. There, phase 0 is an ordinary single-host engagement — the machine loop, exactly as written, because there is no frontier to rank and depth is the only move.</p>

<p>The campaign proper begins <strong>at the first shell</strong>, and there the priority inverts: mapping outward outranks escalating locally. Interfaces, routes, ARP, DNS, domain trusts — each one becomes a host or a subnet lead <em>before</em> going back to privesc. A second NIC on the entry host is the actual door. Root on the entry host without it is a dead end with a flag attached.</p>

<h2 id="seven-sessions">Seven sessions</h2>

<p>The design assumes an operator with a few hours, not a weekend, and Puppet was run exactly that way: seven sessions, each ending with a deliberate checkpoint rather than just stopping.</p>

<p>Two things happened that I could not have arranged on purpose, and both are worth more than the completion.</p>

<p><strong>The lab went dark mid-run.</strong> Every implant on one host dropped at the same instant, and the secondary access path — a key-based login that had worked for two sessions — started being refused too. The correct read there is not “my technique broke”, it is “the environment moved”: simultaneous, uniform failure across independent mechanisms is an <em>environment</em> event. The loop tested once with verbose output, established that a clean key rejection is not a lockout, waited six minutes without hammering anything, retested once, and then wrote a checkpoint and stopped. That restraint is the whole game — the wrong move there is a retry storm that turns a temporary outage into a real lockout.</p>

<p><strong>The next session got it all back.</strong> It resumed from disk, found the environment recovered, re-established access and finished. That is precisely the property the state model exists to produce, and it is the first time I have seen it pay off under conditions I did not create.</p>

<h2 id="what-the-run-changed">What the run changed</h2>

<p>Finishing a campaign requires writing back into the methodology — it is not optional and it is not the report’s job. This one exposed two capability gaps that had been invisible while the loop only ever ran against single machines, and produced four more entries:</p>

<p><img src="/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/04.png" alt="What the campaign wrote back" /></p>

<p>The two new files are the ones I would point at. The reference set had <strong>nothing on deployment infrastructure</strong> — configuration management is an authenticated API whose entire purpose is running code as root on every node it manages, which makes it the highest-value target on an internal network, and the loop had no page telling it to look. And it had <strong>nothing on operating through a C2 framework</strong>, which is how a modern internal engagement actually moves, as opposed to the single shell a machine gives you.</p>

<p>The constraint on writing any of that down is the interesting part. <code class="language-plaintext highlighter-rouge">skills/</code> and <code class="language-plaintext highlighter-rouge">references/</code> are public, and a Pro Lab is never retired — so the write-back rule that is normally about <em>methodology quality</em> is here also a platform-rules boundary. The test before an entry is committed: <strong>could a reader use it to identify the lab, or skip a step on it?</strong> If yes, it goes in a gitignored memory file instead. Write the class, never the chain; and if a technique genuinely needs a concrete example, take it from a retired machine or vendor documentation, never from the campaign that prompted it.</p>

<h2 id="what-the-run-broke">What the run broke</h2>

<p>The more useful half. A live campaign found six defects, and the pattern in them is not subtle — five of the six are cases where a mechanism <strong>silently did nothing</strong> while everything looked fine:</p>

<p><img src="/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/05.png" alt="What the first live campaign broke" /></p>

<p>The one I would frame is the last one. <code class="language-plaintext highlighter-rouge">campaigns/</code> was gitignored on the branch that shipped the feature, but the working copy was checked out on a branch that predated it — so for a window, live engagement data sat in a repository that would happily have committed it. The content rules (private-key material, flag-shaped strings) would have caught it at commit time, which is the wrong layer to depend on. A path rule that only exists on one branch is not a control.</p>

<h2 id="why-there-is-no-write-up">Why there is no write-up</h2>

<p>The single-host loop produces a teaching write-up, and ten of them are in the repository. Campaign mode deliberately produces <strong>none</strong>.</p>

<p>Machines retire, and a retired machine’s solution becomes publishable. Pro Labs never retire — there is no future date at which sharing one stops being a violation, so the honest position is that the chain, the hostnames, the credentials, the per-host ledgers and the report stay in <code class="language-plaintext highlighter-rouge">campaigns/</code>, gitignored, on the machine that ran them, indefinitely.</p>

<p>What a campaign <em>may</em> publish is the numbers, and I think they are actually the more interesting artifact. A write-up proves a machine fell to <em>someone</em>; it reads the same whether the path took forty minutes or was known in advance. Hosts owned per session, how much frontier was open when a session ended, how long a resumed session took to get back to productive work, how many credentials the matrix replayed into a foothold — those describe the <strong>loop</strong>, not the lab. They are what should improve between one campaign and the next, and none of them says what any host was running.</p>

<p>The lab’s own page is a different matter: its name, entry point, machine count and scenario are the vendor’s marketing copy, and repeating them discloses nothing. The line is between what the platform tells everyone and what the lab told <em>me</em>.</p>

<h2 id="honest-limitations">Honest limitations</h2>

<p><strong>Three hosts is a small network.</strong> Campaign mode is designed for nineteen. Two of its mechanisms barely fired: the credential matrix ended with ten credentials and a single recorded attempt, and per-host <strong>delegation to subagents never triggered at all</strong>, because it only kicks in at three or more live hosts on one subnet. The machinery that is supposed to matter most at scale is, at this point, untested at scale. (The empty matrix is also how one of the six defects was found: a campaign can accumulate credentials while recording no attempts, silently disabling the exact mechanism that prevents re-spraying and lockouts. <code class="language-plaintext highlighter-rouge">campaign status</code> now says so loudly.)</p>

<p><strong>Wall-clock is not effort.</strong> Four and a half hours is elapsed time across seven sessions on a three-host lab. It is a baseline for <em>this lab, this methodology</em> — the number a second run gets measured against — not a claim about Pro Labs in general.</p>

<p><strong>One campaign is not a trend.</strong> Same caveat as the ten machines. The honest test of “the loop got better” is the same lab twice, with a methodology that changed in between, recording what memory short-circuited and what stayed slow anyway.</p>

<p><strong>A campaign is its own only record.</strong> Because nothing publishable comes out of one, the campaign directory has no replication path — which is why this release also added <code class="language-plaintext highlighter-rouge">pwnloop backup</code>, an encrypted archive of exactly the two things git does not track and cannot recover: the local memory file, and <code class="language-plaintext highlighter-rouge">campaigns/</code>.</p>

<hr />

<p><a href="https://github.com/euriconicacio/pwnloop"><strong>github.com/euriconicacio/pwnloop</strong></a> — MIT. The results index is <a href="https://github.com/euriconicacio/pwnloop/blob/main/labs.md"><code class="language-plaintext highlighter-rouge">labs.md</code></a>; it explains exactly what may and may not go in a row.</p>

<p>The next thing I want is a bigger network — enough hosts that delegation and the credential matrix have to carry real weight, and enough sessions that the resume protocol is doing something other than working. If you run this against something that breaks it, that is the most useful thing you could send me.</p>

<p><em>Eurico Nicacio —</em> <a href="https://github.com/euriconicacio"><em>@h3llh0und</em></a></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[Campaign mode points the loop at a network instead of a box — a frontier, a credential matrix, tunnels that prove themselves. Its first run took HTB's free Puppet mini Pro Lab end to end: 3/3 hosts, 4/4 flags, seven sessions, ~4h30.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-lab-mode-a-pro-lab-in-four-and-a-half-hours/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">pwnloop: an autonomous engagement loop for lab machines</title><link href="https://euriconicacio.github.io/blog/pwnloop-an-autonomous-engagement-loop-for-lab-machines/" rel="alternate" type="text/html" title="pwnloop: an autonomous engagement loop for lab machines" /><published>2026-08-03T12:00:00+00:00</published><updated>2026-08-03T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/pwnloop-an-autonomous-engagement-loop-for-lab-machines</id><content type="html" xml:base="https://euriconicacio.github.io/blog/pwnloop-an-autonomous-engagement-loop-for-lab-machines/"><![CDATA[<blockquote>
  <p><strong><em>Disclaimer.</em></strong> <em>This is a personal open-source project, built and run on my own equipment and my own accounts. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every target was a</em> <strong><em>retired</em></strong> <em>Hack The Box machine, engaged over that platform’s own VPN and within its terms of service. No flag values appear in this post or in the repository — flag sharing is a platform violation regardless of a machine’s status.</em></p>
</blockquote>

<p><img src="/blog/assets/images/posts/pwnloop-an-autonomous-engagement-loop-for-lab-machines/01.png" alt="pwnloop: an autonomous engagement loop for lab machines" /></p>

<p><code class="language-plaintext highlighter-rouge">pwnloop</code> is a Claude Code skill plus a disposable Kali container. You spawn a lab machine, hand it the address — <strong>just the address</strong> — and it runs the engagement end to end:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; /pwnloop 10.129.x.x
  [recon]    3 ports — 21 vsftpd 3.0.3, 22 OpenSSH 8.2p1, 80 Gunicorn
  [web]      /capture → 302 /data/1 — sequential id, no ownership check
  [web]      /data/0 belongs to another user. IDOR confirmed
  [analysis] pcap holds a cleartext FTP login
  [foothold] password reused on SSH
             user.txt — &lt;32-hex-flag&gt;
  [privesc]  cap_setuid on /usr/bin/python3.8
             root.txt — &lt;32-hex-flag&gt;
  [cleanup]  1 artifact removed, verified
</code></pre></div></div>

<p>It does not stop between phases. It announces each flag the moment it reads one, so you can submit while it keeps working. It removes what it created on the target before it finishes. It writes three documents: a live findings ledger, a defender-facing report, and a teaching write-up. And then it does the part I care about most — it changes its own methodology based on what the run taught it, because the run is not allowed to close until it has.</p>

<p>It is MIT-licensed: <a href="https://github.com/euriconicacio/pwnloop"><strong>github.com/euriconicacio/pwnloop</strong></a></p>

<h2 id="getting-it-running">Getting it running</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/euriconicacio/pwnloop ~/pwnloop
<span class="nb">cd</span> ~/pwnloop <span class="o">&amp;&amp;</span> ./install.sh
</code></pre></div></div>

<p>That links the skill into <code class="language-plaintext highlighter-rouge">~/.claude/skills/</code> and builds the container. Then the VPN — which runs <em>inside</em> the container, so your host’s routing table is never touched:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> ~/Downloads/lab_yourname.ovpn ~/pwnloop/vpn/
pwnloop vpn lab_yourname.ovpn
pwnloop vpn-status          <span class="c"># expect an address on tun0</span>
</code></pre></div></div>

<p>Spawn a machine, then <code class="language-plaintext highlighter-rouge">cd ~/pwnloop &amp;&amp; claude</code> and <code class="language-plaintext highlighter-rouge">/pwnloop &lt;ip&gt;</code>.</p>

<p>The container ships the usual toolchain — nmap, ffuf, feroxbuster, netexec, impacket, evil-winrm, certipy, kerbrute, sqlmap, hashcat, responder, tshark, chisel, SecLists — plus linpeas, winPEAS and pspy staged for delivery to targets. On Apple Silicon it builds native arm64, no emulation.</p>

<h2 id="why-only-the-address">Why only the address</h2>

<p>This is the first thing people push back on, so it is worth taking early.</p>

<p>The machine’s name is the strongest recall trigger there is. Hand a model a well-known box’s name and it can return the published chain before a single packet has been sent — and you will never know how much of the run was discovery and how much was retrieval. Working from an address alone means recognition can only happen <em>after</em> enumeration has earned the fingerprint, by which point the search order was already set honestly.</p>

<p>Three rules follow from it, and they are in the skill file rather than in my good intentions:</p>

<ul>
  <li><strong>The engagement directory is named after the address</strong>, and the run asks what the box is called only at the very end, when the work is done and the search order is on record.</li>
  <li><strong>Never look up the answer.</strong> Researching a <em>technology</em> — a CVE, a protocol, an exploit’s source, what a capability actually grants — is expected and constant. Opening the target’s own walkthrough is not. The line is between “how does this thing work” and “what is the path on this box.”</li>
  <li><strong>Every action must trace to an artifact already collected.</strong> Before running a command you should be able to name the file and the line of output that motivated it. If the reason is “boxes like this usually have X,” that is recall, not deduction — go collect the observation first.</li>
</ul>

<p>And when recognition happens anyway, the run declares it in the ledger before the next command. A recognised machine is still worth running; it validates tooling and coverage. What it stops being is evidence that the loop <em>discovers</em>. Recording that distinction is the difference between a demo and a measurement.</p>

<h2 id="what-you-actually-get">What you actually get</h2>

<p>The output is the point, more than the root shell is.</p>

<p><code class="language-plaintext highlighter-rouge">**FINDINGS.md**</code> is an append-only ledger, updated live. Every finding carries an evidence file and a status: <code class="language-plaintext highlighter-rouge">LEAD</code>, <code class="language-plaintext highlighter-rouge">CONFIRMED</code>, <code class="language-plaintext highlighter-rouge">PARKED</code>, or <code class="language-plaintext highlighter-rouge">DEAD</code> — and <code class="language-plaintext highlighter-rouge">DEAD</code> carries the reason it was ruled out. If you are demonstrating this to a room, project a <code class="language-plaintext highlighter-rouge">tail -f</code> of this file rather than the transcript. Watching findings land in real time is the thing that lands.</p>

<p><code class="language-plaintext highlighter-rouge">**REPORT.md**</code> argues to a defender: the chain as a numbered path, findings with impact and remediation, and a section naming the single control that would have broken the chain earliest. That last part is the one that changes what gets funded.</p>

<p><code class="language-plaintext highlighter-rouge">**WRITEUP.md**</code> teaches a reader: the narrative, including — and this is the section most write-ups skip — the leads that failed and why they were dead ends.</p>

<p>They are separate documents on purpose. A defender does not want your narrative, and a learner does not want your CVSS table. Merging them serves neither.</p>

<h2 id="ten-machines">Ten machines</h2>

<p>I started this on a Friday with a container and a 200-line skill file. By Sunday it had rooted ten retired Hack The Box machines and gone through seven releases, every one of them driven by something a run discovered the methodology was missing.</p>

<p><img src="/blog/assets/images/posts/pwnloop-an-autonomous-engagement-loop-for-lab-machines/02.png" alt="pwnloop: an autonomous engagement loop for lab machines" /></p>

<p>Redacted write-ups for all ten are in the repository. Times ranged from <strong>three minutes</strong> to <strong>just over two hours</strong>, and the spread is not really about difficulty — it is about how much of the chain was enumeration versus how much was code that had to be written on the spot.</p>

<p>Two of them are worth reading in full, for opposite reasons.</p>

<p><strong>Nexus</strong> is the one where composition mattered. A world-readable maintenance script synchronised “template” repositories: for each one it walked the git tree and extracted every blob to <code class="language-plaintext highlighter-rouge">os.path.join(staging_dir, filepath)</code>, where <code class="language-plaintext highlighter-rouge">filepath</code> came straight out of <code class="language-plaintext highlighter-rouge">git ls-tree</code>. No normalisation, no containment check, and a systemd unit ran it as root every sixty seconds.</p>

<p>The exploit is a path that escapes the staging directory. Git will not build one through the index — <code class="language-plaintext highlighter-rouge">git add</code> rejects <code class="language-plaintext highlighter-rouge">..</code>. But the index is a convenience layer; tree objects are just name-to-hash maps, and <code class="language-plaintext highlighter-rouge">git mktree</code> writes them directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BLOB=$(cat key.pub | git hash-object -w --stdin)
T=$(printf '100644 blob %s\tauthorized_keys\n' "$BLOB" | git mktree)
T=$(printf '040000 tree %s\t.ssh\n' "$T" | git mktree)
T=$(printf '040000 tree %s\troot\n' "$T" | git mktree)
for i in 1 2 3 4 5; do T=$(printf '040000 tree %s\t..\n' "$T" | git mktree); done
</code></pre></div></div>

<p>The forge accepted the push without running fsck on the objects. A minute later, in root’s log:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">Found 1 template repo(s)</span>
  <span class="s">synced</span><span class="err">:</span> <span class="s">../../../../../root/.ssh/authorized_keys</span>
</code></pre></div></div>

<p>To be precise about what that shows: the loop did not invent a technique. It read a script it could not modify, noticed a filesystem path came from a source an ordinary user controls, checked what the systemd unit ran as, and composed the primitive out of shell. Ordinary competent work — executed without stopping to ask, with an evidence file behind every step.</p>

<p><strong>Forest</strong> is the one where the interesting part was a failure in the middle. The chain itself is one of the most documented paths in Active Directory. But the loop’s first attempt added itself to a group over an existing WinRM session and then could not use the new rights, because a Windows access token is fixed at logon: the session it already held did not contain the group it had just joined. And the box runs a reset job that reverts changes every few minutes, so a fresh login raced against it.</p>

<p>It diagnosed both — read <code class="language-plaintext highlighter-rouge">memberOf</code> back out of LDAP, saw the membership was gone — and switched to chaining the whole sequence from a single Linux-side window, re-authenticating on each step so that neither a stale token nor the reset window could interfere. That is the behaviour I was actually testing for: not the exploit, but what it does when the obvious approach silently fails.</p>

<h2 id="why-it-is-built-this-way">Why it is built this way</h2>

<p>This is where the tool differs from a prompt that says “hack this box,” and it is worth explaining, because the difference is most of the value.</p>

<p>Almost every agent I have seen fail, fails the same way, and it is not a capability problem. It re-derives what it already worked out, because nothing was written down. It cycles through the same three ideas, because nothing records that idea two was ruled out. It spends an hour on the most <em>interesting</em> lead rather than the most <em>productive</em> one, because nothing tells it when to quit. It reports a step it never verified. Or it stops to ask a question it had everything it needed to answer.</p>

<p>Five failures, all of them control-flow. So the skill file is not written as a prompt. It is written as a loop specification, and every rule maps to a failure it prevents:</p>

<p><img src="/blog/assets/images/posts/pwnloop-an-autonomous-engagement-loop-for-lab-machines/03.png" alt="pwnloop: an autonomous engagement loop for lab machines" /></p>

<p>The grounding invariant is the one I would keep if I could keep only one. It forces the loop to operate on <em>observed</em> state rather than <em>plausible</em> state. An agent permitted to write “the service appears vulnerable” will eventually build on a vulnerability that was never there. An agent required to attach the request and the response cannot.</p>

<p>The escalation predicate is the one people get wrong most often. Generous criteria for “ask the human” are how an autonomous agent quietly becomes a chat session.</p>

<h2 id="the-second-loop">The second loop</h2>

<p>Every engagement changed the tool — not because I sat down afterwards to improve it, but because <strong>finishing a run requires writing back into it</strong>. A pattern that generalises goes to a memory file the <em>next</em> run reads before it starts. A tool that had to be installed mid-run becomes a package. A technique that worked and was not documented becomes a reference section. A mistake becomes a rule.</p>

<p>Over the weekend that turned 14 reference files into 23 — Kubernetes, LLM and agent platforms, container escape, cloud metadata, the full AD CS ESC1–ESC16 catalog, NTLM/Kerberos coercion and relay, binary exploitation — roughly 3,500 lines of methodology, all of it produced by a run that needed it and did not have it.</p>

<p>The rule that keeps that file useful is a small one: <strong>write the method, never the box’s answer.</strong> A reference entry that reads “product X version N → CVE-Y → run this payload” bakes one machine’s solution into the methodology and turns the next run into recall. The transferable class goes into the shared references; the box-specific recipe stays in a local memory file that is not shared at all.</p>

<p>The single change I am proudest of is a <strong>deletion</strong>. An early run left the rule “fix Kerberos clock skew with <code class="language-plaintext highlighter-rouge">ntpdate -u &lt;dc&gt;</code>.” A later one proved that cannot work — the container has no <code class="language-plaintext highlighter-rouge">CAP_SYS_TIME</code>, so <code class="language-plaintext highlighter-rouge">ntpdate</code> measures the offset and then fails to apply it — and it would have blocked certificate authentication outright. The entry was removed and replaced with a <code class="language-plaintext highlighter-rouge">faketime</code> shim that shifts one process instead of the system clock.</p>

<p>A loop that only accumulates gets worse over time. The interesting property is that this one can also take something out.</p>

<h2 id="what-its-failure-looks-like">What its failure looks like</h2>

<p>The version of this post I wrote after three machines ended with an admission: none of them had defeated the loop, so I did not know what its failure looked like, and a loop you have never seen fail is a loop you do not understand.</p>

<p>Seven machines later I know. It failed three times, in three distinct ways, and these are the most valuable results of the weekend.</p>

<p><strong>It skipped the CVE hunt and paid for the whole run.</strong> On one Linux box the loop enumerated well, found the service, pinned the product — and then started hand-rolling exploitation against it instead of going to look for published vulnerabilities in the version it had just pinned. The intended path was a recent CVE with a public proof-of-concept, and the delivery detail that made it work (which protocol field was the actual sink) was sitting in that PoC. The fix is now a primary step, not an afterthought: pin the precise version with a protocol-specific probe rather than an <code class="language-plaintext highlighter-rouge">nmap -sV</code> guess, then hunt CVEs and public exploits <em>before</em> improvising.</p>

<p><strong>It tunnelled on the wrong CVE in the right binary.</strong> On the last machine of the weekend, root was a custom daemon running as root on localhost. The loop found a memory-corruption CVE in it, confirmed the overflow triggered on-target, got an address leak that defeated PIE — and spent an hour and a half designing an exploit. The primitive turned out to be a <em>linear-forward</em> write rather than an arbitrary one, which put the obvious target out of reach; public research on that CVE stops short of a working 64-bit exploit for exactly the same reason. Days of exploit development sat behind that door. Meanwhile a second, much cheaper CVE in the same daemon — an argument injection in a format string — was the intended path and is a one-liner.</p>

<p>That produced the rule I would hand to anyone building this kind of thing: for a pinned version, enumerate <em>all</em> of its CVEs and reason about the <strong>set</strong>. Rank by cost and reliability — an auth bypass or an argument injection beats a memory-corruption bug on a hardened target — consider chaining them, and exhaust public weaponised exploits before writing your own. Search tools index detection PoCs; the working exploit is often in a GitHub repository they do not index.</p>

<p><strong>It hit a genuine dead end and needed a human.</strong> On a hardened domain controller, with every remote relay path closed — no egress, no WebClient, mandatory SMB and LDAP signing — the loop exhausted every evidenced lead and was still one technique short. That is a legitimate moment to come back to the operator, and the contract now says so explicitly: surface a <em>precise</em> status — what is confirmed, what is ruled out and why, and the specific fork you are stuck on — and accept a steer.</p>

<p>With one condition attached. If the steer brings in outside knowledge, the run <strong>declares it in the ledger</strong>, exactly as it declares recognition. The run then stops being evidence that the loop found that step on its own, and hiding it would make every other result untrustworthy. The escalation is also kept out of the shared methodology: a human-supplied technique does not get filed as a self-found pattern.</p>

<p>Three failures, three structural changes. That is the mechanism working, and it is worth more than the seven runs where nothing went wrong.</p>

<h2 id="two-design-decisions-people-ask-about">Two design decisions people ask about</h2>

<p><strong>Why not a Kali MCP server?</strong> There are good ones, and I considered it. MCP gives you typed, discoverable, individually-permissioned tools. It also fixes your surface to an enumeration: you can only do what the server wrapped. Half of Nexus’s chain was shell composed on the spot — nested <code class="language-plaintext highlighter-rouge">git mktree</code> in a loop, a three-request session dance with CSRF tokens, parallel requests via <code class="language-plaintext highlighter-rouge">xargs -P</code>. No <code class="language-plaintext highlighter-rouge">run_nmap(target, ports)</code> builds a git tree. Arbitrary shell composition <em>is</em> the capability here.</p>

<p>The isolation people assume MCP provides comes from somewhere else anyway: the container is the boundary — the offensive toolchain and the lab VPN never touch the host — and the permission allowlist means Claude can invoke the wrapper and nothing else. MCP is an interface; the container is a fence. Worth not confusing them.</p>

<p><strong>Why not just install one of the existing plugins?</strong> I read through them before starting. Several are excellent and far broader — one ships thirty-one skills covering command-and-control, EDR evasion, shellcode development and mobile testing. I took four ideas and rejected the rest on purpose. A lab machine needs none of that, and a repository shipping it is a different kind of artifact with different obligations. There was also a practical reason: for a tool I would demonstrate to a security team, I wanted something whose entire surface can be read in an afternoon rather than a marketplace plugin with automatic hooks.</p>

<p>The most interesting rejection was a “look up the write-up” capability. It would raise the completion rate immediately, and destroy the only thing being measured. If the loop cannot find the path on its own, <em>that</em> is the result I want.</p>

<h2 id="honest-limitations">Honest limitations</h2>

<p><strong>Recognition is a confound and I cannot remove it.</strong> Several of these are well-known retired machines, and a model that has read the public internet has read their write-ups. Withholding the name is the one control I can actually enforce rather than merely trust, and declaring recognition when it happens is the other half. But “ten machines rooted” is not “ten machines discovered,” and I would not present it as such. The runs where the loop reasoned its way through a failure — a fixed access token, a reset job, a linear-forward write — are better evidence than the completion count is.</p>

<p><strong>Ten machines is still a small sample.</strong> Two of them are AD CS variants, three are credential-reuse chains. There is a lot of the space nobody has pointed this at: no serious binary exploitation succeeded end to end, nothing with an active defender, nothing multi-host.</p>

<p><strong>The container is not reproducible.</strong> It builds from a rolling-release base with unpinned packages, so builds are resilient — a package that disappears is logged rather than breaking the image — but not bit-for-bit repeatable. Fine for lab work. For engagement tooling behind a report someone relies on, I would pin the base image by digest and freeze the package snapshot.</p>

<p><strong>Re-running is the measurement I have barely done.</strong> The honest test of “the loop got better” is the same machine twice, with a methodology that changed in between, recording what memory short-circuited and what stayed slow anyway. The skill supports it and the format is specified. I have one weekend of it, which is not enough to claim a trend.</p>

<p><a href="https://github.com/euriconicacio/pwnloop"><strong>github.com/euriconicacio/pwnloop</strong></a> — MIT. Issues and forks welcome; if you run it against something that breaks it, that is the most useful thing you could send me. A target that beats the loop is worth more to me right now than another one it solves.</p>

<p><em>Eurico Nicacio —</em> <a href="https://github.com/euriconicacio"><em>@h3llh0und</em></a></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[Hand it an IP and it runs recon to root to cleanup to report without checking in — then rewrites its own methodology before it is allowed to finish.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-an-autonomous-engagement-loop-for-lab-machines/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/pwnloop-an-autonomous-engagement-loop-for-lab-machines/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cloud Forensics in the Age of AI Agents: A Field Report</title><link href="https://euriconicacio.github.io/blog/cloud-forensics-in-the-age-of-ai-agents-a-field-report/" rel="alternate" type="text/html" title="Cloud Forensics in the Age of AI Agents: A Field Report" /><published>2026-07-31T12:00:00+00:00</published><updated>2026-07-31T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/cloud-forensics-in-the-age-of-ai-agents-a-field-report</id><content type="html" xml:base="https://euriconicacio.github.io/blog/cloud-forensics-in-the-age-of-ai-agents-a-field-report/"><![CDATA[<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/01.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<blockquote>
  <p>⚠️ Disclosure — please read this first</p>

  <p><strong>This work was performed under an independent consulting engagement, in a client environment that has no connection to any current or former employer of mine.</strong> Nothing here describes, references, or is drawn from any employer’s infrastructure, incidents, or data.</p>

  <p><strong>Every client-identifying detail has been removed or replaced:</strong> the AWS account, the organization, the support case, the access key and principal, bucket and resource names, the source address (now an RFC 5737 documentation address), and all absolute dates, which appear only as offsets from the credential’s first known use. What remains is method and structure.</p>

  <p>Nothing in this post is an indicator of compromise for any real, currently-reachable environment.</p>
</blockquote>

<p>If you run anything on AWS, you already know the email. Subject line in brackets, and the brackets are the tell:</p>

<blockquote>
  <p><strong>[Action Required] Irregular Activity Detected for Your AWS Access Key</strong></p>

  <p>As part of our standard monitoring of AWS systems, we observed anomalous activity in your AWS account that indicated your AWS access key(s), along with the corresponding secret key, may have been inappropriately accessed by a third party…</p>
</blockquote>

<p>Then the instructions: delete the key — <strong>delete</strong>, not disable — rotate what it touched, review the account, confirm back. Attached, a short list of the events their monitoring flagged, all from a single day.</p>

<p>Four steps, and one of them is doing an enormous amount of load-bearing work in a single sentence: <em>review your account for unauthorized activity.</em></p>

<p>Here is what that sentence actually asked for, once we went looking:</p>

<ul>
  <li><strong>3,122 API calls</strong> recovered across seventeen regions and a twenty-day window — nineteen days longer than the notification suggested;</li>
  <li>the same 3,122 reconstructed <strong>a second time</strong>, from 35,486 raw log files parsed from scratch, to confirm the first number wasn’t an artifact of the tool that produced it;</li>
  <li>a <strong>third source</strong> — 17,776 S3 server access logs — covering activity the first two structurally could not record;</li>
  <li>exfiltration assessed <strong>without the direct evidence existing</strong>, from billing telemetry, with the limits of that inference written down;</li>
  <li>a <strong>controlled experiment</strong> in a clean account to resolve one ambiguous line in the report;</li>
  <li>and the case-closing response to AWS Support, cut to fit a hard character limit without giving away more than it had to.</li>
</ul>

<p>That took two and a half hours and fourteen messages from me. A conventional version of the same engagement is four to six days, and it does not include the second and third sources at all — those are the first things a deadline removes.</p>

<p>This is a field report on how that work actually goes: the order it happened in, what the agent did, where it was confidently wrong, and what a human still had to do.</p>

<p>The thesis, stated up front so you can argue with it as you read: <strong>the agent didn’t do the investigation. It collapsed the cost of “let me just check that” to near zero, and that changes which hypotheses you’re willing to test.</strong> Everything else in this post is evidence for or against that sentence.</p>

<h2 id="ground-rules">Ground rules</h2>

<p>Beyond the disclosure above: the structural numbers — call counts, service counts, byte counts, timings — are real and unmodified. They’re the only part of this with any teaching value, and rounding them would make the post useless without making anyone safer.</p>

<p>The cloud is AWS and I’m going to say so, because every service name below already gives it away and pretending otherwise would just make this harder to read. Naming the platform identifies nobody; the specifics that would are the ones I’ve taken out.</p>

<p>The client received a prioritized remediation plan covering the posture findings this work surfaced. Those specifics aren’t in this post and won’t be — what’s here is the method, plus the one or two findings that generalize past the environment that produced them.</p>

<p>I’m also going to be specific about the tooling, because vagueness is how this genre becomes marketing. I worked in a terminal, with an agentic assistant that can run shell commands, read their output, and decide the next command on its own. It had the AWS CLI, read access to the account through SSO, a scratch directory, and no ability to change anything in the environment. Everything below that sounds like magic is a shell loop the agent wrote, ran, and read back.</p>

<h2 id="hour-zero">Hour zero</h2>

<p>That attached list of events is where most incidents go wrong. It reads like the scope of the compromise. It is, in fact, a <em>sample</em> — the calls that happened to trip AWS’s own monitoring, on the day it tripped. Treating it as the incident is the first and most common error, and here it was off by nineteen days.</p>

<p>What we actually knew:</p>

<ul>
  <li>one access key, belonging to an IAM user whose name described its job (something like <code class="language-plaintext highlighter-rouge">s3-manager</code>), carrying broad S3 permissions and nothing else;</li>
  <li>one source address, in a European hosting range;</li>
  <li>a handful of flagged events, all from the final day.</li>
</ul>

<p>What we didn’t know: when it started, what it reached, whether anything was written, whether anything was read, whether other credentials were involved, and whether the audit trail itself was intact.</p>

<p>And the constraints, which are the part worth memorizing, because they’re everyone’s constraints:</p>

<ol>
  <li><strong>CloudTrail data events were not enabled.</strong> No <code class="language-plaintext highlighter-rouge">GetObject</code>, no <code class="language-plaintext highlighter-rouge">ListObjectsV2</code>, no <code class="language-plaintext highlighter-rouge">PutObject</code> — none of it in the trail. The control plane sees who asked <em>about</em> your buckets. It does not see who read them.</li>
  <li><strong>Lookup is capped at ninety days</strong>, and the log bucket’s lifecycle matched it. The key had existed for years. Anything before that horizon is structurally unknowable, and no amount of cleverness recovers it.</li>
  <li><strong>Seventeen Regions.</strong> An enumeration campaign doesn’t respect the two or three Regions you actually deploy to.</li>
  <li><strong>The detective controls that should have caught this were, in aggregate, absent or non-functional.</strong> Why, and what that cost in days, is Act IV — but it isn’t knowable yet at this point in the story, and that ordering is the point.</li>
</ol>

<p>A human working this alone triages by budget. You pick the two Regions you deploy in, pull events for the flagged key, build a timeline, and write it up. It’s not sloppy — it’s arithmetic. Sweeping seventeen Regions with pagination, then independently parsing every raw log file the trail ever delivered as a cross-check, then downloading eighteen thousand access logs on a hunch, is a week of work to answer questions you’re fairly sure are already answered.</p>

<p>That arithmetic is what changed. Not the judgment. The arithmetic.</p>

<h2 id="act-itwo-independent-sources-or-it-didnt-happen">Act I — Two independent sources, or it didn’t happen</h2>

<p>The first thing I asked for was the obvious one: every event associated with that key, all Regions, full retention window, paginated properly.</p>

<p>The result came back at <strong>3,122 events</strong>. All from a single source address. All from a single key.</p>

<ul>
  <li><strong>84 successes</strong></li>
  <li><strong>2,539</strong> <code class="language-plaintext highlighter-rouge">AccessDenied</code></li>
  <li><strong>499</strong> <code class="language-plaintext highlighter-rouge">Client.UnauthorizedOperation</code></li>
  <li><strong>0 writes</strong> — not one event with <code class="language-plaintext highlighter-rouge">readOnly=false</code></li>
</ul>

<p>A 97.3% denial rate, which by itself tells you the shape of the thing: this is not an operator who knows what they have. This is a script finding out.</p>

<p>Now. That number, 3,122, was produced by a machine that I could not fully audit, running a command I skimmed, against an API whose pagination behavior is a well-known source of silent truncation. I have seen <code class="language-plaintext highlighter-rouge">lookup-events</code> quietly return the first page and move on. Earlier in the same investigation, a per-user collection did exactly that: capped at fifty results and reported success.</p>

<p>So the second thing I asked for was the same answer by a different road: download every raw log file the trail had delivered to S3 across the window, and parse them from scratch.</p>

<p>That’s <strong>35,486 gzipped files, about 172 MB, 298,196 events</strong> — the entire account’s activity, every principal, every Region, not just our key. Written to disk, decompressed, parsed by a small script with no knowledge of the first result.</p>

<p>The two methods agreed exactly:</p>

<blockquote>
  <p><strong>3,122 events. 84 successes. 2,539 denials. 499 unauthorized operations. Zero writes.</strong></p>
</blockquote>

<p>Number for number, from two collection paths that share no code, no API, and no assumptions.</p>

<p>This is the part I want to press on, because it’s the actual methodological shift and it’s the opposite of the one people expect. Agents make it cheap to <em>produce</em> an answer. That makes the answer <em>less</em> trustworthy per unit of effort, not more — you didn’t feel the cost of producing it, so you don’t instinctively weigh it. The correction isn’t to slow down. It’s to spend the surplus on <strong>independent confirmation of anything you’ll put your name on.</strong></p>

<p>A second source that shares the first source’s collection path is not a second source. Re-running <code class="language-plaintext highlighter-rouge">lookup-events</code> with different flags proves nothing about <code class="language-plaintext highlighter-rouge">lookup-events</code>. The raw files were a genuine second source because the only thing they have in common with the API is the service that wrote them.</p>

<p>The full-account parse paid for itself twice more, incidentally. It established that in 298,196 events across the window, <strong>the compromised key was the only access key with any activity at all</strong> — the other keys on the account had exactly zero. And it established that the account’s entire population of sensitive writes in that period was six events, all from a legitimate automated setup process on a day the attacker wasn’t active. Both of those are negative results, both took minutes, and neither would have been worth a human’s afternoon.</p>

<h2 id="the-shape-of-the-campaign">The shape of the campaign</h2>

<p>With the inventory settled, the sessionization is trivial: sort by time, cut wherever the gap exceeds thirty minutes. <strong>Fourteen sessions across seven active days</strong>, from Day 0 to Day +20.</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/02.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>Session 02 is the signature: <strong>805 calls against 41 distinct services across 16 Regions in eighteen minutes.</strong> Nobody does that by hand. That’s a permission-brute-forcing tool walking a service catalogue.</p>

<p>Session 12 is my favorite artifact of the whole campaign. One call — <code class="language-plaintext highlighter-rouge">sts:GetCallerIdentity</code> — with the literal user agent string <code class="language-plaintext highlighter-rouge">aws-cred-validator</code>. Thirty-six minutes before the largest session of the last day. That’s not the attacker’s exploitation tooling; that’s their <em>inventory management</em>. The credential was sitting in a collection, and something checked whether it was still alive before the operator spent time on it. The other sixty-nine user-agent variants were rotating Boto3 strings on Python 3.12 on a generic Linux kernel, which tells you nothing except that they weren’t trying to hide.</p>

<p>And the 84 successes, in full:</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/03.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>Four read-only APIs. Twenty days of effort against forty-three services in sixteen Regions produced <em>a list of bucket names and confirmation of their own identity</em>. Least privilege at the service boundary held — the grant was broad within S3 and nonexistent outside it, and that boundedness is the entire reason this is a post about detection rather than a post about a breach.</p>

<p>Hold on to those first two rows, though. Fifty-six successful calls to a service the account didn’t use. They come back later, and they’re the most interesting thing in the dataset.</p>

<h2 id="act-iiproving-an-absence">Act II — Proving an absence</h2>

<p>Here’s the question the client actually needed answered, and the one no amount of event-counting resolves: <strong>did anything leave?</strong></p>

<p>The credential had broad S3 permissions. There were no data events. <code class="language-plaintext highlighter-rouge">GetObject</code> is invisible in the trail by construction. The direct evidence doesn’t exist and never did — you cannot go get it, because it was never recorded.</p>

<p>So you reason about ghosts. Reading an object costs money and generates telemetry in the billing system, which is an entirely different subsystem from CloudTrail, with different failure modes and — critically — no attacker-facing surface. Nobody tampers with Cost Explorer.</p>

<p>The agent pulled usage by usage type for a 25-day pre-attack baseline and the 21-day attack window, computed the deviation of each attack-window peak from its baseline mean, and flagged the outliers:</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/04.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>Read rate went <em>down</em> during the attack window. Three rows got flagged, and this is where a fast tool becomes a liability if you let it write your conclusions: a z-score of 15,497 looks like the smoking gun of the century, and it is nothing at all. The baseline standard deviation is effectively zero, so any rounding-level movement produces an astronomical score. Look at the absolute columns instead — zeroes at the precision the API reports. The 44.5 is the same artifact: 854.6 against a baseline of 865.4 is <em>less</em> traffic wearing a scary number.</p>

<p>The one genuine outlier, the 5.5, took real work to dismiss. It traced to organic traffic from a CDN-fronted site in that Region, on days that didn’t intersect the attacker’s sessions at all.</p>

<p>Then the same question from the events themselves, which do carry byte counts even for management-plane calls: <code class="language-plaintext highlighter-rouge">additionalEventData.bytesTransferredIn/Out</code> for every S3 event in the window.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bytes out : 71,388     (12 × ListBuckets, byte-identical at 5,949 bytes each)
bytes in  : 0
</code></pre></div></div>

<p>Twelve identical responses. The same bucket list, fetched twelve times over twenty days, unchanged. Nothing else moved.</p>

<p>And now the sentence that matters more than any of the numbers above, which went into the report in exactly this form:</p>

<blockquote>
  <p><strong>This method excludes bulk exfiltration. It does not exclude the retrieval of a small number of small objects.</strong></p>
</blockquote>

<p>That is the honest boundary of billing-as-proxy. A few hundred kilobytes of credentials pulled out of a config file would hide comfortably inside the noise of an account’s daily variance. I can prove nothing large left. I cannot prove nothing left.</p>

<p>Writing that down is not humility as a personal virtue. It’s the load-bearing structure of the whole report. Every reader who knows the domain is going to ask “but data events were off, so how do you know?” — and a report that has already answered it survives that question, while a report that quietly rounds “no evidence of exfiltration” into “no exfiltration” dies on it, along with everything else it claims.</p>

<p>Speed makes overclaiming easier, because the confident summary arrives at the same moment as the evidence, in the same voice, formatted the same way. The model will write “no exfiltration occurred” as readily as it writes the nuanced version. Deciding which one is true is not a task you can delegate, and it is not a task that gets easier when the evidence arrives faster.</p>

<h2 id="act-iiithe-third-source-and-the-thing-only-it-could-see">Act III — The third source, and the thing only it could see</h2>

<p>Act II reasoned about data access indirectly, because the direct record didn’t exist. There was one exception, and it was worth chasing.</p>

<p>The trail’s own log bucket was the single bucket in the account with S3 server access logging enabled. That’s a data-plane record — <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">DELETE</code> on objects and buckets, exactly what CloudTrail wasn’t capturing. It covers one bucket out of many, but it’s the one that matters most: it holds the audit trail itself. If the attacker went near the logs, this is the only place it would appear.</p>

<p>So: pull the access logs and search for the attacker’s address. <strong>17,776 files.</strong> Not an interesting task — it’s a sync, a decompress, and a grep — but at conventional cost it’s most of a day of babysitting for a hunch about a single bucket. That’s the calculation that changed. It ran in the background while other work continued.</p>

<p>It returned <strong>four hits</strong>, all on the last active day:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Day +20 02:27:22]  203.0.113.42  -                 REST.GET.BUCKET   301  PermanentRedirect   490 B
[Day +20 02:27:23]  203.0.113.42  user/s3-manager   REST.GET.BUCKET   200  -                18,693 B
[Day +20 03:01:26]  203.0.113.42  -                 REST.GET.BUCKET   301  PermanentRedirect   490 B
[Day +20 03:01:30]  203.0.113.42  user/s3-manager   REST.GET.BUCKET   200  -                18,693 B
</code></pre></div></div>

<p>The attacker ran <code class="language-plaintext highlighter-rouge">ListObjectsV2</code> against the bucket holding the audit trail. Twice. Successfully. Two hundred OK, 18,693 bytes each. The 301s are the same request hitting the wrong regional endpoint one second earlier and being redirected — the tooling didn’t know which Region the bucket lived in, which is itself a small tell about how blind they were.</p>

<p>Correlate against the control-plane timeline and the sequence is unambiguous: <code class="language-plaintext highlighter-rouge">s3:ListBuckets</code> succeeds, and roughly two minutes later <code class="language-plaintext highlighter-rouge">ListObjectsV2</code> fires at one of the names it returned. Twice, on the same day, thirty-four minutes apart. That’s a human or a script reading a bucket list and going after the interesting-looking name.</p>

<p><strong>None of it is in CloudTrail.</strong> Not one of those four requests. If the access log on that single bucket hadn’t happened to be enabled, this would not exist in any record anywhere, and my report would have said the attacker’s activity was confined to the control plane — confidently, in writing, to a client, and to AWS.</p>

<p>The full picture across all 17,776 files, and it is precise in both directions:</p>

<ul>
  <li>four requests, all <code class="language-plaintext highlighter-rouge">REST.GET.BUCKET</code>, two redirected, two successful;</li>
  <li><strong>zero</strong> <code class="language-plaintext highlighter-rouge">REST.GET.OBJECT</code> — not a single object retrieved;</li>
  <li><strong>zero</strong> writes or deletes, on a bucket where this credential had permission to erase everything;</li>
  <li><strong>no pagination</strong> — <code class="language-plaintext highlighter-rouge">max-keys=40</code>, twice, no continuation token. They listed the first page and stopped.</li>
</ul>

<p>So the audit trail was enumerated, not read and not altered. That is a genuinely reassuring finding, and — this is the point of the act — it is a <em>finding</em>, not an assumption. Two days earlier the same conclusion would have been an inference from the absence of contrary evidence in a log source that never recorded the relevant events.</p>

<p>This is also the third independent source in the investigation, and the only one of the three that could see the data plane at all. That’s what makes it worth the day of grinding it would conventionally have cost: not that it was likely to find something, but that it was the only instrument pointed at the question.</p>

<blockquote>
  <p><strong>A note on how this nearly went wrong.</strong> While the download was still running, the agent grepped what had landed and reported zero occurrences — and I put that in a draft. It wasn’t lying; the grep had simply covered the first two days of a twenty-day window, and the answer arrived in exactly the tone and format it would have used over the complete set. Coverage isn’t something the tool tracks for you. The rule I took away, and now apply without exception: <strong>a negative finding is invalid unless the coverage it rests on is stated in the same sentence.</strong> Not “the IP does not appear in the access logs,” but “the IP does not appear in 17,776 of 17,776 files covering Day 0 through Day +20.” The second can’t fail that way, because writing it forces you to go count.</p>
</blockquote>

<h2 id="act-ivasking-why-nothing-fired">Act IV — Asking why nothing fired</h2>

<p>With the timeline settled, a question became answerable that hadn’t been before: the attacker made 2,539 denied calls across twenty days. <strong>Why did no control anywhere say anything?</strong></p>

<p>Note the shape of that question. It isn’t “is the alarm configured” — a checkbox anyone can tick in a console. It’s “given this exact sequence of events, at these exact times, what should have fired, and what did?” You can only ask it once you have the event inventory, which is why it comes fourth in this post rather than first. The reconstruction is what turned a posture question into a testable one.</p>

<p>The answer had two layers, and the second is the one nobody talks about.</p>

<h2 id="a-alarms-that-die-silently">(a) Alarms that die silently</h2>

<p>The account had twelve CIS-benchmark alarms, including one for authorization failures with a <strong>threshold of one</strong> — a single denied call anywhere and it fires — wired to two notification topics, one of them named after this precise scenario. It had been reporting <code class="language-plaintext highlighter-rouge">OK</code> for seven months.</p>

<p>Not because nothing was failing. Because nothing was arriving. Here’s the chain, and where it was cut:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>API call
     │
     ▼
  CloudTrail  ──────────────► S3 bucket        ✅ delivered, 100% intact
     │
     ▼
  CloudWatch Logs group      ❌ does not exist
                                (LatestCloudWatchLogsDeliveryError: ResourceNotFound;
                                 last successful delivery ~7 months earlier)
     │
     ▼
  Metric filter              ❌ zero metric filters, any Region
     │
     ▼
  Metric                     ❌ 0 datapoints / 60 days
     │
     ▼
  Alarm (Threshold = 1)      🟢 OK  ← reports healthy, forever
     │
     ▼
  SNS → humans               (never reached)
</code></pre></div></div>

<p>The trail itself was fine — delivery to S3 never missed, which is exactly why the raw-file cross-check in Act I worked at all. What broke was the branch to CloudWatch Logs, months before the incident, silently.</p>

<p>The failure mode is the dangerous one. Not an alarm that fired and got ignored — an alarm that is green <strong>because it is receiving nothing</strong>. <code class="language-plaintext highlighter-rouge">TreatMissingData = notBreaching</code> is the accelerant: correct for a capacity alarm (no traffic, no problem), inverted for a security alarm, where “no evidence of attacks” and “no data at all” collapse into the same state, and it’s the good one. Twelve alarms hung off that chain and all twelve were green.</p>

<p>Had it been intact, the authorization-failure alarm fires on <strong>Day 0 at 22:57 UTC</strong>, on the first denial of 2,539 — nineteen days and three hours before AWS’s notification, and before five of the seven active attack days.</p>

<p>The generalizable part is one paragraph: if you deployed a CIS baseline from a module and never tested it end to end, publish a synthetic event and watch it traverse trail → log group → metric filter → metric → alarm to a human’s inbox. Five links, any of which a Terraform refactor or an unrelated cleanup can sever without a word. I’d guess a meaningful number of green dashboards have a cut somewhere in there right now.</p>

<p>The client got the full finding and a prioritized remediation plan. What’s interesting <em>here</em> isn’t the misconfiguration — those are everywhere — it’s that pinning down “would have fired on Day 0 at 22:57” required the event-level reconstruction from Act I. Without it, this is a generic audit note. With it, it’s a measured nineteen-day gap.</p>

<h2 id="b-apis-that-deny-without-producing-a-signal">(b) APIs that deny without producing a signal</h2>

<p>Now those fifty-six Elastic Beanstalk calls.</p>

<p><code class="language-plaintext highlighter-rouge">DescribeApplications</code> and <code class="language-plaintext highlighter-rouge">DescribeEnvironments</code> returned <code class="language-plaintext highlighter-rouge">200 OK</code> to a principal with no Elastic Beanstalk permissions whatsoever. No <code class="language-plaintext highlighter-rouge">AccessDenied</code>. No error code in CloudTrail at all — the field is simply <code class="language-plaintext highlighter-rouge">None</code>, exactly as it is for a legitimately authorized call.</p>

<p>Sit with the detection consequence for a second, because it generalizes far past one service.</p>

<p>Essentially every reconnaissance detection in cloud security is built on <strong>counting authorization failures</strong>. The CIS alarm counts them. Managed threat-detection findings for credential misuse lean on them. Every SIEM rule anyone has ever written for “principal is enumerating” is a <code class="language-plaintext highlighter-rouge">COUNT(errorCode = AccessDenied) GROUP BY principal</code> with a threshold on it.</p>

<p>An API that denies you by returning <code class="language-plaintext highlighter-rouge">200</code> and an empty list <strong>produces no such signal</strong>. Not a weak signal — none.</p>

<p>In this incident that’s measurable. The 3,038 denials would have tripped the threshold-of-one alarm instantly, if the alarm had been alive. The fifty-six Elastic Beanstalk calls would have produced <strong>nothing, even with every control functioning perfectly.</strong> An attacker who confined themselves to APIs that fail this way would walk through a fully-instrumented account leaving a trail indistinguishable from an authorized service.</p>

<p>I don’t think this is a defect — I’ll get to why in a moment, and the answer surprised me. But it’s a real blind spot in how the industry builds recon detection, and it’s the kind of thing you only notice when you’re staring at fifty-six unexplained rows at three in the morning wondering why they don’t have an error code.</p>

<p>Which brings me to the experiment I shouldn’t have run.</p>

<h2 id="act-vthe-proof-of-concept-that-killed-its-own-finding">Act V — The proof of concept that killed its own finding</h2>

<p>At this point the draft report contained a sentence I’d written with more confidence than the evidence supported:</p>

<blockquote>
  <p>“The API does not perform an effective authorization check.”</p>
</blockquote>

<p>That was an <strong>inference from an absence</strong> — no <code class="language-plaintext highlighter-rouge">errorCode</code>, therefore no check — dressed up as a finding. And there was a second hypothesis that fit the same data exactly as well: the API <em>does</em> authorize, and returns a permission-filtered empty list. Both produce <code class="language-plaintext highlighter-rouge">200 OK</code> and an empty array. Both look identical in CloudTrail.</p>

<p>I couldn’t tell them apart, for a reason that’s almost funny: <strong>the account had no Elastic Beanstalk applications at all.</strong> Empty-because-you-lack-permission and empty-because-there’s-nothing-there are the same response. The one thing I needed to distinguish the hypotheses was the one thing the environment couldn’t give me.</p>

<p>The severities are not close:</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/05.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>You cannot ship that ambiguity in a report. So we built the test — in a separate account, on infrastructure with no relationship to the client’s.</p>

<p><strong>Setup:</strong> one Elastic Beanstalk <em>Application</em> — a logical metadata object, no environment, therefore no compute provisioned and no cost. One IAM role with a single inline policy granting exactly one permission: <code class="language-plaintext highlighter-rouge">s3:ListAllMyBuckets</code>. No managed policies. No <code class="language-plaintext highlighter-rouge">elasticbeanstalk:*</code> of any kind.</p>

<p><strong>Controls, because a test without controls is an anecdote:</strong> the same role calling two other services it also lacked permission for.</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/06.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>The controls confirm the role genuinely has nothing: two other services denied it explicitly, with two <em>different</em> error shapes. And the decisive comparison is the last two rows against the baseline — <strong>the application existed. It was returned to the admin caller and withheld from the unprivileged one.</strong></p>

<p>So: authorization is enforced. Only the failure <em>signal</em> differs. Hypothesis two. No bypass.</p>

<p>Total elapsed time from “we need to test this” to a controlled experiment with three controls and a clean result: about eight minutes. That number is the whole argument for working this way. This test was always <em>possible</em> — it’s four CLI calls and an IAM policy. It just never cleared the bar of “worth an afternoon to close a footnote in a report.”</p>

<h2 id="the-question-i-hadnt-thought-of">The question I hadn’t thought of</h2>

<p>I presented that result. The response I got back was one sentence, and it broke my test:</p>

<blockquote>
  <p>“And if no applications existed — would it still return 200 and empty? If not, that’s a way to detect whether applications exist without permission.”</p>
</blockquote>

<p>That’s an <strong>existence oracle</strong>, and it would be a real disclosure class, mild but real. If the unprivileged response differed depending on whether resources exist, an unauthorized caller could infer “this account runs Elastic Beanstalk” — reconnaissance with no permission at all.</p>

<p>My test had covered one cell of a two-by-two. I’d tested <em>application exists × no permission</em> and <em>application exists × permission</em>, and concluded. The empty-account row was missing, which is remarkable given that the empty-account row is the exact condition that created the ambiguity in the first place.</p>

<p>Fortunately the test infrastructure was still up, so closing the matrix took minutes: one Region holding the application, two Regions holding nothing.</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/07.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p><strong>The two top cells are byte-identical</strong>, across three Regions, same status code, same body, no error of any kind.</p>

<p>The sharpest way to put it: an unauthorized caller sees <em>exactly</em> what an authorized caller would see against an empty account. There is no side channel — not in the body, not in the status code, not in the error type, not in latency in any way I could measure. It fails closed and it fails indistinguishably. That’s correct security behavior, arguably more correct than an explicit denial, which at least confirms the service is in use.</p>

<p>I also confirmed how those calls land in the trail, which closes the loop with the incident:</p>

<p><img src="/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/08.png" alt="Cloud Forensics in the Age of AI Agents: A Field Report" /></p>

<p>Identical. That’s the incident’s signature, reproduced on demand in a controlled account.</p>

<h2 id="and-then-i-read-the-documentation">And then I read the documentation</h2>

<p>The behavior is documented. By the vendor. On the API reference page for the exact call, in plain language:</p>

<blockquote>
  <p>“This action only returns information about applications that the calling principle has IAM permissions to access. […] If the user doesn’t have access to any of the applications an empty result is returned.”</p>
</blockquote>

<p>Not a bug. Not a discovery. Published contract, sitting on the reference page, where it had been the entire time. My experiment had faithfully reproduced the documentation.</p>

<p><strong>That’s the method error, and it’s the one I’d most want you to take from this post.</strong> I should have read the reference page before designing the experiment. It cost me maybe an hour, and it produced a moment of real clarity about what changes when the cost of doing collapses:</p>

<blockquote>
  <p><strong>When running the experiment gets cheap enough, running it becomes easier than looking it up. The cheaper it is to test, the more expensive it becomes to skip the documentation.</strong></p>
</blockquote>

<p>The old economics enforced discipline. Building a test rig was expensive, so you read first, and reading first is <em>correct</em> — the answer might already exist. When the rig costs eight minutes, that guardrail is gone, and you have to reinstate it deliberately.</p>

<p>The test wasn’t wasted, to be fair. It closed an open anomaly in the report with first-party evidence rather than a citation, it established that no oracle exists — which the documentation doesn’t state and which was a legitimate open question — and it produced the detection argument in the previous section, which I think is the genuinely useful idea in this whole investigation. But the primary question had a published answer and I spent effort rediscovering it.</p>

<p><strong>Disclosure decision: nothing to report.</strong> No authorization bypass, no cross-boundary disclosure, nothing a vendor security team would open a case on. Reporting documented behavior as a vulnerability costs you exactly the credibility you’d want on the day you find something real. That decision took me thirty seconds and no tool was involved in it, which is the point of the next section.</p>

<h2 id="act-vithe-last-mile-which-nobody-writes-about">Act VI — The last mile, which nobody writes about</h2>

<p>Half of incident response is writing. Not the technical report — the <em>communication</em>. And it’s the half where I’ve seen the most damage done, because the instinct that serves you well internally is precisely wrong externally.</p>

<p>AWS Support wanted the response in their console: confirm the steps, describe the review, request closure. Fixed field, hard character limit.</p>

<p>The technical post-mortem ran to tens of thousands of characters. The limit was 8,000. So it went:</p>

<p><strong>14,685 → 7,955 → 6,616 characters.</strong></p>

<p>The first cut was mechanical: enumerated lists of denied APIs became counts, billing tables became one line with the numbers in it, the remediation plan went from three blocks to two paragraphs. Nothing load-bearing was lost. An agent is genuinely excellent at this — it’s rewriting under a constraint with a checkable objective, which is close to the ideal task shape.</p>

<p>The second cut wasn’t mechanical, and it wasn’t the model’s idea. It came from asking a different question:</p>

<blockquote>
  <p><strong>“What am I handing them that nobody asked for?”</strong></p>
</blockquote>

<p>Eight items came out. Not one of them was false, and not one of them was requested:</p>

<ul>
  <li>a summary of the client’s data footprint — storage volume, object counts. Nobody asked. It’s an inventory of what’s worth stealing.</li>
  <li>“twelve alarms non-functional for seven months” — a written admission of an unremediated control failure, handed unprompted to an external party, in a record that persists.</li>
  <li>the status of every detective control in the account. A map of where nobody is looking.</li>
  <li>misconfigurations found during the review that were still open.</li>
  <li>a phrase conceding possible exposure of secrets, hedged, unnecessary, and quotable out of context.</li>
  <li>a note on how long a credential had gone unrotated. Self-incriminating, adds nothing to their assessment.</li>
  <li>a commercial relationship with a third party, irrelevant to the recipient.</li>
  <li>an invitation for them to comment on our findings, which is an invitation to a discussion nobody needed.</li>
</ul>

<p>The principle I settled on is three distinct commitments, and most teams collapse the last two:</p>

<ol>
  <li><strong>Don’t lie.</strong> Non-negotiable, including by omission of anything that changes their assessment.</li>
  <li><strong>Don’t withhold what they need to evaluate the situation.</strong> They’re assessing whether an account is a threat. Everything bearing on that goes in, in full.</li>
  <li><strong>Don’t volunteer your internal posture.</strong> Your control gaps, your inventory, your unremediated findings, your organizational weaknesses. They didn’t ask, they don’t need it to close the case, and it will exist in a record you don’t control, forever.</li>
</ol>

<p>Two and three feel like the same value when you’re being cooperative at four in the morning. They’re not. The rewrite kept every number that proved the review had been done — 3,122 calls, 84 successes, zero writes, sixteen Regions, forty-three services, the confirmed data-plane listing, the honest statement of residual uncertainty — and dropped every sentence that described the client rather than the incident.</p>

<p>Then a detail that’s pure agent-collaboration hazard and I’d never have predicted it.</p>

<p>I prepared an evidence package: seven files, event inventory, timeline, service matrix, the access-log lines. On a final read-through: <strong>four of the seven had Portuguese section headers.</strong> The analysis scripts had been written during a conversation conducted in Portuguese, so the agent had generated the report headers in Portuguese — perfectly reasonably, and nobody had said otherwise. Artifacts inherit the language of the conversation that produced them, silently, and the mismatch only surfaces at the moment you’re about to hand them to someone.</p>

<p>If you work with an agent in one language and deliver in another, that’s a checklist item. It costs nothing to check and it’s embarrassing to miss.</p>

<p>The package didn’t go, in the end — the response stood on its own, and the smaller thing you hand an external party, the smaller the surface for follow-up questions. The remaining artifacts stayed with the client, where they belong.</p>

<h2 id="what-actually-changed-and-what-didnt">What actually changed, and what didn’t</h2>

<p><strong>Didn’t change — every decision:</strong></p>

<ul>
  <li>Judgment about what the numbers mean. The z-score of 15,497 was nothing; that call was mine.</li>
  <li>Scope. What to investigate, what to leave, when the evidence was sufficient.</li>
  <li>The disclosure decision. That was thirty seconds of professional judgment about credibility, and no tool contributed to it.</li>
  <li>Calibration of what to say to an external party. The eight removals came from asking a question the model wasn’t going to ask itself.</li>
  <li>The question that broke my own test. “And if no applications existed?” was a human noticing a missing quadrant.</li>
</ul>

<p><strong>Changed — the number of hypotheses that got tested rather than estimated:</strong></p>

<p>Every one of these would have been “not worth the time” in a conventional engagement with a deadline:</p>

<ul>
  <li>parsing 298,196 raw events from scratch purely to cross-check a number I already had;</li>
  <li>downloading 17,776 access log files on a hunch about a single bucket;</li>
  <li>computing a per-usage-type statistical baseline across two windows to reason about exfiltration;</li>
  <li>building a controlled authorization test in a clean account to resolve one footnote;</li>
  <li>extending that test across three Regions to close a two-by-two.</li>
</ul>

<p>Two of those five materially changed the report. The raw-file parse validated the central numbers, without which the whole document is one tool’s opinion. The access-log download produced the only direct evidence anyone will ever have about what the attacker did on the data plane — and turned the most sensitive conclusion in the report from an inference into a finding.</p>

<p>I want to be careful about the causal claim here. The agent didn’t discover anything. It ran a grep I asked for, on data I asked it to fetch. What it did was make the fetch cheap enough that I asked for it at all, on a hunch about one bucket, while other work carried on.</p>

<p>That’s the actual shift, and it’s smaller and more interesting than “AI does forensics”:</p>

<blockquote>
  <p><strong>The threshold for “let me just check that” dropped to near zero. So marginal hypotheses — the ones you’d previously drop for budget — get tested. And every so often, one of them is load-bearing.</strong></p>
</blockquote>

<p>The work shifted from <em>executing</em> to <em>directing and verifying</em>. And verification is now the scarce skill. The agent produced a number I couldn’t audit by reading; I could only audit it by producing it a second way. Someone who doesn’t know which number to double-check, or what a second independent source even means in this context, gets all of the speed and none of the safety. The tool amplifies whatever methodology you already had — including the absence of one.</p>

<h2 id="what-id-have-you-check-tomorrow">What I’d have you check tomorrow</h2>

<p><strong>Method — if you work this way:</strong></p>

<ol>
  <li><strong>State coverage inside every negative finding.</strong> “X does not appear” is not a finding. “X does not appear in N of N files covering the full window” is. The first sat unsupported in my draft for forty minutes and looked identical to the second.</li>
  <li><strong>Confirm any agent-produced number through a path that shares no code with the first one.</strong> Same tool with different flags is not a second source.</li>
  <li><strong>Read the API documentation before you build the test rig.</strong> Especially now that building the rig is the cheaper of the two.</li>
  <li><strong>Write down what you did not prove, in the same document as what you did.</strong> It’s the only reason the rest of the document survives a competent reader.</li>
  <li><strong>Decide what you’re volunteering before you send it.</strong> Don’t lie, don’t omit what the recipient needs, don’t hand over your internal posture. The last two feel like the same value at 4 a.m. and aren’t.</li>
</ol>

<p><strong>Environment — what this particular investigation surfaced:</strong></p>

<ol>
  <li><strong>Test every security alarm end to end.</strong> Publish a synthetic event and follow it: trail → log group → metric filter → metric → alarm → a human’s inbox. A green dashboard proves nothing about a chain you’ve never exercised. While you’re there, check <code class="language-plaintext highlighter-rouge">TreatMissingData</code>: <code class="language-plaintext highlighter-rouge">notBreaching</code> on a security alarm means “silence is health,” and silence is exactly what a broken pipeline produces.</li>
  <li><strong>Enable S3 data events on your trail’s own log bucket at minimum.</strong> The only reason I know what the attacker did on the data plane is that one bucket happened to have access logging on. That was luck. Don’t run on luck.</li>
  <li><strong>Alert on shape, not on events.</strong> No single call in this campaign was remarkable. The shape was: one principal, forty-three services, sixteen Regions, a 97% denial rate, inside twenty minutes. Distinct-service and distinct-Region counts per principal per hour would have fired on Day 0. So would a denial count. None of these need a threat feed.</li>
</ol>

<h2 id="one-last-thing-about-honesty">One last thing, about honesty</h2>

<p>There’s a fifth artifact I haven’t mentioned. I started a cryptographic validation of the trail’s digest files — the mechanism that proves log files weren’t altered after delivery. It ran for the better part of an hour across twenty-three days and seventeen Regions, and I aborted it before it finished.</p>

<p>So the report does not say the audit trail’s integrity was cryptographically verified. It says the validation was started and not completed, and then it says what two independent sources <em>do</em> support: zero write events of any kind from that credential in CloudTrail — no <code class="language-plaintext highlighter-rouge">StopLogging</code>, no <code class="language-plaintext highlighter-rouge">DeleteTrail</code>, no <code class="language-plaintext highlighter-rouge">UpdateTrail</code>, no <code class="language-plaintext highlighter-rouge">PutEventSelectors</code>, with <code class="language-plaintext highlighter-rouge">DescribeTrails</code> attempted in sixteen Regions and denied in all sixteen — and, in the log bucket’s own access logs, four requests from that address, all reads, no <code class="language-plaintext highlighter-rouge">PUT</code>, no <code class="language-plaintext highlighter-rouge">DELETE</code>, no <code class="language-plaintext highlighter-rouge">POST</code>, on a bucket where the credential had permission to erase everything.</p>

<p>We didn’t prove the digests. We proved nobody executed an operation capable of altering the record. Those are different sentences and the report uses the second one.</p>

<p>It would have been very easy to let the first one stand. Nobody was going to check. The agent would have written either sentence just as fluently, in the same confident register, formatted the same way — and that, in the end, is the thing to internalize about working like this. These tools do not produce false confidence; they produce <em>fluent</em> confidence, and fluency is what we’ve all been trained to read as rigor.</p>

<p>The difference between a report that survives scrutiny and one that doesn’t now lives almost entirely in a human’s willingness to write the less impressive sentence.</p>

<p><em>Details of the environment, the organization and the timeline have been changed or removed. The structural findings, counts and behaviors are unmodified.</em></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[One compromised credential, twenty days, seventeen regions — reconstructed twice from independent sources. What the agent changed, where it was confidently wrong, and what a human still had to do.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/cloud-forensics-in-the-age-of-ai-agents-a-field-report/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">I ran a full cloud forensic investigation with an AI agent. It cost $67.</title><link href="https://euriconicacio.github.io/blog/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/" rel="alternate" type="text/html" title="I ran a full cloud forensic investigation with an AI agent. It cost $67." /><published>2026-07-31T12:00:00+00:00</published><updated>2026-07-31T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67</id><content type="html" xml:base="https://euriconicacio.github.io/blog/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/"><![CDATA[<p><img src="/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/01.png" alt="I ran a full cloud forensic investigation with an AI agent. It cost $67." /></p>

<blockquote>
  <p>⚠️ Disclosure — please read this first</p>

  <p><strong>This work was performed under an independent consulting engagement, in a client environment that has no connection to any current or former employer of mine.</strong> Nothing here describes, references, or is drawn from any employer’s infrastructure, incidents, or data.</p>

  <p>The token counts, timings and costs below are mine — they measure my own tooling, not the client’s. Every client-identifying detail has been removed or replaced, as in the companion post.</p>
</blockquote>

<p>In <a href="/blog/cloud-forensics-in-the-age-of-ai-agents-a-field-report/">the previous post</a> I described the method: an agent-assisted incident response on a compromised AWS credential — 3,122 API calls reconstructed twice from independent sources, 298,196 raw log events parsed from scratch as a cross-check, 17,776 access logs downloaded on a hunch that turned out to reverse a published conclusion, a controlled authorization experiment, and the writing of the response that closed the case.</p>

<p>That post was about how the work was done, and about the three times the machine was confidently wrong.</p>

<p>This one is the invoice.</p>

<p>I have not seen many honest accountings of what agent-assisted work actually costs — where the money goes, what it displaces, and what it quietly adds back. So here is mine, with the measurement method included so you can run it against your own history.</p>

<h2 id="what-i-measured-and-how">What I measured, and how</h2>

<p>Everything below comes from one session transcript — a JSONL file where every model response records a <code class="language-plaintext highlighter-rouge">usage</code> object. No estimation, no sampling. Four fields matter:</p>

<p><img src="/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/02.png" alt="I ran a full cloud forensic investigation with an AI agent. It cost $67." /></p>

<p>Sum them across the session and apply your provider’s rates. The script is at the end of this post; it is about fifteen lines.</p>

<p>Two caveats stated up front, because they bound everything that follows.</p>

<p><strong>First, prices change.</strong> The rates I use are the list prices in effect when I measured: <strong>$5 per million input tokens, $25 per million output</strong>, with cached reads at <strong>0.1×</strong> the input rate and cache writes at <strong>1.25×</strong> (five-minute TTL) or <strong>2×</strong> (one hour). Anything you compute from this post should be recomputed against today’s numbers.</p>

<p><strong>Second, this covers one session.</strong> There was earlier work on this incident, in a separate session I did not instrument. The figures below are the main investigation only, and they are therefore a floor, not a total. I would rather publish a number I can defend than a bigger one I can’t.</p>

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

<p><img src="/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/03.png" alt="I ran a full cloud forensic investigation with an AI agent. It cost $67." /></p>

<p>Fourteen messages. That number matters later.</p>

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

<p><img src="/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/04.png" alt="I ran a full cloud forensic investigation with an AI agent. It cost $67." /></p>

<p>Sixty-seven dollars for the forensic reconstruction of a twenty-day intrusion across seventeen regions, twice, from independent sources.</p>

<p>That’s the headline, and it is the least interesting number in the post.</p>

<h2 id="ninety-nine-percent-of-it-was-re-reading">Ninety-nine percent of it was re-reading</h2>

<p>Look at the input column again.</p>

<ul>
  <li>Uncached input: <strong>928 tokens</strong></li>
  <li>Cache reads: <strong>97,002,227 tokens</strong></li>
</ul>

<p><strong>99.1% of all input was cache reads.</strong> The model was fed roughly 98 million tokens of context over 464 turns, and 928 of them were new.</p>

<p>That is not a quirk of this session. It’s the arithmetic of how these systems work. Every turn resends the conversation: the system prompt, the tool definitions, every prior tool call, every prior result. A 464-turn investigation with large tool outputs — log dumps, CSV excerpts, JSON blobs — accumulates a context that gets re-transmitted on every single turn. The volume of <em>re-reading</em> dwarfs the volume of <em>writing</em> by two orders of magnitude.</p>

<p>Which produces the number that should change how you budget:</p>

<blockquote>
  <p><strong>Without prompt caching, the same session costs $502 instead of $67.</strong></p>
</blockquote>

<p>97.9 M input tokens at full price is $489. Add the $13 of output and you are at roughly <strong>$502</strong> — a <strong>7.4×</strong> multiplier, paid for nothing but reading the same bytes again.</p>

<p>Two consequences follow, and both are load-bearing.</p>

<p><strong>Budget by input, not by output.</strong> The instinct is to estimate agent cost from how much it produces — the reports, the code, the analysis. On this workload, output was <strong>$13 of a $67 bill: 20%</strong>. If you scope a project by imagining how much the model will write, you will be wrong by roughly 5×. Estimate the context that gets re-read instead.</p>

<p><strong>Caching is not an optimization here; it’s the business case.</strong> At $502 a run, “let the agent parse 300,000 raw events as a cross-check” is a decision you’d think about. At $67 it isn’t a decision at all. The entire argument of the previous post — that marginal hypotheses become worth testing — depends on that 7.4×. If your provider or configuration isn’t caching effectively, you are not running the workload I described. You’re running a much more expensive one that will push you back toward the human triage-by-budget habits.</p>

<p>Forensics has an unusually bad profile for this. Log lines are long, poorly compressible, and mostly semantically identical to each other. A single day of raw trail parsing pushes a lot of near-duplicate text through context. If you’re going to run this kind of work, verify your cache hit rate before you scale it, not after the invoice.</p>

<h2 id="what-it-displaced">What it displaced</h2>

<p>Now the other side. What would this have cost in human time?</p>

<p>This part is an <strong>estimate</strong>, not a measurement, and I’m labelling it as such. It’s my honest read on how long these phases take a competent cloud security engineer with access already in place and no interruptions.</p>

<p><strong>To be explicit about the baseline, because this is where this kind of comparison usually cheats: the human writes scripts too.</strong> Nobody reads 298,196 log events, and nobody greps 17,776 files by hand. They write a paginator loop, a gzip parser, a field-offset parser for a log format they haven’t parsed before, a billing query. Those are exactly the artifacts the agent produced — same language, same libraries, often the same approach. The hours below are authoring, debugging and running time, not reading time.</p>

<p><img src="/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/05.png" alt="I ran a full cloud forensic investigation with an AI agent. It cost $67." /></p>

<p>I’m not converting that into money. I don’t know what an hour of your team’s time costs, and inventing a rate would weaken the comparison rather than strengthen it. Price it yourself.</p>

<p>So if both sides write scripts, what actually differs? Three things, and only three:</p>

<p><strong>Authoring latency per throwaway script.</strong> Not whether the script gets written, but how long the gap is between wanting it and having output. A field-offset parser for an unfamiliar log format is twenty minutes of reading a spec and fixing off-by-ones. That gap is where the decision “is this worth it?” lives, and shortening it is what changes the decision.</p>

<p><strong>Parallelism without context-switching cost.</strong> Three collections ran in the background while I worked on the billing analysis. A person can technically do that too — and then pays for it in the re-orientation tax every time they come back to a half-finished parser.</p>

<p><strong>Willingness to write the sixth one.</strong> By the fifth throwaway script of a long day, the marginal hunch stops feeling worth a new file. That’s the mechanism that killed phases 2 and 5 in the conventional version — not incapacity, fatigue and budget.</p>

<p>And the counterweight, which belongs in the same breath: <strong>the agent’s scripts needed debugging exactly like a person’s.</strong> Its first collection pass silently truncated at fifty results and reported success. Its background monitors deadlocked on a <code class="language-plaintext highlighter-rouge">pgrep</code> that matched their own command line. Neither is a mistake a careful engineer wouldn’t also make at 3 a.m. — the difference is that fixing them cost a sentence instead of a context switch.</p>

<h2 id="why-24-hours-versus-6-working-days-is-the-wrong-comparison">Why “2.4 hours versus 6 working days” is the wrong comparison</h2>

<p>It’s the number everyone reaches for, and it’s misleading in a specific way.</p>

<p><strong>A human working this incident under a deadline does not do phases 2 and 5.</strong> Downloading 35,000 log files to independently re-derive a number you already have, and downloading 17,776 access logs on a hunch about one bucket, are exactly the tasks that get cut when time is the constraint. They’re not sloppy to cut. They’re arithmetic.</p>

<p>And those two phases are where the value was:</p>

<ul>
  <li>Phase 2 produced the independent cross-validation. Without it, every number in the report is one tool’s unaudited opinion.</li>
  <li>Phase 5 surfaced the data-plane activity that the control-plane logs structurally could not see — and <strong>forced a correction to a conclusion I had already written down</strong>.</li>
</ul>

<p>So the realistic human version isn’t six days of the same work. It’s one or two days producing a subset — and stating, with no way to know it, that the attacker never reached the audit-log bucket. That claim would have happened to be close enough: they listed it and read nothing. But it would have been asserted without evidence, on a bucket where the stolen credential could have deleted everything.</p>

<p>The honest framing:</p>

<blockquote>
  <p>The gain was not doing the same work faster. It was that work previously cut for budget became cheap enough to do — and one of those cuts was load-bearing.</p>
</blockquote>

<p>That’s a smaller claim than the usual pitch, and it’s the one the evidence supports.</p>

<h2 id="the-costs-that-dont-show-up-on-the-invoice">The costs that don’t show up on the invoice</h2>

<p>If the post stopped here it would be an advertisement. It shouldn’t, because this run generated real costs that a token counter never sees.</p>

<p><strong>Verification time — the big one.</strong> Every number the agent produced needed confirmation from a second source before it could go in a report. That verification is human time, it doesn’t shrink as generation gets faster, and it is not optional: an unverified number in a forensic report has <em>negative</em> value, because it will be believed. The $67 buys you generation. It does not buy you the right to trust the output.</p>

<p><strong>The fourteen human messages were load-bearing.</strong> Not oversight, not approvals — actual contributions:</p>

<ul>
  <li>One question — <em>“and if no applications existed, would it still return empty?”</em> — exposed that my authorization test had covered one quadrant of four.</li>
  <li>One question — <em>“are those background tasks still running?”</em> — surfaced five processes stuck in an infinite loop.</li>
  <li>One directive on disclosure scope removed eight items from an external communication that nobody had asked for.</li>
</ul>

<p>Fourteen messages across two and a half hours is roughly one every ten minutes. That’s the actual human cost of “autonomous” work on something consequential, and any planning that assumes fire-and-forget is planning for a different task than this one.</p>

<p><strong>Rework from concluding early.</strong> Generation is cheap, so conclusions land early — and when one is corrected, the correction has to be chased through every document already written from it. The data-plane correction propagated through two finished reports. Fast generation means fast propagation of error; budget for it, or write the conclusions last.</p>

<p><strong>Compute spent on nothing.</strong> Five background monitors ran in an infinite loop for most of the session. The cause was a one-line bug the agent wrote and I didn’t catch: each monitor waited on <code class="language-plaintext highlighter-rouge">pgrep -f "fetch-ct.sh"</code> returning nothing, and <code class="language-plaintext highlighter-rouge">pgrep</code> matched <em>the monitor’s own command line</em>, because the string was right there in it. Five processes, each waiting forever for itself to exit. Zero output, real consumption, and the only reason it was caught is that a human asked what was still running.</p>

<p><strong>An hour of work the documentation had already answered.</strong> The authorization experiment was well-designed, controlled, and conclusive — and the vendor’s own API reference stated the behavior in plain language the whole time. When building the test rig gets cheap enough, running it becomes easier than looking it up. That’s a new failure mode, and it has a line item.</p>

<h2 id="when-this-doesnt-pay">When this doesn’t pay</h2>

<p>Three cases where I would not run this pattern:</p>

<p><strong>Small, well-specified tasks.</strong> The overhead — setup, verification, the human attention tax — doesn’t amortize. If you can do it in twenty minutes, do it in twenty minutes.</p>

<p><strong>Environments with no independent telemetry.</strong> The entire method rests on cross-validation. In an environment where every number comes from one source, an agent produces confident output you have no way to check, faster than before. That is worse than not having it.</p>

<p><strong>Teams without someone who knows which number to check.</strong> This is the real constraint. The agent gave me a count of 3,122 events. Knowing that this particular API silently truncates pagination, that the raw log files are a genuinely independent path, and that agreement between those two is what makes the number defensible — none of that came from the tool. Someone who doesn’t already know that gets all of the speed and none of the safety.</p>

<h2 id="measure-your-own">Measure your own</h2>

<p>Here’s the script. Point it at a session transcript and it prints the same table:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">json</span><span class="p">,</span> <span class="n">sys</span>

<span class="n">RATES</span> <span class="o">=</span> <span class="p">{</span>           <span class="c1"># $ per million tokens — update to current list prices
</span>    <span class="s">"in"</span><span class="p">:</span> <span class="mf">5.00</span><span class="p">,</span> <span class="s">"out"</span><span class="p">:</span> <span class="mf">25.00</span><span class="p">,</span>
    <span class="s">"cache_read"</span><span class="p">:</span> <span class="mf">0.50</span><span class="p">,</span>   <span class="c1"># 0.1x input
</span>    <span class="s">"cache_write"</span><span class="p">:</span> <span class="mf">6.25</span><span class="p">,</span>  <span class="c1"># 1.25x input (5-minute TTL)
</span><span class="p">}</span>
<span class="n">t</span> <span class="o">=</span> <span class="p">{</span><span class="s">"in"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s">"out"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s">"cache_read"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s">"cache_write"</span><span class="p">:</span> <span class="mi">0</span><span class="p">}</span>

<span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="nb">open</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]):</span>
    <span class="n">u</span> <span class="o">=</span> <span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">line</span><span class="p">).</span><span class="n">get</span><span class="p">(</span><span class="s">"message"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}).</span><span class="n">get</span><span class="p">(</span><span class="s">"usage"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}</span>
    <span class="n">t</span><span class="p">[</span><span class="s">"in"</span><span class="p">]</span>          <span class="o">+=</span> <span class="n">u</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"input_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="n">t</span><span class="p">[</span><span class="s">"out"</span><span class="p">]</span>         <span class="o">+=</span> <span class="n">u</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"output_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="n">t</span><span class="p">[</span><span class="s">"cache_read"</span><span class="p">]</span>  <span class="o">+=</span> <span class="n">u</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"cache_read_input_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="n">t</span><span class="p">[</span><span class="s">"cache_write"</span><span class="p">]</span> <span class="o">+=</span> <span class="n">u</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"cache_creation_input_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>

<span class="n">total</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">t</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="o">/</span> <span class="mf">1e6</span> <span class="o">*</span> <span class="n">RATES</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">t</span><span class="p">)</span>
<span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">t</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">k</span><span class="si">:</span><span class="mi">12</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">t</span><span class="p">[</span><span class="n">k</span><span class="p">]</span><span class="si">:</span><span class="o">&gt;</span><span class="mi">12</span><span class="p">,</span><span class="si">}</span><span class="s">  $</span><span class="si">{</span><span class="n">t</span><span class="p">[</span><span class="n">k</span><span class="p">]</span><span class="o">/</span><span class="mf">1e6</span><span class="o">*</span><span class="n">RATES</span><span class="p">[</span><span class="n">k</span><span class="p">]</span><span class="si">:</span><span class="o">&gt;</span><span class="mf">8.2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="s">'TOTAL'</span><span class="si">:</span><span class="mi">12</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="s">''</span><span class="si">:</span><span class="o">&gt;</span><span class="mi">12</span><span class="si">}</span><span class="s">  $</span><span class="si">{</span><span class="n">total</span><span class="si">:</span><span class="o">&gt;</span><span class="mf">8.2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"cache read = </span><span class="si">{</span><span class="n">t</span><span class="p">[</span><span class="s">'cache_read'</span><span class="p">]</span><span class="o">/</span><span class="nb">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="n">t</span><span class="p">[</span><span class="s">'cache_read'</span><span class="p">]</span><span class="o">+</span><span class="n">t</span><span class="p">[</span><span class="s">'in'</span><span class="p">]</span><span class="o">+</span><span class="n">t</span><span class="p">[</span><span class="s">'cache_write'</span><span class="p">])</span><span class="si">:</span><span class="p">.</span><span class="mi">1</span><span class="o">%</span><span class="si">}</span><span class="s"> of input"</span><span class="p">)</span>
</code></pre></div></div>

<p>Run it against a session you already consider expensive. If that last line prints something in the high nineties, your bill is a re-reading bill, and the lever is caching — not shorter outputs, not a cheaper model.</p>

<h2 id="what-actually-changed">What actually changed</h2>

<p>Two and a half hours, sixty-seven dollars, and roughly four to six days of specialist work that didn’t have to happen — set against fourteen human interventions that did, verification that doesn’t compress, and an hour spent rediscovering something the documentation already said.</p>

<p>The bottleneck moved. It used to be execution: could you get the data, parse it, cross-check it in the time available. Now it’s verification: can you tell whether what came back is true.</p>

<p>Verification doesn’t scale with tokens. It scales with whether someone on the team knows which number to check — and that is the part you still have to hire for.</p>

<p><em>Details of the environment and the organization have been changed or removed. The token counts, timings, and costs are unmodified. Prices are list prices as of the measurement and will have moved.</em></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[And 99% of that bill was the model re-reading context it had already been given. If you budget for this work by counting what the model writes, you will be wrong by roughly 7×.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://euriconicacio.github.io/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/01.png" /><media:content medium="image" url="https://euriconicacio.github.io/blog/assets/images/posts/i-ran-a-full-cloud-forensic-investigation-with-an-ai-agent-it-cost-67/01.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Securing Fintech Apps in 2025: Addressing Modern Threats with Adaptive Defenses</title><link href="https://euriconicacio.github.io/blog/securing-fintech-apps-in-2025-addressing-modern-threats-with-adaptive/" rel="alternate" type="text/html" title="Securing Fintech Apps in 2025: Addressing Modern Threats with Adaptive Defenses" /><published>2025-06-05T12:00:00+00:00</published><updated>2025-06-05T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/securing-fintech-apps-in-2025-addressing-modern-threats-with-adaptive</id><content type="html" xml:base="https://euriconicacio.github.io/blog/securing-fintech-apps-in-2025-addressing-modern-threats-with-adaptive/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>Fintech is at the center of today’s digital transformation, blending finance and technology at a breakneck pace. As attackers get more sophisticated and compliance pressure increases, security teams need to move beyond legacy controls. Protecting users, sensitive financial data, and the integrity of your platforms means staying ahead of evolving risks. This post highlights the top three security threats facing fintechs right now — and the practical strategies you should implement in 2025.</p>

<h2 id="1-account-takeover-ato--identity-threats">1. Account Takeover (ATO) &amp; Identity Threats</h2>

<p><strong>What’s New:</strong></p>

<p>Account Takeover is no longer just about stolen passwords. Today, ATO leverages advanced phishing kits, credential stuffing from huge breach dumps, MFA fatigue attacks, and session token hijacking. Synthetic identities and AI-driven social engineering make detection even harder.</p>

<p><strong>Modern Defenses:</strong></p>

<ul>
  <li><strong>Continuous Authentication:</strong> Don’t just check at login. Monitor behavioral biometrics (typing speed, device signals, geo-velocity) to flag suspicious activity throughout the session.</li>
  <li><strong>MFA That’s Actually Secure:</strong> Move away from SMS-based codes. Use phishing-resistant MFA like FIDO2, passkeys, or push-based authenticators. Offer options and educate users.</li>
  <li><strong>User Visibility:</strong> Provide real-time account activity dashboards, with instant user notification and self-service lockout if anomalies are detected.</li>
  <li><strong>Adaptive Access:</strong> Dynamically step up authentication based on transaction risk, device posture, and behavioral anomalies. Combine velocity checks, device fingerprinting, and transaction analytics.</li>
  <li><strong>Session Protection:</strong> Invalidate sessions on risky events and rotate tokens regularly to minimize hijack windows.</li>
</ul>

<p><strong>Key Point:</strong></p>

<p>Don’t rely on static controls. Implement layered, adaptive defenses that evolve as attacker techniques do.</p>

<h2 id="2-third-party--supply-chain-risk">2. Third-Party &amp; Supply Chain Risk</h2>

<p><strong>What’s New:</strong></p>

<p>Fintech stacks are more interconnected than ever: open banking APIs, embedded finance, SaaS integrations, and AI-powered fintech tools. Every integration is a potential supply chain risk, as seen in high-profile vendor breaches and dependency attacks (e.g., SolarWinds, 3CX, open-source package poisoning).</p>

<p><strong>Modern Defenses:</strong></p>

<ul>
  <li><strong>Third-Party Risk Management (TPRM) Automation:</strong> Use modern TPRM tools for automated vendor security scoring, continuous monitoring, and contract enforcement — not just annual questionnaires.</li>
  <li><strong>Zero Trust for Integrations:</strong> Treat every third-party (and their code/data) as untrusted by default. Isolate integrations using API gateways, fine-grained IAM, private connectivity (VPC peering, PrivateLink), and runtime sandboxing.</li>
  <li><strong>Runtime Controls:</strong> For high-risk integrations (payments, KYC, data exchange), require features like signed/encrypted webhooks, mTLS, and replay protection. Monitor integration traffic for anomalies, not just static checks.</li>
  <li><strong>SBOMs and Code Integrity:</strong> Demand a Software Bill of Materials (SBOM) and verify code signatures for any embedded SDKs or dependencies.</li>
  <li><strong>Incident Response Playbooks:</strong> Assume compromise is possible. Have plans for rapid deactivation, credential rotation, and customer notification if a vendor is breached.</li>
</ul>

<p><strong>Key Point:</strong></p>

<p>Your security is only as strong as your weakest vendor. Automate, isolate, and monitor everything.</p>

<h2 id="3-api-security--abuse">3. API Security &amp; Abuse</h2>

<p><strong>What’s New:</strong></p>

<p>APIs power everything in fintech — from onboarding to real-time payments. Attackers leverage API discovery tools, exploit business logic flaws, and bypass traditional WAF/rate limiting with botnets and IP rotation. LLM-based attackers can even adapt payloads on the fly.</p>

<p><strong>Modern Defenses:</strong></p>

<ul>
  <li><strong>API Discovery &amp; Inventory:</strong> Use automated API discovery (including shadow APIs) and keep real-time inventory. Document exposed endpoints and validate data flows.</li>
  <li><strong>API Threat Detection:</strong> Deploy API security platforms that go beyond traffic rate limits — look for anomalies in payload structure, sequence, and user behavior. Detect token abuse, injection, and logic attacks in real time.</li>
  <li><strong>Shift-Left Security:</strong> Enforce API schema validation, authentication, and authorization by design (use frameworks like OAuth 2.1, OpenID Connect, and fine-grained RBAC/ABAC).</li>
  <li><strong>Zero Trust API Gateways:</strong> Isolate API backends, enforce least privilege, and block direct internet access where possible.</li>
  <li><strong>Regular Penetration Testing &amp; Bug Bounties:</strong> Continuously test APIs for new attack vectors, not just during release cycles.</li>
</ul>

<p><strong>Key Point:</strong></p>

<p>API security isn’t a one-time project — it’s a continuous process of discovery, monitoring, and adaptation.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Fintech will always be a high-value target. The combination of financial incentives, broad attack surface, and regulatory scrutiny makes modern, adaptive security a necessity. In summary:</p>

<ul>
  <li><strong>ATO/Identity:</strong> Move to continuous, adaptive authentication and real-time user alerting.</li>
  <li><strong>Third-Party/Supply Chain:</strong> Automate vendor risk, isolate integrations, and prepare for breaches.</li>
  <li><strong>API Security:</strong> Continuously discover, monitor, and protect all APIs — don’t trust legacy defenses.</li>
</ul>

<p>The threat landscape evolves, but so can your defenses. Make security a continuous, data-driven process — learn, adapt, and keep your customers’ trust.</p>

<p><em>Let me know your thoughts or reach out if you want to discuss these topics in depth!</em></p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[Fintech is at the center of today’s digital transformation, blending finance and technology at a breakneck pace. As attackers get more sophisticated and compliance pressure increases, security teams…]]></summary></entry><entry><title type="html">Cultivating a Cost-Aware Culture for High-Performance Payment Systems on AWS</title><link href="https://euriconicacio.github.io/blog/cultivating-a-cost-aware-culture-for-high-performance-payment-systems/" rel="alternate" type="text/html" title="Cultivating a Cost-Aware Culture for High-Performance Payment Systems on AWS" /><published>2025-03-19T12:00:00+00:00</published><updated>2025-03-19T12:00:00+00:00</updated><id>https://euriconicacio.github.io/blog/cultivating-a-cost-aware-culture-for-high-performance-payment-systems</id><content type="html" xml:base="https://euriconicacio.github.io/blog/cultivating-a-cost-aware-culture-for-high-performance-payment-systems/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>In the world of <strong>digital payments</strong>, businesses must maintain <strong>high performance, scalability, and reliability</strong> while keeping <strong>cloud costs under control</strong>. <strong>Amazon Web Services (AWS)</strong> provides a powerful and flexible infrastructure for handling <strong>large-scale payment transactions</strong>, but without a <strong>cost-aware culture</strong>, organizations risk <strong>budget overruns and inefficiencies</strong>.</p>

<p>A <strong>cost-conscious approach</strong> ensures that <strong>engineering, operations, and finance teams</strong> work together to optimize cloud resources <strong>without sacrificing speed or availability</strong>. This article explores <strong>best practices</strong> for <strong>efficient cloud management</strong> in high-performance payment systems on AWS, helping businesses <strong>balance cost control with operational excellence</strong>.</p>

<h2 id="1-designing-a-cost-optimized-payment-system-architecture">1. Designing a Cost-Optimized Payment System Architecture</h2>

<p>The foundation of a <strong>cost-effective payment platform</strong> begins with <strong>a well-architected cloud infrastructure</strong> that prioritizes <strong>scalability, availability, and efficiency</strong>.</p>

<h3 id="key-practices">Key Practices:</h3>

<ul>
  <li><strong>Adopt a microservices architecture</strong> — By breaking down payment processing into smaller, modular services, businesses can <strong>scale only the necessary components</strong>, preventing overprovisioning and reducing costs.</li>
  <li><strong>Leverage serverless computing for event-driven transactions</strong> — Using <strong>AWS Lambda</strong> for real-time processing (e.g., fraud detection, transaction validation) eliminates idle resources and <strong>lowers compute expenses</strong>.</li>
  <li><strong>Deploy workloads strategically across Availability Zones</strong> — While <strong>multi-AZ replication</strong> enhances reliability, deploying <strong>only critical workloads</strong> across zones prevents unnecessary <strong>infrastructure costs</strong>.</li>
</ul>

<h3 id="aws-tools">AWS Tools:</h3>

<ul>
  <li><strong>AWS Well-Architected Framework</strong> — Helps assess performance and cost efficiency.</li>
  <li><strong>AWS Compute Optimizer</strong> — Recommends right-sizing for compute resources.</li>
</ul>

<h2 id="2-establishing-budget-visibility-and-cost-ownership">2. Establishing Budget Visibility and Cost Ownership</h2>

<p>A cost-aware culture requires <strong>clear visibility into cloud expenses</strong> and a <strong>shared responsibility model</strong> where all teams understand the financial impact of their choices.</p>

<h3 id="key-practices-1">Key Practices:</h3>

<ul>
  <li><strong>Implement cost allocation tagging</strong> — Assign cost categories (e.g., <code class="language-plaintext highlighter-rouge">"Card Payments"</code>, <code class="language-plaintext highlighter-rouge">"Mobile Transactions"</code>, <code class="language-plaintext highlighter-rouge">"Fraud Prevention"</code>) to track spending per function.</li>
  <li><strong>Set budget thresholds with automated alerts</strong> — Define <strong>monthly and project-based budgets</strong>, ensuring that teams are alerted before exceeding limits.</li>
  <li><strong>Foster cost accountability</strong> — Encourage developers and operations teams to <strong>actively manage and optimize their AWS usage</strong>, making cost awareness a key performance metric.</li>
</ul>

<h3 id="aws-tools-1">AWS Tools:</h3>

<ul>
  <li><strong>AWS Cost Explorer</strong> — Provides insights into cost trends and forecasts.</li>
  <li><strong>AWS Budgets</strong> — Sends automated alerts when usage nears budget thresholds.</li>
  <li><strong>AWS Resource Groups</strong> — Organizes resources to track spending across services.</li>
</ul>

<h2 id="3-real-time-monitoring-and-cost-control">3. Real-Time Monitoring and Cost Control</h2>

<p>With fluctuating transaction volumes, <strong>continuous monitoring</strong> is crucial to avoid unnecessary cloud expenses while maintaining <strong>high system performance</strong>.</p>

<h3 id="key-practices-2">Key Practices:</h3>

<ul>
  <li><strong>Consolidate monitoring into dashboards</strong> — Use <strong>Amazon CloudWatch</strong> to aggregate key performance metrics, transaction volumes, and cloud resource consumption in a <strong>centralized view</strong>.</li>
  <li><strong>Set up automated cost anomaly detection</strong> — Implement alerts for sudden <strong>spikes in compute, storage, or data transfer costs</strong>, allowing teams to take immediate action.</li>
  <li><strong>Optimize auto-scaling policies</strong> — Ensure that <strong>Auto Scaling Groups (ASG) prioritize Reserved Instances and Savings Plans before On-Demand capacity</strong>.</li>
</ul>

<h3 id="aws-tools-2">AWS Tools:</h3>

<ul>
  <li><strong>Amazon CloudWatch</strong> — Monitors system health and resource consumption.</li>
  <li><strong>AWS Budgets</strong> — Tracks spending and triggers alerts for cost overruns.</li>
</ul>

<h2 id="4-scaling-smartly-with-reserved-instances-savings-plans-and-private-pricing-agreements">4. Scaling Smartly with Reserved Instances, Savings Plans, and Private Pricing Agreements</h2>

<p>Handling <strong>high-volume payment transactions</strong> requires <strong>scalable and cost-efficient infrastructure</strong>. A mix of <strong>Reserved Instances (RIs), Savings Plans, and Private Pricing Agreements (PPA)</strong> helps businesses optimize spending.</p>

<h3 id="key-practices-3">Key Practices:</h3>

<ul>
  <li><strong>Use Reserved Instances (RIs) for predictable workloads</strong> — Lock in <strong>1-year or 3-year RIs</strong> for critical components like <strong>databases (RDS, DynamoDB), caching (ElastiCache), and core processing servers</strong>, achieving up to <strong>72% savings</strong>.</li>
  <li><strong>Negotiate AWS Private Pricing Agreements (PPA) for large-scale usage</strong> — For <strong>businesses with high transaction volumes</strong>, negotiating <strong>custom pricing agreements</strong> ensures better per-unit cost efficiency.</li>
  <li><strong>Adopt Savings Plans for flexible cost reductions</strong> — Unlike RIs, <strong>Compute Savings Plans</strong> offer <strong>automatic discounts across EC2, Lambda, and Fargate</strong>, making them ideal for <strong>both steady-state and dynamic workloads</strong>.</li>
  <li><strong>Prioritize Reserved Capacity in Auto Scaling Groups</strong> — Configure auto-scaling to <strong>use RIs and Savings Plans first</strong>, with On-Demand instances as a fallback.</li>
  <li><strong>Run batch processing on Spot Instances</strong> — Leverage <strong>Spot Instances</strong> for tasks like <strong>fraud detection, transaction reconciliation, and analytics</strong>, cutting compute costs by up to <strong>90%</strong>.</li>
</ul>

<h3 id="aws-tools-3">AWS Tools:</h3>

<ul>
  <li><strong>AWS Reserved Instances</strong> — Reduces costs for long-term workloads.</li>
  <li><strong>AWS Savings Plans</strong> — Offers flexible, commitment-based cost savings.</li>
  <li><strong>AWS Auto Scaling</strong> — Dynamically adjusts capacity while prioritizing cost-efficient resources.</li>
</ul>

<h2 id="5-managing-storage-and-data-transfer-costs">5. Managing Storage and Data Transfer Costs</h2>

<p>Payment systems generate <strong>large volumes of data</strong>, making <strong>efficient storage and data transfer management</strong> essential for cost control.</p>

<h3 id="key-practices-4">Key Practices:</h3>

<ul>
  <li><strong>Use Amazon S3 Intelligent-Tiering</strong> — Automatically move infrequently accessed transaction logs to <strong>lower-cost storage tiers</strong>.</li>
  <li><strong>Optimize Amazon RDS and DynamoDB storage configurations</strong> — Regularly monitor database storage to prevent over-provisioning.</li>
  <li><strong>Minimize cross-region data transfer fees</strong> — Keep compute and storage resources within <strong>the same AWS region</strong> whenever possible to reduce network costs.</li>
</ul>

<h3 id="aws-tools-4">AWS Tools:</h3>

<ul>
  <li><strong>Amazon S3 Lifecycle Policies</strong> — Automates storage tiering.</li>
  <li><strong>Amazon RDS Reserved Instances</strong> — Cuts database storage expenses.</li>
  <li><strong>AWS Data Transfer Cost Calculator</strong> — Helps estimate and manage network costs.</li>
</ul>

<h2 id="6-securing-transactions-without-overspending">6. Securing Transactions Without Overspending</h2>

<p>Security is paramount in payment processing, but organizations must <strong>balance protection with cost efficiency</strong>.</p>

<h3 id="key-practices-5">Key Practices:</h3>

<ul>
  <li><strong>Deploy AWS WAF for fraud prevention</strong> — Protect against fraudulent transactions and bot activity <strong>without excessive data processing overhead</strong>.</li>
  <li><strong>Use AWS Shield Standard for free DDoS protection</strong> — Avoid costly third-party services by leveraging <strong>AWS’s built-in security measures</strong>.</li>
  <li><strong>Optimize encryption with AWS KMS</strong> — Encrypt transaction data efficiently while minimizing cryptographic key management costs.</li>
</ul>

<h3 id="aws-tools-5">AWS Tools:</h3>

<ul>
  <li><strong>AWS Security Hub</strong> — Centralizes compliance and security monitoring.</li>
  <li><strong>AWS IAM</strong> — Manages access controls for secure transactions.</li>
  <li><strong>AWS KMS</strong> — Ensures cost-effective encryption and data security.</li>
</ul>

<h2 id="7-continuous-cost-optimization-and-performance-reviews">7. Continuous Cost Optimization and Performance Reviews</h2>

<p>A <strong>cost-aware culture</strong> requires <strong>ongoing evaluation and optimization</strong> to maintain <strong>efficient cloud operations</strong>.</p>

<h3 id="key-practices-6">Key Practices:</h3>

<ul>
  <li><strong>Conduct quarterly cost and performance audits</strong> — Regularly assess cloud usage and identify cost-saving opportunities.</li>
  <li><strong>Eliminate underutilized resources</strong> — Decommission unused <strong>Elastic Load Balancers, unattached EBS volumes, and over-provisioned instances</strong>.</li>
  <li><strong>Continuously refine performance vs. cost trade-offs</strong> — Reevaluate instance types, storage configurations, and network usage to ensure <strong>the best cost-to-performance ratio</strong>.</li>
</ul>

<h3 id="aws-tools-6">AWS Tools:</h3>

<ul>
  <li><strong>AWS Trusted Advisor</strong> — Detects underutilized resources and cost inefficiencies.</li>
  <li><strong>AWS Compute Optimizer</strong> — Recommends ideal compute configurations.</li>
  <li><strong>AWS Cost and Usage Report</strong> — Provides in-depth cost tracking insights.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Managing <strong>high-performance payment systems on AWS</strong> requires <strong>a cost-aware mindset</strong> that permeates every aspect of cloud operations. By focusing on <strong>budget visibility, real-time monitoring, efficient scaling, and continuous optimization</strong>, businesses can <strong>maintain top-tier performance while controlling costs</strong>.</p>

<p>By adopting these best practices, organizations can <strong>maximize their AWS investment, ensure reliable transaction processing, and build a sustainable, cost-efficient cloud environment</strong>.</p>]]></content><author><name>Eurico Nicacio</name><email>nckbr@proton.me</email></author><summary type="html"><![CDATA[In the world of digital payments, businesses must maintain high performance, scalability, and reliability while keeping cloud costs under control. Amazon Web Services (AWS) provides a powerful and…]]></summary></entry></feed>