Note: This article has been extensively revised on March 11, 2019.
This article describes how I built a honeypot script that catches robots and human miscreants that attempt to access CMS login pages that don’t exist on my sites, and reports them to AbuseIPDB, an organization that tracks IP addresses being used by malicious individuals and makes them available to webmasters, server administrators, and others who need to protect public-facing computers from malicious actors.
Before I go on, I want to emphasize again that this script, as I’m going to present it, is to log and report login attempts to CMS login pages THAT DO NOT EXIST ON A GIVEN SITE. In other words, it’s for sites that are hand-coded and don’t have (and never will have) any of the following pages:
wp-login.php
admin.php
login.php
xmlrpc.php
Do NOT use this script on a WordPress site or any other site that uses, or may in the future use, any of the above pages.
The idea behind this script is simple: It’s a honeypot to trap and report would-be hackers, crackers, script kiddies, and bots who attempt to access common CMS login pages that DO NOT exist on your site. Those pages are juicy targets for malicious actors; but on a hand-coded site that doesn’t contain them, they can be used as effective honeypots.
I chose to use AbuseIPDB as the organization to which I’d report the access attempts. You’ll need to register an account and create an APIv2 key to make this script work. If you use another organization, you’ll have to modify the script accordingly. Here are the steps I took to create the honeypot.
Step 1: Create a Database and User
I created a MySQL database to store the reports and prevent duplicate reports. AbuseIPDB rejects reports from users who reported the same IP address in the past 15 minutes, and the database is an easy way for the script to check on that and skip the rest of the script if the IP has been reported by your account within the past 900 seconds.
Having a database also provides a running record of the reports that you can use for other purposes, if you like. You may, for example, want to import the IP addresses into your firewall or make a “Wall of Shame” page listing them.
I created a database named abuse-reports
and a database user named abuse-reporter
. The database has only one table and six columns: id
, datetime
, timestamp
, ip
, domain
, and comment
. Only the IP address is required for the AbuseIPDB report. The rest are for my own purposes.
Step 2: Create the Honeypot Page
I called mine “busted.php.” It consists of a script that first checks the database to determine if the IP has been reported within the past 900 seconds (15 minutes). If not, it makes a database entry, submits a report, and displays a nasty page just in case the would-be attacker is human.
Start by defining some variables:
<?php
date_default_timezone_set('Your/Region');
$ip=$_SERVER['REMOTE_ADDR'];
//ip="127.0.0.2"; // for testing
$timeNow = time();
$fresh = time() - 900;
$domain = "yourdomain.com";
$currentDateTime = (date("M d, Y h:i:s a"));
$comment="Hit on CMS login honeypot"; // for AbuseIPDB Report and database entry
$categories="21"; // for AbuseIPDB Report
Note the declaration for
$ip=$_SERVER['REMOTE_ADDR'];
is commented-out. You need to use 127.0.0.2 as the testing IP when using AbuseIPDB. If your script is working and connecting properly, it will return an error that you can’t report the same IP more than once every 15 minutes. Once you know the script is working properly, delete or comment out
$ip="127.0.0.2";
Replace it with
$ip=$_SERVER['REMOTE_ADDR'];
and NEVER access the honeypot page again. If you do, you will be reporting your own IP address as malicious.
The $domain
variable is needed only because I use this script on several sites on the same server. It enables me to look at the database and see which domains are getting hit the hardest.
In this example, $comment
is declared as “Hit on CMS login honeypot” and $categories
is defined as “21”. AbuseIPDB uses 21 as the category for a Web app attack, which is the category used for attempts to log in to CMS admin pages. If you’re using the script for other type of attacks, you would define those variables accordingly.
Next, the script needs to check the database to see if the address has been reported in the past 900 seconds. I used:
$con = mysqli_connect("localhost","prefix_abuse-reporter","{password}","prefix_abuse-reports");
if (!$con) { die('Could not connect: ' . mysqli_error($con)); }
$result = mysqli_query($con, "SELECT * FROM reports WHERE (ip LIKE '$ip' AND time >= '$fresh')");
$row = mysqli_fetch_array($result);
$reportDate = $row['datetime'];
You really don’t need to SELECT *
. I have other possible plans in mind for this script, so I wanted to select all the data. prefix_
refers to the prefix required on most shared servers and usually will be your account’s user name on the server (for example, youraccount_abuse-reports
).
The next step is a conditional. If there’s no result for the query (no reports on that IP in the past 900 seconds), it inserts the data for the event into the database, and then makes the report. First, the database input:
if (empty($reportDate)) {
// sanitize
$timeNow = mysqli_real_escape_string($con, $timeNow);
$ip = mysqli_real_escape_string($con, $ip);
$domain = mysqli_real_escape_string($con, $domain);
$currentDateTime = mysqli_real_escape_string($con, currentDateTime);
// insert to db
mysqli_select_db($con, "prefix_abuse-reports");
$sql = "INSERT INTO reports (datetime, time, ip, domain, comment) VALUES ('$currentDateTime','$timeNow','$ip','$domain','comment')";
if (!mysqli_query($con,$sql)) {
echo("Error description: " . mysqli_error($con));
}
Once the database entry has been inserted, we can make the report, still under the same conditional. In the following example, the “comment” entry is defined as “Hit on CMS login honeypot” and the “categories” entry is defined as “21”. AbuseIPDB uses 21 as the category for a Web app attack, which is the category used for attempts to log in to CMS admin pages. If you’re using the script for other types of attacks, you would define those variables accordingly.
Finally, the scripts files the abuse report with AbuseIPDB and calls the 401 page:
$data = (array(
"ip" => $ip,
"categories" => $categories,
"comment" => $comment
));
$headers = array('Key: {Your AbuseIPDB API key goes here, without the brackets}', 'Accept: application/json');
$ch = curl_init("https://api.abuseipdb.com/api/v2/report");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1 ); // Set to 0 for testing to display response from AbuseIPDB
curl_setopt($ch, CURLOPT_POST, 1 );
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$output=curl_exec($ch);
curl_close($ch);
}
include("401.php");
die;
?>
Note that
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 0 );
should be set to 0 for testing. That will allow the response from AbuseIPDB to display on the page for testing purposes. Once you know the script is working properly, change that line to
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1 );
to mute the response.
The last thing the honeypot script does is includes the site’s 401 page. That’s preferable to using a meta-refresh because it’s server-side and can’t be disabled by a malicious actor or robot. I use PHP, so my 401.php page starts with the line:
<?php
header('X-PHP-Response-Code: 401', true, 401);
which forces the page to return a “401: Unauthorized” response. The advantage to that is that many firewalls have an option to block an IP after a certain number of 401 responses. If you don’t set the response header, by default it will return a “200 OK” response.
Yes, yes, I know that the 401 isn’t supposed to be used except for failed authorizations. I also know that I’m dealing mainly with bad robots and a few malicious human miscreants to whom I owe no courtesies. If some hacker, cracker, or script kiddie is upset that I violated protocol and gave them a 401 rather than a 403, they can take me to court and sue me.
The specific reason I use the 401 is that it implies a refusal to allow a specific party from accessing the resource because of some act on their part (usually a failed authentication attempt). The 403, on the other hand, is often used for more benign acts, such as when someone innocently tries to access the root of an image directory that forbids indexing. I’m not looking to block those folks.
But if you prefer using a 403, be my guest.
Step 3: Test the Script
To test the script, double-check to make sure that $ip
is declared as 127.0.0.2
. Then load the page in a browser. You should get an error message across the top of the page informing you that you can’t report 127.0.0.2 more than once every 15 minutes. That means your script connected successfully and tried to report 127.0.0.2 as a malicious IP.
Once you know your script is working, re-declare $ip
as
$_SERVER['REMOTE_ADDR']
and change
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 0 );
to
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1 );
Let me say this again: Never access your honeypot yourself unless you revert the $ip
declaration back to 127.0.0.2
. You will be reporting your own IP address as malicious if you do.
Step 4: Redirect the Non-Existent CMS Login Pages
Once we know the honeypot works, we need to redirect attempts to access the non-existent login pages to the honeypot. On Apache, assuming that your honeypot is named “busted.php” and that it’s in the root of your site, you would do this by adding the following lines to the .htaccess file:
#Redirects non-existent login attempts
Redirect 301 /wp-login.php https://www.yourdomain.com/busted.php
Redirect 301 /admin.php https://www.yourdomain.com/busted.php
Redirect 301 /login.php https://www.yourdomain.com/busted.php
Redirect 301 /xmlrpc.php https://www.yourdomain.com/busted.php
Here’s the entire script:
<?php
date_default_timezone_set('Your/Region');
unset($ip,$timeNow,$fresh,$currentDateTime);
$ip=$_SERVER['REMOTE_ADDR'];
//ip="127.0.0.2"; // for testing
$timeNow = time();
$fresh = time() - 900;
$domain = "yourdomain.com";
$currentDateTime = (date("M d, Y h:i:s a"));
$comment="Hit on CMS login honeypot"; // for AbuseIPDB Report and database entry
$categories="21"; // for AbuseIPDB Report
$con = mysqli_connect("localhost","prefix_abuse-reporter","{password}","prefix_abuse-reports");
if (!$con) {
die('Could not connect: ' . mysqli_error($con));
}
$result = mysqli_query($con, "SELECT * FROM reports WHERE (ip LIKE '$ip' AND time >= '$fresh')");
$row = mysqli_fetch_array($result);
$reportDate = $row['datetime'];
if (empty($reportDate)) {
// sanitize
$timeNow = mysqli_real_escape_string($con, $timeNow);
$ip = mysqli_real_escape_string($con, $ip);
$domain = mysqli_real_escape_string($con, $domain);
$currentDateTime = mysqli_real_escape_string($con, currentDateTime);
// insert to db mysqli_select_db($con, "prefix_abuse-reports");
$sql = "INSERT INTO reports (datetime, time, ip, domain, comment)
VALUES ('$currentDateTime','$timeNow','$ip','$domain','comment')";
if (!mysqli_query($con,$sql)) {
echo("Error description: " . mysqli_error($con));
}
$data = (array(
"ip" => $ip,
"categories" => $categories,
"comment" => $comment
));
$headers = array('Key: {Your AbuseIPDB API key goes here, without the brackets}', 'Accept: application/json');
$ch = curl_init("https://api.abuseipdb.com/api/v2/report");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1 ); // Set to 0 for testing to display response from AbuseIPDB
curl_setopt($ch, CURLOPT_POST, 1 );
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$output=curl_exec($ch);
curl_close($ch);
}
include("401.php");
die;
?>
This is a new script, so it needs some tweaks and tidying up. So far, however, it’s working fine on about half a dozen domains. If nothing else, it may give you some ideas for building a better script than this one.
Richard