<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://yili-soc.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://yili-soc.github.io/" rel="alternate" type="text/html" /><updated>2026-10-10T15:06:05-06:00</updated><id>https://yili-soc.github.io/feed.xml</id><title type="html">All About Security</title><subtitle>Write-ups on detecting and responding to real-world attacks.</subtitle><author><name>Yi Li</name></author><entry><title type="html">ClickFix Detection: Comparing the Public Approaches</title><link href="https://yili-soc.github.io/2026/10/10/clickfix-detection-approaches.html" rel="alternate" type="text/html" title="ClickFix Detection: Comparing the Public Approaches" /><published>2026-10-10T15:00:00-06:00</published><updated>2026-10-10T15:00:00-06:00</updated><id>https://yili-soc.github.io/2026/10/10/clickfix-detection-approaches</id><content type="html" xml:base="https://yili-soc.github.io/2026/10/10/clickfix-detection-approaches.html"><![CDATA[<h2 id="0-introduction">0. Introduction</h2>

<p>In a Sophos talk at BSides Edmonton 2026, ClickFix was listed as one of the most common ways attackers get initial access. There are already many public detections for it, and they look at different things. This post is my study note: I put the public approaches side by side and compare where each one fits.</p>

<blockquote>
  <p><strong>Disclaimer</strong></p>

  <ul>
    <li>The views in this post are my own and do not represent my employer.</li>
    <li>The KQL queries are examples. Adjust and validate them for your own environment before using them in production.</li>
  </ul>
</blockquote>

<hr />

<h2 id="1-what-clickfix-is">1. What ClickFix is</h2>

<p>A lure page pretends to be a CAPTCHA or an error message. It quietly copies a command to the clipboard, then tells the user to paste it somewhere and press Enter.</p>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1204/004/">T1204.004</a> Malicious Copy and Paste. The execution step usually also involves <a href="https://attack.mitre.org/techniques/T1059/001/">T1059.001</a> PowerShell.</p>

<p>Where the user pastes the command decides what traces it leaves:</p>

<table>
  <thead>
    <tr>
      <th>Variant</th>
      <th>Where the command is pasted</th>
      <th>Traces</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ClickFix</td>
      <td>Win+R Run dialog</td>
      <td><code class="language-plaintext highlighter-rouge">RunMRU</code> registry key; parent process <code class="language-plaintext highlighter-rouge">explorer.exe</code></td>
    </tr>
    <tr>
      <td>FileFix</td>
      <td>Address bar of the browser’s file upload dialog</td>
      <td><code class="language-plaintext highlighter-rouge">TypedPaths</code> registry key; parent process is the browser</td>
    </tr>
    <tr>
      <td>TerminalFix</td>
      <td>A PowerShell window the user opens</td>
      <td>Almost none</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="2-five-ways-to-detect-it">2. Five ways to detect it</h2>

<p><strong>① Registry.</strong> Whatever the user types into the Run dialog is saved as-is in RunMRU. What they type into the file dialog’s address bar is saved in TypedPaths. Query these two keys and you see the full command the user pasted (<a href="https://kqlquery.com/posts/investigate-clickfix/">kqlquery.com</a>, <a href="https://github.com/elastic/detection-rules/pull/6914">Elastic</a>).</p>

<p><strong>② Process chain.</strong> <code class="language-plaintext highlighter-rouge">explorer.exe</code> or a browser directly starts PowerShell, cmd, mshta, or a similar program (<a href="https://www.huntress.com/blog/dont-sweat-clickfix-techniques">Huntress</a>).</p>

<p><strong>③ Keywords.</strong> Lure commands often end with a <code class="language-plaintext highlighter-rouge">#</code> comment and words like “captcha”, “robot”, or “verification” (<a href="https://github.com/SigmaHQ/sigma/blob/master/rules/windows/registry/registry_set/registry_set_potential_clickfix_execution.yml">SigmaHQ</a>). This is accurate, but it stops working as soon as the attacker changes the wording (<a href="https://www.prophetsecurity.ai/blog/clickfix-attack">Prophet Security</a>).</p>

<p><strong>④ Built-in alerts.</strong> Microsoft Defender for Endpoint (MDE) already has ClickFix alerts. But the gap between PowerShell starting and the alert firing is often more than a minute (<a href="https://www.glueckkanja.com/en/posts/2026-09-21-clickfix-prevent-detect">glueckkanja</a>).</p>

<p><strong>⑤ Browser layer.</strong> Catch the command at the moment the page writes it to the clipboard. At that point nothing has run yet, so it doesn’t matter which program the attacker uses or how the command is obfuscated. Push Security works this way, and Opera’s Paste Protect, released in July 2026, follows the same idea (<a href="https://pushsecurity.com/blog/the-state-of-clickfix-by-detection-data">Push Security</a>, <a href="https://blogs.opera.com/news/2026/07/opera-introduces-paste-protect-to-keep-you-safe-from-clipboard-attacks/">Opera</a>). The downside is that you need to deploy a browser extension or a different browser.</p>

<h3 id="kql">KQL</h3>

<p>The queries below map to methods ① to ④ above. Method ⑤ runs in the browser, so there is no KQL for it.</p>

<p>For ①, I think the most useful version ignores keywords and looks only at the “shape” of the command: longer than 50 characters (<a href="https://detect.fyi/hunting-clickfix-initial-access-techniques-8c1b38d5ef9b">detect.fyi</a> found the average sample was about 179), and containing a URL, an IP address, or a base64 block.</p>

<pre><code class="language-kql">// ① Registry + command shape: RunMRU (Win+R) and TypedPaths (FileFix)
// No filter on the writing process: for FileFix, the browser writes TypedPaths, not explorer.exe
DeviceRegistryEvents
| where ActionType == "RegistryValueSet"
| where RegistryKey has_any ("RunMRU", "TypedPaths")
| where RegistryValueName != "MRUList"
| where strlen(RegistryValueData) &gt; 50
| where RegistryValueData matches regex @"(?i)(https?://|\b\d{1,3}(\.\d{1,3}){3}\b|[A-Za-z0-9+/]{40,})"
| project Timestamp, ReportId, DeviceId, DeviceName, InitiatingProcessFileName, RegistryKey, RegistryValueData
</code></pre>

<pre><code class="language-kql">// ② Process chain: explorer.exe or a browser starts a script interpreter or LOLBin
// OriginalFileName also catches renamed binaries (CrashFix renames finger.exe to ct.exe)
let lolbins = dynamic(["powershell.exe","pwsh.exe","cmd.exe","mshta.exe","rundll32.exe",
    "regsvr32.exe","msiexec.exe","curl.exe","finger.exe","wscript.exe","cscript.exe"]);
DeviceProcessEvents
| where InitiatingProcessFileName in~ ("explorer.exe","chrome.exe","msedge.exe","firefox.exe","brave.exe")
| where FileName in~ (lolbins) or ProcessVersionInfoOriginalFileName in~ (lolbins)
// Command-line filter: without it, every PowerShell opened from the Start menu matches
| where ProcessCommandLine matches regex @"(?i)(https?://|\\\\[\w.\-@]+\\|-enc|iex|iwr)"
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, FileName, ProcessCommandLine
</code></pre>

<pre><code class="language-kql">// ③ Keywords: a "#" comment plus typical lure words
DeviceRegistryEvents
| where RegistryKey has_any ("RunMRU", "TypedPaths")
| where RegistryValueData contains "#"
| where RegistryValueData has_any ("captcha", "robot", "verification", "verify", "human")
| project Timestamp, DeviceName, InitiatingProcessAccountName, RegistryKey, RegistryValueData
</code></pre>

<pre><code class="language-kql">// ④ Built-in alerts: see what MDE has already raised for ClickFix
AlertInfo
| where Title has_any ("ClickFix", "RunMRU", "Run dialog")
| project Timestamp, Title, Severity, DetectionSource
</code></pre>

<p>Two things to keep in mind:</p>

<ul>
  <li>Don’t limit the writing process to <code class="language-plaintext highlighter-rouge">explorer.exe</code> in query ①. For FileFix, the browser writes TypedPaths.</li>
  <li>Query ① can also be bypassed. In October 2026, Microsoft described a “browser cache smuggling” variant: the payload is hidden in the browser cache ahead of time, and the pasted command only finds it on disk and runs it. The command contains no URL, IP address, or base64 (<a href="https://thehackernews.com/2026/10/clickfix-smuggles-payloads-through.html">The Hacker News</a>). RunMRU still records the command, so you would filter on something else, such as a browser profile path or <code class="language-plaintext highlighter-rouge">wscript</code> in the command.</li>
</ul>

<hr />

<h2 id="3-comparison">3. Comparison</h2>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Covers</th>
      <th>Strength</th>
      <th>Weakness</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>① Registry</td>
      <td>ClickFix, FileFix</td>
      <td>Shows the full command; simple rule</td>
      <td>Can’t see TerminalFix</td>
    </tr>
    <tr>
      <td>② Process chain</td>
      <td>ClickFix, FileFix</td>
      <td>Doesn’t depend on the command text</td>
      <td>Noisy; needs command-line filters</td>
    </tr>
    <tr>
      <td>③ Keywords</td>
      <td>Depends on the wording</td>
      <td>High accuracy</td>
      <td>Easiest to bypass</td>
    </tr>
    <tr>
      <td>④ Built-in alerts</td>
      <td>Depends on the vendor</td>
      <td>Nothing to write yourself</td>
      <td>Delayed</td>
    </tr>
    <tr>
      <td>⑤ Browser layer</td>
      <td>All variants</td>
      <td>Stops it before anything runs</td>
      <td>Extra deployment</td>
    </tr>
  </tbody>
</table>

<p>TerminalFix is a blind spot for all the endpoint methods (① to ④). The browser layer is the only one that covers it.</p>

<hr />

<h2 id="4-which-to-use">4. Which to use</h2>

<ul>
  <li><strong>Few custom rules so far:</strong> rely on the built-in alerts, and add query ① as an MDE custom detection rule.</li>
  <li><strong>Threat hunting:</strong> run ② and ③ on a schedule. When one hits, check whether that process made outbound connections.</li>
  <li><strong>If budget allows:</strong> add browser-layer protection. It is the only method that stops the command before it runs.</li>
  <li><strong>Prevention:</strong> most users don’t need the Run dialog, so you can disable Win+R with Group Policy. This helps less than it sounds: attack kits are moving to Win+X, and there is no built-in Group Policy to disable Win+X (<a href="https://pushsecurity.com/blog/the-state-of-clickfix-by-detection-data">Push Security</a>).</li>
</ul>

<hr />

<h2 id="references">References</h2>

<ul>
  <li>Prophet Security: <a href="https://www.prophetsecurity.ai/blog/clickfix-attack">ClickFix Attacks: How to Detect and Investigate Fake CAPTCHA Lures</a></li>
  <li>Huntress: <a href="https://www.huntress.com/blog/dont-sweat-clickfix-techniques">ClickFix Attack: Variants, Detection &amp; How It Works</a></li>
  <li>glueckkanja: <a href="https://www.glueckkanja.com/en/posts/2026-09-21-clickfix-prevent-detect">ClickFix. When the user is the exploit, and how to stop it</a></li>
  <li>kqlquery.com: <a href="https://kqlquery.com/posts/investigate-clickfix/">Investigating ClickFix Incidents</a></li>
  <li>detect.fyi: <a href="https://detect.fyi/hunting-clickfix-initial-access-techniques-8c1b38d5ef9b">Hunting ClickFix Initial Access Techniques</a></li>
  <li>Elastic: <a href="https://github.com/elastic/detection-rules/pull/6914">detection-rules PR #6914</a></li>
  <li>SigmaHQ: <a href="https://github.com/SigmaHQ/sigma/blob/master/rules/windows/registry/registry_set/registry_set_potential_clickfix_execution.yml">Potential ClickFix Execution Pattern - Registry</a></li>
  <li>Push Security: <a href="https://pushsecurity.com/blog/the-state-of-clickfix-by-detection-data">The numbers behind ClickFix attacks in H2 2026</a></li>
  <li>Opera: <a href="https://blogs.opera.com/news/2026/07/opera-introduces-paste-protect-to-keep-you-safe-from-clipboard-attacks/">Opera introduces Paste Protect</a></li>
  <li>The Hacker News: <a href="https://thehackernews.com/2026/10/clickfix-smuggles-payloads-through.html">ClickFix Smuggles Payloads Through Browser Cache to Bypass Windows Run Limits</a></li>
  <li>MITRE ATT&amp;CK: technique IDs in this post are mapped to <a href="https://attack.mitre.org/resources/versions/">ATT&amp;CK v19</a>.</li>
</ul>]]></content><author><name>Yi Li</name></author><category term="BSidesEdmonton2026" /><category term="detection" /><category term="ClickFix" /><category term="FileFix" /><category term="Microsoft Defender for Endpoint" /><category term="KQL" /><summary type="html"><![CDATA[A study note on the public ways to detect ClickFix: where each one looks, which variants it covers, and when to use it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yili-soc.github.io/assets/images/share-card.png" /><media:content medium="image" url="https://yili-soc.github.io/assets/images/share-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">When the Vendor Is the Way In: Detecting Abused SaaS Integrations in Microsoft Sentinel</title><link href="https://yili-soc.github.io/2026/09/26/klue-supply-chain-detection.html" rel="alternate" type="text/html" title="When the Vendor Is the Way In: Detecting Abused SaaS Integrations in Microsoft Sentinel" /><published>2026-09-26T20:00:00-06:00</published><updated>2026-09-26T20:00:00-06:00</updated><id>https://yili-soc.github.io/2026/09/26/klue-supply-chain-detection</id><content type="html" xml:base="https://yili-soc.github.io/2026/09/26/klue-supply-chain-detection.html"><![CDATA[<h2 id="0-introduction">0. Introduction</h2>

<p>This case comes from the BSides Edmonton 2026 talk <em>Advanced SaaS Threats: Case Studies from the Field</em> by <a href="https://www.linkedin.com/in/damien-miller-mcandrews/">Damien Miller-McAndrews</a>, a threat researcher at Obsidian Security. He walked through three real cases to make one point: many SaaS breaches today use no exploits and no malware. Attackers simply abuse legitimate features and identity trust.</p>

<p>This post covers the third case: the competitive intelligence platform Klue was breached, and attackers stole the OAuth tokens it held on behalf of its customers, then used those tokens to export customers’ Salesforce data in bulk. This post adds the customer’s side: how to detect and respond to an abused third-party integration in Microsoft Sentinel.</p>

<blockquote>
  <p><strong>Disclaimer</strong></p>

  <ul>
    <li>The views in this post are my own and do not represent my employer.</li>
    <li>Incident details come from public sources and the original talk, which are cited in the text. I am not affiliated with Obsidian Security.</li>
    <li>The KQL queries illustrate detection logic. Adjust and validate them for your own environment before use.</li>
  </ul>
</blockquote>

<hr />

<h2 id="1-background">1. Background</h2>

<p>Klue is a competitive intelligence SaaS platform. It connects to customers’ Salesforce, HubSpot, SharePoint, Gong, Slack and other systems through OAuth to read sales data. In June 2026, Icarus, an extortion group that only appeared in April, breached Klue. The attackers used a dormant but still valid credential that had been created for a prototype integration, got into Klue’s backend, planted malicious code, and stole the OAuth tokens customers had granted to Klue. Over roughly the next 24 hours, they used Python scripts to pull CRM data from several customers through the Salesforce REST API, then sent extortion emails to the victims. Klue then disabled all customer integrations, and Salesforce disabled Klue’s app (<a href="https://www.bleepingcomputer.com/news/security/klue-oauth-breach-linked-to-icarus-salesforce-data-theft-attacks/">BleepingComputer</a>).</p>

<p>More than a dozen organizations were affected. Those named publicly include LastPass, 8x8, and Snyk (<a href="https://www.finra.org/rules-guidance/guidance/cybersecurity-alert-klue-oauth-breach-and-salesforce-data-exfiltration">FINRA</a>). Huntress also confirmed it was one of the victims and published the details of its investigation (<a href="https://www.huntress.com/blog/klue-breach-investigation">Huntress</a>).</p>

<p>This is a textbook SaaS supply chain attack: instead of attacking the target directly, the attacker breaks into a vendor the target trusts and uses the vendor’s access to get in. In MITRE ATT&amp;CK terms, it falls under T1199 Trusted Relationship.</p>

<p>This post is based on Obsidian’s <a href="https://www.obsidiansecurity.com/blog/icarus-klue-salesforce-integration-supply-chain-attack">technical analysis</a>, Huntress’s victim-side investigation, and the original talk.</p>

<hr />

<h2 id="2-how-the-attack-works">2. How the attack works</h2>

<h3 id="the-big-picture">The big picture</h3>

<p>The customers did nothing wrong. At some point they authorized Klue to access Salesforce, and Klue used the token to sync data on a schedule. Everything was normal. The attacker never touched any customer account. They broke into Klue and took those tokens. To Salesforce, the attacker’s requests were simply “Klue calling the API”: no sign-in, no MFA, and nothing that would trigger an identity alert. The attacker first worked out what data each customer’s Salesforce held, then used scripts to page through it and pull everything out.</p>

<h3 id="-the-vendor-is-breached-invisible-to-customers">① The vendor is breached (invisible to customers)</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1528/">T1528</a> Steal Application Access Token (on the vendor side)</p>

<p>The attacker used a dormant credential at Klue that had never been cleaned up, got into the backend, planted code, and collected customers’ OAuth tokens. According to the talk, that credential was a GitHub personal access token (PAT) that had been alive for about four years. This step happens inside the vendor. Customers can’t see it and can’t detect it.</p>

<h3 id="-stolen-tokens-used-against-customers-salesforce">② Stolen tokens used against customers’ Salesforce</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1550/001/">T1550.001</a> Application Access Token</p>

<p>This is the first step customers can see, and it left a clear change in the integration’s “fingerprint” (Obsidian, Huntress):</p>

<ul>
  <li><strong>The User-Agent changed:</strong> Klue’s normal integration used <code class="language-plaintext highlighter-rouge">python-httpx</code>. The attack traffic switched to <code class="language-plaintext highlighter-rouge">python-urllib/3.12</code> and <code class="language-plaintext highlighter-rouge">python-urllib/3.14</code>, plus User-Agents of “5238” or blank.</li>
  <li><strong>The API version changed:</strong> normal calls went to <code class="language-plaintext highlighter-rouge">/services/data/v64.0/</code>; attack calls used <code class="language-plaintext highlighter-rouge">v59.0</code>.</li>
  <li><strong>The source IPs changed:</strong> attack traffic came from a few IPs the integration had never used, located in the Netherlands, France, and Ukraine.</li>
</ul>

<h3 id="-discovery">③ Discovery</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1526/">T1526</a> Cloud Service Discovery (closest fit, my mapping)</p>

<p>The attacker first called Salesforce’s Global Describe to list every object in the org. A normal business integration only reads the few objects it needs and almost never enumerates everything.</p>

<h3 id="-bulk-export">④ Bulk export</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1213/004/">T1213.004</a> Customer Relationship Management Software · <a href="https://attack.mitre.org/techniques/T1020/">T1020</a> Automated Exfiltration</p>

<p>The attacker ran bulk SOQL queries against objects such as Account, Contact, Opportunity, and Task, then paged through the results with QueryMore to pull everything out. In the worst-hit organization, about 13.9 million records were exported.</p>

<hr />

<h2 id="3-detection-approach-and-kql">3. Detection approach and KQL</h2>

<p>What makes this case hard: the attacker uses a <strong>legitimate token</strong>. Detections based on sign-ins, MFA, or identity risk all fail. The only trace is in the API call logs.</p>

<p>But the case also has a useful property: <strong>integrations are far more predictable than people.</strong> An integration usually calls from the same few IPs, with the same User-Agent and the same API version, reads the same few objects, and moves a fairly steady amount of data each day. Build a “fingerprint” like that for each integration and alert when it changes. What Obsidian saw in this incident was every part of the fingerprint changing.</p>

<p>The KQL below applies this idea to Microsoft 365, for two reasons. First, Klue’s connections included SharePoint, so the same attack could just as easily land in M365. Second, the Graph API log in M365, <code class="language-plaintext highlighter-rouge">MicrosoftGraphActivityLogs</code>, records every field the fingerprint needs (<code class="language-plaintext highlighter-rouge">AppId</code>, <code class="language-plaintext highlighter-rouge">IPAddress</code>, <code class="language-plaintext highlighter-rouge">UserAgent</code>, <code class="language-plaintext highlighter-rouge">ApiVersion</code>, <code class="language-plaintext highlighter-rouge">RequestUri</code>, <code class="language-plaintext highlighter-rouge">ResponseSizeBytes</code>). This table has to be turned on separately in Entra’s diagnostic settings, and many environments don’t have it on by default.</p>

<h3 id="detection-point-1-the-integrations-fingerprint-changes-step-">Detection point 1: the integration’s fingerprint changes (step ②)</h3>

<p><strong>Idea:</strong> ask one question: has this app’s “User-Agent + API version” combination from today been seen in the last 30 days? Klue normally used <code class="language-plaintext highlighter-rouge">python-httpx</code> + <code class="language-plaintext highlighter-rouge">v64.0</code>. During the attack, <code class="language-plaintext highlighter-rouge">python-urllib</code> + <code class="language-plaintext highlighter-rouge">v59.0</code> appeared, and that new combination gets listed right away. IP isn’t used as a condition, because vendors change cloud servers all the time and IP on its own is too noisy. The results still list the source IPs for the analyst to review.</p>

<pre><code class="language-kql">let Known = MicrosoftGraphActivityLogs
| where TimeGenerated between (ago(30d) .. ago(1d))
| distinct AppId, UserAgent, ApiVersion;
MicrosoftGraphActivityLogs
| where TimeGenerated &gt; ago(1d)
| where AppId in ((Known | distinct AppId))
| join kind=leftanti Known on AppId, UserAgent, ApiVersion
| summarize Requests = count(), IPs = make_set(IPAddress, 10) by AppId, UserAgent, ApiVersion
</code></pre>

<p><strong>How it works:</strong> <code class="language-plaintext highlighter-rouge">Known</code> lists every combination each app used in the last 30 days. The <code class="language-plaintext highlighter-rouge">leftanti</code> join keeps only combinations that appear today but never appear in <code class="language-plaintext highlighter-rouge">Known</code>. Apps connected less than 30 days ago have no baseline, so they’re left out.</p>

<p><strong>False positives:</strong> the vendor upgrades its libraries or API version. Changes like that are usually announced in advance, or show up for every customer on the same day. Check with the vendor.</p>

<p><strong>On the Salesforce side:</strong> Salesforce API logs are only kept for 24 hours, so they need to be pulled into the SIEM on a schedule. If you have Defender for Cloud Apps, you can use its built-in detections.</p>

<h3 id="detection-point-2-discovery-and-bulk-pulls-steps--and-">Detection point 2: discovery and bulk pulls (steps ③ and ④)</h3>

<p><strong>Idea:</strong> an integration usually touches a fairly steady set of resource types and a fairly steady volume of data each day. If one day it suddenly touches many resource types it has never used, or pulls ten times its usual volume, that points to discovery followed by bulk export.</p>

<pre><code class="language-kql">let Daily = MicrosoftGraphActivityLogs
| where TimeGenerated &gt; ago(14d)
| extend Resource = tostring(split(tostring(parse_url(RequestUri).Path), "/")[2])
| summarize
    Resources = dcount(Resource),
    MB = sum(ResponseSizeBytes) / 1048576.0
    by AppId, Day = startofday(TimeGenerated);
Daily
| summarize
    TodayMB = sumif(MB, Day == startofday(now())),
    AvgMB = avgif(MB, Day &lt; startofday(now())),
    TodayResources = maxif(Resources, Day == startofday(now())),
    MaxResourcesBefore = maxif(Resources, Day &lt; startofday(now()))
    by AppId
| where (TodayMB &gt; 50 and TodayMB &gt; 10 * AvgMB)
    or TodayResources &gt; MaxResourcesBefore + 3
</code></pre>

<p><strong>When both detections fire for the same app</strong>, it’s almost certainly token abuse.</p>

<hr />

<h2 id="4-response-and-automation">4. Response and automation</h2>

<h3 id="key-points">Key points</h3>

<p><strong>1. Isolate the integration yourself; don’t wait for the vendor.</strong> When both detections fire, or the vendor publicly discloses a breach, block the connected app in Salesforce and revoke all of its tokens, and disable the matching enterprise app in Entra. Isolate the whole app, not just one token.</p>

<p><strong>2. Scope the impact and warn about follow-on abuse.</strong> Find every other system connected to this vendor, isolate each one, and review its logs. The stolen contact data is exactly the target list a vishing campaign needs, so warn employees to watch for calls, emails, and extortion messages that impersonate the vendor.</p>

<h3 id="is-automation-needed">Is automation needed?</h3>

<p>This case is <strong>a good fit for automation</strong>: isolating a third-party integration has limited business impact and can be undone at any time, so clients are far more willing to pre-authorize it than, say, disabling an employee’s account.</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Actions</th>
      <th>Impact and reversibility</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fully automatic</td>
      <td>Alert; list the app’s permissions, recent IPs, and calls; match against public IOCs; notify the client</td>
      <td>No business impact</td>
    </tr>
    <tr>
      <td>Automatic with pre-authorization</td>
      <td>When detection points 1 and 2 both fire, or the vendor publicly discloses a breach: disable the integration and revoke its tokens</td>
      <td>Low impact, reversible: the integration stops syncing and can be re-authorized once it’s safe</td>
    </tr>
    <tr>
      <td>Manual approval</td>
      <td>Assess data impact, notify the client’s legal team, coordinate with the vendor</td>
      <td>Needs business and legal judgment</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="5-summary">5. Summary</h2>

<ul>
  <li>The attacker uses a legitimate token, so identity-based detections all fail. <strong>Detection has to rely on API call logs.</strong></li>
  <li>Integrations are far more predictable than people. <strong>Build a fingerprint for each integration</strong> (IP, User-Agent, API version, objects accessed, data volume) and alert when it changes.</li>
  <li>Isolating an integration has low business impact, which makes it one of the best candidates for pre-authorized automation.</li>
</ul>

<h3 id="further-discussion-what-to-ask-in-third-party-risk-reviews">Further discussion: what to ask in third-party risk reviews</h3>

<p>The way in was the vendor, so everything the customer can detect happens after the fact. What customers can do beforehand is ask the right questions during procurement and annual reviews, instead of only asking for a SOC 2 report:</p>

<ol>
  <li>How do you manage development and integration credentials? Do they expire, and are they cleaned up?</li>
  <li>Where do you store the OAuth tokens you hold for us, and how are they protected?</li>
  <li>Which fixed IPs does your integration use to reach our systems? (With that list, the customer can set login IP ranges for the integration user in Salesforce, which is the most direct preventive control. Klue didn’t publish its egress IPs, so its customers couldn’t do this even if they wanted to.)</li>
  <li>If you’re breached, how quickly will you tell us?</li>
</ol>

<hr />

<h2 id="references">References</h2>

<ul>
  <li>Obsidian Security: <a href="https://www.obsidiansecurity.com/blog/icarus-klue-salesforce-integration-supply-chain-attack">Technical Analysis of the Klue Attack: OAuth Abuse, Stale Integrations, and Salesforce Exfiltration</a></li>
  <li>Huntress: <a href="https://www.huntress.com/blog/klue-breach-investigation">Cybercrime Breaches Klue: Salesforce Data Impacted for Many Victims, including Huntress</a></li>
  <li>BleepingComputer: <a href="https://www.bleepingcomputer.com/news/security/klue-oauth-breach-linked-to-icarus-salesforce-data-theft-attacks/">Klue OAuth breach linked to ‘Icarus’ Salesforce data theft attacks</a></li>
  <li>FINRA: <a href="https://www.finra.org/rules-guidance/guidance/cybersecurity-alert-klue-oauth-breach-and-salesforce-data-exfiltration">Cybersecurity Alert: Klue OAuth Breach and Salesforce Data Exfiltration</a></li>
  <li>Microsoft Learn: <a href="https://learn.microsoft.com/graph/microsoft-graph-activity-logs-overview">Access Microsoft Graph activity logs</a></li>
  <li>MITRE ATT&amp;CK: technique IDs in this post are mapped to <a href="https://attack.mitre.org/resources/versions/">ATT&amp;CK v19</a>. Steps with no close match are left unmapped.</li>
</ul>]]></content><author><name>Yi Li</name></author><category term="BSidesEdmonton2026" /><category term="detection" /><category term="supply chain" /><category term="OAuth" /><category term="SaaS" /><category term="Microsoft Sentinel" /><category term="KQL" /><category term="incident response" /><summary type="html"><![CDATA[Attackers breached Klue, stole the OAuth tokens its customers had granted, and pulled their Salesforce data. How to spot a trusted integration that has changed hands.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yili-soc.github.io/assets/images/share-card.png" /><media:content medium="image" url="https://yili-soc.github.io/assets/images/share-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From DNS Takeover to Atlassian Org Admin: Detection and Response in Microsoft Sentinel</title><link href="https://yili-soc.github.io/2026/09/26/atlassian-org-takeover-detection.html" rel="alternate" type="text/html" title="From DNS Takeover to Atlassian Org Admin: Detection and Response in Microsoft Sentinel" /><published>2026-09-26T18:00:00-06:00</published><updated>2026-09-26T18:00:00-06:00</updated><id>https://yili-soc.github.io/2026/09/26/atlassian-org-takeover-detection</id><content type="html" xml:base="https://yili-soc.github.io/2026/09/26/atlassian-org-takeover-detection.html"><![CDATA[<h2 id="0-introduction">0. Introduction</h2>

<p>This case comes from the BSides Edmonton 2026 talk <em>Advanced SaaS Threats: Case Studies from the Field</em> by <a href="https://www.linkedin.com/in/damien-miller-mcandrews/">Damien Miller-McAndrews</a>, a threat researcher at Obsidian Security. He walked through three real cases to make one point: many SaaS breaches today use no exploits and no malware. Attackers simply abuse legitimate features and identity trust.</p>

<p>This post covers the second case: an attacker first took control of the victim company’s DNS, then used Atlassian’s domain verification feature to become an admin of the victim’s Atlassian organization. This post adds how to detect and respond to this kind of attack in Microsoft Sentinel.</p>

<blockquote>
  <p><strong>Disclaimer</strong></p>

  <ul>
    <li>The views in this post are my own and do not represent my employer.</li>
    <li>Incident details come from public sources and the original talk, which are cited in the text. I am not affiliated with Obsidian Security.</li>
    <li>The KQL queries illustrate detection logic and rely on custom log tables. Adjust and validate them for your own environment before use.</li>
  </ul>
</blockquote>

<hr />

<h2 id="1-background">1. Background</h2>

<h3 id="the-features-being-abused">The features being abused</h3>

<p>To help companies manage shadow IT, Atlassian offers three features that work together:</p>

<ul>
  <li><strong>Domain verification</strong>: a company proves it owns a domain with a DNS TXT record, a verification file on its website, or an IdP integration.</li>
  <li><strong>Account claiming</strong>: once the domain is verified, the company can turn every Atlassian account registered with that email domain into a managed account, including those accounts’ identities in other Atlassian organizations.</li>
  <li><strong>Join as admin</strong>: admins can see the Atlassian apps that managed accounts use outside the organization, and join the organization that owns those apps as an org admin with one click.</li>
</ul>

<p>The problem: <strong>the same domain can be verified by more than one organization</strong>, and verification only takes one DNS record. The platform assumes “whoever can verify this domain is this company’s IT”, but it doesn’t check whether the “discovered” organization might actually be the company’s real one. Whoever controls DNS can walk through all three steps. In Obsidian’s words, DNS effectively becomes a single point of failure for domain-based trust.</p>

<hr />

<h2 id="2-how-the-attack-works">2. How the attack works</h2>

<h3 id="the-big-picture">The big picture</h3>

<p>The incident below is based on the original BSides Edmonton 2026 talk.</p>

<p>The attacker first took control of the victim company’s domain. Instead of attacking Atlassian directly, they created their own Atlassian organization and added a verification record to the victim’s DNS. Atlassian then treated the attacker’s organization as the “owner” of the victim’s domain, and the victim’s employee accounts became managed accounts of the attacker’s organization. From the attacker’s side, the victim’s real Atlassian organization now looked like a shadow IT app. The attacker clicked Join as admin, became an admin of the victim’s organization, and removed the legitimate admins, locking the security team out. After the security team cleaned up, the attacker came back the same way several times, and the two sides ended up in a long tug of war, because the DNS was still in the attacker’s hands.</p>

<h3 id="-taking-control-of-dns">① Taking control of DNS</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1584/001/">T1584.001</a> Compromise Infrastructure: Domains</p>

<p>The public blog post doesn’t say how the attacker gained control of DNS. According to the talk, in this case the attacker social-engineered the domain registrar to get control of the domain, then changed the NS and MX records. Changing MX meant that all of the victim’s inbound email also went to the attacker.</p>

<p>In general, there are a few ways in, and what defenders can see differs for each:</p>

<ul>
  <li><strong>Registrar account or registrar support</strong>: change NS and move resolution for the whole domain elsewhere. This happens outside the company, so nothing shows up in the company’s own logs.</li>
  <li><strong>DNS hosting account</strong> (Cloudflare, Route 53, Azure DNS, etc.): add or delete records directly. The hosting platform keeps audit logs.</li>
  <li><strong>Subdomain takeover</strong>: Atlassian accepts a verification file on the website. If a CNAME for <code class="language-plaintext highlighter-rouge">www</code> or another subdomain points to a cloud resource that has been deleted, the attacker can register a resource with the same name and host the verification file there, without controlling the whole DNS.</li>
</ul>

<p><strong>Evidence:</strong> changes to the domain’s NS, MX, or TXT records.</p>

<h3 id="-verifying-the-victims-domain-in-the-attackers-own-organization">② Verifying the victim’s domain in the attacker’s own organization</h3>

<p>The attacker adds the victim’s domain to their own Atlassian organization and writes Atlassian’s verification TXT record. Because the same domain can be verified by more than one organization, this doesn’t affect the victim’s own verification status, and the victim gets no notification.</p>

<p><strong>Evidence:</strong> a new TXT record of the <code class="language-plaintext highlighter-rouge">atlassian-domain-verification</code> type under the domain.</p>

<h3 id="-joining-the-victims-organization-as-admin">③ Joining the victim’s organization as admin</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1098/003/">T1098.003</a> Additional Cloud Roles</p>

<p>According to the talk, the attacker used an external email account that was not on the victim’s domain, so the victim’s SSO and domain policies had no control over it.</p>

<p><strong>Evidence:</strong> a <code class="language-plaintext highlighter-rouge">joined-as-org-admin</code> event in the victim organization’s audit log. Obsidian notes that this event is only triggered by the shadow IT workflow and is very rare in normal use.</p>

<h3 id="-taking-over-and-persisting">④ Taking over and persisting</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1531/">T1531</a> Account Access Removal · <a href="https://attack.mitre.org/techniques/T1098/001/">T1098.001</a> Additional Cloud Credentials (API key)</p>

<p>The attacker removes the legitimate admins and locks the defenders out. According to the talk, the attacker also created an org-level API key and deleted a security policy that prevented data export. The API key doesn’t depend on a user account, so deleting the attacker’s account doesn’t revoke it.</p>

<h3 id="-tug-of-war">⑤ Tug of war</h3>

<p>The security team removes the attacker, the attacker comes back through Join as admin, and removes the legitimate admins again. As long as the attacker still controls DNS and that verification record, the loop doesn’t stop.</p>

<hr />

<h2 id="3-detection-approach-and-kql">3. Detection approach and KQL</h2>

<p>What sets this case apart: <strong>the attack doesn’t start in the identity system; it starts in DNS.</strong> Every step the attacker takes in Atlassian is a normal use of a legitimate feature, so sign-in and identity-based detections are of almost no use. Detection has to cover two layers: whether DNS itself has been changed, and whether Atlassian shows admin actions that only this kind of attack would trigger.</p>

<p><strong>About the log sources:</strong> the Atlassian connectors in the Sentinel Content hub mainly collect Confluence and Jira product audit logs. The org-level admin log that detection point 2 needs has to be brought in yourself through the events endpoint of the Atlassian Organizations API (for example with a Logic App or the Codeless Connector Platform), and it requires an Atlassian Guard subscription. Detection point 1 also needs a collection job you build yourself. The custom table and field names below are examples only; use whatever your ingestion produces.</p>

<h3 id="detection-point-1-changes-to-key-dns-records-steps--and-">Detection point 1: changes to key DNS records (steps ① and ②)</h3>

<p><strong>Idea:</strong> if the attacker takes over the domain at the registrar, they point NS to their own servers, and the company’s own DNS platform logs show nothing at all. So you can’t rely only on the DNS platform’s activity logs. You need to <strong>resolve your own domains from the outside on a schedule</strong> and compare the NS, MX, and TXT results against a baseline. This can be simple: a scheduled script (for example PowerShell’s <code class="language-plaintext highlighter-rouge">Resolve-DnsName</code>) resolves the domains every hour and writes the results to a custom table, <code class="language-plaintext highlighter-rouge">DnsSnapshot_CL</code>, through the Logs Ingestion API.</p>

<pre><code class="language-kql">let Monitored = dynamic(["NS", "MX", "TXT"]);
let Current = DnsSnapshot_CL
| where TimeGenerated &gt; ago(1h)
| where RecordType in (Monitored)
| summarize CurrentValues = make_set(Value) by Domain, RecordType;
let Baseline = DnsSnapshot_CL
| where TimeGenerated between (ago(7d) .. ago(1h))
| where RecordType in (Monitored)
| summarize BaselineValues = make_set(Value) by Domain, RecordType;
Current
| join kind=inner Baseline on Domain, RecordType
| extend
    Added = set_difference(CurrentValues, BaselineValues),
    Removed = set_difference(BaselineValues, CurrentValues)
| where array_length(Added) &gt; 0 or array_length(Removed) &gt; 0
| project Domain, RecordType, Added, Removed
</code></pre>

<p><strong>What to focus on:</strong> any change to NS or MX, and any new <code class="language-plaintext highlighter-rouge">atlassian-domain-verification</code> TXT record. Normally, changes like these can be matched to a change ticket.</p>

<p><strong>What it doesn’t cover:</strong> this query only sees the domains you monitor. Subdomain takeover needs a separate, regular review of your CNAMEs to check that the cloud resources they point to still exist.</p>

<h3 id="detection-point-2-joined-as-org-admin-and-what-follows-steps--and-">Detection point 2: <code class="language-plaintext highlighter-rouge">joined-as-org-admin</code> and what follows (steps ③ and ④)</h3>

<p><strong>Idea:</strong> this is the highest-fidelity signal in the whole chain. In normal business almost nobody uses Join as admin to join their own company’s organization, so a single occurrence is worth investigating right away. The query doesn’t just return this event; it also lists everything that account did in the hour after joining, so an analyst can see at a glance whether admins were removed, API keys created, or policies deleted.</p>

<pre><code class="language-kql">let Joiners = AtlassianOrgAudit_CL
| where TimeGenerated &gt; ago(1d)
| where Action == "joined-as-org-admin"
| project JoinTime = TimeGenerated, ActorId, ActorName, JoinIp = SourceIp;
AtlassianOrgAudit_CL
| where TimeGenerated &gt; ago(1d)
| join kind=inner Joiners on ActorId
| where TimeGenerated between (JoinTime .. JoinTime + 1h)
| summarize
    Actions = make_list(Action, 50),
    FirstAction = min(TimeGenerated)
    by ActorId, ActorName, JoinTime, JoinIp
</code></pre>

<p><strong>Extra check:</strong> review the org admin list regularly. Any admin account outside the company’s domain needs a clear reason to be there.</p>

<hr />

<h2 id="4-response-and-automation">4. Response and automation</h2>

<h3 id="key-points">Key points</h3>

<p>If detection point 2 fires, or detection point 1 shows an NS or MX change that can’t be matched to a change ticket, treat it as a domain takeover. Three things matter most in this case:</p>

<p><strong>1. Take back DNS first, then clean up Atlassian.</strong> This is the biggest difference from an ordinary account takeover. The way in is DNS. As long as the attacker still controls DNS and the verification record, any cleanup in Atlassian will be undone again, which is exactly where the tug of war comes from. So the first step is to work with the registrar or DNS host to regain control, restore NS and MX, and delete the attacker’s verification TXT record. At the same time, contact Atlassian’s security team and tell them another organization has maliciously verified your domain.</p>

<p><strong>2. Communicate out of band, and clean up everything in one window.</strong> The attacker may control MX, so don’t coordinate over company email during the incident. Once DNS is back, remove every persistence point at once: the attacker’s account, every API key created during the incident window, and the deleted policies, while restoring the legitimate admins. Cleaning up one piece at a time gives the attacker a way back in.</p>

<p><strong>3. The impact goes beyond Atlassian.</strong> While MX was changed, every email sent to the company may have been read by the attacker, including password reset emails from other systems. Check every SaaS app for password resets, email changes, and new device sign-ins during that window, and check whether the attacker used the same domain verification trick to take over other SaaS apps.</p>

<h3 id="is-automation-needed">Is automation needed?</h3>

<p>This case shows one thing clearly: <strong>automation doesn’t fix the root cause.</strong> As long as the way in stays open, automatically kicking the attacker out of Atlassian just hands the tug of war to a machine, and the attacker still comes back. Here, the value of automation is mainly faster detection and faster context. The actual response depends on a plan prepared in advance.</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Actions</th>
      <th>Impact and reversibility</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fully automatic</td>
      <td>Alert; collect the current NS/MX/TXT records, the org admin list, and recently created API keys; notify the client</td>
      <td>No business impact</td>
    </tr>
    <tr>
      <td>Automatic with pre-authorization</td>
      <td>Suspend new admin accounts that joined through Join as admin (if the platform API supports it)</td>
      <td>Low impact, reversible; but until DNS is back, it only buys time</td>
    </tr>
    <tr>
      <td>Manual approval</td>
      <td>Take back the registrar and DNS, remove persistence, contact vendors</td>
      <td>Needs coordination across teams; can’t be automated</td>
    </tr>
  </tbody>
</table>

<p>More important than automation is preparing these in advance: security contacts at the registrar and each SaaS vendor, an out-of-band communication channel for incidents, and an emergency admin account that doesn’t depend on the company domain.</p>

<hr />

<h2 id="5-summary">5. Summary</h2>

<ul>
  <li>In this case the attacker didn’t break into any account; they only took control of DNS. <strong>Whoever controls DNS “owns” the domain, and with it every SaaS permission built on domain-based trust.</strong></li>
  <li>Detection has to extend from the identity layer to the DNS layer: monitor NS, MX, and TXT changes from the outside, and watch for events like <code class="language-plaintext highlighter-rouge">joined-as-org-admin</code> that only show up when the feature is abused.</li>
  <li>The order of response decides the outcome: <strong>take back DNS first, then clean up everything in one window</strong>, or the tug of war never ends.</li>
</ul>

<h3 id="further-discussion-this-isnt-just-an-atlassian-problem">Further discussion: this isn’t just an Atlassian problem</h3>

<p>Many SaaS platforms let you verify a domain through DNS and claim the accounts under it, including common collaboration, meeting, and code hosting platforms. When evaluating any SaaS admin feature, ask three questions:</p>

<ol>
  <li>What can this feature do?</li>
  <li>What makes it trust that the person using it is legitimate?</li>
  <li>How cheap is it to compromise that trust?</li>
</ol>

<p>If the trust rests on nothing more than a DNS record, that could be where the next case like this happens.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li>Obsidian Security: <a href="https://www.obsidiansecurity.com/blog/from-dns-takeover-to-org-admin-secondary-attacks-on-atlassian-cloud">From DNS Takeover to Org Admin: Secondary Attacks on Atlassian Cloud</a></li>
  <li>Atlassian Support: <a href="https://support.atlassian.com/user-management/docs/verify-a-domain-to-manage-accounts/">Verify a domain to manage accounts</a></li>
  <li>Atlassian Support: <a href="https://support.atlassian.com/organization-administration/docs/how-to-work-with-admins-of-discovered-products/">What are my options for shadow IT apps?</a></li>
  <li>Atlassian Support: <a href="https://support.atlassian.com/security-and-access-policies/docs/accessing-audit-log-activities/">What activities does the audit log include?</a></li>
  <li>MITRE ATT&amp;CK: technique IDs in this post are mapped to <a href="https://attack.mitre.org/resources/versions/">ATT&amp;CK v19</a>. Steps with no close match are left unmapped.</li>
</ul>]]></content><author><name>Yi Li</name></author><category term="BSidesEdmonton2026" /><category term="detection" /><category term="Atlassian" /><category term="DNS" /><category term="SaaS" /><category term="Microsoft Sentinel" /><category term="KQL" /><category term="incident response" /><summary type="html"><![CDATA[How an attacker who controlled a company's DNS used Atlassian's own domain verification to become org admin, and how to detect it when identity logs show nothing.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yili-soc.github.io/assets/images/share-card.png" /><media:content medium="image" url="https://yili-soc.github.io/assets/images/share-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Detecting the ShinyHunters Vishing Attack Chain in Microsoft Sentinel</title><link href="https://yili-soc.github.io/2026/09/26/shinyhunters-vishing-detection.html" rel="alternate" type="text/html" title="Detecting the ShinyHunters Vishing Attack Chain in Microsoft Sentinel" /><published>2026-09-26T00:00:00-06:00</published><updated>2026-09-26T00:00:00-06:00</updated><id>https://yili-soc.github.io/2026/09/26/shinyhunters-vishing-detection</id><content type="html" xml:base="https://yili-soc.github.io/2026/09/26/shinyhunters-vishing-detection.html"><![CDATA[<h2 id="0-introduction">0. Introduction</h2>

<p>This post is based on the BSides Edmonton 2026 talk <em>Advanced SaaS Threats: Case Studies from the Field</em> by <a href="https://www.linkedin.com/in/damien-miller-mcandrews/">Damien Miller-McAndrews</a>, a threat researcher at Obsidian Security. He walked through three real cases to make one point: many SaaS breaches today use no exploits and no malware. Attackers simply abuse legitimate features and identity trust.</p>

<p>The talk focused on how the attacks worked and what a vendor saw. This post adds the other half: <strong>how to detect and respond to this kind of attack in Microsoft Sentinel.</strong> The talk covered three cases. This post covers the first one: the voice phishing (vishing) campaign run by ShinyHunters in 2026.</p>

<blockquote>
  <p><strong>Disclaimer</strong></p>

  <ul>
    <li>The views in this post are my own and do not represent my employer.</li>
    <li>All incident details come from public sources, which are linked in the text. I am not affiliated with Obsidian Security.</li>
    <li>The KQL queries were tested in a personal lab. Adjust and validate them for your own environment before using them in production.</li>
  </ul>
</blockquote>

<hr />

<h2 id="1-background">1. Background</h2>

<h3 id="who-is-shinyhunters">Who is ShinyHunters</h3>

<p>ShinyHunters is a cybercrime group focused on data theft and extortion. It has been active since around 2019. Its usual model is “pay or we leak”: steal data, demand a ransom, and publish the data on a leak site if the victim does not pay. The group has been linked to several large breaches, including the 2024 campaign that used stolen credentials to pull data from Snowflake customers. In the last two years it has shifted toward SaaS: instead of attacking servers, it tricks employees into handing over their identity, then exports data straight out of SaaS apps (<a href="https://www.huntress.com/threat-library/threat-actors/shinyhunters">Huntress</a>).</p>

<p>In this campaign it used a <strong>real-time phishing panel</strong>, a phishing tool that lets the attacker control the victim’s page live from a back end (<a href="https://pushsecurity.com/blog/inside-criminal-phishing-panel">Push Security</a>). These panels are shared or sold in criminal circles and are not unique to ShinyHunters. Section 2 explains how they work.</p>

<h3 id="the-2026-vishing-campaign">The 2026 vishing campaign</h3>

<p>Starting in early 2026, several security firms reported a wave of vishing attacks against SSO accounts. ShinyHunters claimed responsibility and began naming victims on its leak site (<a href="https://cyberscoop.com/shinyhunters-voice-phishing-sso-okta-mfa-bypass-data-theft/">CyberScoop</a>). One public case is <strong>ADT</strong>: in April 2026, attackers obtained an employee’s Okta SSO credentials, got into Salesforce, and exported customer data. ShinyHunters claimed to have stolen more than 10 million records (<a href="https://www.rescana.com/post/adt-salesforce-data-breach-2026-shinyhunters-compromise-okta-sso-via-vishing-attack">Rescana</a>).</p>

<p>This post is based on three incidents that Obsidian disclosed anonymously in a <a href="https://www.obsidiansecurity.com/blog/behind-the-breach-shinyhunters-2026-voice-phishing-campaign">public blog post</a> (all part of the same campaign), plus the original BSides Edmonton talk.</p>

<hr />

<h2 id="2-how-the-attack-works">2. How the attack works</h2>

<h3 id="the-big-picture">The big picture</h3>

<p>An employee gets a call from someone claiming to be from the company’s IT department. The caller says there is a problem with the account and it needs to be re-verified right away, then reads out a URL that looks a lot like the company login page. The employee opens it, enters their password, and types in the verification code when the caller asks for it. It feels like a routine check. In reality, the attacker on the other end of the line is using everything the employee enters to log in to the real Okta, live. Once logged in, the attacker immediately registers their own MFA device on the account. From that point on, the attacker can keep logging in even after the employee hangs up or changes their password. Next, the attacker opens the SSO portal, clicks through every app the account can reach, finds valuable data, and starts downloading it in bulk. Along the way, they leave the company’s Slack security channel to avoid catching the security team’s attention.</p>

<h3 id="-initial-access-vishing--real-time-phishing-tools">① Initial access: vishing + real-time phishing tools</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1684/001/">T1684.001</a> Impersonation · <a href="https://attack.mitre.org/techniques/T1598/004/">T1598.004</a> Spearphishing Voice · <a href="https://attack.mitre.org/techniques/T1608/005/">T1608.005</a> Link Target · <a href="https://attack.mitre.org/techniques/T1078/004/">T1078.004</a> Cloud Accounts · AitM variant: <a href="https://attack.mitre.org/techniques/T1539/">T1539</a> Steal Web Session Cookie, <a href="https://attack.mitre.org/techniques/T1550/004/">T1550.004</a> Web Session Cookie</p>

<p>Two kinds of phishing tools are common in this type of attack, and they affect detection differently:</p>

<ul>
  <li><strong>AitM (Adversary-in-the-Middle) reverse proxy</strong>: the phishing site automatically forwards the employee’s input to the real login page, then steals the session cookie issued after a successful login and uses it somewhere else. The same session shows up from two locations, which can be detected.</li>
  <li><strong>Real-time operator panel</strong>: there is no automatic proxy. The attacker works the panel by hand and logs in from their own machine; the employee only supplies the password and MFA. In Push Security’s words, it is functionally the same as AitM, except that each step is done manually by the attacker. The logs show a single login that “looks normal”, which makes it harder to detect.</li>
</ul>

<p>In this campaign, ShinyHunters used a real-time operator panel (<a href="https://pushsecurity.com/blog/inside-criminal-phishing-panel">Push Security</a>). According to the talk, both kinds of tools showed up in the incidents Obsidian handled.</p>

<p><strong>Log evidence:</strong> Obsidian saw an authentication sequence that lasted about 10 minutes, with repeated failures before a final success. Public sources do not explain why the failures happen, but the way the panel works suggests an answer: the login is relayed by a human, so typos, expired codes, and push timeouts all cause failures. The panel also has an “incorrect password” button that lets the attacker make the employee type the password again.</p>

<h3 id="-persistence-registering-the-attackers-own-mfa-device">② Persistence: registering the attacker’s own MFA device</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1098/005/">T1098.005</a> Device Registration</p>

<p>Right after logging in, the attacker registers a new MFA device on the account. This turns “needs the employee’s help” into “can act alone”. <strong>This is the key step in the whole chain.</strong></p>

<p><strong>Log evidence:</strong> in Obsidian’s incidents, the new device was named “Passkey”, and the User-Agent contained <code class="language-plaintext highlighter-rouge">Genymobile</code> (an Android emulator), which shows the device was not a real phone.</p>

<h3 id="-discovery-mapping-access-through-the-sso-portal">③ Discovery: mapping access through the SSO portal</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1538/">T1538</a> Cloud Service Dashboard (closest fit, my mapping)</p>

<p>The attacker opens the SSO portal and clicks through the apps the account can reach, to see which systems are available.</p>

<p><strong>Log evidence:</strong> an unusually high number of SSO apps accessed in a short time.</p>

<h3 id="-exfiltration-bulk-downloads">④ Exfiltration: bulk downloads</h3>

<p><strong>ATT&amp;CK:</strong> <a href="https://attack.mitre.org/techniques/T1530/">T1530</a> Data from Cloud Storage (Google Drive) · <a href="https://attack.mitre.org/techniques/T1213/005/">T1213.005</a> Messaging Applications (Slack)</p>

<p>In one of Obsidian’s incidents, the attacker downloaded a large number of files from Google Drive over about 90 minutes. In another, the attacker downloaded several files from Slack within seconds of each other, which points to a script rather than a person.</p>

<h3 id="-evasion-leaving-the-security-channel">⑤ Evasion: leaving the security channel</h3>

<p>In one incident, the compromised account left the company’s Slack security channel and rejoined a few minutes later, probably to lower the chance of being noticed.</p>

<hr />

<h2 id="3-detection-approach-and-kql">3. Detection approach and KQL</h2>

<p>The hardest part of this attack is that <strong>each step, on its own, looks like normal activity.</strong> Employees mistype passwords, switch phones and register new MFA, and sometimes open many apps or download many files in a day. Obsidian’s post makes the same point: each of these activities is a low-confidence signal on its own, and the account takeover only becomes clear when you correlate them at the identity layer.</p>

<p>So the core idea is: <strong>catch the actions that follow each other in the attack chain and link them by user and time.</strong> Of the four detection points below, detection points 1 and 2 cover initial access and persistence (steps ① and ②), one for each tool type: the real-time panel and AitM. Detection points 3 and 4 cover discovery and exfiltration after login (steps ③ and ④) and apply to both tool types.</p>

<p>The queries use <strong>Microsoft Entra ID</strong> logs (<code class="language-plaintext highlighter-rouge">SigninLogs</code>, <code class="language-plaintext highlighter-rouge">AADNonInteractiveUserSignInLogs</code>, <code class="language-plaintext highlighter-rouge">AuditLogs</code>) and Microsoft 365 logs (<code class="language-plaintext highlighter-rouge">OfficeActivity</code>). The victims in the original incidents used Okta. If your IdP is Okta, the logic is the same, but you need to switch to the tables and fields from the Okta connector. All thresholds are starting points; tune them to your own baseline.</p>

<h3 id="detection-point-1-mfa-registration-right-after-an-abnormal-login-real-time-panel">Detection point 1: MFA registration right after an abnormal login (real-time panel)</h3>

<p><strong>Idea:</strong> employees normally register a new MFA method when they join the company or change phones, and that usually isn’t preceded by a string of password failures. The pattern “several failures → success → new MFA registered within 30 minutes” is rare in normal business. This pattern is left by the manual relay of the real-time panel and does not apply to AitM.</p>

<p><strong>KQL:</strong> <code class="language-plaintext highlighter-rouge">ResultType</code> only counts wrong passwords (50126) and failed MFA (500121). A normal Entra sign-in also produces some non-zero interim codes, so counting every non-zero code as a failure would create a lot of false positives.</p>

<pre><code class="language-kql">let lookback = 1d;
let SuspiciousLogins = SigninLogs
| where TimeGenerated &gt; ago(lookback)
| summarize
    Failures = countif(ResultType in ("50126", "500121")),
    Successes = countif(ResultType == "0"),
    LastSuccess = maxif(TimeGenerated, ResultType == "0")
    by UserPrincipalName = tolower(UserPrincipalName), IPAddress, bin(TimeGenerated, 15m)
| where Failures &gt;= 2 and Successes &gt;= 1;
AuditLogs
| where TimeGenerated &gt; ago(lookback)
| where OperationName in ("User registered security info", "User registered all required security info")
| extend UserPrincipalName = tolower(coalesce(
    tostring(InitiatedBy.user.userPrincipalName),
    tostring(TargetResources[0].userPrincipalName)))
| extend MfaTime = TimeGenerated
| join kind=inner SuspiciousLogins on UserPrincipalName
| where MfaTime between (LastSuccess .. LastSuccess + 30m)
| project MfaTime, UserPrincipalName, OperationName, LoginIP = IPAddress, Failures, Successes, LastSuccess
</code></pre>

<p><strong>Extra indicators:</strong> an MFA device registered from an Android emulator (User-Agent containing <code class="language-plaintext highlighter-rouge">Genymobile</code> or <code class="language-plaintext highlighter-rouge">VirtualBox</code>) and a device named “Passkey” are both high-fidelity signs. If you use Okta, you can search for them directly in the Okta logs. But attackers can change tools and names at any time, and Obsidian’s third incident showed no clear emulator traces, so treat them as supporting evidence only.</p>

<h3 id="detection-point-2-the-same-session-from-multiple-ips--countries-aitm">Detection point 2: the same session from multiple IPs / countries (AitM)</h3>

<p><strong>Idea:</strong> AitM steals the session cookie issued after login, and the attacker uses it on their own machine. As a result, the same <code class="language-plaintext highlighter-rouge">SessionId</code> shows up in two places: the IP of the phishing proxy and the attacker’s own IP. A real-time panel does not leave this trace, because the attacker uses their own session from start to finish, which is also why the panel is harder to detect. After a cookie is replayed, most follow-up requests are logged as non-interactive sign-ins, so you need to query <code class="language-plaintext highlighter-rouge">AADNonInteractiveUserSignInLogs</code> too. Querying only <code class="language-plaintext highlighter-rouge">SigninLogs</code> will miss them. Note that <code class="language-plaintext highlighter-rouge">LocationDetails</code> is a dynamic field in <code class="language-plaintext highlighter-rouge">SigninLogs</code> but a string in <code class="language-plaintext highlighter-rouge">AADNonInteractiveUserSignInLogs</code>, so the country is extracted from each table before the <code class="language-plaintext highlighter-rouge">union</code>.</p>

<pre><code class="language-kql">union
    (SigninLogs
    | extend Country = tostring(LocationDetails.countryOrRegion)),
    (AADNonInteractiveUserSignInLogs
    | extend Country = tostring(parse_json(LocationDetails).countryOrRegion))
| where TimeGenerated &gt; ago(1d)
| where ResultType == "0" and isnotempty(SessionId)
| summarize
    IPs = dcount(IPAddress),
    Countries = dcount(Country),
    IPList = make_set(IPAddress, 10),
    CountryList = make_set(Country, 10),
    Apps = make_set(AppDisplayName, 10),
    First = min(TimeGenerated),
    Last = max(TimeGenerated)
    by SessionId, UserPrincipalName
| where IPs &gt; 1 and Countries &gt; 1
</code></pre>

<p><strong>Built-in detections:</strong> Entra ID Protection and Defender XDR already include detections for AitM and token replay. If you have the licenses, use the built-in ones first. This query is mainly here to show how the detection works.</p>

<h3 id="detection-point-3-many-sso-apps-accessed-in-a-short-time">Detection point 3: many SSO apps accessed in a short time</h3>

<p><strong>Idea:</strong> this detection point rests on an assumption: <strong>once attackers have an identity, they will quickly find out which systems it can reach.</strong> Normal employees use a fairly fixed set of apps each day. An account that opens eight or nine apps one after another within half an hour looks more like someone mapping out access.</p>

<pre><code class="language-kql">SigninLogs
| where TimeGenerated &gt; ago(1d)
| where ResultType == "0"
| summarize
    Apps = dcount(AppDisplayName),
    AppList = make_set(AppDisplayName, 50),
    Start = min(TimeGenerated),
    End = max(TimeGenerated)
    by UserPrincipalName, IPAddress, bin(TimeGenerated, 30m)
| where Apps &gt;= 8
</code></pre>

<p><strong>False positives:</strong> new hires on their first day, and IT admins testing. You can add these accounts to an exclusion list. On its own this detection is low confidence; it is most useful when it fires together with detection point 1 or 2.</p>

<h3 id="detection-point-4-bulk-downloads-soon-after-mfa-registration">Detection point 4: bulk downloads soon after MFA registration</h3>

<p><strong>Idea:</strong> this detection point also rests on an assumption: <strong>attackers will grab the data as fast as they can before they are caught.</strong> Download volume alone is noisy, and by the time it fires the data is often already gone. But if the bulk download happens on an account that “just registered a new MFA method”, the signal is much stronger.</p>

<pre><code class="language-kql">let MfaReg = AuditLogs
| where TimeGenerated &gt; ago(1d)
| where OperationName in ("User registered security info", "User registered all required security info")
| extend UserPrincipalName = tolower(coalesce(
    tostring(InitiatedBy.user.userPrincipalName),
    tostring(TargetResources[0].userPrincipalName)))
| project MfaTime = TimeGenerated, UserPrincipalName;
OfficeActivity
| where TimeGenerated &gt; ago(1d)
| where Operation in ("FileDownloaded", "FileSyncDownloadedFull")
| extend UserPrincipalName = tolower(UserId)
| join kind=inner MfaReg on UserPrincipalName
| where TimeGenerated between (MfaTime .. MfaTime + 4h)
| summarize
    Files = count(),
    Sites = make_set(Site_Url, 10),
    FirstDownload = min(TimeGenerated)
    by UserPrincipalName, MfaTime, ClientIP
| where Files &gt;= 100
</code></pre>

<p><strong>Coverage:</strong> this query only covers OneDrive / SharePoint. To cover downloads from Google Drive, Slack, or Salesforce, you need to bring each app’s logs into Sentinel.</p>

<hr />

<h2 id="4-response-and-automation">4. Response and automation</h2>

<h3 id="response-process">Response process</h3>

<p>Four steps: triage → containment → scoping → hardening.</p>

<p><strong>Step 1: Triage.</strong> Use the detection points that fired to judge confidence and decide the next step:</p>

<table>
  <thead>
    <tr>
      <th>Confidence</th>
      <th>What fired</th>
      <th>Next step</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Low</td>
      <td>Only detection point 3 or 4</td>
      <td>First check for a normal business reason (new hire, IT staff, etc.). Contact the user only if you can’t rule it out</td>
    </tr>
    <tr>
      <td>Medium</td>
      <td>Detection point 1 or 2</td>
      <td>Contact the user right away to confirm</td>
    </tr>
    <tr>
      <td>High</td>
      <td>Detection point 1 or 2, plus 3 or 4</td>
      <td>Go straight to containment and confirm with the user afterwards</td>
    </tr>
  </tbody>
</table>

<p>When confirming, reach the user <strong>out of band</strong> (for example by phone, not through their company account), because the attacker may be using that account. Ask only two questions: Did you recently get a call from “IT”? Is the newly registered MFA device yours? If either answer is wrong, treat the account as compromised.</p>

<p><strong>Step 2: Containment.</strong></p>

<ol>
  <li><strong>Revoke all sign-in sessions</strong> (“Revoke sessions” on the user’s page in Entra ID) so the attacker’s active sessions stop working right away.</li>
  <li><strong>Delete the MFA methods and devices the attacker registered.</strong> Changing the password alone is not enough. The attacker’s MFA device is still there, and with self-service password reset they can get back in.</li>
  <li>Reset the password. If you can’t quickly tell which MFA methods belong to the user, disable the account first.</li>
</ol>

<p><strong>Step 3: Scoping.</strong> Through SSO the attacker can reach every connected app, so don’t stop at Microsoft 365:</p>

<ul>
  <li>Which SSO apps were accessed during the attack (the output of detection point 3 is your list)</li>
  <li>In each app: any downloads, exports, or new sharing links</li>
  <li>In the mailbox: any new forwarding or delete rules</li>
  <li>In Slack and other collaboration tools: any channel departures or bulk downloads</li>
</ul>

<p><strong>Step 4: Hardening.</strong> Treat “register MFA” as a sensitive action (for example, use Conditional Access to allow it only from the corporate network or managed devices), move toward phishing-resistant MFA (FIDO2 security keys, passkeys), and review how the service desk verifies identity before resetting passwords or MFA.</p>

<h3 id="is-automation-needed">Is automation needed?</h3>

<p>In one of Obsidian’s incidents, the attacker downloaded a large number of files from Google Drive in about 90 minutes. A typical manual response chain looks like this: L1 analyst reviews the alert → escalates to L2 → contacts the client → client approves → action is taken. That chain often takes hours. <strong>No matter how fast the detection is, if the response can’t keep up, the data still leaves.</strong> This was a recurring point in the talk: detection is not enough; what matters is cutting containment time. So it makes sense to set up some automated playbooks. But not every action should run automatically. The deciding factor is <strong>how much the action affects the business and whether it can be undone if it’s wrong</strong>. The table below is a design idea, not a full implementation:</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Actions</th>
      <th>Impact and reversibility</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fully automatic</td>
      <td>Gather context: the user’s recent MFA registrations, sign-in IPs and devices, apps accessed, download volume; notify the client and open a ticket</td>
      <td>No business impact</td>
    </tr>
    <tr>
      <td>Automatic with pre-authorization</td>
      <td>Revoke sessions; mark the user as compromised in Entra ID Protection (“Confirm user compromised”)</td>
      <td>Low impact, reversible: a mistake only costs the user one extra sign-in</td>
    </tr>
    <tr>
      <td>Manual approval</td>
      <td>Delete MFA methods; disable the account</td>
      <td>High impact: deleting the wrong device or disabling the account stops the employee from working, and a person has to decide which device belongs to the attacker</td>
    </tr>
  </tbody>
</table>

<p>Automatically revoking sessions has one easy-to-miss limit: <strong>it only works at the IdP layer.</strong> Access tokens already issued stay valid until they expire (except in apps that support CAE). And the apps the attacker reached through SSO, such as Salesforce, Slack, and Google Drive, each keep their own sessions. Revoking the IdP session does not kick the attacker out of them. So after the playbook revokes sessions, you still need to go through the app list from detection point 3 and end the user’s session in each app.</p>

<p>The real challenge here is process, not technology: <strong>pre-authorization has to be agreed with the client before an incident happens.</strong></p>

<hr />

<h2 id="5-summary">5. Summary</h2>

<ul>
  <li>This attack does not rely on vulnerabilities. It relies on <strong>tricking someone out of their identity</strong>. SSO lets one identity reach every app, which also concentrates the attack surface on that one identity.</li>
  <li>Each step in the chain looks normal on its own. The key to detection is <strong>linking the actions by user and time</strong>, especially the “abnormal login → new MFA registration” sequence.</li>
  <li>The step most often missed during response is <strong>deleting the MFA device the attacker registered</strong>. Changing the password is not enough.</li>
  <li>Detection is about finding the attack; containment speed decides how much damage is done. Low-risk information gathering can be fully automated; high-impact response actions need pre-authorization agreed with the business in advance to move faster.</li>
</ul>

<h3 id="further-discussion-what-if-the-attacker-changes-tactics">Further discussion: what if the attacker changes tactics</h3>

<p>All of the detection points above assume the attacker moves fast. What if they deliberately slow down?</p>

<ul>
  <li><strong>No new MFA device:</strong> detection points 1 and 4 stop working (both depend on MFA registration). If the attacker uses AitM, detection point 2 still works. But the attacker then depends entirely on the existing session. Once it expires or is revoked, they lose access and have to make another call, which raises their risk.</li>
  <li><strong>No rush to open other apps:</strong> detection point 3 stops working.</li>
  <li><strong>No bulk downloads; small amounts over several days instead:</strong> detection point 4 stops working.</li>
</ul>

<p>Slowing down costs the attacker too: the longer it takes, the more likely the employee remembers the suspicious call and reports it, and the more likely the session expires. But it also shows that detections based on speed can always be bypassed to some degree. The more fundamental fix is on the prevention side: phishing-resistant MFA is bound to the website’s domain, so even if a phishing site captures it, it can’t be used.</p>

<h3 id="accounts-to-prioritize">Accounts to prioritize</h3>

<p>This is my own view: the end goal of this attack is data, so two groups of accounts deserve extra attention. One is employees with access to CRM and cloud storage (sales, customer support, and so on; ADT’s data leaked from Salesforce). The other is admins and executives. You can put these accounts on a Sentinel watchlist and raise the alert severity when any of the detection points above fire for them.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li>Obsidian Security: <a href="https://www.obsidiansecurity.com/blog/behind-the-breach-shinyhunters-2026-voice-phishing-campaign">Behind the breach: ShinyHunters’ 2026 voice phishing campaign</a></li>
  <li>Push Security: <a href="https://pushsecurity.com/blog/inside-criminal-phishing-panel">Inside a phishing panel used by ShinyHunters and BlackFile</a></li>
  <li>CyberScoop: <a href="https://cyberscoop.com/shinyhunters-voice-phishing-sso-okta-mfa-bypass-data-theft/">A new wave of ‘vishing’ attacks is breaking into SSO accounts in real time</a></li>
  <li>Rescana: <a href="https://www.rescana.com/post/adt-salesforce-data-breach-2026-shinyhunters-compromise-okta-sso-via-vishing-attack">ADT Salesforce Data Breach 2026: ShinyHunters Compromise Okta SSO via Vishing Attack</a></li>
  <li>The Hacker News: <a href="https://thehackernews.com/2026/01/mandiant-finds-shinyhunters-using.html">Mandiant Finds ShinyHunters-Style Vishing Attacks Stealing MFA to Breach SaaS Platforms</a></li>
  <li>Huntress: <a href="https://www.huntress.com/threat-library/threat-actors/shinyhunters">ShinyHunters Threat Actor Profile</a></li>
  <li>MITRE ATT&amp;CK: technique IDs in this post are mapped to <a href="https://attack.mitre.org/resources/versions/">ATT&amp;CK v19</a>. Steps with no close match are left unmapped.</li>
</ul>]]></content><author><name>Yi Li</name></author><category term="BSidesEdmonton2026" /><category term="detection" /><category term="ShinyHunters" /><category term="vishing" /><category term="identity" /><category term="SaaS" /><category term="Microsoft Sentinel" /><category term="KQL" /><category term="incident response" /><summary type="html"><![CDATA[How the 2026 ShinyHunters vishing campaign went from one phone call to SaaS data theft, and four Microsoft Sentinel detections, with KQL, to catch it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yili-soc.github.io/assets/images/share-card.png" /><media:content medium="image" url="https://yili-soc.github.io/assets/images/share-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>