Detect Command and Control Configuration Resolution via GitHub/Gist Raw Content Dead Drop in Splunk
Adversaries increasingly host a short, tightly-scoped blob of encoded C2 configuration — typically a base64, hex, or single-byte-XOR-obfuscated IP:port or domain string well under a kilobyte — on raw.githubusercontent.com or gist.githubusercontent.com, the plain-text CDN endpoints GitHub serves repository files and gists from with no HTML wrapper. A dropper or stager fetches the raw file directly with a non-browser HTTP client (PowerShell's WebClient/Invoke-WebRequest, Python requests, a compiled .NET or Go HTTP client, certutil, or curl carrying a non-browser user agent), decodes it locally, and then opens a brand-new outbound connection to whatever address the content resolved to. Because raw.githubusercontent.com and gist.githubusercontent.com are legitimate, CDN-fronted GitHub infrastructure that developer tooling, CI runners, package managers, and IDE extensions all depend on, defenders cannot blocklist the hosts outright without breaking normal engineering workflows — the same constraint that makes dead-drop resolvers effective on Pastebin, Twitter, and Google Docs. What is detectable is the behavioral sequence rather than the host itself: legitimate fetches of these endpoints come from identifiable developer tooling (git.exe, VS Code's update/extension-fetch paths, npm/pip package resolution) and are not followed by that same process opening a connection to a destination it has never contacted before, whereas a dead-drop resolver fetch by a scripting engine or unusual binary is, within minutes, followed by exactly that novel outbound connection — the resolver having just told the implant where to go. This record differs from the T1102.001 base detection, which screens a broad, largely static list of dead-drop hosting platforms (Pastebin, GitHub, Twitter, Google Docs, YouTube, TechNet, and others) for non-browser access alone, by narrowing to the specific code-hosting-CDN pattern and adding the resolve-then-connect temporal correlation, which a static domain list has no way to express and which is what actually separates a malicious dead-drop fetch from the very large volume of legitimate raw.githubusercontent.com traffic on any developer-heavy estate.
MITRE ATT&CK
- Tactic
- Command and Control
SPL Detection Query
index=windows sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" (EventCode=22 OR EventCode=3)
| eval ImageLower=lower(coalesce(Image, "unknown"))
| eval QueryLower=lower(coalesce(QueryName, ""))
| eval DestIp=coalesce(DestinationIp, "0.0.0.0")
| eval DestPort=tonumber(coalesce(DestinationPort, "-1"))
| eval IsDeadDropQuery=if(EventCode=22 AND (like(QueryLower, "%raw.githubusercontent.com%") OR like(QueryLower, "%gist.githubusercontent.com%") OR like(QueryLower, "%gist.github.com%") OR like(QueryLower, "%objects.githubusercontent.com%")) AND NOT (like(ImageLower, "%\\chrome.exe") OR like(ImageLower, "%\\msedge.exe") OR like(ImageLower, "%\\firefox.exe") OR like(ImageLower, "%\\git.exe")), 1, 0)
| eval IsPrivateDest=if(cidrmatch("10.0.0.0/8", DestIp) OR cidrmatch("172.16.0.0/12", DestIp) OR cidrmatch("192.168.0.0/16", DestIp) OR cidrmatch("127.0.0.0/8", DestIp), 1, 0)
| eval IsOutboundConnect=if(EventCode=3 AND IsPrivateDest=0, 1, 0)
| where IsDeadDropQuery=1 OR IsOutboundConnect=1
| transaction ComputerName startswith=(IsDeadDropQuery=1) endswith=(IsOutboundConnect=1) maxspan=15m maxevents=2
| eval FetchTime=strftime(_time, "%Y-%m-%d %H:%M:%S")
| table FetchTime, ComputerName, ImageLower, QueryLower, DestIp, DestPort, duration, eventcount
| sort - _time Splunk equivalent over Sysmon telemetry, since Sysmon Event ID 3 (Network Connection) records only DestinationIp/DestinationPort and has no HTTP host field, so the dead-drop-host arm instead uses Sysmon Event ID 22 (DNS Query), which genuinely does carry QueryName and the resolving Image, to detect resolution of the raw-content hosts by a non-browser process. The transaction command stitches that DNS event to a following Event ID 3 outbound connection on the same host within a 15-minute span using startswith/endswith markers, approximating the KQL version's resolve-then-connect correlation. This variant does not implement the 15-day novel-destination check the KQL version does — Sysmon alone has no lookup of prior connections — so it will flag every qualifying second-stage connection rather than only new destinations; treat matches with an already-known destination IP as lower priority during triage, or add an outputlookup-based first-seen table if your SIEM retention allows it. Private-address exclusion uses cidrmatch, the genuine CIDR-aware SPL function, rather than an IN() equality list.
Data Sources
Required Sourcetypes
False Positives & Tuning
- Developer workstations where git, an IDE, or a package manager resolves a raw GitHub/Gist host and then coincidentally opens an unrelated new connection inside the 15-minute correlation window
- CI/build agents and dependency-scanning tools performing multi-host installation sequences that touch a raw-content host followed by a legitimate package registry connection
- Automation, SOAR, or threat-intel tooling that fetches IOC lists or configuration from a GitHub-hosted repository on a recurring schedule
- Any second-stage connection to a destination the host has actually contacted before, since this SPL variant lacks the KQL version's novel-destination filter and cannot distinguish a repeat connection from a genuinely new one
Other platforms for THREAT-C2-GitHubRawContentDeadDropResolver
Testing Methodology
Validate this detection against 3 adversary techniques from Atomic Red Team. Each test below lists the behaviour to exercise and the telemetry you should expect to see. Executable commands and cleanup steps are available with Pro.
- Test 1Fetch Encoded Config from a GitHub Raw Content URL via PowerShell, Then Connect (Windows)
Expected signal: MDE DeviceNetworkEvents / Sysmon Event ID 22: a DNS/HTTP access to raw.githubusercontent.com initiated by powershell.exe. Within seconds, a second network event (Sysmon Event ID 3 / MDE ConnectionSuccess) shows powershell.exe connecting to the decoded test destination and port.
- Test 2Fetch Config from a Gist Raw URL via curl, Then Connect (Linux)
Expected signal: EDR-for-Linux process and network telemetry: curl execution reaching gist.githubusercontent.com, followed within seconds by a new outbound TCP connection from the same shell session to the decoded test host and port.
- Test 3Benign Raw-Content Fetch with No Follow-On Connection, Negative Control (Windows)
Expected signal: A single DeviceNetworkEvents/Sysmon Event ID 22 record for the raw.githubusercontent.com access from powershell.exe, with no accompanying new outbound connection in the following 15 minutes.
References (7)
- https://attack.mitre.org/techniques/T1102/001/
- https://attack.mitre.org/tactics/TA0011/
- https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file
- https://docs.github.com/en/rest/gists/gists
- https://github.com/redcanaryco/atomic-red-team/tree/master/atomics/T1102.001
- https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-devicenetworkevents-table
- https://learn.microsoft.com/en-us/sysinternals/downloads/sysmon
Response Playbook
Triage
- Confirm the fetching process is not a recognised developer or CI tool (git, code.exe, npm, pip). A scripting engine (powershell.exe, wscript.exe, mshta.exe) or an unsigned/renamed binary fetching a raw GitHub/Gist URL is a far stronger indicator than the same fetch from an IDE or package manager.
- Pull the actual raw content that was fetched, if it is still reachable at the URL or recoverable from proxy/EDR content capture. A short blob (well under 1KB) that decodes as base64/hex to an IP:port or domain string is strong confirmation of a dead-drop resolver rather than legitimate file content.
- Check whether the destination reached immediately after the fetch is genuinely novel for that host — a first-ever connection to an address is materially more suspicious than a connection to infrastructure the host talks to routinely.
- Review the file/repository/gist itself for edit history if it is still public: dead-drop resolvers are frequently updated (new commit, new gist revision) shortly before or during active operations to rotate the C2 address without touching the malware binary.
- Identify the process that made the follow-on connection and whether it is the same process that fetched the content, was spawned by it, or is an injected/hollowed process — this establishes whether the resolver output was consumed directly or handed off.
- Search for the same raw-content URL, repository, or gist ID appearing across other hosts in the environment, which would indicate a shared dropper rather than an isolated event.
Containment
- Isolate the endpoint via EDR network isolation rather than relying solely on a URL/domain block, since raw.githubusercontent.com and gist.githubusercontent.com cannot be blocklisted wholesale without breaking developer and CI workflows.
- Block the specific resolved C2 destination IP/domain at the perimeter and in EDR once identified from the follow-on connection, understanding that the operator may republish an updated dead-drop pointing elsewhere.
- Terminate the process that consumed the dead-drop content and any child process it spawned, preserving process memory first where the decoded C2 address may still be resident.
- Where the specific gist ID or repository path is identifiable and clearly malicious, report it to GitHub for takedown, and separately hunt for any other host in the estate that has fetched the same URL.
Evidence Collection
- The raw content payload itself (captured via proxy, EDR content inspection, or a timely re-fetch), plus the exact URL, gist ID, or repository path and commit/revision hash if recoverable.
- Full DeviceNetworkEvents/Sysmon history for the host spanning both the fetch and the follow-on connection, plus at least 15 days prior to establish whether the follow-on destination is genuinely novel.
- Process creation and command-line telemetry for the fetching process and any process it spawned or handed off to, including parent-process lineage back to the initial dropper or delivery vector.
- Any locally cached or dropped file containing the decoded configuration (temp directories, ADS, registry run keys) that would show how the resolved address was persisted or consumed.
Escalation Criteria
- !The fetching process is a scripting engine, LOLBin, or unsigned/renamed binary rather than recognised developer tooling, and it is followed by a genuinely novel outbound connection within minutes.
- !The decoded dead-drop content resolves to infrastructure with existing threat-intelligence associations, or the same gist/repository is referenced by known malware samples.
- !The same dead-drop URL or resolved destination appears across multiple hosts in a short window, indicating operator-deployed tooling rather than an isolated developer coincidence.
- !The activity occurs on a server, domain controller, build/CI system, or other privileged host rather than an ordinary developer workstation.
- !Post-resolution host behaviour shows staging, credential access, or collection activity, indicating the dead-drop successfully bootstrapped an active C2 channel.
Investigation Guide
Related Techniques
Forensic Artifacts
- >
Proxy or EDR-captured HTTP response body from the raw-content fetch, which contains the encoded configuration and is the single most direct piece of evidence available. - >
DeviceNetworkEvents (MDE) or Sysmon Event ID 22 + Event ID 3 pairs showing the DNS resolution of the raw-content host followed by the novel outbound connection. - >
Process creation and command-line history for the fetching process, establishing whether it was an interactive user action, a scheduled task, or a headless dropper. - >
Browser or application cache/history (if the fetch went through a browser-embedded control) versus the absence of any browser artefact (if it went through a bare HTTP client), which helps distinguish a user click from an automated fetch. - >
GitHub's public commit/revision history for the repository or gist, when still accessible, showing how and when the dead-drop content was last changed.
Tuning Guidance
Build the developer/CI allowlist first: run the fleet-wide non-browser fetch hunt over 30 days, identify every process name and host role (build agents, IDE update paths, package managers) that legitimately and routinely touches raw.githubusercontent.com or gist.githubusercontent.com, and exclude those specific DeviceId/process-name pairs rather than excluding the hosts entirely, since the same hosts are exactly what a dead-drop resolver depends on. Do not drop the novel-destination requirement (the KQL leftanti join) to simplify the rule — it is what separates 'this process fetched a raw file and also happened to make a connection' from 'this process fetched a raw file and then went somewhere it has never been,' and removing it will produce far more noise on any estate with active development activity. Where a network sensor or proxy captures response body size or content, layer in the short-response hunting query as a corroborating signal before escalating a match that has no independent confirmation. If your environment cannot support the 15-day prior-contact lookback at scale, shorten it rather than removing it — even a 3-to-5-day window still filters out a host's routine, repeatedly-contacted infrastructure and preserves most of the detection's value.
Hunting Queries
Fleet-wide baseline hunt for any non-browser resolution of the raw-content GitHub/Gist hosts over 30 days, independent of any follow-on connection. This is the right starting point for building an allowlist of legitimate developer and CI processes that routinely touch these hosts, against which the correlated detection's remaining hits can be triaged much faster.
// Hunt: any non-browser fetch of a GitHub/Gist raw-content host in the last 30 days, regardless of follow-on activity
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where RemoteUrl has_any ("raw.githubusercontent.com", "gist.githubusercontent.com", "gist.github.com", "objects.githubusercontent.com")
| where InitiatingProcessFileName !in~ ("chrome.exe", "msedge.exe", "firefox.exe", "brave.exe", "opera.exe")
| summarize FetchCount = count(), Urls = make_set(RemoteUrl, 20), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceName, InitiatingProcessFileName
| sort by FirstSeen asc index=windows sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=22
| eval QueryLower=lower(coalesce(QueryName, ""))
| eval ImageLower=lower(coalesce(Image, "unknown"))
| where (like(QueryLower, "%raw.githubusercontent.com%") OR like(QueryLower, "%gist.githubusercontent.com%") OR like(QueryLower, "%gist.github.com%"))
| where NOT (like(ImageLower, "%\\chrome.exe") OR like(ImageLower, "%\\msedge.exe") OR like(ImageLower, "%\\firefox.exe"))
| stats count as FetchCount, values(QueryLower) as Queries, min(_time) as FirstSeen, max(_time) as LastSeen by ComputerName, ImageLower
| eval FirstSeen=strftime(FirstSeen, "%Y-%m-%d %H:%M:%S"), LastSeen=strftime(LastSeen, "%Y-%m-%d %H:%M:%S")
| sort FirstSeen Hunts for unusually small response bodies from raw-content hosts, which is the size signature of a short encoded config blob rather than genuine source code, a README, or a binary release asset. This is a content-shape signal that is independent of the process-identity and timing correlation used in the primary detection and works well as a second, orthogonal pass over the same data source.
// Hunt: short (sub-1KB) responses from raw-content hosts, a size profile consistent with dead-drop config rather than source/binary content
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where RemoteUrl has_any ("raw.githubusercontent.com", "gist.githubusercontent.com")
| where isnotempty(ResponseBodySizeInBytes)
| where ResponseBodySizeInBytes < 1024
| summarize HitCount = count(), Urls = make_set(RemoteUrl, 20), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceName, InitiatingProcessFileName
| sort by FirstSeen asc index=proxy ("raw.githubusercontent.com" OR "gist.githubusercontent.com")
| eval resp_bytes=tonumber(sc_bytes)
| where resp_bytes < 1024 AND resp_bytes > 0
| stats count as HitCount, values(cs_uri_stem) as Urls, min(_time) as FirstSeen, max(_time) as LastSeen by c_ip, cs_user_agent
| eval FirstSeen=strftime(FirstSeen, "%Y-%m-%d %H:%M:%S"), LastSeen=strftime(LastSeen, "%Y-%m-%d %H:%M:%S")
| sort FirstSeen Atomic Red Team Tests
Simulates the core two-stage pattern: PowerShell fetches a small base64 string from a raw.githubusercontent.com URL using Invoke-WebRequest (a non-browser client), decodes it locally, and immediately opens a new TCP connection to the decoded (attacker-controlled test) address. Use a raw file you control in a scratch repository/gist containing only a harmless base64 string, and point the decoded connection at a benign test listener you control — never at a real third party.
Command
$raw = Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/<your-test-org>/<your-test-repo>/main/config.txt' -UseBasicParsing
$decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($raw.Content.Trim()))
Write-Host "Decoded dead-drop content: $decoded"
$parts = $decoded -split ':'
Test-NetConnection -ComputerName $parts[0] -Port ([int]$parts[1]) | Out-Null
Write-Host 'GitHub raw dead-drop fetch-then-connect test complete' Cleanup
Remove-Variable raw, decoded, parts -ErrorAction SilentlyContinue Expected Telemetry
MDE DeviceNetworkEvents / Sysmon Event ID 22: a DNS/HTTP access to raw.githubusercontent.com initiated by powershell.exe. Within seconds, a second network event (Sysmon Event ID 3 / MDE ConnectionSuccess) shows powershell.exe connecting to the decoded test destination and port.
Expected Detection
The KQL rule's ConfigFetch arm matches the powershell.exe access to the raw-content URL, and the NovelConnections arm matches the immediate follow-on connection provided the test destination has not been contacted by the host in the prior 15 days — use a fresh, never-before-contacted test listener to guarantee this. The two arms join on DeviceId within the 15-minute correlation window and the rule fires. The SPL transaction-based rule fires equivalently on the DNS-query-then-connect pair.
Simulates the same pattern on Linux using curl (a non-browser HTTP client) against a gist.githubusercontent.com raw URL, followed by a netcat connection to the decoded test address. Use a gist you control containing only a benign hex-encoded string.
Command
raw=$(curl -s 'https://gist.githubusercontent.com/<your-test-user>/<gist-id>/raw/config.txt')
decoded=$(echo "$raw" | xxd -r -p)
host=$(echo "$decoded" | cut -d: -f1)
port=$(echo "$decoded" | cut -d: -f2)
timeout 5 bash -c "cat < /dev/null > /dev/tcp/${host}/${port}" 2>/dev/null || true
echo 'Gist raw dead-drop fetch-then-connect test complete' Cleanup
unset raw decoded host port Expected Telemetry
EDR-for-Linux process and network telemetry: curl execution reaching gist.githubusercontent.com, followed within seconds by a new outbound TCP connection from the same shell session to the decoded test host and port.
Expected Detection
The KQL/EQL correlated rules fire on the fetch-then-connect pair from the same DeviceId/host.id within the 15-minute window, provided the test destination is genuinely new to the host. This test also exercises the Elastic EQL sequence rule's dns-then-network correlation.
Fetches the same raw.githubusercontent.com URL via PowerShell but performs no subsequent network activity, validating that Arm 1 alone (without a correlated novel connection) does not trigger the primary correlated detection and only surfaces in the broader baseline hunting query.
Command
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/<your-test-org>/<your-test-repo>/main/config.txt' -UseBasicParsing | Out-Null
Start-Sleep -Seconds 60
Write-Host 'Negative control fetch-only test complete' Cleanup
None required. Expected Telemetry
A single DeviceNetworkEvents/Sysmon Event ID 22 record for the raw.githubusercontent.com access from powershell.exe, with no accompanying new outbound connection in the following 15 minutes.
Expected Detection
The correlated KQL/SPL/EQL rules should NOT fire, since the NovelConnections/endswith arm never materialises. The event should appear only in the fleet-wide baseline hunting query, confirming the primary rule's correlation requirement is functioning as a noise-reduction control rather than alerting on the fetch alone.