Web Shell Detection: How to Find One, and Why Most Rules Miss It
Triage and detection logic for web shells: the process-parent rule, its real false positives, IIS log tells, and an ordered first 30 minutes.

A web shell is a script an attacker leaves inside a web application's own document root, so the web server itself executes attacker commands whenever the file is requested. There is no implant, no beacon, no scheduled task. It runs only when someone asks for it, which is exactly why it hides so well.
The one property that drives every detection decision
Strip away the tooling and a web shell is a file the web application will execute. Drop thumb_cache.aspx into a directory IIS serves, and IIS compiles and runs it the next time somebody requests that path. Capabilities vary: F5 Labs catalogued the fourteen web shells shipped with Kali and found command execution on both Windows and Linux, file upload and download, archive creation, database query and table dumping, and reverse shell spawning. A given shell might have all of that behind a web UI, or one function behind a single parameter.
The property that matters for detection is narrower: a web shell is request-driven. It generates no traffic of its own. Nothing beacons on an interval, nothing registers a service, nothing writes a scheduled task. Every detection idea that works against implants (periodicity, unexpected egress, autostart persistence) is aimed at behavior a web shell simply does not have.
ATT&CK files it under Server Software Component (T1505) as sub-technique 003, which is the right home: the shell is a component of the server software, indistinguishable at the protocol level from the application it lives in. It is a persistence mechanism, which is why patching the vulnerability does not remove the shell. The shell outlives the bug that let it in, and it will still be there after the emergency change window closes.
It gets there two ways in almost every case. Either the application itself was exploited, which is Exploit Public-Facing Application (T1190), or somebody logged in with credentials they should not have had, which is External Remote Services (T1133). The NSA and ASD joint advisory Detect and Prevent Web Shell Malware makes a point that gets ignored in practice: internal web servers get targeted too, and internal content management systems and device management interfaces are often the softer target because they are patched slower.
For scale, Microsoft reported in Web shell attacks continue to rise (February 2021) an average of 140,000 web shell encounters per month on servers between August 2020 and January 2021, roughly double the 77,000 monthly average of the year before. That is a 2021 number, not a current one. Read it as evidence that the technique runs at scale, not as a current rate.
Three surfaces, three blind spots
Everything you can do about a web shell comes from one of three telemetry sources, and the specific way each one fails determines your coverage.
| Surface | What it sees | What it misses | Defeated by |
|---|---|---|---|
| Process telemetry (EDR, 4688, Sysmon, auditd) | Every command handed to an interpreter, with parent lineage and the identity it ran as | Anything the shell does inside the application runtime itself | Eval-only and file-manager shells that never spawn a child process |
| File telemetry (EDR file events, FIM, known-good diff) | The artifact: path, hash, writing process, first appearance | In-memory shells, and malicious code appended to a file that already existed legitimately | Timestomping, fileless execution, modification instead of creation |
| Web server access logs | Which URI, from which client, with which user agent, and how long the response took | The request body, which is where the command lives | HTTPS bodies never being logged, plus deliberate URI and user agent mimicry |
Teams that build only the process-parent rule feel covered and are not.
The process-parent rule, and the false positive that gets it muted
The analytic in one sentence: a web server worker process has no business being the parent of a shell. On Windows the workers are w3wp.exe for IIS, plus httpd.exe, tomcat*, coldfusion.exe and owstimer.exe depending on stack. On Linux they are httpd, nginx, php-fpm and java.
In Defender XDR, against DeviceProcessEvents:
DeviceProcessEvents
| where Timestamp > ago(7d)
| where InitiatingProcessFileName in~ ("w3wp.exe", "httpd.exe", "nginx.exe",
"coldfusion.exe", "owstimer.exe", "visualsvnserver.exe")
or InitiatingProcessFileName startswith "tomcat"
| where FileName !in~ ("csc.exe", "cvtres.exe", "vbc.exe", "conhost.exe", "php-cgi.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName, InitiatingProcessAccountName,
FileName, ProcessCommandLine, InitiatingProcessCommandLine
| order by Timestamp descIf KQL operators are new to you, our KQL guide covers in~, has_any and the summarize pipeline this piece leans on.
The same question in Splunk, against 4688 process-creation events:
index=windows sourcetype="WinEventLog:Security" EventCode=4688
| eval parent=lower(mvindex(split(Creator_Process_Name,"\\"),-1))
| eval child=lower(mvindex(split(New_Process_Name,"\\"),-1))
| search parent IN ("w3wp.exe","httpd.exe","coldfusion.exe","tomcat9.exe")
| search NOT child IN ("csc.exe","cvtres.exe","vbc.exe","conhost.exe","php-cgi.exe")
| stats count AS spawns, values(child) AS children, values(Process_Command_Line) AS cmdline,
min(_time) AS first_seen BY host, parent
| convert ctime(first_seen)
| sort -spawnsTwo traps in that one before you run it. Process_Command_Line is empty unless the "Include command line in process creation events" policy is enabled, and without it a 4688 tells you a shell started but not what it did. And Creator_Process_Name only exists on Windows 8.1, Server 2012 R2 and later; on older hosts you get Creator_Process_ID and must correlate to the parent's own 4688.
Microsoft publishes essentially this hunting query in its own web shell writeup, carrying four exclusions as one-line comments. Each one is load-bearing.
csc.exe and vbc.exe are the ASP.NET runtime, not an attacker. When an ASP.NET page is requested for the first time after a deploy, an app-pool recycle, an IIS restart or a web.config change, the runtime compiles the page and w3wp.exe launches the C# or Visual Basic compiler to do it. cvtres.exe frequently follows. On a fleet that deploys weekly, this fires in a burst on every server, every release, and it is the single biggest reason teams turn the rule off entirely. Exclude the compilers by name. Do not mute the analytic.
conhost.exe is a console host. Windows attaches it to console applications. It is downstream noise, not the thing you are hunting.
php-cgi.exe under a worker is FastCGI working as designed. IIS hands PHP requests to it deliberately.
Monitoring agents, backup agents and CI deployment jobs spawn interpreters from web hosts on purpose. A deployment runner calling powershell.exe from a server that also runs IIS is not a web shell, and this is the exclusion you cannot copy from anyone else, because it depends on what your organization installed. Baseline per host, not per fleet: run the query with | summarize FirstSeen = min(Timestamp) by DeviceName, InitiatingProcessFileName, FileName for two weeks, save the result, and alert on pairs that are not in it.
Everything the shell then runs is Command and Scripting Interpreter (T1059), and the NSA advisory's Sysmon appendix lists the recon binaries worth prioritizing under w3wp.exe: whoami.exe, net.exe, ipconfig.exe, systeminfo.exe, nltest.exe, certutil.exe, bitsadmin.exe, vssadmin.exe, wevtutil.exe. The Linux list under Apache is shorter and just as useful: whoami, uname, id, ifconfig, netstat, crontab.
If the shell drops to PowerShell, turn to 4104 script block logging. A web shell hands PowerShell a base64 blob through -EncodedCommand far more often than it hands it readable text, so the 4688 command line is a wall of base64 while the 4104 record carries the decoded script text. Two events, same second, and only one of them is readable.
Warning
Do not tune this rule with a threshold. Frequency is not the signal: one cmd.exe under w3wp.exe at 03:00 matters more than four hundred csc.exe events at deploy time, and any count-based threshold gets that backwards.
The shells that spawn nothing
A large share of real web shells never call an OS command interpreter. A file-manager shell browses directories, reads files, writes files and downloads archives entirely through the web application's own runtime: PHP's filesystem functions, .NET's System.IO, Java's file API. An eval-only shell evaluates a string from a request parameter, and if that string is application code rather than a system command, nothing leaves the runtime. An operator who wants a database dump can take it through the application's existing database connection.
Process telemetry sees exactly zero of that. Not a suppressed event, not a low-confidence event. Nothing is created, so nothing is logged. That is not a tuning problem and no amount of EDR spend fixes it, which is why the file and log layers are not optional extras.
The advisory pushes it further: some web shells run entirely in memory, on rogue web servers standing up on hosts that are not web servers at all. Those defeat file-based detection too, and the only handle left is network. A host listening on a port it has never used before, or a perimeter server originating connections inward.
Containers move the same problem one layer out. If the web root lives in a pod, an attacker with cluster credentials has no reason to drop a file in it: an exec into the running container hands them the interactive shell directly. Process and file telemetry from the node record nothing, because nothing on the node happened, so a cluster with complete endpoint coverage can still have no witness for it.
Reading the access log
Access logs exist on every web server, retained and centralized, whether or not EDR is deployed. Here is a W3C-format IIS excerpt from a portal server, one line of which is a shell interaction.
#Fields: date time s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) cs(Referer) sc-status sc-bytes cs-bytes time-taken
2026-08-27 03:14:02 10.20.4.15 GET /portal/default.aspx - 443 - 10.44.9.201 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36 https://portal.northwind.example/ 200 18422 412 61
2026-08-27 03:14:03 10.20.4.15 GET /portal/assets/theme.css - 443 - 10.44.9.201 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36 https://portal.northwind.example/portal/default.aspx 200 9310 388 4
2026-08-27 03:14:57 10.20.4.15 POST /portal/uploads/thumb_cache.aspx - 443 - 172.19.6.88 Mozilla/5.0+(Windows+NT+6.1)+Chrome/86.0.4240.75 - 200 214 1877 3812
2026-08-27 03:15:44 10.20.4.15 POST /portal/uploads/thumb_cache.aspx - 443 - 172.19.6.88 Mozilla/5.0+(Windows+NT+6.1)+Chrome/86.0.4240.75 - 200 1096 2231 9407
2026-08-27 03:16:10 10.20.4.15 GET /portal/reports/monthly.aspx - 443 - 10.44.9.201 Mozilla/5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36 https://portal.northwind.example/portal/default.aspx 200 44120 401 88Simulated example generated by SOCSimulator Research.
Field by field, using the names from Microsoft's IIS logging configuration reference. IIS writes - for an empty field and substitutes + for spaces inside values, which trips up naive parsers.
cs-method is POST on lines three and four, against a path under /portal/uploads/. cs-uri-stem is the target, /portal/uploads/thumb_cache.aspx, a file whose name says cache and whose directory says user uploads. cs-uri-query is empty on both, which is consistent: the parameters went in the body. sc-status is 200: the server executed it. sc-bytes is 214 then 1096, tiny responses. cs-bytes is 1877 then 2231, so the client sent an order of magnitude more than it received, which is backwards for a page meant to serve an image. cs(User-Agent) is a Windows 7 Chrome 86 string that appears on no other line. cs(Referer) is - on a deep path, while every legitimate line carries one. c-ip is 172.19.6.88, which appears nowhere else. time-taken is 3812 ms then 9407 ms, against 4 to 88 ms everywhere else.
Those are the tells, and each has a benign twin:
- POST to a path the application never POSTs to. Static-looking paths and upload directories are the classic pair. Benign case: an AJAX endpoint you did not know about, or a health-check probe that POSTs.
- A URI requested by exactly one or two client IPs and one user agent, when neighboring URIs have hundreds. Benign case: a rarely used admin page, or a URI the application generates uniquely per request.
- Missing or nonsensical
Refereron a deep path. The advisory names this explicitly, because operators frequently forget to forge it. Benign case: bookmarks, API clients, monitoring probes, anything typed directly into an address bar. - A user agent that appears nowhere else in the log. Before an attacker has a presence in your network they cannot know what your normal agents look like, so their client's default stands out. Benign case: one developer's curl.
time-takenfar above the app's normal for that path. The response is slow because a command is running behind it. Benign case: a genuinely heavy report, a cold cache, a database under load.
The NSA advisory ships this as an analytic, and the nsacyber/Mitigating-Web-Shells repository has the runnable version. Rank URIs by how few distinct user agents and client IPs requested them:
index=iis sourcetype="ms:iis:auto" sc_status>=200 sc_status<300
| fillnull value="-" cs_User_Agent cs_Referer
| stats count AS hits, dc(cs_User_Agent) AS agents, dc(c_ip) AS clients,
values(cs_User_Agent) AS agent_list, values(c_ip) AS client_list,
min(_time) AS first_seen, max(_time) AS last_seen BY cs_uri_stem
| where agents<=2 AND clients<=2
| convert ctime(first_seen) ctime(last_seen)
| sort hitsWarning
The repository is honest about this one, and you should be too: the analytic "will ALWAYS produce results regardless of whether a web shell is present or not." It ranks the rarest URIs on your server, and the rarest URIs on any server are mostly benign. Ship it as a hunting view, never as an alert, and expect the top of that list to be benign more often than not.
The complementary question in Sentinel, aimed at the first tell rather than the rarity ranking:
let Baseline = W3CIISLog
| where TimeGenerated between (ago(30d) .. ago(2d))
| where csMethod == "POST"
| distinct csUriStem;
W3CIISLog
| where TimeGenerated > ago(2d)
| where csMethod == "POST" and toint(scStatus) between (200 .. 299)
| where csUriStem !in~ (Baseline)
| summarize Hits = count(), Clients = dcount(cIP), Agents = dcount(csUserAgent),
MaxTime = max(TimeTaken), Referers = make_set(csReferer, 10)
by csUriStem, Computer
| order by Hits ascTwo portability notes on those two searches. The Splunk field names come from the IIS add-on's automatic header extraction, so confirm yours before assuming cs_User_Agent. And toint(scStatus) is deliberate: where a workspace ingests the status code as a string, a bare numeric comparison returns nothing at all rather than an error.
Note
Access logs record the request line, not the request body. The command an operator sent, its arguments and its output all travel in the POST body, which IIS and Apache do not log by default and which is inside TLS on the wire. That is the obfuscation layer too: the body is usually base64 or encrypted before it is anything else. The access log tells you which URI, and roughly when. It will not tell you what ran.
The file side
The artifact is a file, so this is the layer that answers what it is and when it appeared. In Defender XDR:
let ScriptExt = dynamic([".aspx", ".asp", ".ashx", ".asmx", ".php", ".php5",
".jsp", ".jspx", ".cfm", ".war"]);
DeviceFileEvents
| where Timestamp > ago(14d)
| where ActionType in ("FileCreated", "FileModified")
| where FolderPath contains @"\wwwroot" or FolderPath contains "/var/www/"
or FolderPath contains "/usr/share/tomcat"
| extend Ext = strcat(".", tostring(split(FileName, ".")[-1]))
| where Ext in~ (ScriptExt)
| project Timestamp, DeviceName, FolderPath, FileName, SHA256,
InitiatingProcessFileName, InitiatingProcessAccountName, InitiatingProcessCommandLine
| order by Timestamp ascUse contains for the path filter, not has_any. KQL's has family matches whole indexed terms, so a path fragment with separators in it is not a term and the filter quietly matches nothing. Microsoft's own string-operator reference makes the point with "KustoExplorerQueryRun" has "Explorer", which returns false. contains is a plain substring match and behaves the way a path filter should.
Reading the output is shape recognition. A normal deploy is many files in one tight window, one service account, a deployment agent or msdeploy or a package manager, and a change ticket matching the timestamp. A web shell is one file, often at an hour nobody deploys, with the worker process itself or one of its children as InitiatingProcessFileName. That last field is the discriminator: w3wp.exe writing an .aspx file into the web root is the web server writing its own executable code, which almost no legitimate application does.
The NSA advisory names known-good comparison as the best detection method. Shells target existing applications by creating or modifying files, so a hash-level diff between a verified build and the production tree surfaces every added and altered file regardless of obfuscation. The repository's dirChecker.ps1 implements it, and diff -r -q does the same job on Linux. The catch is operational: you need a known-good image, captured before the incident.
Warning
Do not adjudicate a diff by timestamp. Attackers timestomp, and the advisory is blunt about the consequence: administrators "should not assume that a modification is authentic simply because it appears to have occurred during a maintenance period." Odd timestamps are a fine way to prioritize which files you check first. They are not evidence that a file is clean.
That is also why dir /od and ls -lt are a starting point rather than an answer. Sorting a web root by modification time finds the careless operator, misses the careful one, and says nothing about a file that was legitimate last month and had four lines appended to it last week. Hashes catch that. Sort order does not.
Family fingerprints, read defensively
Four names come up constantly, and their shapes speed up triage.
China Chopper is tracked by ATT&CK as S0020 and cited throughout T1505.003. Its server component is a single line; the operator's client does all the work. The defensive fingerprint is traffic shape rather than file content: repeated small POSTs to one URI, historically with a distinctive short body, and a file so tiny that a size-based sweep of the web root works against it.
Behinder, Godzilla and AntSword are the modern managed clients. They encrypt their payloads rather than encoding them, so the content matches that worked on older families do not apply. One defender-useful detail from Gigamon's analysis of these shells: Behinder's default build uses a hardcoded AES key, e45e329feb5d925b, the first sixteen characters of the MD5 of the string rebeyond. With TLS inspection at the proxy and an operator who left the default alone, that traffic decrypts with a key you already have.
These families are what the core YARA ruleset in the nsacyber repository targets, and its limits are documented rather than implied. core matches indicators unlikely to appear in benign files, so it produces few false positives, and renaming a variable defeats it. That is the entire reason extended exists: it matches techniques rather than shells, obfuscation, dynamic execution and encoding, all of which legitimate applications also do. The repository's own words are that extended "is likely to produce a significant number of false positive results." Run core first, vet its hits, and reach for extended only when you have the hours.
The first 30 minutes
You have an alert. Work it in this order.
- Confirm the file exists and where. Full path, size, extension, and whether it sits inside a directory the web server actually serves.
- Hash it and copy it off the host before anything else. SHA256 into your case notes, the file into evidence storage. Everything after this point risks destroying it.
- Establish when it was written and by which process. File telemetry gives you
InitiatingProcessFileNameand the account. Treat the timestamp as a lead rather than a fact. - Pull every request to that URI. All client IPs, all user agents, first and last request, and the shape of the sequence. This gives you the dwell window, which sets the scope of everything else you look at.
- Pull the worker process's children across that window. Not just the alert's process, everything
w3wp.exeorphp-fpmspawned between first and last request. - Check what crossed the network boundary. Ingress Tool Transfer (T1105) is the usual next step, so look for
certutil,bitsadmin,curlandwgetunder the worker pulling a second stage in, and for outbound connections from a host that should only ever answer them. - Identify the app pool identity and map what it reaches. Its group memberships, its database logins, its file share access. That set is the blast radius, and it is nearly always larger than the team expects.
Preserve first, then contain. Deleting the file before you hash it is the most common irreversible mistake in this workflow. It destroys the only artifact that tells you what capabilities were on the box, and it does not fix anything: the entry vector is still open, and an operator who loses one shell usually has another.
Containment, and what stops the next one
Fix the entry vector first. Patch it, or virtual-patch it at the proxy if the vendor has nothing yet. Removing the shell without closing the hole gets you a new shell.
Then the two configuration changes that prevent the class. The web root should not be writable by the identity the worker process runs as, which is the advisory's own recommendation and the one that removes the primary infection vector outright; upload handlers can write outside the document root. And script execution belongs off in every upload directory, so a file that lands there is served as bytes rather than executed as code.
A WAF is a delaying layer, not a control. It may block the initial exploit attempt, which is worth having. It is unlikely to recognize web shell traffic once the shell is installed, because that traffic is a valid HTTPS request to a valid path on your own server.
When you cannot prove scope, redeploy from a known-good build instead of deleting files surgically. Surgical deletion assumes you found everything, and operators chain shells across multiple compromised systems precisely so that losing one costs them nothing.
Finally, carry the analytic rather than rewriting it. The process-parent logic is the same idea in Defender XDR, Splunk, Elastic and Sentinel, and expressing it as a Sigma rule means writing the exclusions and the worker-process list once and converting per backend, instead of maintaining four drifting copies that disagree about whether cvtres.exe is excluded.
Free
Train on real alerts, with zero consequences
Practice triage on realistic alert volume in a live SOC console. Free.
The strongest analytic in this set also has the loudest false positive, and the shells that evade it are the ones an operator would pick anyway. So you end up with three imperfect detections rather than one good one: a process rule with four exclusions you have to understand rather than copy, a file-write rule that needs an image captured before you needed it, and a log analytic that always returns something. Build all three and work the overlap. That overlap is what SOCSimulator drills, with the deploy noise and the compiler events left in.
Frequently Asked Questions
- What is a web shell?
- A web shell is a script placed in a web application's document root that lets an attacker run commands through the web server. Requests to it look like ordinary HTTP traffic, and it executes only when the attacker sends a request, so it generates no beaconing and no scheduled activity of its own. MITRE ATT&CK tracks it as T1505.003, a sub-technique of Server Software Component.
- How do you detect a web shell?
- Use three surfaces together. Process telemetry catches a web server worker process spawning a command interpreter, which is the loudest signal but blind to shells that never spawn anything. File telemetry catches script files written under the web root by the worker identity, and a known-good comparison catches modified files. Web server logs rank URIs by how few distinct client IPs and user agents request them.
- What is the difference between a web shell and a reverse shell?
- A web shell is server-side and request-driven: it sits inside the web application and executes only when the attacker sends an HTTP request to it. A reverse shell is initiated by the victim host, which opens an outbound connection back to attacker infrastructure and holds it open. The web shell produces no outbound connection and no persistent session, so egress monitoring and beacon detection do not see it.
- How do attackers install a web shell?
- Most arrive through exploitation of a public-facing application: an unauthenticated file upload, a deserialization bug, a path traversal, or a template injection that writes a file into the document root. The rest come through valid credentials on remote services such as a CMS admin panel, a management interface, or FTP. Internal servers get targeted too, not only internet-facing ones.
- How do you remove a web shell?
- Hash the file and keep a copy before touching it, because it is your only evidence of what ran. Then patch or virtually patch the entry vector, since deleting the file alone leaves the hole that produced it. Where you cannot prove scope, redeploy the application from a known-good build rather than deleting files one by one, and rotate the credentials the app pool identity holds.
Field notes
New walkthroughs and detections, in your inbox
A short email when we publish something worth your time. No spam, unsubscribe in one click.
Community
Continue the conversation
Discuss this with analysts who are actively training and working in the field.
Related Articles

Writing a SOC Playbook an Analyst Will Follow at 3 a.m.
What separates a SOC playbook analysts follow from a document that rots in a wiki: every step names a tool and a query, every branch carries a threshold.

Cyber Threat Hunting Tools: 13 SOC Analysts Use (2026)
Cyber threat hunting tools every SOC analyst needs: Sigma, YARA, KQL, Velociraptor, Wireshark, Zeek, MISP and more — grouped by layer with code examples.

Windows Logon Types: A Triage Guide for Every 4624 Value
Every Windows logon type, what produces it in a healthy environment, what it means when it appears where it should not, and the queries to hunt it.