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

Small security issue with :lockable #4981

Closed
cfeckardt opened this issue Nov 27, 2018 · 16 comments · Fixed by o2web/graphql-auth#19, DEFRA/waste-carriers-frontend#215 or #4996
Closed

Small security issue with :lockable #4981

cfeckardt opened this issue Nov 27, 2018 · 16 comments · Fixed by o2web/graphql-auth#19, DEFRA/waste-carriers-frontend#215 or #4996

Comments

@cfeckardt
Copy link

cfeckardt commented Nov 27, 2018

Environment

  • Ruby any
  • Rails any running a multithreaded server (like puma)
  • Devise 4.5.0

Current behavior

Pentesters found an issue where our users did not lock (using :lockable), despite running many many attempts to brute force the password.
To reproduce, try to login many times at exactly the same time (where your model is :lockable).
100 attempts within 1 milliseconds of each other will not increment the failed_attempts attribute on your user 100 times on a busy or slow database. They will most likely be incremented by approximately 100 % num_threads where num_threads is the number of threads your server is configured to.

This is a test that describes the behaviour that I would reasonably expect:

 describe '#increment_failed_attempts' do
   let(:user) { create :user }
   let(:same_user) { User.find(user.id) }
   it 'increments the count independently across instances' do
     expect {
       same_user.increment_failed_attempts
       same_user.save
       user.increment_failed_attempts
     }.to change { user.reload.failed_attempts }.by(2)
   end
 end

This test will fail in devise 4.5.0

Expected behavior

The issue arises because we read and set failed_attempts in two steps, instead of in one database transaction.

Below is an excerpt of the method from lockable.rb, my comments added:

def increment_failed_attempts
  self.failed_attempts ||= 0 # This line triggers a read in ActiveRecord if not already cached
  # some other thread now increments the counter to 1
  self.failed_attempts += 1 # We have already read the value 0, and will increment that erroneously to 1 again
end

Our workaround we use is something along the lines of:

def increment_failed_attempts
  self.failed_attempts = self.class.connection.execute("UPDATE SET (failed_attempts = failed_attempts + 1) WHERE id = #{self.id} RETURNING failed_attempts").getvalue(0,0)
end

which passes the test above. This solution is postgres specific (and also assumes integer ids) so may not be suitable for devise, but the idea stands. Reading and writing from this attribute needs to happen on at least a row level transaction in the database.

@tegon
Copy link
Member

tegon commented Dec 12, 2018

Hey @cfeckardt, thanks for reporting this.

Yeah, it definitely makes sense to do both operations in a row level transaction. We'll see how we can do this.

Thanks!

tegon added a commit that referenced this issue Dec 26, 2018
As reported in #4981, the
method `#increment_failed_attempts` of `Devise::Models::Lockable` was
not concurrency safe. The increment operation was being done in two
steps: first the value was read from the database, and then incremented
by 1. This may result in wrong values if two requests try to update the
value concurrently. For example:

Browser1 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2
         Browser2 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2

In the example above, `failed_attempts` should have been set to 3, but it will be set to 2.
This commit handles this case by calling ActiveRecord's `#increment!`
method, which will do this operation
[atomically](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-increment-21).
tegon added a commit that referenced this issue Dec 26, 2018
As reported in #4981, the
method `#increment_failed_attempts` of `Devise::Models::Lockable` was
not concurrency safe. The increment operation was being done in two
steps: first the value was read from the database, and then incremented
by 1. This may result in wrong values if two requests try to update the
value concurrently. For example:

Browser1 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2
         Browser2 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2

In the example above, `failed_attempts` should have been set to 3, but it will be set to 2.
This commit handles this case by calling ActiveRecord's `#increment!`
method, which will do this operation
[atomically](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-increment-21).

Co-authored-by: Marcos Ferreira <marcos.ferreira@plataformatec.com.br>
tegon added a commit that referenced this issue Dec 26, 2018
As reported in #4981, the
method `#increment_failed_attempts` of `Devise::Models::Lockable` was
not concurrency safe. The increment operation was being done in two
steps: first the value was read from the database, and then incremented
by 1. This may result in wrong values if two requests try to update the
value concurrently. For example:

Browser1 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2
         Browser2 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2

In the example above, `failed_attempts` should have been set to 3, but it will be set to 2.
This commit handles this case by calling ActiveRecord's `#increment!`
method, which will do both steps at once, reading the value straight
from the database.

More info: https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-increment-21

Co-authored-by: Marcos Ferreira <marcos.ferreira@plataformatec.com.br>
tegon added a commit that referenced this issue Dec 28, 2018
As reported in #4981, the method `#increment_failed_attempts` of `Devise::Models::Lockable` was
not concurrency safe. The increment operation was being done in two steps: first the value was read from the database, and then incremented by 1. This may result in wrong values if two requests try to update the value concurrently. For example:

```
Browser1 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2
    Browser2 -------> Read `failed_attempts` from DB (1) -------> Increment `failed_attempts` to 2
```

In the example above, `failed_attempts` should have been set to 3, but it will be set to 2. 

This commit handles this case by calling `ActiveRecord::CounterCache.increment_counter` method, which will do both steps at once, reading the value straight from the database.

This commit also adds a `ActiveRecord::AttributeMethods::Dirty#reload` call to ensure that the application gets the updated value - i.e. that other request might have updated. 
Although this does not ensure that the value is in fact the most recent one - other request could've updated it after the `reload` call - it seems good enough for this implementation. 
Even if a request does not locks the account because it has a stale value, the next one - that updated that value - will do it. That's why we decided not to use a pessimistic lock here.

Closes #4981.
@reedloden
Copy link

Has a CVE been assigned to this TOCTOU issue?

@tegon
Copy link
Member

tegon commented Feb 7, 2019

@reedloden Not yet. I want to apologize for taking so long to reply on this but I knew nothing about CVEs and had to do some research on this topic before I was able to do it.
Anyway, I requested one via DWF's form and will let you know as soon as I get a response.

@tegon
Copy link
Member

tegon commented Feb 7, 2019

@cfeckardt I'd also like to add that for security issues it's better to report to us directly via the opensource@plataformatec.com.br email (like we mention in our contribution guide). This way we don't leak the vulnerability before we can release a fix.

At first, I thought this issue wasn't a security breach since the attacker still has to succeed on a brute force attack in order to exploit this. After talking to other people and analyzing deeply, it really is a vulnerability since people are using the lockable module thinking that their applications are going to block an account after a defined number of failed attempts, but in this case, this might not be true.

Now the issue is public for quite some time, so I guess it's ok to leave it here. Just to keep everyone on the same page, this was fixed on v4.6.0.

Thanks, everyone!

@reedloden
Copy link

I prepped rubysec/ruby-advisory-db#375 for this. Just need the CVE ID.

@cfeckardt let me know if you run into issues getting a CVE ID. I can make something happen if needed.

@tegon
Copy link
Member

tegon commented Feb 15, 2019

@reedloden Thanks, I've accepted the terms last Sunday and I'm waiting for a response. I'll let you know if I don't get any.

@reedloden
Copy link

@tegon been 19 days... any updates?

@tegon
Copy link
Member

tegon commented Mar 13, 2019

I asked Kurt Seifried yesterday about it and I'm waiting for a response. This was addressed in this pull request that later got closed, so I'm not sure if it's going to be included in a new PR or not.

@tegon
Copy link
Member

tegon commented Mar 14, 2019

@reedloden Kurt said that the DWF has been shut down 😞
Can you help us to get the CVE?

@reedloden
Copy link

@tegon Huh, hadn't heard that. Sad to hear... In any case, this has been assigned CVE-2019-5421. I'll get this updated at MITRE shortly.

reedloden added a commit to reedloden/ruby-advisory-db that referenced this issue Mar 14, 2019
reedloden added a commit to rubysec/ruby-advisory-db that referenced this issue Mar 14, 2019
@tegon
Copy link
Member

tegon commented Mar 14, 2019

@reedloden Thank you!

@dougo
Copy link

dougo commented Mar 14, 2019

Any plans to make a patch release for 4.4.3?

@tegon
Copy link
Member

tegon commented Mar 15, 2019

@dougo There are no plans yet but can we can discuss it. Are you having complications updating to 4.6 - e.g. something is broken for you?

@dougo
Copy link

dougo commented Mar 15, 2019

Yep, the "Set encrypted_password to nil when password is set to nil" bugfix made a bunch of our tests fail when I upgraded (I guess we were relying on it being empty string, and we have a non-null constraint in the db). Probably easy to fix, but not the sort of yak-shaving I wanted to do while addressing a CVE...

tobyprivett added a commit to mcagov/waves-app that referenced this issue Mar 18, 2019
tobyprivett added a commit to mcagov/waves-app that referenced this issue Mar 18, 2019
jsugarman added a commit to ministryofjustice/Claim-for-Crown-Court-Defence that referenced this issue Apr 1, 2019
microweb10 added a commit to AyuntamientoMadrid/consul that referenced this issue Apr 1, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
microweb10 added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 1, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
augustogoldoni-jaya referenced this issue in spree/spree_auth_devise Apr 2, 2019
Updates the requirements on [devise](https://github.com/plataformatec/devise) to permit the latest version.
- [Release notes](https://github.com/plataformatec/devise/releases)
- [Changelog](https://github.com/plataformatec/devise/blob/master/CHANGELOG.md)
- [Commits](heartcombo/devise@v4.4.0...v4.6.1)

Signed-off-by: dependabot[bot] <support@dependabot.com>
antw added a commit to quintel/etengine that referenced this issue Apr 3, 2019
antw added a commit to quintel/etengine that referenced this issue Apr 3, 2019
microweb10 added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 4, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
dsander added a commit to dsander/huginn that referenced this issue Apr 14, 2019
CVE-2019-5421
More information
moderate severity
Vulnerable versions: < 4.6.0
Patched version: 4.6.0

Devise ruby gem before 4.6.0 when the lockable module is used is
vulnerable to a time-of-check time-of-use (TOCTOU) race condition due to
increment_failed_attempts within the Devise::Models::Lockable class not
being concurrency safe.

heartcombo/devise#4981
microweb10 added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 16, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
microweb10 added a commit to AyuntamientoMadrid/consul that referenced this issue Apr 17, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
microweb10 added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 17, 2019
There was a security vulnerability with previous version

heartcombo/devise#4981
moustachu added a commit to OpenSourcePolitics/decidim-questions that referenced this issue May 3, 2019
benreyn added a commit to benreyn/partner that referenced this issue Jun 25, 2019
benreyn added a commit to rubyforgood/partner that referenced this issue Jun 26, 2019
FinnWoelm added a commit to OpenlyOne/openly-rails that referenced this issue Oct 28, 2019
FinnWoelm added a commit to OpenlyOne/openly-rails that referenced this issue Oct 28, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment