Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent user enumeration by timing attacks #909

Merged
merged 1 commit into from
Aug 7, 2020

Conversation

composerinteralia
Copy link
Contributor

Closes #636

Before this commit, we were skipping any BCrypt (or other password
strategy) checks when we couldn't find the user by email. This opened up
the possibility for a bad actor to detect whether an account existed for
a given email address based on the timing of the response.

This commit mitigates the problem by creating a throw away user and
setting a dummy password on it, triggering BCrypt to create an encrypted
password.

The added tests were consistently failing before this change because
authentication for accounts that did NOT exists was taking 1-2ms longer
that for accounts that did exist (with the cost set to
::BCrypt::Engine::MIN_COST in tests). The timings are now very close.
The be_within delta could almost have been 0.0001, but I stuck with
1ms to avoid any flakiness in the test suite.

Closes #636

Before this commit, we were skipping any BCrypt (or other password
strategy) checks when we couldn't find the user by email. This opened up
the possibility for a bad actor to detect whether an account existed for
a given email address based on the timing of the response.

This commit mitigates the problem by creating a throw away user and
setting a dummy password on it, triggering BCrypt to create an encrypted
password.

The added tests were consistently failing before this change because
authentication for accounts that did NOT exists was taking 1-2ms longer
that for accounts that did exist (with the cost set to
::BCrypt::Engine::MIN_COST in tests). The timings are now very close.
The `be_within` delta could almost have been 0.0001, but I stuck with
1ms to avoid any flakiness in the test suite.
spec/models/user_spec.rb Show resolved Hide resolved
spec/models/user_spec.rb Show resolved Hide resolved
spec/models/user_spec.rb Show resolved Hide resolved
spec/models/user_spec.rb Show resolved Hide resolved
spec/models/user_spec.rb Show resolved Hide resolved
lib/clearance/user.rb Show resolved Hide resolved
Copy link
Contributor

@gnfisher gnfisher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL!

I have just one question regarding testing but this looks great! 👍

@@ -47,6 +47,35 @@
expect(User.authenticate(user.email, "bad_password")).to be_nil
end

it "takes the same amount of time to authenticate regardless of whether user exists" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is running just one comparison enough to feel confident here? Or should we run it multiple times and make sure all of them remain within the expected delta?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. How many times do you think would be reasonable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly don't know if it is even necessary. I was just thinking of the fuzz testing I've seen done in Elm apps.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I run each 100 times I can bump the be_within delta from 0.001 to 0.0001 (* 100). That is nice, but it also adds 0.4 seconds to our test suite. I am torn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I am going to leave it alone for now to keep the test suite as fast as possible. These tests in their current form are enough to catch a regression to the old behavior. We can always revisit this if we find that the 1ms delta is not sufficient.

Copy link
Contributor

@eebs eebs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! I had a question about the specifics around the timings.

DUMMY_PASSWORD = "*"

def prevent_timing_attack
new(password: DUMMY_PASSWORD)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that this will call password=, which will result in the password strategy running. For BCrypt that is

def password=(new_password)
@password = new_password
if new_password.present?
self.encrypted_password = ::BCrypt::Password.create(
new_password,
cost: configured_bcrypt_cost,
)
end
end

In the case where a user was found, we'd end up calling authenticated?, which would again call the password strategy

def authenticated?(password)
if encrypted_password.present?
::BCrypt::Password.new(encrypted_password) == password
end
end

We want these two code paths to take the same amount of time to avoid leaking via timing attacks. Do BCrypt::Password#== and BCrypt::Password.create use the same process under the hood that causes them to take the same amount of time? Will this be true for all password strategies?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For BCrypt the timings seem to be the same

require 'benchmark'
require 'benchmark/ips'
require "bcrypt"

encrypted_password = BCrypt::Password.create("password")

Benchmark.ips do |bm|
  bm.report("compare") { BCrypt::Password.new(encrypted_password) == "password" }
  bm.report("create") { BCrypt::Password.create("password") }
  bm.compare!
end
Warming up --------------------------------------
             compare     1.000  i/100ms
              create     1.000  i/100ms
Calculating -------------------------------------
             compare      4.889  (± 0.0%) i/s -     25.000  in   5.114814s
              create      4.870  (± 0.0%) i/s -     25.000  in   5.134016s

Comparison:
             compare:        4.9 i/s
              create:        4.9 i/s - 1.00x  (± 0.00) slower

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For argon2 as well:

require 'benchmark'
require 'benchmark/ips'
require "argon2"

encrypted_password = Argon2::Password.new.create("password")

Benchmark.ips do |bm|
  bm.report("compare") { Argon2::Password.verify_password("password", encrypted_password) }
  bm.report("create") { Argon2::Password.new.create("password") }
  bm.compare!
end
             compare     1.000  i/100ms
              create     1.000  i/100ms
Calculating -------------------------------------
             compare      8.335  (± 0.0%) i/s -     42.000  in   5.044675s
              create      8.314  (± 0.0%) i/s -     42.000  in   5.057424s

Comparison:
             compare:        8.3 i/s
              create:        8.3 i/s - 1.00x  (± 0.00) slower

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for the benchmark! I think this is the best we can do until something changes. 👍

Copy link
Contributor Author

@composerinteralia composerinteralia Aug 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with this approach because it was the only way I could think of fix this for any password strategy. Another way to fix would be to build a DUMMY_ENCRYPTED_PASSWORD and check against that, but the password strategies don't currently have an API for doing that so that would only work for our officially supported password strategies by modifying the API a bit.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think what you have here is excellent. If we find that some supported strategy in the future has different timings for creating and equality checks we can revisit this.

@composerinteralia
Copy link
Contributor Author

composerinteralia commented Aug 7, 2020

@eebs @gnfisher I am thinking of sending out a patch release today with just this change (we can release the other bug fixes when we meet again next week). I would create a new branch off of v2.2.0, apply this one commit, and release from that branch.

@composerinteralia composerinteralia merged commit ce17d2d into master Aug 7, 2020
@composerinteralia composerinteralia deleted the user-enumeration-by-timing branch August 7, 2020 12:56
@eebs
Copy link
Contributor

eebs commented Aug 7, 2020

Sounds good. I'd love to pair on that with you if we can find some overlapping time.

@gnfisher
Copy link
Contributor

gnfisher commented Aug 7, 2020

I would also join if the time works out!

gingerlime pushed a commit to gingerlime/clearance that referenced this pull request Nov 29, 2020
* see thoughtbot#916
* similar to thoughtbot#909
* also see GHSA-hrqr-hxpp-chr3
  for an example of the type of attack that could be possible with an
  injectable cookie value
* Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html
  since Rails 3 (??) which prevents tampering
* using a signed cookie instead of a plain one, means the attacker cannot
  forge the cookie value, and therefore cannot perform timing attacks
  to find a valid token
* another added value is that tampering with the cookie will
  not even hit the database
* added a configuration parameter `signed_cookie` so this is optional
  and defaults to false for backwards compatibility
  (however, for better security, it might be better to issue a breaking
   change and default to true)
* updated specs
gingerlime pushed a commit to gingerlime/clearance that referenced this pull request Nov 29, 2020
* see thoughtbot#916
* similar to thoughtbot#909
* also see GHSA-hrqr-hxpp-chr3
  for an example of the type of attack that could be possible with an
  injectable cookie value
* Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html
  since Rails 3 (??) which prevents tampering
* using a signed cookie instead of a plain one, means the attacker cannot
  forge the cookie value, and therefore cannot perform timing attacks
  to find a valid token
* another added value is that tampering with the cookie will
  not even hit the database
* added a configuration parameter `signed_cookie` so this is optional
  and defaults to false for backwards compatibility
  (however, for better security, it might be better to issue a breaking
   change and default to true)
* changed the add_cookies_to_headers method to use ActionDispatch /
  Rails' cookie-handling code to set the cookie
* updated specs
gingerlime pushed a commit to gingerlime/clearance that referenced this pull request Nov 29, 2020
* see thoughtbot#916
* similar to thoughtbot#909
* also see GHSA-hrqr-hxpp-chr3
  for an example of the type of attack that could be possible with an
  injectable cookie value
* Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html
  since Rails 3 (??) which prevents tampering
* using a signed cookie instead of a plain one, means the attacker cannot
  forge the cookie value, and therefore cannot perform timing attacks
  to find a valid token
* another added value is that tampering with the cookie will
  not even hit the database
* added a configuration parameter `signed_cookie` so this is optional
  and defaults to false for backwards compatibility
  (however, for better security, it might be better to issue a breaking
   change and default to true)
* changed the add_cookies_to_headers method to use ActionDispatch /
  Rails' cookie-handling code to set the cookie
* updated specs
gnfisher pushed a commit that referenced this pull request Mar 5, 2021
This commit introduces signed cookies into Clearance using the signed cookies
functionality provided by
[ActionDispatch](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html).
By using a signed cookie an attacker cannot forge the cookie value, and
therefore cannot perform timing attacks to find a valid token. See [this Rack
security
advisory](GHSA-hrqr-hxpp-chr3)
for an example of the type of attack that could be possible with an injectable
cookie value.

This change adds an optional configuration parameter `signed_cookie` which
defaults to false for backwards compatibility and does not use a signed cookie.
The other two options are `true` to use a signed cookie and `:migrate` which
converts unsigned cookies to signed ones and provides a safe transition path.
You can set this via `Clearance.configure` in an initializer:

```ruby
# ./config/initializers/clearance.rb

Clearance.configure do |config|
  # ...

  config.signed_cookie = :migrate

  # ...
end
```

This change also switched to using Rail's `ActionDispatch` for cookie handling
rather than `Rack::Utils`.

See related issues and pull requests:

* #916
* Similar to #909

Co-authored-by: Yoav Aner <[email protected]>
Co-authored-by: Eebs Kobeissi <[email protected]>
MottiniMauro pushed a commit that referenced this pull request Mar 5, 2021
This commit introduces signed cookies into Clearance using the signed cookies
functionality provided by
[ActionDispatch](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html).
By using a signed cookie an attacker cannot forge the cookie value, and
therefore cannot perform timing attacks to find a valid token. See [this Rack
security
advisory](GHSA-hrqr-hxpp-chr3)
for an example of the type of attack that could be possible with an injectable
cookie value.

This change adds an optional configuration parameter `signed_cookie` which
defaults to false for backwards compatibility and does not use a signed cookie.
The other two options are `true` to use a signed cookie and `:migrate` which
converts unsigned cookies to signed ones and provides a safe transition path.
You can set this via `Clearance.configure` in an initializer:

```ruby
# ./config/initializers/clearance.rb

Clearance.configure do |config|
  # ...

  config.signed_cookie = :migrate

  # ...
end
```

This change also switched to using Rail's `ActionDispatch` for cookie handling
rather than `Rack::Utils`.

See related issues and pull requests:

* #916
* Similar to #909

Co-authored-by: Yoav Aner <[email protected]>
Co-authored-by: Eebs Kobeissi <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Make user enumeration by timing attacks more difficult
3 participants