EDIT 2023-02

This security issue will finally be fixed (breaking change) in Ransack 4.0.

Thanks to wonda-tea-coffee for reporting this issue on ransack's issues tracker.

Thanks to David Rodríguez for fixing this issue.

One of the great things about Ruby on Rails is that there are so many gems that do stuff that you can build an app super fast. Awesome, right?

Yeah, everything works out of the box, it's magical, I love it!

Take a second to think about it: how much code are you including in your codebase that you don't understand or even know of? A lot actually, and if you don't take a moment to think about what you put in your apps, you're living a dangerous life, my friend...

Let's see how ransack, a ~5k Github stars gem, can dig a nice security hole in your app.

I hear you people telling me the problem is not ransack but the (lazy) developer who uses it. I almost agree with that but bear with me, we'll talk about this in a bit.

Build a simple Rails app

rails new hack_with_ransack
cd hack_with_ransack
rails g scaffold user email password
rails g scaffold article user:references title content:text
rails db:migrate

Now seed your application with some users and articles.

Install ransack

Add gem 'ransack' in your Gemfile and run bundle install. Next, update the index method of the articles controller this way:

def index
  @q = Article.ransack(params[:q])
  @articles = @q.result

That's all you have to do. You now have a search engine on your articles index action. How easy was that?

If you want to try it, open the following URL: http://localhost:3000/articles?q[title_cont]=whatever. Just replace whatever with the word you want to search for and voila, articles with a title that contains that word will be listed.

If you want to know more about ransack predicates, read the documentation.

At this point, the lazy developer says:

Hey boss, the search feature is already done. Am I a great developer or what?

Let's not be lazy, let's read some more documentation.

Search based on relations' attributes

Ransack is so powerful that it lets you run searches based on a record's relations' attributes. For instance, if article belongs_to category and each category has a name, you can search for the articles of a specific category without any additional code.

If you were to add a searbox to your index for this, it would look like:

<%= f.label :category_name_cont %>
<%= f.search_field :category_name_cont %>

This produces the query q[category_name_cont]=value. It means that you can build a search condition as such: q[relation_attribute_predicate]=value.

In the same way, you could filter articles that belong to a specific user using their email address like this: http://localhost:3000/articles?q[user_email_eq]=your-favorite-author@mail.com

You can do that, or you could steal the author's account...

Hacking time

Allow me to remind you, oh beloved reader, that my only goal with this article is to demonstrate how a security breach may be introduced inadvertently. I am in no way inciting anyone to use such knowledge to access unauthorized data. No. It's illegal. Don't do that. Don't go to jail. Don't be like that.

To illustrate my point, let's assume that:

  • we have two models: article and user
  • article belongs_to user
  • user has a password attribute
  • we are targeting the user id 123

Using ransack _start predicate, we can extract the password of a user with a smart brute-force attack.

Let's find the first character of their password. We will try every caracter until the index gives us results, which would mean the first caracter is right:

  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_start]=a => no result
  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_start]=b => no result
  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_start]=c => results found!

Okay, so the password starts with 'c'. Let's move on to the next character:

  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_start]=ca => no result
  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_start]=cb => no result
  • [...]

You get the idea. At some point, you'll have extracted the full password:

  • http://localhost:3000/articles?q[user_id_eq]=123&q[user_password_eq]=c740223ac93aabf4d => results found!

Notice I changed the predicate from _start (that I use for guessing) to _eq (that I use to confirm I extracted the full password).

It's a guessing game. When you have no articles on the index page, it means that you're testing the wrong symbol and need to try another one. Once you find results, it means that the user's password starts with the string you tested, which means you're one step closer to the full password.

But we use encrypted passwords. They are safe, right?

It depends... A secure password (long enough with a mix of upper/lowercase characters, digits, symbols) would give a hash that'll be harder to brute-force, but don't forget that many users use simple passwords that are present in brute-force dictionaries or whose corresponding hashes are present in rainbow tables. Those would be cracked in minutes.

That said, I now invite you to take a step back and look at the big picture. Think as a hacker does. Where is ransack used in your application? Which relations are at play? With this knowledge, is there a way to access any sensitive information? After all, not only passwords are to be kept safe: personal addresses, phone numbers, email addresses, private conversations, etc. Basically, all private data are to be protected from this attack.

Limitation to this attack

This attack is mainly based on the use of the _start predicate. This predicate performs a case insensitive comparison, which means it's easy to extract data where the case is not important (SHA/MD5 hashes such as devise's reset_password_token, social security numbers, ...). On the other hand, case-sensitive data (devise's bcrypt-ed encrypted_password for instance) are harder to extract as this predicate extracts them in lowercase, thus requiring extra processing before finding the exact value.

Note I said harder -not impossible- because we can always use the _eq predicate; it makes the brute-force attack tedious but possible.

Optimization of this attack

To reduce the number of HTTP requests made, one can use the _start_any predicate to build the equivalent of a binary search. At each request, you try not one single character but half of the remaining possibilities you have.

So ransack is bad? Should I just use another gem?

I never said ransack is a bad gem. I use it in many projects. Am I dumb to keep using it while knowing all we discussed? I'd like to think not, all I did is I kept reading the documentation to find a solution: https://github.com/activerecord-hackery/ransack#authorization-whitelistingblacklisting

There is a class method you can put in your models to choose which attributes are searchable:

# `ransackable_attributes` by default returns all column names
# and any defined ransackers as an array of strings.
# For overriding with a whitelist array of strings.
def ransackable_attributes(auth_object = nil)
  column_names + _ransackers.keys

There is also a class method to choose which associations are searchable:

# `ransackable_associations` by default returns the names
# of all associations as an array of strings.
# For overriding with a whitelist array of strings.
def ransackable_associations(auth_object = nil)
  reflect_on_all_associations.map { |a| a.name.to_s }

You can either list the searchable attributes and/or relations (this is the approach I prefer because it's safer):

class User
  def self.ransackable_attributes(auth_object = nil)
    ['id', 'email']

Or you can go the other way around and only exclude the ones that are not:

class User
  def self.ransackable_attributes(auth_object = nil)
    column_names + _ransackers.keys - ['encrypted_password', 'reset_password_token', ...]
    # Take a look at https://github.com/heartcombo/devise/blob/master/lib/devise/models/authenticatable.rb#L59 : UNSAFE_ATTRIBUTES_FOR_SERIALIZATION

Securing your usage of ransack is that simple.

So why did I say that I almost agree earlier? Because:

  • Yes it's the developer's job to read the documentation and understand what code they add to their application
  • But heck, ransack developers, why choose a by-default unsecure behavior?

In my opinion, ransack should at least let the developer decide by configuration which default behavior they prefer. Since it's not the case, I suggest you put the hereafter code in your app/models/application_record.rb, then allow on a per-model basis the attributes and associations you decide are safe to be searchable.

class ApplicationRecord
  def self.ransackable_attributes(auth_object = nil)

  def self.ransackable_associations(auth_object = nil)

Thank you for reading!