Web server log analysis for web shell detection
Find web shells in IIS, Apache and nginx access logs: rare URIs, POST-heavy files, few-client patterns, odd user agents and response sizes — with awk, PowerShell, KQL and SPL examples.
Every interaction with a file-based web shell is an HTTP request, and every HTTP request that reaches the web server is — by default — logged. Access logs are therefore both a detection source and the primary evidence for reconstructing what an intruder did. This guide explains what to look for and gives ready-to-adapt queries.
What the logs contain (and what they don't)
| Field | IIS (W3C) | Apache / nginx (combined) | Useful for |
|---|---|---|---|
| Timestamp | date time (UTC by default) | [%t] (server local time with offset) | Timeline |
| Client IP | c-ip | %h / $remote_addr | Attribution, pivoting |
| Method | cs-method | in %r | POST-heavy files |
| URI + query | cs-uri-stem, cs-uri-query | in %r | Rare pages, parameters |
| Status | sc-status | %>s | Probing (404s), success |
| Response size | sc-bytes (if enabled) | %b / $body_bytes_sent | Output-bearing responses |
| User agent | cs(User-Agent) | %{User-Agent}i | Tooling fingerprints |
| Referer | cs(Referer) | %{Referer}i | Direct access vs navigation |
| Time taken | time-taken | %D / $request_time (custom) | Long-running commands |
| Username | cs-username | %u | Authenticated abuse |
Two gaps matter. First, request bodies are not logged, so POST parameters — where most web shells receive commands — are invisible. Second, IIS logs in UTC while Apache and nginx log in local time with an offset; normalise before building a timeline.
Enable the optional fields you will need before an incident: sc-bytes, cs-bytes, time-taken and X-Forwarded-For (via custom fields) on IIS; $request_time and the real client IP header on nginx behind a proxy.
The behavioural signature
Legitimate pages are requested by many clients, usually via GET, often with a referer, and return consistent sizes. A web shell tends to show the opposite profile:
| Indicator | Normal page | Typical web shell |
|---|---|---|
| Distinct client IPs | Many | One or a handful |
| Method mix | Mostly GET | Mostly or only POST |
| Referer | Usually present | Often empty |
| First-seen date | Since deployment | Recent, near a suspicious event |
| Request cadence | Organic | Bursts during operator sessions |
| Response size | Stable | Highly variable (depends on command output) |
| Path | Linked from other pages | Not linked anywhere; odd directory |
| User agent | Browsers | Scripting libraries, outdated or spoofed browser strings |
No single indicator is conclusive; stacking several gives high-confidence leads.
Hunt 1 — Rare URIs ("least frequency of occurrence")
Most web shells are requested by very few clients. List executable URIs by the number of distinct IPs that requested them, and review the bottom of the list.
Apache/nginx combined log (awk):
awk '$7 ~ /\.(php|phtml|jsp|jspx|aspx|ashx)(\?|$)/ { split($7,u,"?"); print u[1], $1 }' access.log* \
| sort -u | awk '{c[$1]++} END {for (k in c) print c[k], k}' | sort -n | head -50
IIS W3C logs (PowerShell, with Log Parser-style parsing):
Get-ChildItem 'C:\inetpub\logs\LogFiles\W3SVC*\u_ex*.log' | Get-Content |
Where-Object { $_ -notmatch '^#' } |
ForEach-Object { $f = $_ -split ' '; [pscustomobject]@{ Uri = $f[4]; Ip = $f[8] } } |
Where-Object { $_.Uri -match '\.(aspx|ashx|asmx|asp)$' } |
Group-Object Uri | ForEach-Object {
[pscustomobject]@{ Uri = $_.Name; Hits = $_.Count; Ips = ($_.Group.Ip | Sort-Object -Unique).Count }
} | Sort-Object Ips, Hits | Select-Object -First 50
Field positions differ between IIS configurations — check the #Fields: header line and adjust the indexes.
Hunt 2 — POST-only executable files
Pages that receive only POST requests and never a GET are unusual outside of APIs and form handlers you know about.
Splunk SPL:
index=web sourcetype IN (iis, access_combined)
| rex field=uri_path "\.(?<ext>aspx|ashx|php|jsp)$"
| where isnotnull(ext)
| stats count(eval(method="POST")) AS posts count(eval(method="GET")) AS gets dc(clientip) AS ips
min(_time) AS first max(_time) AS last BY uri_path
| where posts > 0 AND gets = 0 AND ips < 5
| convert ctime(first) ctime(last)
| sort - posts
Microsoft Sentinel / Defender KQL (IIS logs via the W3CIISLog table):
W3CIISLog
| where TimeGenerated > ago(30d)
| where csUriStem matches regex @"(?i)\.(aspx|ashx|asmx)$"
| summarize Posts = countif(csMethod == "POST"), Gets = countif(csMethod == "GET"),
Clients = dcount(cIP), First = min(TimeGenerated), Last = max(TimeGenerated)
by sSiteName, csUriStem
| where Posts > 0 and Gets == 0 and Clients < 5
| order by First desc
Hunt 3 — New pages
Compare URIs seen in the last few days against a longer history. Anything executable that never appeared before and now receives requests deserves review.
let lookback = W3CIISLog | where TimeGenerated between (ago(60d) .. ago(3d)) | distinct csUriStem;
W3CIISLog
| where TimeGenerated > ago(3d)
| where csUriStem matches regex @"(?i)\.(aspx|ashx|asmx|asp)$"
| where csUriStem !in (lookback)
| summarize Requests = count(), Clients = make_set(cIP, 10), First = min(TimeGenerated) by csUriStem
Hunt 4 — Suspicious parameters and user agents
Some interactive shells use query-string parameters, which are logged. Look for parameter names or values suggestive of command execution or file operations, and for encoded blobs in query strings. Treat this as a lead generator, not proof:
grep -Ei '\.(php|aspx|jsp)\?[^ ]*(cmd|exec|command|shell|upload|download|dir|path)=' access.log*
grep -E '\?[^ ]*=[A-Za-z0-9+/]{80,}={0,2}( |&)' access.log* # long base64-like values
User agents from scripting libraries (for example python-requests, curl, Go-http-client) or very old browser versions hitting executable pages from a single IP are worth correlating with the hunts above.
Hunt 5 — Response-size variance
A static page returns roughly the same number of bytes each time. A command runner returns output whose size varies wildly. High standard deviation of sc-bytes per URI, combined with low client count, is a strong indicator:
index=web | stats dc(clientip) AS ips stdev(bytes) AS sd avg(bytes) AS avg count BY uri_path
| where ips < 5 AND count > 10 AND sd > avg
Hunt 6 — Probing before success
Before landing a shell, attackers often request the planned path while testing (404s), then the successful upload or exploit request, then the first 200 on the new file. Sequences of 404 → 200 on the same unusual path from the same IP bracket the deployment moment — very useful for the timeline.
From a hit to a story
Once you have a suspect URI:
- Confirm on disk — does the file exist? Hash it, copy it to evidence storage, record timestamps (see file integrity detection).
- Extract every request to that URI across all servers in the farm and all retained log files.
- Pivot on IPs and user agents — what else did those clients request? Other shells? The upload or exploit endpoint?
- Find the first request — the earliest access usually follows the planting within seconds or minutes.
- Correlate with process telemetry — each POST to the shell should align with child processes of the worker (see process-tree detection).
Remember load balancers, reverse proxies and CDNs: the c-ip in the origin log may be the proxy. Collect the upstream logs or ensure the original client IP is logged via X-Forwarded-For.
Log hygiene that pays off during incidents
- Forward logs off-host in near real time; local logs can be wiped.
- Keep at least 90 days of hot, searchable web logs for internet-facing systems.
- Synchronise clocks (NTP) across web servers, proxies and domain controllers.
- Log the true client IP behind proxies, and log response sizes and durations.
- Include WAF and load-balancer logs in the same store — they often record blocked attempts that preceded the successful one.