Skip to content

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.

Published on 5 min read

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)

FieldIIS (W3C)Apache / nginx (combined)Useful for
Timestampdate time (UTC by default)[%t] (server local time with offset)Timeline
Client IPc-ip%h / $remote_addrAttribution, pivoting
Methodcs-methodin %rPOST-heavy files
URI + querycs-uri-stem, cs-uri-queryin %rRare pages, parameters
Statussc-status%>sProbing (404s), success
Response sizesc-bytes (if enabled)%b / $body_bytes_sentOutput-bearing responses
User agentcs(User-Agent)%{User-Agent}iTooling fingerprints
Referercs(Referer)%{Referer}iDirect access vs navigation
Time takentime-taken%D / $request_time (custom)Long-running commands
Usernamecs-username%uAuthenticated 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:

IndicatorNormal pageTypical web shell
Distinct client IPsManyOne or a handful
Method mixMostly GETMostly or only POST
RefererUsually presentOften empty
First-seen dateSince deploymentRecent, near a suspicious event
Request cadenceOrganicBursts during operator sessions
Response sizeStableHighly variable (depends on command output)
PathLinked from other pagesNot linked anywhere; odd directory
User agentBrowsersScripting 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:

  1. Confirm on disk — does the file exist? Hash it, copy it to evidence storage, record timestamps (see file integrity detection).
  2. Extract every request to that URI across all servers in the farm and all retained log files.
  3. Pivot on IPs and user agents — what else did those clients request? Other shells? The upload or exploit endpoint?
  4. Find the first request — the earliest access usually follows the planting within seconds or minutes.
  5. 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.

Related guides

Indicators & detection

YARA and Sigma rules for web shell detection

Write, test and tune YARA rules for suspicious server-side scripts and Sigma rules for web shell behaviour in logs and process telemetry — with annotated examples and a false-positive strategy.