Search code examples
javascriptphpjsonwordpresssecurity

Inconsistency in logged fingerprint (abuse/fraud combat) on Wordpress


I'm working on a WordPress site with BuddyPress, the BuddyX Pro theme, and the Wise Chat Pro plugin. Recently, I’ve been tackling abuse on the group chat page, where users often bypass IP-based bans using TOR or VPNs. To improve security, I wrote a custom plugin that does the following:

Plugin functionality:

  • IP lookup: The plugin uses MaxMind to check the user's IP location and identify if it's a known proxy or VPN. Optionally, it can also query the ProxyCheck.io API (currently disabled).
  • Device fingerprinting: For each unique device, a set of fingerprints is generated (both advanced—based on server-side and client-side data—and fallback, using only server-side data). Note: I am aware this is not 100% fool-proof, but in my use-case I would like to have it as an extra resource.
  • Bot whitelisting: Known bots (e.g., Google, Microsoft) are whitelisted and always allowed to enter.
  • Conditional rules: IPs from certain countries (e.g., China, Russia) are blocked and users are redirected to another page.

How it works:

  • IP blocks: IPs that don’t meet the conditions (e.g., geographic location, VPN status) are correctly redirected.
  • Fingerprint generation: Two sets of fingerprints (advanced and fallback) are generated, hashed, and saved in a protected JSON file for 5 days before being purged.
  • Blocking fingerprints: I added a feature to manually block specific fingerprints as a last resort. This works as intended, but I’ve run into an issue where the fingerprint generated and checked against the blocklist does not match the one written to the JSON file.

The issue:

I generate a set of 2 fingerprints (advanced and fallback):

function generate_fingerprint() {
    $fallback = generate_fallback_fingerprint();
    $advanced = generate_advanced_fingerprint();
    $combined = md5($fallback . $advanced);
    $reliability = calculate_reliability_score();
    
    return [
        'fallback_fingerprint' => $fallback,
        'advanced_fingerprint' => $combined,
        'reliability_score' => $reliability
    ];
}

This set of fingerprings is than saved to a fingerprints.json logfile:

function save_fingerprint($ip, $fingerprint_data) {
    $data = [];
    if (file_exists(FINGERPRINT_FILE)) {
        $data = json_decode(file_get_contents(FINGERPRINT_FILE), true) ?? [];
    }
    $timestamp = time();
    $key = $ip . '_' . $timestamp;
    $data[$key] = [
        'ip' => $ip,
        'fallback_fingerprint' => $fingerprint_data['fallback_fingerprint'],
        'advanced_fingerprint' => $fingerprint_data['advanced_fingerprint'],
        'reliability_score' => $fingerprint_data['reliability_score'],
        'timestamp' => $timestamp,
        // 'client_data' => $_POST  // Only enabled when needed for debugging
    ];
    file_put_contents(FINGERPRINT_FILE, json_encode($data, JSON_PRETTY_PRINT));
}

When the user returns, I would expect that the same fingerprint (advanced or fallback) that is generated and logged to the fingerprints.json would be recognized and if held against the blocklist (blocked_fingerprints.json), would result in the user being redirected as expected (in case of a blocked fingerprint).

   $blocked_fingerprints = get_blocked_fingerprints();
    if (in_array($fingerprint_data['fallback_fingerprint'], $blocked_fingerprints)) {
        wp_safe_redirect($redirect_url);
        exit;
    }
    if (in_array($fingerprint_data['advanced_fingerprint'], $blocked_fingerprints)) {
        wp_safe_redirect($redirect_url);
        exit;
    }

However, this does not happen.

The fingerprints generated and checked against the blocklist do not match the one written to the JSON file.

I have tried to see what fingerprints are checked against the blocklist, by adding them to my errorlog:
$fingerprint_data = generate_fingerprint();
save_fingerprint($fingerprint_data);
error_log("Fingerprint saved for IP: $ip");

The error log than shows a different set of fingerprints than the one written to the fingerprints.json file. This suggests there's some kind of mismatch in how the fingerprint is generated upon checking the blocklist. Even for the serverside (fallback) fingerprint, which should always be identical. So there seems to be a different way the fingerprints get calculated and/or hashed and I can not figure out how or why. I have no idea what the difference is between both situations.

My full code files (for review) are located here: https://pastebin.com/u/flower88/1/JUEMKr6z
The password to the files is: G3F80fhczm

My setup:

  • /wp-content/plugins/pluginname/block-anonymous.php
  • /wp-content/plugins/pluginname/fingerprint.js (loaded in the page header)
  • The snippet is executed via the WPCode plugin in the header of the specific page.

What works:

  • IPs that don’t meet the criteria are correctly redirected.
  • Whitelisted bots are allowed as expected.
  • The two sets of fingerprints (advanced and fallback) are generated and stored in the JSON file correctly. Note: These fingerprints remain identical, stable and consistent over multiple sessions and days and are unique to each device.
  • Old fingerprints are purged after 5 days.
  • Blocking specific fingerprints works, but the fingerprint comparison is inconsistent.

What i’ve tried:

  • Logging shows me that the generated fingerprints that are being checked against the blocked fingerprints do not match, for both advanced and fallback fingerprint alike
  • Pause the PHP file with sleep() before adding block, to give the site enough time to generate the correct fingerprint (did not work)
  • I’ve spent 4 days troubleshooting this issue and even consulted ChatGPT for help, but I haven’t found a solution yet. At this point I am even doubting if I understand the issue correctly. ChatGPT hasn't been helpful as I have been going into circles over and over.
  • I am out of ideas and would greatly appreciate any insights or suggestions to resolve the fingerprint mismatch with a clean, fresh, human look at it.

Thank you very much in advance!


Solution

  • I managed to figure this one out by myself eventually.

    For reference, this is what I did:

    • Clearly clarified fingerprint components (serverside, clientside, combined) for both generating and saving the fingerprint
        function generate_fingerprint() {
            static $cached_fingerprint = null;
            
            if ($cached_fingerprint !== null) {
                return $cached_fingerprint;
            }
            
            $fallback = generate_fallback_fingerprint();
            $advanced_client = generate_advanced_fingerprint();
            $combined = md5($fallback . $advanced_client);
            $reliability = calculate_reliability_score();
            
            $fingerprint = [
                'fallback_fingerprint' => $fallback,
                'advanced_fingerprint' => $combined,
                'advanced_client_only' => $advanced_client,
                'reliability_score' => $reliability
            ];
            $cached_fingerprint = $fingerprint;
            return $fingerprint;
        }
    
        function save_fingerprint($ip, $fingerprint_data) {
            $data = [];
            if (file_exists(FINGERPRINT_FILE)) {
                $data = json_decode(file_get_contents(FINGERPRINT_FILE), true) ?? [];
            }
            $timestamp = time();
            $key = $ip . '_' . $timestamp;
            $data[$key] = [
                'ip' => $ip,
                'fallback_fingerprint' => $fingerprint_data['fallback_fingerprint'],
                'advanced_fingerprint' => $fingerprint_data['advanced_fingerprint'],
                'advanced_client_only' => $fingerprint_data['advanced_client_only'],
                'reliability_score' => $fingerprint_data['reliability_score'],
                'timestamp' => $timestamp,
            ];
        
            // Voeg client_data toe als de constante is gedefinieerd en true is
            if (defined('SAVE_CLIENT_DATA') && SAVE_CLIENT_DATA) {
                $data[$key]['client_data'] = $_POST;
            }
        
            file_put_contents(FINGERPRINT_FILE, json_encode($data, JSON_PRETTY_PRINT));
        }
    
    • Added the same logic to the blocking
        function is_fingerprint_blocked($fingerprint) {
            $blocked_fingerprints = get_blocked_fingerprints();
            $is_post_request = ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER['HTTP_X_FINGERPRINT_REQUEST']));
            $redirect_type = $is_post_request ? "JavaScript redirect" : "wp_safe_redirect";
            $ip = get_visitor_ip();
        
            $fingerprint_types = [
                'fallback_fingerprint' => 'fallback',
                'advanced_fingerprint' => 'gecombineerde advanced',
                'advanced_client_only' => 'clientside-only advanced'
            ];
        
            foreach ($fingerprint_types as $key => $type) {
                if (isset($fingerprint[$key]) && in_array($fingerprint[$key], $blocked_fingerprints)) {
                    return true;
                }
            }
        
            return false;
        }
    

    I no longer need help, so resolving my own request.