Apache and Nginx Logs: Linux Web Server Forensics
Apache httpd and nginx access and error logs on Linux: log formats, paths per distro, rotation and how to hunt web shells and exploitation.
- Location
- /var/log/apache2/, /var/log/httpd/, /var/log/nginx/
- Distributions
- Proves
- Which clients requested which URLs, when, with what result and user agent, including exploitation and web shell use
- Timestamps
- Local time with numeric UTC offset, second precision (default combined format)
- Access
- root or adm group (Debian/Ubuntu); root (RHEL)
- Retention
- Debian/Ubuntu: daily, 14 kept; RHEL httpd: global weekly, 4 kept; Fedora nginx: daily, 10 kept
- Collection
- UAC, Velociraptor, cp -a, tar
Tools
Compare all tools- grep / zgrepCLI · built into Linux
- awkCLI · built into Linux
- GoAccessCLI · open source
- lnavCLI · open source
- PlasoCLI · open source
What it is
Apache httpd and nginx write one access log line per request and a separate error log for server-side problems. On an internet-facing Linux host these logs are often the only record of initial access: vulnerability scans, exploitation of an application, uploads of a web shell and every later command sent to it through HTTP.
Both servers default to the NCSA combined format, so the same parsing works for either. Many sites customise LogFormat (Apache) or log_format (nginx), for example to add response time, virtual host or the real client IP behind a proxy, so read the configuration before trusting column positions.
Where it lives
| Item | Debian / Ubuntu | RHEL / Fedora | Notes |
|---|---|---|---|
| Apache access log | /var/log/apache2/access.log | /var/log/httpd/access_log | RHEL also ssl_access_log, ssl_request_log |
| Apache error log | /var/log/apache2/error.log | /var/log/httpd/error_log | |
| Apache vhosts without own log | /var/log/apache2/other_vhosts_access.log | per CustomLog in vhost | |
| Apache config | /etc/apache2/apache2.conf, sites-enabled/, conf-enabled/, mods-enabled/ | /etc/httpd/conf/httpd.conf, conf.d/, conf.modules.d/ | LogFormat, CustomLog, ErrorLog |
| nginx logs | /var/log/nginx/access.log, error.log | same | |
| nginx config | /etc/nginx/nginx.conf, sites-enabled/, conf.d/ | /etc/nginx/nginx.conf, conf.d/ | log_format, access_log |
| Default web root | /var/www/html | /var/www/html (httpd), /usr/share/nginx/html (nginx) | Where dropped files land |
| Rotation | /etc/logrotate.d/apache2, nginx | /etc/logrotate.d/httpd, nginx |
Containers usually send web logs to stdout, so look in the container runtime logs instead. Control panels, reverse proxies and application servers (Tomcat, PHP-FPM) keep their own logs.
What it proves
- Source IP, method, path, query string, protocol, status code, response size, referrer and user agent of each request.
- Exploitation attempts and success: a
POSTto a vulnerable endpoint followed by200responses from a new file. - Web shell interaction: repeated requests to one unusual script, often
POST, from few IPs, with small varying response sizes. - Authenticated user name for HTTP basic auth (
%u/$remote_user). - Errors from the error log: PHP warnings with file paths,
File does not exist, module crashes, permission errors that reveal attacker file writes. - It does not record POST bodies, cookies or most headers by default, so the command sent to a web shell is usually not in the log. Behind a proxy or load balancer the first field is the proxy's IP unless the real IP module or
X-Forwarded-Foris logged.
Key fields
Combined format (Apache "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"", nginx '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"'):
203.0.113.50 - - [20/Sep/2026:03:14:07 +0200] "POST /wp-content/uploads/2026/09/cache.php HTTP/1.1" 200 64 "-" "Mozilla/5.0"
| Field | Apache | nginx | Meaning |
|---|---|---|---|
| Client | %h | $remote_addr | IP (or hostname if HostnameLookups On) |
| Identity | %l | literal - | RFC 1413 ident, almost always - |
| User | %u | $remote_user | HTTP auth user or - |
| Time | %t | $time_local | [dd/Mon/yyyy:HH:MM:SS +zzzz] |
| Request | %r | $request | Method, path with query string, protocol |
| Status | %>s | $status | Final status code |
| Size | %b | $body_bytes_sent | Body bytes; Apache logs - for zero |
| Referrer, UA | %{Referer}i, %{User-agent}i | $http_referer, $http_user_agent | Client-supplied, can be forged |
Error log line formats:
[Sun Sep 20 03:14:09.512004 2026] [php:error] [pid 4121] [client 203.0.113.50:40318] PHP Fatal error: ...
2026/09/20 03:14:09 [error] 4121#4121: *17 open() "/var/www/html/x.php" failed (2: No such file or directory), client: 203.0.113.50, ...
Timestamps
- Access logs use local time with an explicit offset (
+0200), so conversion to UTC is unambiguous. Precision is one second unless the format adds%{usec}t,$msecor$request_time. - Apache's
%tis the time the request was received; nginx writes the entry when processing ends, so a long upload appears at its completion. Lines are written in completion order in both servers, so small out-of-order times are normal. - Error logs use local time without offset: Apache with microseconds, nginx
YYYY/MM/DD HH:MM:SS. - Web root files: compare mtime/ctime and ext4 birth time of suspicious scripts with the first access log hit (see ext4 timestamps). Uploaded files often have a birth time matching a
POSTto the upload endpoint.
date -u -d '20 Sep 2026 03:14:07 +0200'
Retention
| Package | Schedule | Kept |
|---|---|---|
Debian/Ubuntu apache2 | daily | 14, compressed (delaycompress) |
Debian/Ubuntu nginx | daily | 14, compressed |
RHEL/Fedora httpd | no own schedule, so the global /etc/logrotate.conf (weekly, 4, date suffix) | uncompressed unless compress is set globally |
Fedora nginx | daily | 10, compressed |
Two weeks of logs is often shorter than attacker dwell time, so check for centralised logging (syslog forwarding, SIEM, CDN or WAF logs) early. Apache can also rotate itself through rotatelogs piped logs, with its own naming.
Collection
UAC collects Apache and nginx logs from /var/log/apache, /var/log/apache2, /var/log/httpd, /var/log/nginx and any access.log/error_log pattern under /var/log, plus /etc for the configuration.
E=/mnt/evidence
tar -C "$E" -cpf /cases/2026-017/web.tar var/log/apache2 var/log/httpd var/log/nginx \
etc/apache2 etc/httpd etc/nginx 2>/dev/null
# Web root with timestamps preserved, plus an mtime/ctime listing
tar -C "$E" -cpf /cases/2026-017/webroot.tar var/www
find "$E/var/www" -type f -printf '%T+ %C+ %s %p\n' | sort > webroot-mtime.txt
Collect the web root itself: the logs point to a file, and only the file shows what it does.
Parsing
# Requests per status and path for a suspect script
zcat -f access.log* | grep -F '/uploads/2026/09/cache.php' | awk '{print $1, $4, $6, $9, $10}'
# Rare scripts receiving POSTs (candidate web shells)
zcat -f access.log* | awk '$6 ~ /POST/ {print $7}' | sed 's/?.*//' | grep -Ei '\.(php|jsp|aspx?|cgi|pl|py)$' \
| sort | uniq -c | sort -n | head -30
# All activity from an attacker IP, oldest first
zgrep -h '^203\.0\.113\.50 ' access.log* | sort -t'[' -k2,2 | head
# Common exploitation patterns in URLs
zgrep -hiE '(\.\./|%2e%2e|/etc/passwd|union.+select|<script|\$\{jndi:|cmd=|exec=)' access.log*
Plaso's text/apache_access plugin parses common and combined formats, which covers default nginx logs as well; GoAccess and lnav help with interactive review.
Investigator tips
- Pivot from the file: find the first request to a web shell, then everything the same IP (and user agent) did before it, which usually shows the vulnerable entry point.
- Scripts in upload, cache or image directories, files with double extensions (
.jpg.php), and scripts owned by the web server user (www-data,apache,nginx) that no package or deployment created are prime suspects. - Repeated
POSTrequests to one script answered with200and small, varying body sizes fit a web shell returning command output; a script under an upload or image directory should never answerPOSTat all. - Look at the web server's children on a live host: a shell or
curlparented byapache2,httpd,php-fpmornginxis post-exploitation (see /proc); dropped tools often go to /tmp and /dev/shm. - Check that
CustomLog/access_logwere not disabled or pointed to/dev/null, and that log files have no gap between rotated generations. Truncation shows as a size drop or a first line newer than the previous file's last line. - Changes to Apache or nginx configuration (new
Alias,AddTypeorAddHandlerfor.php, a new module) can be persistence; the sedexp campaign hid a modified Apache configuration.