From log to attack vector: reading Apache, NGINX and LiteSpeed access logs

Format, paths, time zones and rotation of Apache, NGINX and LiteSpeed logs, plus grep and awk recipes to trace an infected file back to the attack request.

A terminal window listing the POST requests of an access log with one line to admin-ajax.php highlighted in red, linked to a PHP file with its status change date, next to the names Apache, NGINX and LiteSpeed

Every request that reaches a website leaves a line in the web server’s access log: who made it, when, to which address, with what result. When a WordPress site is compromised, somewhere in those files is the request through which the attack got in. Finding it is the most reliable way to know which hole to close, instead of just deleting the symptoms.

In a previous article we described how we use local artificial intelligence models to sort through the requests that no rule recognizes. Here we take a step back to the manual part: what a log line looks like on Apache, NGINX and LiteSpeed, where to find it, what traps time zones and rotation hide, and a few recipes with grep and awk for the most common cases.

What a log line looks like

The three web servers most commonly used with WordPress use, barring special configurations, the same basic format: the so-called combined log format, born with Apache and adopted as the default by NGINX and LiteSpeed too. A typical line looks like this:

203.0.113.45 - - [27/Aug/2026:22:41:07 +0200] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 312 "https://www.esempio.it/contatti/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"

From the left: the client’s IP address, two identity fields (almost always empty, shown as a dash), date and time with the time zone, the request line with method, path and protocol, the response code, the bytes sent, the referer and the user agent. In Apache this corresponds to the directive LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" combined; in NGINX to the default combined format, built with the variables $remote_addr, $time_local, $request, $status and so on.

Anatomy of an access log line in combined format, divided into labeled fields: client IP address, empty identity fields, date and time with time zone, request method, path and protocol, response code, bytes sent, referer and user agent; under each field is the position to use with awk, from $1 to $10, and a note reminds that the body of POST requests is not logged
The fields of a combined-format line and their position for awk. What is missing, the body of POST requests, is often the most interesting part.

Two things are missing. The first is the body of POST requests: you can see that someone sent data to admin-ajax.php, but not which data. The second is the site name, if several domains share the same file. That is why, on the servers we manage, we extend the format with a few fields useful for analysis, knowing that every extra field shifts the numbering used in scripts:

# NGINX: ISO 8601 date, response time, host and original IP behind a proxy
log_format forense '$remote_addr - $remote_user [$time_iso8601] "$request" '
                   '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                   '$request_time $host "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log forense;

# Apache: same concepts (%D is the response time in microseconds, %v the virtual host)
LogFormat "%a %l %u %{%Y-%m-%dT%H:%M:%S%z}t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\" %D %v" forense
CustomLog ${APACHE_LOG_DIR}/access.log forense

In LiteSpeed the format is set from the admin panel, at the server level or for each virtual host, with a syntax derived from Apache’s. On cPanel installations, where LiteSpeed reads Apache’s configuration, the Apache directives apply.

Where to find the logs

The location depends on the web server, the distribution and, above all, any hosting panel. The most common default paths are:

  • Apache: /var/log/apache2/ on Debian and Ubuntu, /var/log/httpd/ on RHEL, AlmaLinux and Rocky Linux, often with one file per virtual host;
  • NGINX: /var/log/nginx/, with separate files if the access_log directive is defined in individual server blocks;
  • LiteSpeed and OpenLiteSpeed: /usr/local/lsws/logs/ for the server, and for virtual hosts the folder given in their configuration, often logs/ inside the virtual host root;
  • cPanel: /usr/local/apache/domlogs/, one file per domain and one with the -ssl_log suffix for HTTPS traffic; users see the compressed archives in ~/logs/, if archiving is enabled;
  • Plesk: /var/www/vhosts/system/<dominio>/logs/, with access_log, access_ssl_log and, if NGINX acts as a proxy in front of Apache, the proxy_access_log and proxy_access_ssl_log files as well.

On shared hosting without command-line access, logs are usually downloaded from the panel, in sections with names like “Raw Access”, “Logs” or “Statistics”. If the panel shows only aggregated statistics, ask the provider’s support for the raw files, specifying the period you are interested in: many keep them longer than they make available to the customer, but not forever, so the request should be made right away.

Map of access log paths: Apache in /var/log/apache2 on Debian and Ubuntu or /var/log/httpd on RHEL; NGINX in /var/log/nginx; LiteSpeed and OpenLiteSpeed in /usr/local/lsws/logs or in the virtual host’s logs folder; cPanel in /usr/local/apache/domlogs with separate files for HTTPS; Plesk in /var/www/vhosts/system/dominio/logs; at the bottom a warning that behind a CDN or proxy the address logged is the proxy’s, unless mod_remoteip or real_ip is configured
Where to look for access logs depending on the web server and the panel. Behind a CDN, the first thing to figure out is which IP address is being logged.

An important warning concerns sites behind a CDN or reverse proxy, such as Cloudflare. In that case the web server sees the proxy as the client, and without specific configuration it always logs the same few CDN addresses: analysis by IP becomes useless. The real address arrives in a header, such as X-Forwarded-For or CF-Connecting-IP, which you need to make the web server use with Apache’s mod_remoteip module or NGINX’s real_ip, trusting only headers coming from the proxy’s addresses. Otherwise, anyone could insert a fake address.

Time zones and rotation: the two traps

The date field reports the server’s local time with its offset from universal time: +0200 in Italy in summer, +0100 in winter. The problem arises when you cross-reference different sources. A proxy or CDN log may be in UTC, WordPress stores some dates in the time zone set on the site and others in UTC, and the find command shows file dates in the session’s time zone. A one-hour offset is enough to look for the attack in the wrong window. Before correlating, bring everything to the same time zone, for example by prefixing commands with TZ=UTC.

The second trap is rotation. Almost all systems use logrotate, which renames the file every day or every week, compresses it and deletes the oldest after a certain number of cycles. The access.log file therefore contains only the last few hours or days; the rest is in access.log.1, access.log.2.gz and so on, for as long as it exists. On many shared hosts retention is only a few days: if the infection was discovered after a month, the logs from the time of the attack may be gone.

# How far back the available logs go
ls -ltr /var/log/nginx/
zcat -f $(ls -tr /var/log/nginx/access.log*) | head -1

# Search all files, compressed or not, in chronological order
zcat -f $(ls -tr /var/log/nginx/access.log*) | grep '"POST /wp-login.php'

# Configured retention
grep -A8 '/var/log/nginx' /etc/logrotate.d/nginx

From files to logs: correlating dates

The most solid starting point is almost always a malicious file you have already identified. Its modification date can be forged with a simple touch, while the ctime, the moment of the inode’s last status change, is updated by the system and cannot be set at will without elevated privileges. So you start from the list of PHP files with a recent ctime:

# PHP files with a status change after August 1, in chronological order
TZ=Europe/Rome find /var/www/esempio.it/public_html -name '*.php' \
  -newerct '2026-08-01' -printf '%C+ %T+ %p\n' | sort

# 2026-08-27+22:41:13.4817260440 2023-11-02+10:14:55.0000000000 ./wp-content/plugins/form-x/tmp/sess.php
#   ^ ctime: real write             ^ mtime: forged

With the time in hand, you look at two windows in the log. Before the write: which request, a few seconds or minutes earlier, could have created the file? Usually a POST to a vulnerable component, an upload, an admin login. After the write: who requested the new file directly, and from which address? Those requests confirm the use of the backdoor and give you the addresses to search for in the rest of the logs.

# All requests between 22:36 and 22:41 on August 27
grep -E '27/Aug/2026:22:(3[6-9]|4[01]):' access.log

# Who used the new file, and when
zgrep -h 'form-x/tmp/sess.php' access.log* | awk '{print $1, $4, $6, $9}'

# Everything those addresses did, from the start of the available logs
zcat -f $(ls -tr access.log*) | grep -E '^(203\.0\.113\.45|198\.51\.100\.7) '
Timeline correlating logs and file system: at 22:38 a series of scanning requests to a plugin’s paths; at 22:41:07 a POST to admin-ajax.php with a 200 response; at 22:41:13 the ctime of the sess.php file created in the plugin folder; at 22:43 and in the following days GET and POST requests directly to sess.php from a second IP address; at the bottom it is highlighted that the file’s modification date, from 2023, is forged while the ctime indicates the real moment of the write
The typical reconstruction: reconnaissance, exploitation, file write, use of the backdoor. The file’s ctime serves as an anchor between the file system and the log.

Once you’ve found the entry point, it’s worth separating noise from targeted attack. Every exposed site constantly receives automated requests to paths that don’t exist, such as backup copies of wp-config.php, .env files or panels of other software, and most end with a 404. These are scanners that try everything on everyone; they matter little, unless one of those requests received a 200. Targeted requests, by contrast, hit paths that really exist on the site, meaning installed components, and get consistent responses.

# Most requested paths with a 404: the background noise
awk '$9 == 404 {split($7, p, "?"); print p[1]}' access.log | sort | uniq -c | sort -rn | head -20

# The same "scanner" paths, but with a 200: these need a look
grep -E '"GET /[^ ]*(wp-config\.php\.(bak|old|save)|\.env|\.git/config)[^ ]* HTTP/[0-9.]+" 200 ' access.log

Brute force on wp-login.php and xmlrpc.php

Password-guessing attempts are the background noise of any WordPress site, and almost always fail. The point is not to count them, but to work out whether one succeeded. A detail of WordPress behavior helps here: a POST to wp-login.php with wrong credentials returns the login page again with a 200 code, while a successful login responds with a 302 redirect to the dashboard.

# Login attempts per address
grep '"POST /wp-login.php' access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

# Logins with a 302: likely successful logins, to verify one by one
awk '$6 == "\"POST" && $7 == "/wp-login.php" && $9 == 302 {print $1, $4}' access.log

# xmlrpc.php: requests per address
awk '$7 ~ /^\/xmlrpc\.php/ {print $1}' access.log | sort | uniq -c | sort -rn | head

A 302 is not proof: security or custom login plugins can redirect too. But a 302 from an address that until a minute earlier was collecting 200s is a strong signal, to be compared with the site’s sessions and users. For xmlrpc.php there is one more caution: with the system.multicall method a single request can contain hundreds of login attempts, and the log shows only one line. Counting lines underestimates the attack; it helps to look at response size as well, which grows with the number of attempts included.

admin-ajax.php and the REST API

Many plugin vulnerabilities go through two common entry points: wp-admin/admin-ajax.php, where the action parameter decides which plugin function runs, and the REST API routes under /wp-json/, also reachable in the ?rest_route= form. REST routes appear in full in the log; for admin-ajax.php, on the other hand, action is visible only if sent in the query string, because in POSTs it sits in the body.

# admin-ajax.php actions visible in the query string
grep -oE 'admin-ajax\.php\?[^" ]*action=[^&" ]+' access.log | sed 's/.*action=//' | sort | uniq -c | sort -rn

# Most requested REST routes, in both forms
grep -oE '"(GET|POST) /(wp-json/[^ ?"]+|[^ "]*\?rest_route=[^ &"]+)' access.log | sort | uniq -c | sort -rn | head -20

# User enumeration
grep -E 'wp-json/wp/v2/users|[?&]author=[0-9]+' access.log

What you’re looking for is the anomaly compared with the site’s normal traffic: a plugin route that no visitor uses, called from a single address a few seconds before a file appeared; a sequence of POSTs to admin-ajax.php with no page visited beforehand, which a real browser doesn’t do; an action whose name matches a recently published vulnerability for an installed plugin. Once the vulnerable component is identified, the fix is to update or remove it; while waiting for the patch, the route can be blocked at the web server or application firewall level.

User agents and addresses: what they tell you and what they don’t

The user agent is chosen by whoever makes the request, so it should be read as a claim, not a fact. It is useful in reverse: careless automated tools give themselves away with values like python-requests, Go-http-client, curl or an empty string, and an attack run with the same script often keeps the same user agent even when changing addresses, which lets you link requests coming from different IPs.

# Most frequent user agents among POST requests
awk -F'"' '$2 ~ /^POST / {print $6}' access.log | sort | uniq -c | sort -rn | head -20

# Does a request claiming to be Googlebot really come from Google?
host 66.249.66.1                          # must resolve to *.googlebot.com or *.google.com
host crawl-66-249-66-1.googlebot.com      # and the name must resolve back to the same address

The IP address also says less than it seems. Attacks come from rented servers, compromised residential networks, proxies and VPNs, and the same operator can change address with every request. It serves to group requests and to temporarily block whoever persists, not to identify who is behind them. Reverse DNS verification, however, is the method Google recommends for recognizing its own crawlers, and it is valuable for working out whether malware shows different content to Googlebot, typical of SEO spam with cloaking.

A method, and its limits

Lined up, the recipes become a method: set the time anchors from the file system, bring everything to the same time zone, look at the windows before and after each anchor, collect the addresses and user agents involved, trace their activity backwards to the first suspicious request. The result is a sequence you can explain line by line, and it is what lets you say with reasonable certainty where the attack got in.

The limits must be taken into account. Without the body of POST requests many hypotheses remain hypotheses; if the logs have been rotated away, the attack window may be lost; if the attack came through SFTP, the hosting panel or another site on the same account, there is nothing to find in the web server log. That is why the analysis always cross-references multiple sources: error logs, PHP-FPM logs, SSH and FTP access, scheduled tasks and the database.

Every cleanup we do ends with the most likely attack vector and the measures to close it, reconstructed exactly this way, with the help of locally run models for the most repetitive part. Restoring a WordPress installation is guaranteed within 6, 12 or 24 hours depending on urgency. If you have the logs and want to understand what happened to your site, get in touch: the first thing we’ll ask you is not to delete them.

THE NEXT STEP

Is your site
infected?

Every hour counts: the malware keeps hitting visitors and customers, and Google, your host and the blacklists won’t wait. Choose a plan, pay online and share your access: the cleanup starts right away, at a fixed price and with guaranteed times.

Request a cleanup

Takes you to the plans: cleanup within 6, 12 or 24 hours of receiving access.

MANAGED SERVER S.R.L.info@malwarecleanup.com +39 02 5656 9681

Monday – Friday · 9:30am – 7:30pm Italian time (CET)

Prefer an online form?
Contact us at managedserver.it ↗