This post was originally published on my blog.
There are a lot of good reasons to roll your own authentication rather than use something off the shelf like 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.
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.
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:
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 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.
The methods to authenticate any subsequent requests would be something like:
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 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.
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 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. 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.
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.
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'
<!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.
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!
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.
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:
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.
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.
Let's run the same attack described above and see what happens.
As seen in the above gif, 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.
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 by Jason Meller.
The app I used in the above demos is available on GitHub. The master branch has the vulnerability and the session-replay-fixed branch contains the fix.