Rich Gibbs

Reading the WordPress honeypot log: POSTs first, yourself out

homelab · honeypot · wordpress · logs · linux · security

The fake WordPress honeypot logs one JSON object per request. A small Python script called wp-posts turns that log into a POST-only table, drops my own test traffic, and labels common Nmap probes so login attempts stand out. One-off sed, sort, uniq and grep pipelines rank paths, IPs and methods across rotated logs. Watch out: the table view reads only the current log file, and grep -v Nmap removes Nmap rows, not every scanner.

The fake WordPress site behind nginx writes every request it gets as one JSON object per line, append-only. The raw file is hard to skim. This runbook is how I read it day to day, and these are the parts I want to remember.

Run it on the real host

Everything here runs on the real VPS. The log lives under /var/log/wp-honeypot/, and that path does not exist inside Cowrie. If a command can't find the file, check which shell I'm in before debugging anything else.

The fields I lean on are ts (a UTC timestamp), ip, method, path, body and headers. The ip field is the real client, taken from nginx's X-Real-IP header.

Read POSTs, not GETs

The core tool is wp-posts, a short Python 3 script installed in /usr/local/bin. It reads the log, skips blank lines, keeps only POST requests, URL-decodes the body with unquote_plus, and prints an aligned table: time, IP, path, and what the body was.

The reasoning behind POST-only: GETs are mostly scanners pulling static pages. The interesting behaviour, such as login attempts and xmlrpc probes, shows up in POST bodies. Decoding matters because form data arrives URL-encoded, and a login attempt is only legible once it is turned back into plain text.

It goes in with a heredoc and a quoted delimiter. The general shape, as an example:

cat > /usr/local/bin/wp-posts << 'EOF'
...script...
EOF
chmod +x /usr/local/bin/wp-posts

Quoting 'EOF' is the important part. It stops the shell expanding anything, so $, backticks and Python's {} format braces land in the file exactly as written. /usr/local/bin is on the default PATH, so after chmod +x the script runs as a bare wp-posts from anywhere.

There's also a version that never touches disk: the same script fed to python3 - on stdin with a quoted 'PY' heredoc. It suits a box where I don't want the script left behind, or trying a tweak before saving it. Same quoting rule applies.

Subtract yourself before trusting the view

The script drops one hard-coded IP, which is my own test traffic. In the real file it's a constant. Here's the shape, with a placeholder standing in for the real value:

SKIP_IP = "<my-test-ip>"   # example placeholder only

It's the same discipline as when ranking Cowrie IPs. If my own testing is in the data, it clutters the table and can outrank actual attackers. Take myself out first, then read.

Turn known noise into one word

If the User-Agent contains "Nmap", the script doesn't print the payload. It prints a tag instead:

  • Nmap xmlrpc listMethods when the body asks for listMethods
  • Nmap VMware/SOAP /sdk when the body has RetrieveServiceContent or the path is /sdk
  • Nmap empty POST when there is no body
  • plain Nmap otherwise

Everything else shows the first 80 characters of the decoded body, or (empty). Recognisable scanner noise collapses to a label, and anything that isn't Nmap stands out.

Day to day that means three commands: wp-posts for every POST except mine, wp-posts | grep -v Nmap to drop the tagged rows, and tail -f on the raw log to watch requests arrive. The runbook's note on that last one is that a bare tail -f doesn't need --line-buffered; it's the filtering pipes that buffer.

One-off questions straight off the raw log

When I don't want the table, plain pipelines do the job:

  • wc -l across the log files gives a total request count, because one line is one request.
  • A sed -n capture on "path": "...", then sort | uniq -c | sort -rn, ranks the most-requested paths. The runbook expects that to show what scanners hunt for, things like wp-login.php, xmlrpc.php, .env and /sdk.
  • The same capture on ip lists the top talkers, and on method gives the GET/POST/PUT/HEAD breakdown. The runbook's note: a spike in PUTs suggests upload probing and is worth a closer look.
  • grep -E across wp-login, xmlrpc, wp-json, wp-content, wlwmanifest and admin-ajax is a quick "is this actually a WordPress scanner" filter. -E is what makes the | alternation work.
  • For the full raw JSON of each interesting hit: grep for the POST method, grep -v my own IP, grep -v 'Nmap Scripting Engine'. That's the manual version of wp-posts | grep -v Nmap.

Mind this next time

The runbook has a few small traps of its own.

The one-off section says everything uses the http.jsonl* glob so rotated days are included. That's true for wc, the sed rankings and the grep -E filter. It isn't true for the raw POST pipeline, which reads only the current http.jsonl. wp-posts and the inline printer also read only the current file. So the table shows today's file and the rankings show every day. Those two views won't agree, and that's expected.

The code comment on wp-posts | grep -v Nmap says "humans / real attackers only". That's overstated. It only drops rows tagged Nmap, and any other scanner stays in. The runbook's prose has it right: genuine login attempts and anything not obviously a scanner.

grep -v with my IP as a plain string drops any line containing that string anywhere, not only in the ip field. The Python version compares the field exactly. They are close equivalents, not identical ones.

The sed captures assume the logger writes "key": "value" with a space after the colon. If that format ever changes, they'll quietly match nothing rather than error.

wp-posts reads the whole file into memory in one go. That's fine for a small honeypot log.

And the skip IP is hard-coded. Whether my own address stays the same isn't written down in the runbook, so that line is worth checking before I trust the table.

How the log gets produced in the first place lives in the earlier WordPress and nginx build notes, not here. This one is just for reading it.