Common mistakes when creating secure PHP websites

Do you remember the last website which was hacked by some kids? What was your first thought, maybe “Phew, that’s not mine”? A hacked website is terrible and the clean-up is a lot of work for the site owner. As a website owner you need to be sure that your site is always secure. It’s important for your business and of course for your site listing in Google. Remember these tips when creating secure PHP websites:

MySQL queries and SQL injection attacks

If your web page accepts user input a SQL injection might happen if the data isn’t validated or sanitized before the database query is done. The following example is a common mistake, made by the beginning programmer or webmaster. Check this code, do you ever wrote a MySQL query like this one?

$result = mysql_query("SELECT * FROM users WHERE username = '". $_POST["username"] ."' && password = '". $_POST["password"] ."');

This row of code is written to accept two values from a login form: a user name and a password. But what if someone will submit an empty user name and for the password value the following string:  ‘ OR username = ‘admin the query would look like:

$result = mysql_query("SELECT * FROM users WHERE username = '' && password = '' OR username = 'admin'");

If this query was used for a login script, the hacker would get access to the account for the user with the name “admin”. Imagine what a hacker could do in your web application if he has administrator rights. Don’t worry there are several ways to protect your queries against SQL injections.

First of all it’s better to use the MySQL improved extension (MySQLi). The old PHP functions for MySQL are still available, but the MySQLi extension offers more secure features. If you use the MySQLi functions you can choose between procedural style and the object oriented style. I use for my examples the object oriented style.

Before I escape the values for our queries we use the function filter_var() to sanitize the input values.

$username = filter_var($_POST["username"], FILTER_SANITIZE_STRING);
$password = filter_var($_POST["password"], FILTER_SANITIZE_STRING);

My example is using the default value for the filter type FILTER_SANITIZE_STRING. Next I use the MySQLi variant of mysql_real_escape_string() to prepare the strings for the database query. Before I can continue with the input validation, I need to create a database object first. Don’t store passwords as plain text! To match the value in your database we need to use the same md5 / salt (secret string) to create the comparison string inside the query.

$db = new mysqli("localhost", "db_user", "db_password", "db_name");

if (mysqli_connect_errno()) { /* check connection */
    die("Connect failed: ".mysqli_connect_error());
}
$username = $db->mysqli_real_escape_string($username);
$password = $db->mysqli_real_escape_string(md5('YOUR_SECRET_STRING', $password));

Now it’s safe to pass these values to our database query:

$result = $db->query(sprintf("SELECT FROM users WHERE username = '%s' && password = '%s'", $username, $password));
$db->close();

In my example I used the function sprintf() to add the values to the query and use the $db->close() to destroy the database object. Another great and secure MySQLi feature the prepared statements function.

Cross Site Request Forgery (CSRF) Attacks

The basic principal behind a CSRF attack is not to get access to a site, but forcing a user or admin to an unwanted action. For example we have an admin page with a HTML structure like this one.

<ul>
    <li><a href="delete.php?page_id=10">delete home page</a></li>
    <li><a href="delete.php?page_id=20">delete news page</a></li>
</ul>

We have protected the page named delete.php with some login script, that a user without permissions can’t access the page or script.

if( logged_in() == false ) {
    // User not logged in
    die();
} else { 
    // User logged in
    $db->query(sprintf("DELETE FROM pages WHERE page_id = %d", $_GET['page_id']));
}

The script dies if the user isn’t logged in and otherwise the page with the page ID from the GET variable will be deleted. By using the function sprintf() I’m able to format the value to an integer value by using the type specifier %d. Seems to be safe or not?

Let’s say the authorized (logged in) would visit a page where a comment was posted including an image. If a hacker has posted an image with the URL like the one below you wouldn’t notice it, because the image doesn’t show up because the URL doesn’t exists.

<img src="http://www.yoururl.com/delete_page.php?page_id=20" />

Sure this is a very stupid example and the hack is only possible if the hacker knows your PHP website security. A much better solution would be to use a unique token for each important action. The best way is to create a unique token for the admin user during login. Let’s say the code below is a part from your login script. Inside the login_user() function I create a session variable than contains a md5() encrypted string.

session_start();

function logged_in() { 
    // your code to check a valid login 
}
function login_user() {
    // your authentication process
    // comes
    $id = md5(uniqid(mt_rand(), true));
    $_SESSION['token'] = $id;
}
function get_token() {
    return (!empty($_SESSION['token'])) ? $_SESSION['token'] : 0;
}

Inside the page with the HTML structure I need to add the token variables after each link.

$token = get_token();
echo '
<ul>
    <li><a href="delete.php?page_id=10&token='.$token.'">delete home page</a></li>
    <li><a href="delete.php?page_id=20&token='.$token.'">delete news page</a></li>
</ul>';

With this additional token variable inside the delete.php script I’m able to validate the data input.

if( logged_in() == false ) {
    // User not logged in
    die();
} else {
    // User logged in
    if (empty($_GET['token']) || $_GET['token'] != $_SESSION['token']) {
        die();
    } else {
        $db->query(sprintf("DELETE FROM pages WHERE page_id = %d", $_GET['page_id']));
    }
}

This simple validation will stop the script if the token is missing or not equal to the token session variable.

Cross site scripting (XSS) Attacks

The basic idea of XSS attack is that a hacker has embedded some client-side code on your web site which is executed or download by a visitor. This happens in different ways. For example by using a link to your website where some malicious JavaScript is added or the hacker has posted some JavaScript code to your website. The last one happens mostly by using unsafe comment forms where the content find a place on your website.

In any situation it’s important that your web application sanitizes the user’s input before the data is stored or parsed in your web page. Use different validation functions like preg_match(), filter_var() or better htmlspecialchars() to filter or convert possible attacks from hackers. The function htmlspecialchars() will convert HTML tags into entities.

Limit script functionality

If your website has a login function, it’s possible that a hacker will use a script that will try guess a user name and password. While using thousands of combinations it’s possible that the hacker will succeed. Throttle down the access to your login page if a visitor has made more than X submissions and use always passwords which are hard to guess. Common passwords like “welcome” or “default” are most of the time the cause of getting hacked.

Be careful with the function to recover a password. Never send the recovery instructions to some new email address and let the owner of the registered email address execute the recovery action.

Captcha images are a great and simple way to stop bots accessing your web forms. Use them where a remote submission can harm your web application.

Disable PHP error reporting

There are many reasons why some PHP error can happen on a website. Many of them doesn’t have any influence on the web site’s functionality. For a hacker is a notice or error message a source to get information about your website and/or sever configuration. Test your website always for possible error, because they are bad for your website and/or business, too. Would you trust a service that is broken?
There is also a functions that disables the output of error messages, add ini_set(‘display_errors’, 0); to your script and show errors ONLY on your test location!

I hope this tutorial helps to understand some basic concepts how a hacker will try to attack your websites.

Disclaimer: This article is written to explain common problems I have seen very often. Don’t use the examples in production!

Don’t make these mistakes when creating secure PHP websites!

Published in: PHP Scripts

7 Comments

  1. Good post. It is very important to understand that your website can be hacked at any time. This can at least make people be aware of what can happen.

    1. Developers should always care about input validation: Via form fields, excepting data, URL query strings, several server variables…

    1. Hi Steven, I’m glad you like my post. How do you think can help this guide to secure a WordPress website?

  2. Hi Olaf!
    I’ve seen the link to this article being posted around and I see that it’s actually from 2014, I’m so sorry for being this late. I still feel I should add my comments to help you and other readers build secure sites. If you’d ask for a headline, I’d just name the comment Common mistakes when talking about common mistakes. Please, don’t get offended, I hope that the comment will be at least a bit useful for you and your readers.

    Anyway, thanks for the post, while it’s always great to talk and explain website security, though some of the advice here could actually be quite dangerous. Let me go through the issues:

    I’d not recommend escaping values as it’s really easy to omit the quotes in the SQL queries, like this: SELECT FROM users WHERE username = %s && password = %s. I’d suggest using binding variables all the way and always. Binding variables in ext/mysqli is not really usable, though, so PDO might be a better idea, at least until you realize you need to do something like query(‘INSERT INTO table VALUES %a’, $array) which is not supported in PDO out of the box. So you go and create a PDO wrapper and build such functionality yourself. While defending against a vulnerability, you actually might introduce it. PDO_MYSQL driver in default mode uses emulated prepared statements and allows stacked queries (‘SELECT …; DROP TABLE …’) to be executed. You can read more about what happened to Drupal a year ago when they wanted to extend stock PDO and failed miserably: http://blog.ircmaxell.com/2014/10/a-lesson-in-security.html

    So my suggestion for safe db queries is: no escaping, and use a db layer your framework provides, do not extend MySQLI or PDO yourself, ever.

    When talking about escaping, your example does not set charset. In some encodings (GBK) it’s possible to inject SQL even when the values are escaped, because ¿’ becomes ¿\’ and that is one multibyte char (¿\) followed by a single quote. You should always set charset, especially when escaping (and PDO does escaping by default, even when you use prepare() and execute()).

    The query SELECT FROM users WHERE username = ‘%s’ && password = ‘%s’ hints that the passwords are stored in plain text. While it probably is out of scope of this article, the example should at least call a proper password hashing function, like this (I’ll stick with MySQLI for the sake of the example):

    $stmt = $db->prepare('SELECT password FROM users WHERE username = ?');
    $stmt->bind_param('s', $username);
    $stmt->execute();
    $stmt->bind_result($password);
    $stmt->fetch();
    $stmt->close();
    $ok = password_verify($_POST['password'], $password);
    

    Beginners will see this article about secure PHP websites and think storing passwords in plain text is fine. It’s not.

    Creating a CSRF token like $id = md5(uniqid(mt_rand(), true)) will create predictable tokens. You don’t want CSRF tokens to be predictable. Don’t use mt_rand()/uniqid() to create secure tokens. Use rand_bytes() from PHP 7, or mcrypt_create_iv(8, MCRYPT_DEV_URANDOM) – MCRYPT_DEV_URANDOM is default since PHP 5.6, not before though. Or even better, use this polyfill compat package https://github.com/paragonie/random_compat

    While I agree that PHP errors and warnings should not be displayed to users, they definitely should be logged for later inspection. error_reporting(0) disables error generation completely, not just the output of error messages as incorrectly stated in the article. Leave your error_reporting on whatever level you want and disable output by ini_set(‘display_errors’, 0), or even better do it in server config (httpd.conf, .htaccess or similar) because sometimes the error might be generated before the ini_set() will get executed, for example when loading an extension or so.

    I’ve missed some smaller issues, but the comment was not supposed to be a comprehensive guide to secure websites, just as your article was not. Hope my comment helps and for closing note, let me borrow your last sentence: Don’t make these mistakes when creating secure PHP websites!

    Oh, and btw, I had to remove from the page source code to actually post the comment, otherwise two fields named “comment” are sent and that results in a “Please enter your comment” error message. Thought you might fix it.

  3. Don’t use that as a token, as it’s completely predictable. Use bin2hex(openssl_random_pseudo_bytes(16))

    1. Hi Matthew,
      thanks for your suggestion and your right, this is not the best way to choose a token and on some cheap shared hosting it might be a problem.
      I really like the functions openssl_random_pseudo_bytes(), I will use it in future projects. Do you have some experience how “unique” is a shorter string (for example 8 or 10 chars)? Do you ever had trouble while using such a string as a key?

Comments are closed.