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)
- TL;DR
- Why this scenario matters
- The setup
- Step 1: Orient before you hunt
- Step 2: Find the lure
- Step 3: The 87-minute gap
- Step 4: What does normal Copilot look like?
- Step 5: The words she never typed
- Step 6: It was not five files. It was thirteen
- Step 7: Was it Emily or was it Copilot?
- Step 8: How the data got out
- Step 9: Three attack patterns in one session
- Step 10: Ruling out everything else
- What the logs cannot tell you
- Mapping it: ATT&CK, ATLAS, and where I disagreed
- Detections that would have caught it
- What I would tell Cormorant Capital
- KQL lessons from this hunt
- What I took away
- 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.
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:
| Table | What it tells you |
|---|---|
OfficeActivity_CL | What happened: mail delivered, files accessed (the Microsoft 365 unified audit log) |
CopilotAudit_CL | Why it happened: prompts, tool calls, searches, responses |
EntraSignIn_CL | Who 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.
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.
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.
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.
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.
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
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
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.
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.
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".
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) | File | Site |
|---|---|---|
| 10:42:47 | Board Meeting Minutes - October 2026.docx | Executive |
| 10:42:49 | Project Falcon - M&A Target Assessment.xlsx | Executive |
| 10:42:51 | Salary Review FY2027 - Executive Summary.pdf | HR |
| 10:42:53 | Client Portfolio - Henderson Family Office.xlsx | Finance |
| 10:42:55 | Copilot System Configuration - Cormorant.json | IT |
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.
Nobody else touched those files, and this was the only session in the window that reached labelled content at all.
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".
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.
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.
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:




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.
Thirteen exfiltration links, one host, three responses:
| Response (UTC) | Tag | Links | What was sent | Pattern |
|---|---|---|---|---|
| 10:43:08 | s=1 to s=4 | 4 | The first four wave 1 files | EchoLeak |
| 10:44:19 | s=config | 1 | system_prompt_cormorant_capital | Configuration leak |
| 10:46:35 | s=sl1 to s=sl8 | 8 | The eight wave 2 files | Search-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.
| Hypothesis | Evidence | Result |
|---|---|---|
| Emily's account was compromised | All sign-ins successful from her known IP with MFA, no failures anywhere in the tenant, benign prompt | Ruled out |
| Copilot's configuration was tampered with | Configuration file read but never modified; Emily's later sessions at 11:45 and 12:58 UTC behave normally with zero tool calls | Ruled out |
| External input manipulated Copilot | The only abnormal session is grounded on the external document; unrelated searches appear 6 seconds after the prompt | Confirmed |
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.
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.
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.
| Question | Why these tables cannot answer it | What would close the gap |
|---|---|---|
| Did the data reach the attacker? | The image fetch happens in the client | Proxy, 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 identifiers | Proxy logs with full URLs, network capture, DLP with content inspection |
| What did the poisoned document say? | No table stores document text | Mail detonation or an eDiscovery copy of the attachment |
| Who received the two ambiguous emails? | No recipient field | Exchange message trace |
| Why was the config read again at 10:44:15 with no tool call? | No ToolCall row precedes it | Fuller 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.
| Technique | ID |
|---|---|
| Phishing: Spearphishing Attachment | T1566.001 |
| User Execution: Malicious File | T1204.002 |
| LLM Prompt Injection: Indirect (ATLAS) | AML.T0051.001 |
| File and Directory Discovery | T1083 |
| System Information Discovery | T1082 |
| Data from Information Repositories: SharePoint | T1213.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:
| ID | Logic | Would have fired at |
|---|---|---|
| D-001 | Copilot response contains Markdown image syntax pointing to a non-approved external host | 10:43:08 |
| D-002 | One Copilot session reads 3 or more files labelled Confidential or higher | 10:42:51 |
| D-003 | External attachment, then a Copilot session grounded on it, then 5 or more Copilot file reads | 10:42:55 |
| D-004 | 5 or more distinct files in 1 minute on sites the user has not touched in 30 days | 10:42:55 |
| D-005 | 3 or more Copilot searches in one session within 5 minutes | 10: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
hasmatches whole terms.has "Summar"silently missed every "Summarise" prompt, andhas_any("Send")missedMessageSent. For partial text usecontainsorstartswith.- Strings that look like numbers are still strings. Custom tables often store everything as strings. Check with
getschemabefore 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).fileIdturned 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.