Detect Attacks with PHP and .htaccess
This tutorial explains how to detect and block security threats via .htaccess, and then pass that information to a PHP script for further processing. This is a powerful technique that combines the power of Apache with the flexibility of PHP. Enabling you to do things like log all unwanted traffic, send email reports for blocked requests, create a UI to display logged data, and just about anything else you can imagine. It’s an excellent way to keep a close eye on any malicious activity happening on your site. And a great tool to have in your belt.
Part 1: Detecting attacks
For this technique, detection happens at the server level, using Apache’s rewrite module, mod_rewrite
. So for detecting and logging attacks, we can use any techniques that use mod_rewrite to handle HTTP requests. For example, we can log traffic from any of the following .htaccess techniques:
- 6G Firewall
- Block Bad User Agents
- Block Greasy Uploads Scanners
- Block Proxy Servers
- Blacklist Eight Ways
In general, each of these techniques executes the following logic:
- Check each HTTP request against a set of criteria
- Evaluate whether or not to deny access based on that criteria
- Redirect or block the request based on the evaluation
- Send the appropriate server response header
The trick for this tutorial happens in step 3. That’s where we can connect Apache/.htaccess to PHP by redirecting the request to a PHP script. For example, say we are using the following .htaccess technique to control request methods:
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} !^(GET|HEAD|OPTIONS|POST|PROPFIND|PUT) [NC]
RewriteRule (.*) - [F,L]
</IfModule>
Using mod_rewrite
, step 3 always happens in the RewriteRule
directive. In this example, we simply are blocking the request via 403 “Forbidden” response. To better understand this, consider the logic used by the RewriteRule
:
(.*)
– If the request matches any character-
– Deny the request[F]
– Send the default server 403 response[L]
– Instruct Apache to stop processing the rule set
Now, here is where it gets interesting. Instead of denying the request via the RewriteRule
, we can redirect the request to a custom PHP script. Our PHP script will then do whatever we want. And then after we do whatever we want, we can deny access to the request at the end of our PHP script. This way, we are using Apache to detect unwanted requests, and then using PHP to further process and log the request as desired.
Continuing with our example, let’s modify the RewriteRule
so that it redirects each matched request to our custom PHP file:
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} !^(GET|HEAD|OPTIONS|POST|PROPFIND|PUT) [NC]
RewriteRule (.*) /path/to/custom.php [R=302,L]
</IfModule>
That is the magic bullet for this tutorial. In the RewriteRule
, we are redirecting all unwanted requests to our custom PHP script, located at /path/to/custom.php
. We also can pass variables from Apache to PHP. For example, to pass the matching REQUEST_METHOD
, we can append a query-string, like so:
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} !^(GET|HEAD|OPTIONS|POST|PROPFIND|PUT) [NC]
RewriteRule (.*) /path/to/custom.php?request_method=%1 [R=302,L]
</IfModule>
Notice that we appended ?request_method=%1
to the URL of our PHP script. The %1
represents the pattern that was matched inside of the parentheses ()
in the previous RewriteCond
. So in our PHP script, we can grab the query string value via the $_GET
variable. It will contain either GET
, HEAD
, OPTIONS
, and so forth. This is very powerful because it tells us the pattern that was matched by our rule set.
Part 2: Logging attacks
Equipped with the information provided in Part 1 of this tutorial, we can create a custom PHP script that will log the request data, send the data via email report, or do pretty much anything that is necessary. Let’s look at an example to get a better idea.
Let’s say that want to use the 6G Firewall to block bad query strings. Here is the code provided by 6G Firewall:
# 6G:[QUERY STRINGS]
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{QUERY_STRING} (eval\() [NC,OR]
RewriteCond %{QUERY_STRING} (127\.0\.0\.1) [NC,OR]
RewriteCond %{QUERY_STRING} ([a-z0-9]{2000,}) [NC,OR]
RewriteCond %{QUERY_STRING} (javascript:)(.*)(;) [NC,OR]
RewriteCond %{QUERY_STRING} (base64_encode)(.*)(\() [NC,OR]
RewriteCond %{QUERY_STRING} (GLOBALS|REQUEST)(=|\[|%) [NC,OR]
RewriteCond %{QUERY_STRING} (<|%3C)(.*)script(.*)(>|%3) [NC,OR]
RewriteCond %{QUERY_STRING} (\\|\.\.\.|\.\./|~|`|<|>|\|) [NC,OR]
RewriteCond %{QUERY_STRING} (boot\.ini|etc/passwd|self/environ) [NC,OR]
RewriteCond %{QUERY_STRING} (thumbs?(_editor|open)?|tim(thumb)?)\.php [NC,OR]
RewriteCond %{QUERY_STRING} (\'|\")(.*)(drop|insert|md5|select|union) [NC]
RewriteRule .* - [F]
</IfModule>
This code checks the query string of each request. If the query string matches any of the 6G patterns, it will be denied access. So let’s change the RewriteRule
to redirect all matching requests to our PHP script. We change the RewriteRule
like so:
RewriteRule (.*) /path/to/custom.php?6g_1=%1&6g_2=%2&6g_3=%3 [R=302,L]
Nice. This new rule redirects matching requests, and also appends a query string to pass the following information:
6g_1=%1
– pattern matched in the first set of parentheses6g_2=%2
– pattern matched in the second set of parentheses6g_3=%3
– pattern matched in the third set of parentheses
If any of the RewriteCond
directives were to match with more than three sets of parentheses ()
, we could pass those data as well, by simply appending 6g_4=%4
, 6g_5=%5
, and so forth.
Now that we have our .htaccess properly configured, we’re pretty much done. Everything else from this point depends on what you want to do with each attack or unwanted request. For this tutorial, we will keep it simple and just say that we want to create a PHP script that does the following:
- Get the set of matching patterns
- Get the requested URI
- Get the query string
- Get the IP address
- Get the user agent
- Send all of this info in an email report
To achieve this, we can add the following basic PHP script to our file located at /path/to/custom.php
.
<?php
// report blocked requests
// @ https://perishablepress.com/detect-attacks-php-htaccess/
$email = 'admin@example.com';
$subject = 'Blocked Request Report';
$patterns = array('6g_1', '6g_2', '6g_3');
$matches = '';
foreach ($patterns as $pattern) {
if (isset($_GET[$pattern])) {
$matches .= !empty($_GET[$pattern]) ? htmlentities($_GET[$pattern], ENT_QUOTES, 'UTF-8') : 'n/a';
$matches .= ', ';
}
}
$matches = trim(trim($matches), ',');
$request_uri = isset($_SERVER['REQUEST_URI']) ? urlencode($_SERVER['REQUEST_URI']) : 'n/a';
$query_string = isset($_SERVER['QUERY_STRING']) ? urlencode($_SERVER['QUERY_STRING']) : 'n/a';
$ip_address = isset($_SERVER['REMOTE_ADDR']) ? htmlentities($_SERVER['REMOTE_ADDR'], ENT_QUOTES, 'UTF-8') : 'n/a';
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? htmlentities($_SERVER['HTTP_USER_AGENT'], ENT_QUOTES, 'UTF-8') : 'n/a';
$report = 'Matches: ' . $matches ."\n";
$report .= 'Request URI: ' . $request_uri ."\n";
$report .= 'Query String: ' . $query_string ."\n";
$report .= 'IP Address: ' . $ip_address ."\n";
$report .= 'User Agent: ' . $user_agent ."\n";
mail($email, $subject, $report);
die('Invalid Request');
?>
Once in place, this script will send an email report that contains all of the desired information. Some notes:
- You can edit the email address and subject via the
$email
and$subject
variables - Any empty/undefined values will be replaced with
n/a
- After sending the report, the request is blocked via
die()
This is just an example so you can see how the technique works. If implementing on an actual/live site, you would want to extend and fine-tune the script to suit your needs. For example, you could log the requests to a database or text file. You also could create a fancy UI to display the logged data. When combining the power of Apache’s mod_rewrite
with the flexibility of PHP, the sky’s the limit.
Related resources
If you’re into the sort of functionality discussed in this tutorial, you may want to check out the following resources.
These plugins can help secure your site against bad bots, bad requests, HTTP attacks, and other malicious activity. And they also provide statistics, email alerts, logging of request data, and much more. Full disclosure, I am the author of these plugins, so you know they’re the best around :)
9 responses to “Detect Attacks with PHP and .htaccess”
I was recently thinking about how I could more efficiently deal with some of the malicious requests my sites get and your technique might just be the key to what I’ve been wanting to accomplish.
Rejecting a request in .htaccess is great, but a lot of times I see repeated probing for different things from the same IP address. There are some requests that would only happen with malicious intent and so I’m confident in just blocking that IP from doing anything else.
With this technique I can pass those obviously malicious requests on to a PHP script that bans the IP in the server’s firewall. That’s way more efficient because additional requests will be stopped before even getting to the web server.
Nice idea, sounds like a good approach.
This sounds really interesting.
I’m currently utilising BBQ Pro and it works great in blocking bad requests.
On one of my sites BBQ Pro is currently working overtime repeatedly blocking bad requests from the same IP address.
It would be good to understand how it might be possible to use the power of BBQ Pro as an input to writing a rule in the server firewall (IPTable?) to block the IP address that made the bad request.
Is that something that depends on the configuration of the server itself?
Stopping bad requests before reaching the web server would surely reduce resource requirements and potential further risks?
I have a sketchy knowledge of the pieces that might make up this puzzle, but it is not an area that I’m competent enough to build a solution myself.
I would ask your host about blocking IPs via the server firewall. As far as BBQ Pro, you can add any IP via the Custom Patterns to block all future requests.
Hey Jeff,
Great bit of code! It came in very handy for me to trouble-shoot my own .htaccess files. I have made a modified drop-in solution from it for my own website. It has also become an important part of my home web-development system (running under XAMPP, too), so that I can test other patterns that I see in my “security logs”.
I am getting ready to put together a good logging system (and database – “Nice suggestion, by the way”) to work together with this module. – I’d be more than happy to opensource and share it when I get a good working copy! – ;-)
Other than that,
I do believe I am starting to get a very good handle on the subject of .htaccess files, and assembling good filters. Yeah, it does takes some time, but well worth it!
– Jim
Just Another check-in, Jeff,
I added a few lines of code to your example, and BOY – does that neat, little module tell me a lot about what is being tried on my website(s). I thought I’d share my little tidbits with you and your readership:
First off, I corrected what looked like a typo in the code, and got it working perfectly:
FROM:
$matches .= !empty($pattern) ? htmlentities($pattern, ENT_QUOTES, 'UTF-8') : 'n/a';
TO:
$matches .= !empty($_GET[$pattern]) && $_GET[$pattern]!==null ? $pattern.' = '.htmlentities($_GET[$pattern], ENT_QUOTES, 'UTF-8').', ' : '';
Because we needed to check if the specific “GET” variable was set, not the query-flag itself.
* * *
Then, I added this, so that we could tell what it was that so-n-so (bot?) was trying to use to “probe” my website:
$report .= $_SERVER['REQUEST_METHOD']!='GET' ? "\n".'METHOD: ** '.$_SERVER['REQUEST_METHOD'].' **'."\n" : ''; $report .= (!@empty($_POST) ? 'POST VARS: '.implode("\t",$_POST)."\n\n" : '');
So far, This is just ONE instance of an attempted probing it caught:
MATCHES: ==> Query String: Request URI: /xmlrpc.php METHOD: ** POST ** POST VARS: \'1.0\'\'?> <methodCall> <methodName>wp.getUsersBlogs</methodName> <params> <param> <value> <string>admin</string> </value> </param> <param> <value> <string>admin</string> </value> </param> </params> </methodCall> VISITOR: IP Address:Port = 196.17.14.45:49434 ==> Domain: ( 196.17.14.45 ) User Agent: n/a
* * *
In conclusion, This example code turned out to be one of the best pieces of code to explore, and use for part of my website security! – I DO intend to further it with some coding for log-keeping and/or databasing. I really want to thank you very much for some very good, informative articles.
I would also recommend your blog as serious reading for anyone interested in keeping current with web-development, security, and even just good, general coding practices. Much of my own knowledge and experience came self-taught, and through “trial-by-fire” – as my own websites suffered a massive hack a few years ago. – This became the basis for bringing back my old “hacking” know-how’s, and how to effectively defeat the various methods in use today.
Because of the wide scope of knowledge to be found on the internet, this makes such “self-study” a very do-able one.
Again, Thank you!
If you are looking for some co-contributors for ideas, and perhaps other methods you may not have yet seen or realized, I would be more than happy to help. Web-security seems to be a very big topic these days, but with crowd-intelligence, it becomes more of a constantly-evolving field.
– Jim
You’re welcome, glad to help. And thanks for the heads up on the code error — got that fixed up proper. Thanks also for sharing your thoughts and code. I’m sure others will find it useful. I got your email as well, and will reply to it shortly. Cheers.
Another possible “mod” in the code additions I previously suggested,
The bit:
implode("\t",$_POST)
was good for one idea on what to show in a given probing attempt.
Another idea occurred to me,
What if we, instead, made this addition?
var_export($_POST,true)
–
THAT snippet would give what POST-variables were being tried, and with WHAT values were in them. (May make for a bit more readable notice too!)
–
As for if one wanted to send this output to a database instead,
Then one could use:
serialize($_POST)
This would help keep the data footprint in the database more manageable.
Anyway,
Getting a lot of “intel” on the activity out there, due to now using a variation this module.
Thanks again!
– Jim
Nice tips, Jim! Thanks for sharing :)