Skip to content

LogsNetworkPersistence

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/
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

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

ItemDebian / UbuntuRHEL / FedoraNotes
Apache access log/var/log/apache2/access.log/var/log/httpd/access_logRHEL 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.logper 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.logsame
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 POST to a vulnerable endpoint followed by 200 responses 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-For is 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"
FieldApachenginxMeaning
Client%h$remote_addrIP (or hostname if HostnameLookups On)
Identity%lliteral -RFC 1413 ident, almost always -
User%u$remote_userHTTP auth user or -
Time%t$time_local[dd/Mon/yyyy:HH:MM:SS +zzzz]
Request%r$requestMethod, path with query string, protocol
Status%>s$statusFinal status code
Size%b$body_bytes_sentBody bytes; Apache logs - for zero
Referrer, UA%{Referer}i, %{User-agent}i$http_referer, $http_user_agentClient-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, $msec or $request_time.
  • Apache's %t is 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 POST to the upload endpoint.
date -u -d '20 Sep 2026 03:14:07 +0200'

Retention

PackageScheduleKept
Debian/Ubuntu apache2daily14, compressed (delaycompress)
Debian/Ubuntu nginxdaily14, compressed
RHEL/Fedora httpdno own schedule, so the global /etc/logrotate.conf (weekly, 4, date suffix)uncompressed unless compress is set globally
Fedora nginxdaily10, 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 POST requests to one script answered with 200 and small, varying body sizes fit a web shell returning command output; a script under an upload or image directory should never answer POST at all.
  • Look at the web server's children on a live host: a shell or curl parented by apache2, httpd, php-fpm or nginx is post-exploitation (see /proc); dropped tools often go to /tmp and /dev/shm.
  • Check that CustomLog/access_log were 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, AddType or AddHandler for .php, a new module) can be persistence; the sedexp campaign hid a modified Apache configuration.

See also