Avoiding session replay attacks in Rails
Tags: ruby, rails, security, webdev
Published: 2021-01-10 16:45:34

This post was originally published [on my blog](https://binarysolo.chapter24.blog/avoiding-session-replay-attacks-in-rails/).

There are a lot of good reasons to roll your own authentication rather than use something off the shelf like [Devise](https://github.com/heartcombo/devise). This comes with its own pitfalls and a rather easy mistake to make is to leave a vulnerability for session replay attacks by solely using the "user id" as an authentication instrument.&nbsp;  
  
In this post I'll describe how such an attack can be carried out; why it's so serious and how to protect your app against it.&nbsp;  
  

# A typical login mechanism

  
A common pattern for Rails authentication is to have a **SessionsController** with a **create** action for login and a **destroy** action for logout. This is what these actions would usually look like:  
  

```ruby
def create
  user = User.find_by(email: params[:session][:email].downcase)

  if user && user.authenticate(params[:session][:password])
    reset_session
    session[:user_id] = user.id
    redirect_to home_path
  else
    flash[:error] = "Invalid email/password combination"
    redirect_to login_path
  end
end
 
def destroy
  reset_session

  flash[:info] = "You're logged out"
  redirect_to root_path
end
```
    

> _(The popular_ [_Ruby on Rails Tutorial_](https://www.railstutorial.org/book) _by Michael Hartl initially uses the above technique; a later exercise explains the session replay issue and includes a full solution for how to fix it.)_
  

The _user\_id_ is a permanent entity as it is almost always the primary key for that record in the database; so it never changes after creation.&nbsp;  
  
The methods to authenticate any subsequent requests would be something like:  
  
```ruby
def current_user
  if session[:user_id]
    @current_user ||= User.find_by(id: session[:user_id])
  end
end

def authenticate
  if current_user.nil?
    flash[:error] = "Please log in to view this page."
    redirect_to login_path
  end
end
```
  
  
[Rails sessions are usually stored in cookies](https://api.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html) that are encrypted; so users cannot read the raw data or tamper with it.   
  
Since the _user\_id_ is only data point being used to authenticate a request, the session cookie can be used to access your app in perpetuity; because it contains the _user\_id_. The fact that it is encrypted doesn't matter as even though the user cannot see the value of the _user\_id_, your app will still decrypt and read it.  
  

# How to perform a session replay attack

  
A session replay attack requires access to a potential victim's session cookie. There's a few ways an attacker could acquire this such as a [man-in-the-middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) attack or by having physical access to the victim's machine. Both these scenarios are relatively uncommon but nevertheless possible; and since the session cookie can be used to access your app in perpetuity, it's definitely worth guarding against.  
  
So to get started, we need a demo app with a basic authentication system. I've created one [that's available on GitHub](https://github.com/ayushn21/session-replay-demo). It just has sign up, login and logout mechanisms and should be easy to understand for anyone familiar with Rails. The code examples above were extracted from this demo app.

If we run this demo app, create a user and log in; we can see the session cookie in the developer console under the **storage** tab. It looks like gibberish because it's encrypted but that doesn't matter.  
  
Since we're starting with the assumption that an attacker already has a session cookie; we need to copy-paste the cookie into a text editor so we can "replay" it to the app in the future and gain access to privileged information.  
  
Next, we click "Log out" which will remove the _user\_id_ from the session cookie and redirect us to the root path. At this point, if we try accessing the home path (as shown in the screenshot above), we'll be redirected to the login page. This proves that the app has logged us out successfully.  
  

![Unauthenticated users are redirected if they try to access /home](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1pt0o9bzpts8nroitxx6.gif)
*Unauthenticated users are redirected if they try to access /home*
  
Now, if we overwrite the session cookie with the value we saved earlier; and then try to access the home path again, we find that it doesn't redirect and shows the page just as if we were logged in!  
    
The scary thing in this scenario is that the attacker can use this cookie value to impersonate the victim in perpetuity unless the _user\_id_ is changed; which is neither ideal nor feasible. There's nothing the user can do to kick the attacker out including logging out (as we just proved); or even changing their password because that still wouldn't affect the _user\_id_ which is the only data point used to authenticate a user.&nbsp;  
  
The same attack can be carried out using a cURL request as well so it would be very easy for an attacker to write a script to extract privileged information using only the session cookie.  

```
curl "http://localhost:3000/home" \ -H 'Cookie: \_session\_replay\_demo\_session=UilsMmVIWNLxP942Thy4rsDVPRtA4rxQay3XY67KvsGb1L0rmj44msG74tybL3fCXQMvR6HZZRzEh2NuM4obAOiTfBTyJl4riaxOJoUnllemicERpqDX2VHz6N2hHlPnfXzhymloTMcEdFpp9ya44%2BJ3%2FlRcD28kBXz6rdRmnLOCd7V3NFU%2FKL1O7dciYpPUdO%2BYeWCqHo3d3Dy%2BIPb8mK8YjAieROV9W0fiUByqLinHu%2Ff4R3aT10FhmnnhA3fXkgnTvp75tLp2aOyvTOwKnZvaJeQDlrOoKTXxEHm98%2BySz2aLvWD8toL4ApHg1WVrfIk95A%3D%3D--2UyaUc6l2osxLVBK--RkADbyHYZk48p4o507jlcA%3D%3D'
```


```html
<!DOCTYPE html>
<html>
  <head>
    <title>Session Replay Demo</title>
    <meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="3Zf9qkAdJRKMm6C0Gh5OAj231fc+YYhXp4YhfFj8NEGXLmCepLmfymZpvrk75DVbwOvItEe4DdxvrzHpBn477A==" />


    <link rel="stylesheet" media="all" href="/assets/application.debug-df863906fdbe195d0c50142af134379b3ef729118094b1fd531c9c61b0d8d74a.css" data-turbolinks-track="reload" />
    <script src="/packs/js/application-0ac71d79a9cfb06b89c2.js" data-turbolinks-track="reload"></script>
  </head>

  <body>

    <h1>Home</h1>
<h2>Ayush</h2>
<br>
<a rel="nofollow" data-method="delete" href="/logout">Log out</a>

  </body>
</html>
```
  
As the above cURL request and response shows, the protected user home page was returned instead of a redirect.  
  
The likelihood of a session cookie becoming compromised in this way is relatively low. Man-in-the-middle attacks can be prevented by using SSL and an attacker having physical access to an unlocked machine with the user logged in is a pretty unlikely scenario; it is still possible in theory.&nbsp;  
  
The fix is only a few lines of code and since the downside is a chance, however small, of an attacker gaining access to a victim's account in perpetuity; there's no reason not to implement the fix!  
  

## How to guard against session replay attacks

As mentioned above briefly, the vulnerability was caused because we used just one immutable data point, the _user\_id_ to authenticate a user. The fix is to add another mutable data point in addition to the _user\_id_ and authenticating the user only when both are valid.  
  
The mutable data point we'll add is a "session token". This is an ephemeral value we'll store securely in the database as well as the session cookie. We'll only authenticate a user if both the _user\_id_ and this new "session token" are valid. Now, if the session cookie was compromised; all we need to do is delete the session token from the database and ask the user to log in again. The attacker's cookie will contain an outdated value of the session token and their access will be revoked.  
  
To implement the addition of the session token, we add a _session\_digest_ column to the **Users** table and add the following methods to the **User** model.  
  
```ruby
def log_in
  session_token = SecureRandom.urlsafe_base64
  update(session_digest: BCrypt::Password.create(session_token))
  
  return session_token
end
 
 
def log_out
  update(session_digest: nil)
end 

 
def authenticated?(token)
  return false if session_digest.nil?

  BCrypt::Password.new(session_digest).is_password?(token)
end
```
  
The code should be fairly self explanatory. The _log\_in_ method creates a new session token, securely stores it in the database and then returns it. The _log\_out_ method clears the saved session digest. And the _authenticated?_ method checks the token against the value stored in the database.

  
We'll need to make use of these new additions in our authentication machinery as well. So the **create** and **destroy** actions shown above now become:  
 
```ruby 
def create
  user = User.find_by(email: params[:session][:email].downcase)
  
  if user && user.authenticate(params[:session][:password])
    reset_session
    session[:user_id] = user.id
    session[:token] = user.log_in
    redirect_to home_path
  else
    flash[:error] = "Invalid email/password combination"
    redirect_to login_path
  end
end

def destroy
  current_user&.log_out

  reset_session

  flash[:info] = "You're logged out"
  redirect_to root_path
end
```
  
And the _current\_user_ method used during authentication now needs to compare the session token as well as the _user\_id_.  
  
```ruby
def current_user
  if (user_id = session[:user_id])
    user = User.find_by(id: user_id)
  
    if user && user.authenticated?(session[:token])
      @current_user ||= user
    end
  end
end
```
  
And that's it! Our app is now safe from attacks involving a compromised session cookie.  
  

# The proof is in the pudding

  
Run the same attack described above and see what happens.  
    
If we save the session cookie while logged in; then log out and try to use the same cookie to get access to the protected home page, it doesn't work. This is because the compromised cookie contains the older session token that was deleted when the user logged out and hence is no longer valid.  
  
## Conclusion

Rolling your own authentication system can be tricky but there's a lot of value in the flexibility it gives you. I hope this post was useful in helping you avoid a common pitfall when building a Rails authentication system!  
  
This post and the fix was inspired by [this RailsConf talk](https://railsconf.com/2020/video/jason-meller-inoculating-rails-auth-against-bug-bounty-hunters) by Jason Meller.  
  
_The app I used in the above demos is_ [_available on GitHub_](https://github.com/ayushn21/session-replay-demo)_. The_ **_master_** _branch has the vulnerability and the_ **_session-replay-fixed_** _branch contains the fix._