Difference between revisions of "Web Application Security, Part 2"

From CSE330 Wiki
Jump to navigationJump to search
(Adding section about CAPTCHA abuse)
(Moving CSRF to Web Application Security, Part 2)
Line 1: Line 1:
 
This is Part 2 of the Web Application Security article, geared toward the material covered in [[Module 3]].  For material covered in [[Module 2]] (HTML, CSS, and PHP), see [[Web Application Security, Part 1]]. For material covered in [[Module 6]] (JavaScript), see [[Web Application Security, Part 3]].
 
This is Part 2 of the Web Application Security article, geared toward the material covered in [[Module 3]].  For material covered in [[Module 2]] (HTML, CSS, and PHP), see [[Web Application Security, Part 1]]. For material covered in [[Module 6]] (JavaScript), see [[Web Application Security, Part 3]].
 
== SQL Injection ==
 
 
[[File:Exploits of a mom.png]]
 
 
SQL injection occurs when an attacker submits specially-crafted input into your server, which is then included in an SQL query.  The input modifies the query to perform additional actions on the database or to access unwanted information.
 
 
For instance, suppose you had the following code:
 
 
<source lang="php">
 
<?php
 
require 'database.php';
 
 
/* DISCLAIMER: THIS CODE IS BAD IN MANY MORE WAYS THAN JUST
 
BEING VULNERABLE TO SQL INJECTION! IT IS FOR DEMONSTRATION OF
 
CONCEPT ONLY. DO NOT USE THIS CODE IN YOUR OWN PROJECTS! */
 
 
$res = $mysqli->query("SELECT id FROM users WHERE username='".$_POST['username']."' AND password='".$_POST['password']."'");
 
 
if( $res->num_rows==1 ){
 
    $row = $res->fetch_assoc();
 
    $_SESSION['user_id'] = $row["id"];
 
}else{
 
    echo "Login failed.";
 
    exit;
 
}
 
?>
 
</source>
 
 
This code is vulnerable to SQL injection.  For example, suppose the attacker used the following string of text for his username:
 
 
mother-goose' --
 
 
Here's what the resulting query would look like:
 
 
<source lang="mysql">
 
SELECT id FROM users WHERE username='mother-goose' --' AND password=''
 
</source>
 
 
Since <code>--</code> is the start of a comment in SQL, when MySQL interprets this query, it will ''completely ignore'' the password-checking part of the query!  Dr. Evil can log in using anyone's username and steal all of their money!
 
 
=== Solution ===
 
 
If you write your queries manually (as in the example above), you need to use <code>$mysqli->real_escape_string()</code> to sanitize your input:
 
 
<source lang="php">
 
<?php
 
$safe_username = $mysqli->real_escape_string($_POST['username']);
 
// ...
 
?>
 
</source>
 
 
However, the better solution is to use prepared queries.  For more information on prepared queries, see [[PHP and MySQL]].
 
 
'''IMPORTANT:''' If you correctly use the sample code in the PHP and MySQL guide above, you are already safe from SQL injection attacks.
 
 
=== Real-Life Examples ===
 
 
* [http://www.zdnet.com/unknowns-hack-european-space-agency-4010026071/ European Space Agency, May 2012]
 
* [http://www.dutchnews.nl/news/archives/2012/04/new_online_medical_records_sca.php Dutch Department Stores, April 2012]
 
* [http://www.msnbc.msn.com/id/46735808/ns/technology_and_science-security/ Ancestry.com, March 2012]
 
* [http://www.scmagazine.com.au/News/292592,allphones-hacked-staff-passwords-exposed.aspx Allphones (Australian Telecommunications Retailer), March 2012]
 
* [http://www.abc4.com/content/news/slc/story/More-fallout-Salt-Lake-City-police-website-hacked/PiSspE768UiioitJ3K4gyQ.cspx Salt Lake City Police Department, February 2012]
 
  
 
== Password Security ==
 
== Password Security ==
Line 204: Line 141:
  
 
Here is a constantly-updated list of sites that do not use proper password security: http://plaintextoffenders.com/
 
Here is a constantly-updated list of sites that do not use proper password security: http://plaintextoffenders.com/
 +
 +
== Cross-Site Request Forgery ==
 +
 +
{{DoctypeTV
 +
|youtube_id=3IOKaIRg8V4
 +
|time=3m45s
 +
|desc=Cross Site Request Forgeries
 +
}}
 +
 +
A cross-site request forgery (CSRF, pronounced ''sea-surf'') involves a victim, who is logged in to the targeted site, visiting an attacker’s site.  The attacker has code on his site that forces the victim to unwittingly perform actions on the targeted site.
 +
 +
For example, suppose Mother Goose visited Dr. Evil's blog.  Dr. Evil had the following tag embedded in his bloc:
 +
 +
<nowiki><img src="http://www.bank.com/transfer.php?dest=dr-evil&amp;amount=5000" /></nowiki>
 +
 +
This would cause Mother Goose to authorize a $5000 transfer to Dr. Evil, completely without Mother Goose's knowledge!
 +
 +
Worse yet, Dr. Evil could just send an e-mail to Mother Goose with this image tag.  All Mother Goose would need to do to be attacked is open the e-mail!  (Now you know why sometimes your e-mail client turns off images from suspicious sources.)
 +
 +
=== Solution ===
 +
 +
The first precautionary measure is to always use POST requests (as opposed to GET requests) for actions that change something on your server.  This will fend off all except the most hard-core CSRF attacks.
 +
 +
However, fully preventing CSRF attacks is not difficult.  To do this, you can use a '''CSRF token'''.  A CSRF token is a known string of text that is submitted in all of the forms on your site.  If the string is not what you expect, then you can assume that the request was forged.
 +
 +
For example, consider this form:
 +
 +
<source lang="html4strict">
 +
<form action="transfer.php">
 +
<input type="text" name="dest" />
 +
<input type="number" name="amount" />
 +
<input type="submit" value="Transfer" />
 +
</form>
 +
</source>
 +
 +
We can easily add a hidden CSRF token field like so (as well as making the form POST rather than GET):
 +
 +
<source lang="html4strict">
 +
<form action="transfer.php" method="post">
 +
<input type="text" name="dest" />
 +
<input type="number" name="amount" />
 +
<input type="hidden" name="token" value="<?php echo $_SESSION['token'];?>" />
 +
<input type="submit" value="Transfer" />
 +
</form>
 +
</source>
 +
 +
'''This assumes that <code>$_SESSION['token']</code> contains an alphanumeric string that was randomly generated upon session creation.'''  For example, you could add this line ''beneath beneath where the user successfully authenticates'':
 +
 +
<source lang="php">
 +
$_SESSION['token'] = substr(md5(rand()), 0, 10); // generate a 10-character random string
 +
</source>
 +
 +
We can now test for validity of the CSRF token on the server side (in transfer.php):
 +
 +
<source lang="php">
 +
<?php
 +
$destination_username = $_POST['dest'];
 +
$amount = $_POST['amount'];
 +
if($_SESSION['token'] !== $_POST['token']){
 +
die("Request forgery detected");
 +
}
 +
$mysqli->query(/* perform transfer */);
 +
?>
 +
</source>
 +
 +
Now, if Mother Goose were to view a page containing the malicious <img/> tag, the transfer would not take place.
 +
 +
=== Real-Life Examples ===
 +
 +
* [http://www.zdnet.com/no-data-breach-in-first-weibo-attack-2062301014/ Weibo (the Chinese Twitter), June 2011]
 +
* [http://www.huffingtonpost.com/huff-wires/20110601/us-tec-google-hacking-attack/ Gmail, June 2011]
 +
* [http://www.pcworld.com/businesscenter/article/228609/hackers_steal_hotmail_messages_thanks_to_web_flaw.html Hotmail, May 2011]
 +
* [http://www.theregister.co.uk/2010/05/19/facebook_private_data_leak/ Facebook, May 2010]
 +
 +
== SQL Injection ==
 +
 +
[[File:Exploits of a mom.png]]
 +
 +
SQL injection occurs when an attacker submits specially-crafted input into your server, which is then included in an SQL query.  The input modifies the query to perform additional actions on the database or to access unwanted information.
 +
 +
For instance, suppose you had the following code:
 +
 +
<source lang="php">
 +
<?php
 +
require 'database.php';
 +
 +
/* DISCLAIMER: THIS CODE IS BAD IN MANY MORE WAYS THAN JUST
 +
BEING VULNERABLE TO SQL INJECTION! IT IS FOR DEMONSTRATION OF
 +
CONCEPT ONLY. DO NOT USE THIS CODE IN YOUR OWN PROJECTS! */
 +
 +
$res = $mysqli->query("SELECT id FROM users WHERE username='".$_POST['username']."' AND password='".$_POST['password']."'");
 +
 +
if( $res->num_rows==1 ){
 +
    $row = $res->fetch_assoc();
 +
    $_SESSION['user_id'] = $row["id"];
 +
}else{
 +
    echo "Login failed.";
 +
    exit;
 +
}
 +
?>
 +
</source>
 +
 +
This code is vulnerable to SQL injection.  For example, suppose the attacker used the following string of text for his username:
 +
 +
mother-goose' --
 +
 +
Here's what the resulting query would look like:
 +
 +
<source lang="mysql">
 +
SELECT id FROM users WHERE username='mother-goose' --' AND password=''
 +
</source>
 +
 +
Since <code>--</code> is the start of a comment in SQL, when MySQL interprets this query, it will ''completely ignore'' the password-checking part of the query!  Dr. Evil can log in using anyone's username and steal all of their money!
 +
 +
=== Solution ===
 +
 +
If you write your queries manually (as in the example above), you need to use <code>$mysqli->real_escape_string()</code> to sanitize your input:
 +
 +
<source lang="php">
 +
<?php
 +
$safe_username = $mysqli->real_escape_string($_POST['username']);
 +
// ...
 +
?>
 +
</source>
 +
 +
However, the better solution is to use prepared queries.  For more information on prepared queries, see [[PHP and MySQL]].
 +
 +
'''IMPORTANT:''' If you correctly use the sample code in the PHP and MySQL guide above, you are already safe from SQL injection attacks.
 +
 +
=== Real-Life Examples ===
 +
 +
* [http://www.zdnet.com/unknowns-hack-european-space-agency-4010026071/ European Space Agency, May 2012]
 +
* [http://www.dutchnews.nl/news/archives/2012/04/new_online_medical_records_sca.php Dutch Department Stores, April 2012]
 +
* [http://www.msnbc.msn.com/id/46735808/ns/technology_and_science-security/ Ancestry.com, March 2012]
 +
* [http://www.scmagazine.com.au/News/292592,allphones-hacked-staff-passwords-exposed.aspx Allphones (Australian Telecommunications Retailer), March 2012]
 +
* [http://www.abc4.com/content/news/slc/story/More-fallout-Salt-Lake-City-police-website-hacked/PiSspE768UiioitJ3K4gyQ.cspx Salt Lake City Police Department, February 2012]
  
 
== Abuse of Functionality ==
 
== Abuse of Functionality ==

Revision as of 17:17, 8 September 2013

This is Part 2 of the Web Application Security article, geared toward the material covered in Module 3. For material covered in Module 2 (HTML, CSS, and PHP), see Web Application Security, Part 1. For material covered in Module 6 (JavaScript), see Web Application Security, Part 3.

Password Security

Let's assume for a moment that despite all of your efforts in the other fronts of web security, an attacker was still able to extract information from your database. If you store your passwords as plain text, not only will the attacker be able to log in as whomever he chooses, but the attacker will also likely be able to log in as the users of your site on different sites (since many users employ the same password on several different web sites).

Encryption

The types of encryption and encryption algorithms is a whole class to itself.

In CSE330 and future web application development, you should always use one-way encryption to encrypt your passwords. What this means is that you feed a string of text (a password) to an encryption function, and that encryption function returns another string of text that is a digest of the password. It is impossible to mathematically convert a digest back to its associated password, but encrypting the same password will always yield the same digest.

One-way encryption algorithms can also be salted. What this means is that the string to be encrypted is modified by a salt before the encryption occurs. The same salt and the same password will always yield the same digest. Using a salted hashing algorithm is preferable to a non-salted hashing algorithm for passwords because although digests cannot be reversed, non-salted digests can be looked up in a rainbow table.

Solution

So, the solution is to store salted, one-way-encrypted passwords in your database. PHP provides the crypt() function to do this for you.

<?php
// This is a *good* example of how you can implement password-based user authentication in your web application.

require 'database.php';

// Use a prepared statement
$stmt = $mysqli->prepare("SELECT COUNT(*), id, crypted_password FROM users WHERE username=?");

// Bind the parameter
$stmt->bind_param('s', $user);
$user = $_POST['username'];
$stmt->execute();

// Bind the results
$stmt->bind_result($cnt, $user_id, $pwd_hash);
$stmt->fetch();

$pwd_guess = $_POST['password'];
// Compare the submitted password to the actual password hash
if( $cnt == 1 && crypt($pwd_guess, $pwd_hash)==$pwd_hash){
	// Login succeeded!
	$_SESSION['user_id'] = $user_id;
	// Redirect to your target page
}else{
	// Login failed; redirect back to the login screen
}
?>

Note: You may sometimes see functions like md5() used to encrypt passwords. md5() does indeed perform one-way encryption, but it does so without a salt. THIS IS BAD PRACTICE, because unsalted md5 hashes can be trivially reversed using a rainbow table. (Just Google for "md5 decrypter".) Using a salt prevents the effective use of a rainbow table.

OpenID

One other solution that will solve all issues related to password security is to not have passwords at all. This can be achieved using OpenID, which allows end users to use their accounts from other sites (e.g. Google, Yahoo, and Twitter) to authenticate on your site. Not only does this make your life easier in the security realm, but it also eliminates the need for password recovery, etc.

There are many PHP libraries available for OpenID authentication; one such library is the creatively named OpenID, which you can install using PEAR. You will need to install some other packages first, some from yum (if using RHEL) and some from pear. (If you don't install them, PEAR will yell at you.) These are the commands you need to run in order to install the correct packages (make sure you understand what they do before running them):

$ sudo yum install php-mbstring php-bcmath   # not necessary on Debian
$ sudo apachectl graceful   # restart Apache
$ sudo pear install Crypt_DiffieHellman-0.2.6 Validate-0.8.5 Services_Yadis-0.5.1 OpenID-0.3.3

Here's an example implementation that uses the PEAR package.

Login Page:

<form action="process_openid.php" method="post">
	<input id="start" name="start" type="hidden" value="true" />
	<fieldset>
		<legend>Sign in using OpenID</legend>
		<div id="openid_choice">
			<p>Please select your account provider:</p>
			<select name="identifier">
				<option value="https://www.google.com/accounts/o8/id">Google</option>
				<option value="http://yahoo.com/">Yahoo</option>
			</select>
		</div>
		<p>
			<input type="submit" value="Sign In"/>
		</p>
	</fieldset>
</form>

process_openid.php:

<?php
require_once 'OpenID/RelyingParty.php';
require_once 'OpenID/Message.php';
require_once 'Net/URL2.php';

session_start();

$realm = "http://www.yoursite.com/";
$returnTo = $realm . "path/to/process_openid.php";

$identifier = @$_POST['identifier'] ?: @$_SESSION['identifier'] ?: null; // note: the @ signs suppress "undefined" notices

$o = new OpenID_RelyingParty($returnTo, $realm, $identifier);

// Part 1: We are processing a login request before visiting the OpenID provider
if(@$_POST['start']) {
	$authRequest = $o->prepare();
	$url = $authRequest->getAuthorizeURL();
	
	header("Location: ".$url);
	exit;
}

// Part 2: The user is returning to our site after visiting the OpenID provider's site
else {
	$usid = @$_SESSION['identifier'] ?: null;
	unset($_SESSION['identifier']);

	$queryString = count($_POST) ? file_get_contents('php://input') : $_SERVER['QUERY_STRING'];
	
	$message = new OpenID_Message($queryString, OpenID_Message::FORMAT_HTTP);

	$result = $o->verify(new Net_URL2($returnTo . '?' . $queryString), $message);
	
	if($result->success()){
		// Login Success!
		
		// Get the OpenID identifier, which is unique to every OpenID user (i.e. you can use it in your database to
		// keep track of people between logins), and save it in the session:
		$_SESSION["openid.identity"] = $message->get("openid.identity");
		
		// Now redirect to the target page for logged-in users
	}else{
		// Login Failed.  You can redirect back to the login page or whatever
	}
}
?>

Note: OpenID is by nature a secure form of authentication for a web application; the fact that government agencies like the GSA use OpenID helps attest to this. However, OpenID remains vulnerable to man-in-the-middle, content-spoofing attacks. If you are writing a high-profile application using OpenID, it is therefore recommended that you establish an SSL (https) connection to your OpenID providers to help prevent phishing.

Note: You may use OpenID for modules 4 and higher, but you must implement your own authentication system for module 3.

Real-Life Examples

Here is a constantly-updated list of sites that do not use proper password security: http://plaintextoffenders.com/

Cross-Site Request Forgery

Watch on DOCTYPE

Cross Site Request Forgeries

A cross-site request forgery (CSRF, pronounced sea-surf) involves a victim, who is logged in to the targeted site, visiting an attacker’s site. The attacker has code on his site that forces the victim to unwittingly perform actions on the targeted site.

For example, suppose Mother Goose visited Dr. Evil's blog. Dr. Evil had the following tag embedded in his bloc:

<img src="http://www.bank.com/transfer.php?dest=dr-evil&amount=5000" />

This would cause Mother Goose to authorize a $5000 transfer to Dr. Evil, completely without Mother Goose's knowledge!

Worse yet, Dr. Evil could just send an e-mail to Mother Goose with this image tag. All Mother Goose would need to do to be attacked is open the e-mail! (Now you know why sometimes your e-mail client turns off images from suspicious sources.)

Solution

The first precautionary measure is to always use POST requests (as opposed to GET requests) for actions that change something on your server. This will fend off all except the most hard-core CSRF attacks.

However, fully preventing CSRF attacks is not difficult. To do this, you can use a CSRF token. A CSRF token is a known string of text that is submitted in all of the forms on your site. If the string is not what you expect, then you can assume that the request was forged.

For example, consider this form:

<form action="transfer.php">
<input type="text" name="dest" />
<input type="number" name="amount" />
<input type="submit" value="Transfer" />
</form>

We can easily add a hidden CSRF token field like so (as well as making the form POST rather than GET):

<form action="transfer.php" method="post">
<input type="text" name="dest" />
<input type="number" name="amount" />
<input type="hidden" name="token" value="<?php echo $_SESSION['token'];?>" />
<input type="submit" value="Transfer" />
</form>

This assumes that $_SESSION['token'] contains an alphanumeric string that was randomly generated upon session creation. For example, you could add this line beneath beneath where the user successfully authenticates:

$_SESSION['token'] = substr(md5(rand()), 0, 10); // generate a 10-character random string

We can now test for validity of the CSRF token on the server side (in transfer.php):

<?php
$destination_username = $_POST['dest'];
$amount = $_POST['amount'];
if($_SESSION['token'] !== $_POST['token']){
	die("Request forgery detected");
}
$mysqli->query(/* perform transfer */);
?>

Now, if Mother Goose were to view a page containing the malicious <img/> tag, the transfer would not take place.

Real-Life Examples

SQL Injection

Exploits of a mom.png

SQL injection occurs when an attacker submits specially-crafted input into your server, which is then included in an SQL query. The input modifies the query to perform additional actions on the database or to access unwanted information.

For instance, suppose you had the following code:

<?php
require 'database.php';

/* DISCLAIMER: THIS CODE IS BAD IN MANY MORE WAYS THAN JUST
BEING VULNERABLE TO SQL INJECTION! IT IS FOR DEMONSTRATION OF
CONCEPT ONLY. DO NOT USE THIS CODE IN YOUR OWN PROJECTS! */

$res = $mysqli->query("SELECT id FROM users WHERE username='".$_POST['username']."' AND password='".$_POST['password']."'");

if( $res->num_rows==1 ){
    $row = $res->fetch_assoc();
    $_SESSION['user_id'] = $row["id"];
}else{
    echo "Login failed.";
    exit;
}
?>

This code is vulnerable to SQL injection. For example, suppose the attacker used the following string of text for his username:

mother-goose' --

Here's what the resulting query would look like:

SELECT id FROM users WHERE username='mother-goose' --' AND password=''

Since -- is the start of a comment in SQL, when MySQL interprets this query, it will completely ignore the password-checking part of the query! Dr. Evil can log in using anyone's username and steal all of their money!

Solution

If you write your queries manually (as in the example above), you need to use $mysqli->real_escape_string() to sanitize your input:

<?php
$safe_username = $mysqli->real_escape_string($_POST['username']);
// ...
?>

However, the better solution is to use prepared queries. For more information on prepared queries, see PHP and MySQL.

IMPORTANT: If you correctly use the sample code in the PHP and MySQL guide above, you are already safe from SQL injection attacks.

Real-Life Examples

Abuse of Functionality

Abuse of Functionality is a general term that refers to when an attacker exploits vulnerabilities in the logic of your application.

For example, suppose you were a banking site, and you had the following code to perform a transaction:

<?php
require 'database.php';

$amount = (double) $_POST['amount'];
$destination_username = $mysqli->real_escape_string($_POST['destination']);

$mysqli->autocommit(false); // start transaction
$mysqli->query("UPDATE users SET balance=balance-".$amount."
	WHERE id=".$_SESSION['user_id']);
$mysqli->query("UPDATE users SET balance=balance+".$amount."
	WHERE username='".$destination_username."'");
$mysqli->commit(); // commit transaction
?>

It may not be obvious, but if you don't filter your input, it is trivial for an attacker to insert a negative number into the "amount" field and transfer money from anyone's account to his account! The solution here is to simply filter amount for what you expect (in this case, a positive number that is not greater than the user's current balance).

Filtering on the Client Side is Never Enough

Suppose you have an e-mail field in a form in HTML, and you use some JavaScript function (or HTML5) to check it for form as an e-mail address:

<input type="text" name="email" onchange="checkEmail(this);" /> <!-- HTML versions ≤ XHTML 1.0 -->

<input type="email" name="email" /> <!-- HTML versions ≥ 5 -->

With this filter in place, any layperson using your form is now required to submit an e-mail address in that field. However, it is trivial for an attacker to bypass this client-side filtering (e.g., by using web developer tools like Firebug) and still submit non-email text to your server. This is why IT IS ESSENTIAL THAT YOU FILTER INPUT ON THE SERVER SIDE! Any sort of filtering on the client side are just bells and whistles for the end user.

Information Leakage

Information Leakage is a noteworthy type of Abuse of Functionality attack that involves unprivileged users accessing privileged information. In fact, Information Leakage accounts for a significant percentage of all recorded web application vulnerabilities (second only to Cross-Site Scripting).

The concept is relatively simple. Suppose you have an administration page that loads an image containing a graph of all activity on your site:

admin.php

<?php
if($_SESSION['admin']) echo '<img src="stats.php?day=2012-08-19" alt="Stats for 2012-08-19" />';
?>

stats.php

<?php
// query the database, and save the results in $result

$im = new PNGraph();
$im->takeData($result);

header("Content-Type: image/png");
print $im->toString();
?>

Notice how you check for admin credentials in admin.php. However, you forgot to do this in stats.php itself. An attacker could simply load stats.php directly to see all of the sensitive admin-only information!

Solution

Information Leakage is an attack that requires the developer to see the big picture and really keep track of what's going on in his or her application. As a rule of thumb, whenever you query the database to access sensitive information, check the permissions of the user first.

A Note about Frameworks

Web frameworks are designed to make web development more agile, but they in turn have security weaknesses of their own. For instance, the infamous mass-assignment vulnerability in Ruby on Rails-type MVC frameworks is an abuse of functionality vulnerability that enables attackers to save arbitrary information in your database (!).

The important thing to know is that if you use a web framework, be familiar with the security considerations with that framework. Most of the time, frameworks will have articles on their web sites that discuss these concerns. (If your framework doesn't have a guide like this, you should probably be using a different framework!)

Real-Life Examples

CAPTCHA Abuse

CAPTCHAs are a great way to stop robots from submitting forms in your web application. However, depending on how you implement CAPTCHAs, it might be possible for a hacker to bypass the CAPTCHA on your site.

For example, suppose you are making a home-grown CAPTCHA system for your application. You have a table CaptchaTable with the following fields: id and value. Your form looks like this:

<form action="signup.php" method="post">
	<strong>Subscribe to our newsletter</strong>
	<input type="email" name="email" placeholder="Enter Your Email Here" />
	<input type="text" name="captcha_value" placeholder="Type what you see in the CAPTCHA below." />
	<img src="captcha.php?id=12345" />
	<input type="hidden" name="captcha_id" value="12345" />
	<input type="submit" value="Sign Up" />
</form>

Here's what the code on signup.php might look like:

<?php
$email = $_POST["email"];
$captcha_id = (int) $_POST["captcha_id"];
$captcha_value = $_POST["captcha_value"];

// check CAPTCHA

$captcha_stmt = $mysqli->prepare("select count(*) as match from CaptchaTable where id=? and value=?");
$captcha_stmt->bind_param("is", $captcha_id, $captcha_value);
$captcha_stmt->execute();
$captcha_row = (int) $captcha_stmt->fetch_assoc();
$captcha_stmt->close();

if ($captcha_row["match"] == 0){
	http_response_code(403);
	echo "Invalid CAPTCHA";
	exit;
}

// continue with signup procedure down here
?>

The issue here is that a hacker can simply solve one CAPTCHA and then send that CAPTCHA ID and Value over and over again!

Solution

If you already have a home-grown CAPTCHA system, the best solution here would be to simply issue another query to the database that deletes a CAPTCHA id-value pair as soon as it is submitted in a form.

If you haven't yet implemented your CAPTCHA system, you might want to consider a third-party solution like reCAPTCHA.