Ruby on Rails SQL Sanitization
Special thanks to my bossman Jeff Thomas in the research regarding this issue.
I recently had the opportunity to review some Ruby on Rails (RoR) code as part of grey box testing (dynamic testing + source code analysis). The code base used Active Record, which is an Object Relational Mapping (ORM) framework for Ruby and RoR. What this means put simply, is that instead of using direct/raw SQL queries to interact with the relational database management system, we can use objects instead.
The Basics
Class definitions for a User model can be defined as such which will map the User table to this model:
class User < ActiveRecord::Base
class Book < ApplicationRecord // ApplicationRecord also serves as the base class to interact with the database
The models can inherit additional logic, associations etc such as the following:
class Admin < ApplicationRecord
belongs_to :user // each Admin record belongs to a User
has_many :permissions // each Admin can have multiple permissions
scope :create_new_user, -> { ...queries... } // creates a new method call for the Admin model as a shortcut to a query
We can then call the various attributes of the User class to interact with it. For example:
User.new(...) // creating a new entry
User.save // saving the entry to the database
User.find(id) // find an entry based off an id
User.where(...) // return results based off a condition
This is just an extremely brief overview of Active Record to set the scene, to understand more please visit here.
The Interesting
Sometimes complicated queries are used to extract the appropriate entries from the relational database. If they are engineered inappropriately, the Active Record query may accept unsanitized or insufficiently sanitized input. Take this for example:
sanitised_input = sanitize_sql(unsanitized_string_input)
rel = joins(a).where(b).order(Arel.sql("position(c::text in '#{sanitized_input}')"))
result = rel.first
Let’s break this down:
Our unsanitized_string_input
is sanitized using the Ruby on Rails Active Record input sanitization method - sanitize_sql
. This method is shown here, but it is effectively an alias for sanitize_sql_for_conditions.
The input is added to an SQL query method, which is used to retrieve the first record that matches these conditions (rel.first
). The conditions are:
- joins(a): SQL JOIN clause to attach two tables
- where(b): SQL WHERE clause
- order(…): SQL ORDERBY clause
- Arel.sql: allows the insertion of raw SQL clauses for more complex queries, but is generally done so in a safe manner
position(c::text in '#{sanitized_input}')
: The SQL clause to append after the ORDERBY. This is a PostgreSQL position function which attempts to determine the position of the sanitized_input in the ‘c’ column, cast as text
So where is the issue? When we observe that the raw SQL input accepts #{sanitized_input}
, this is actually string interpolation, and the user input is directly added to the clause. If the input is unsanitized or insufficiently sanitized, this can lead to SQL injection.
As it turns out, and as provided by this rails issue here, the sanitize_sql
method serves as a passthrough for everything except arrays. If we pass a string to the sanitization function, it would ignore it and pass it straight to string interpolation, leading to an SQL injection issue. This is shown in the Rails Active Record as shown:
The Exploitation
When attempting to exploit this issue on the target web application, we noted that they used a Cloudflare WAF which blocked the majority of payloads ( provided by SQLMap, even with the use of tamper scripts, or payload modification as per my previous blog. Thus the initial proof of concept had to be manually crafted.
We observed that only a boolean-based SQL injection was possible, and the application would either respond with a 500 error if the SQL query is broken, such as by injecting a single quote, or returned a 404 with an even number of quotes.
') + ('0 // 500
'') + ('0 // 404
As we know that the injection location occurs after the ORDERBY clause, we need to insert our payload in either the OFFSET or the LIMIT clause to respect the order of clauses, the order being:
- SELECT
- FROM
- JOIN
- WHERE
- GROUP BY
- HAVING
- ORDER BY <– You are here.
- OFFSET
- LIMIT
We can then use another position statement (PostgreSQL specific) to attempt to enumerate the database. For example:
') offset (case when (select position('O' in (select version()))) = '1' then 0 else -1 end) + ('0 // If the first character is 'O' in version(), then OFFSET 0
') offset (case when (select position('P' in (select version()))) = '1' then 0 else -1 end) + ('0 // If the first character is 'P' in version(), then OFFSET -1, which results in an error (ERROR: OFFSET must not be negative)
') offset (case when (select position('Po' in (select version()))) = '2' then 0 else -1 end) + ('0 // Can append the second character to the original clause
Resources:
- https://guides.rubyonrails.org/active_record_basics.html
- https://jeffhacks.com/
- https://github.com/rails/rails/issues/46053
- https://api.rubyonrails.org/v7.1.3.4/classes/ActiveRecord/Sanitization/ClassMethods.html#method-i-sanitize_sql
- https://api.rubyonrails.org/v7.1.3.4/classes/ActiveRecord/Sanitization/ClassMethods.html#method-i-sanitize_sql_for_conditions
Article Information
- Author: Tony Wu
- URL: https://qwutony.github.io/2024/07/31/Ruby-On-Rails-SQL-Sanitization/
- License: Free to share - NonCommercial - NoDerivatives - Attribution required (Creative Commons BY-NC-ND 4.0 License)