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

Throttle second factor attempts #477

Closed
iandunn opened this issue Oct 14, 2022 · 20 comments · Fixed by #482
Closed

Throttle second factor attempts #477

iandunn opened this issue Oct 14, 2022 · 20 comments · Fixed by #482
Assignees
Milestone

Comments

@iandunn
Copy link
Member

iandunn commented Oct 14, 2022

TIL, brute-forcing TOTP is much easier than I assumed (yay for checking assumptions!). That applies to other providers as well to varying degrees.

It seems like a simple solution would be to throttle attempts with increasing severity:

# of Failed Attempts Delay (seconds)
0 0
1 0
2 1
3 3
4 5
5 10
6 20

... and so on. There could be a filter on that mapping of attempts -> delay, to allow customization. Implementing it should be straightforward with a usermeta counter, and a sleep() inside login_form_validate_2fa(), and some kind of notice to the user.

After ~8 attempts, though, it may be safest to assume that the password has been compromised, and to force a reset and email the user.

@kasparsd
Copy link
Collaborator

One thing to consider is that some hosts like Pantheon have a timeout of 30 seconds for all PHP so it might produce unexpected side-effects and prevent users from logging in. It could also trip some debugging tools that monitor long-running PHP processes.

Implementing it should be straightforward with a usermeta counter, and a sleep() inside login_form_validate_2fa()

We could also consider failing all attempts before the timeout is reached. That would work well with a generic notice about "Please try again in X minutes/seconds".

@iandunn
Copy link
Member Author

iandunn commented Oct 17, 2022

Er, yeah, that's a much better UX 👍🏻

It'd be nice to maybe do both, so that it ties up the scammer's resources a little bit. Maybe flush the output buffer so the user sees the error, but keep the connection open. That's probably getting too complicated, though. Your idea is much simpler 👍🏻

@jeffpaul jeffpaul added this to the Future Release milestone Oct 17, 2022
@iandunn iandunn self-assigned this Oct 17, 2022
@dd32
Copy link
Member

dd32 commented Oct 18, 2022

I question if this shouldn't be offloaded into plugins like limit-login-attempts & jetpack rather than being included in this plugin. Do those plugins already work with this perhaps? (I guess it would depend on which hooks they use)

@iandunn
Copy link
Member Author

iandunn commented Oct 18, 2022

🤔 I think it makes sense to do it here because the potential vulnerability is introduced by this plugin, and we're in a much better position to be effective than a general purpose plugin.

In the context of passwords you can't just block the account itself -- because that'd DDoS the real user -- so you're stuck playing whac-a-mole with IPs etc. In this plugin, we have the advantage of knowing that only the real user should be able to reach the 2nd factor to begin with. So any failed attempt is either a legit user making a typo, or a compromised password.

I worry that, in practice, a large percent of users won't have a general purpose plugin installed, and even if they do it might make assumptions that make it fail or allow bypasses in this context.

If folks want though, we could drop the throttling feature, and only keep the reset password feature. That's the most essential part IMO.

@calvinalkan
Copy link
Contributor

@iandunn

a sleep() inside login_form_validate_2fa(),

This is a DOS waiting to happen. You should never sleep for throttling. Instead, after reaching a threshold, all attempts, whether with a valid code or not are just ignored.

@iandunn
Copy link
Member Author

iandunn commented Nov 3, 2022

Yeah, I was hoping it'd be possible to do it in a way that'd avoid that, but I haven't tested that. A DoS would probably still be more likely, just not as much.

I ended up not doing any throttling at all in #482 , since that doesn't address the fundamental problem that the attacker has already compromised the password. It just gets reset after 7 failed attempts on the 2nd factor. That prevents brute forcing while also fixing the root problem.

@planetahuevo
Copy link

It just gets reset after 7 failed attempts on the 2nd factor. That prevents brute forcing while also fixing the root problem.

Would be possible to change the number using a filter? I am thinking on increase it for particular installations.

@planetahuevo
Copy link

Another related question, how often does the fail attempts reset the count? Is it 7 fails in one day? in one month?

@iandunn
Copy link
Member Author

iandunn commented Nov 3, 2022

Yeah, you can use the two_factor_failed_attempt_limit filter to customize the limit:

/**
* Filters the maximum number of failed attempts on a 2nd factor before the user's
* password will be reset. After a reasonable number of attempts, it's safe to assume
* that the password been compromised and an attacker is trying to brute force the 2nd
* factor.
*
* ⚠️ Many 2nd factors -- like TOTP and backup codes -- can be brute-forced in a matter
* of days, so disabling this is strongly discouraged.
*
* @param int $limit The number of attempts before the password is reset.
*/
self::$failed_attempt_limit = apply_filters( 'two_factor_failed_attempt_limit', 7 );

Do you think 7 is too low for some valid users? We could increase it some, it just didn't seem necessary to me, but maybe that was overly strict?

The attempt counter only gets reset after a successful password is entered, there's no time-based reset. Are there scenarios where that would be beneficial?

@planetahuevo
Copy link

Do you think 7 is too low for some valid users? We could increase it some, it just didn't seem necessary to me, but maybe that was overly strict?

I think 7 is good, but I can imagine scenarios where a really high number could be useful, where this is not that important (they do not have it now so...), but it would be great to have it as a "last resort". So probably a 20 o something like that. I can see myself setting this big number for some projects.
If the filter is there, then that is perfect. Thanks!

The attempt counter only gets reset after a successful password is entered, there's no time-based reset. Are there scenarios where that would be beneficial?

No, if it get reset once they login, it is good enough. :)

@planetahuevo
Copy link

I am checking the code now.
Using the admin email to send could not be a good idea. Some people use emails as admin that are not part of the same domain, or the admin email is not the brand email. A filter to customise this? or use the classic [email protected] instead?

@iandunn
Copy link
Member Author

iandunn commented Nov 4, 2022

I'm not sure I understand, are you concerned about the message being flagged as spam? The emails are sent from the default wordpress@ address.

@planetahuevo
Copy link

@iandunn I just think that that email is not good for sending, but for receiving notifications, but I agree this is a default from WP and nothing wrong with your code.
So my only request would be to have a filter to replace the email that send the notifications.

@iandunn
Copy link
Member Author

iandunn commented Nov 7, 2022

Ah, I see; that's a fair point. I think Core's wp_mail filter could be used for that purpose.

@planetahuevo
Copy link

planetahuevo commented Nov 7, 2022

Well, I already do that on some installations, but you could use a filter to replace the variable here: $admin_email

$admin_email = get_option( 'admin_email' );

@iandunn
Copy link
Member Author

iandunn commented Jan 20, 2023

The need for this came up during the security audit, and others seem to agree. @dd32 , do you feel strongly that this and #482 are the wrong approach? If not I'm leaning towards going ahead with the merge.

@dd32
Copy link
Member

dd32 commented Feb 2, 2023

The need for this came up during the security audit, and others seem to agree.

I've put together #510 as an alternative to the approach of resetting the password, and instead simply rate limiting the two-factor login attempts, following a similar option as presented in this issue of simply having an incremental backoff.

I'm concerned that forcing a user to reset their password after 7 failed tries might be a bit too harsh for some, and it has the potential to lock out a legitimate user entirely from their account if their email is no longer accessible or not accessible at that given point in time. It's not unknown for webservers to have outgoing email issues either.

By rate limiting it like that, we're reducing the viability of brute force attacks, while hopefully aggravating as few legitimate logins as possible. I've started with it set at 5s in #510 (Which should allow for an average human to input 2 attempts before seeing the 10s delay) but it could be set even lower to allow for the average user to hit the block after 3-4 attempts, while still being a high enough lockout period to deter brute-force attacks.

On some installations (or for some users), force resetting the password might be the correct way to go about it, as could maybe forcing them to use a backup code or emailed code, but none of these solutions fit every installations need.. rate limiting however is complementary to all of those "more severe" heavy handed approaches.

@iandunn
Copy link
Member Author

iandunn commented Feb 3, 2023

forcing a user to reset their password after 7 failed tries

In hindsight, 7 is too strict 👍🏻 It could be raised to ~30 without having a big impact on security. Rate limiting has the same essential problem of balancing security and UX.

lock out a legitimate user entirely from their account if their email is no longer accessible

Fair point. That situation already exists if they lose their password, etc, though. It's also partially mitigated by Core prompting users to confirm/update it. Resetting would definitely result in more support requests for admins, but I feel like the trade off is worth it.

It's not unknown for webservers to have outgoing email issues either.

Very true 👍🏻 #482 could replace the emails with a wp-login.php notice, similar to what #510 does (or add that in addition to the emails).

rate limiting [aggravates] as few legitimate logins as possible

That still causes aggravation, though, just a different type. In both PRs the important thing is setting the trigger high enough that legitimate folks only encounter it rarely, if at all.

maybe forcing them to use a backup code or emailed code

I'd be leery of having email, since it'd bypass 2FA, but it could be appropriate for some installs/users.

@iandunn
Copy link
Member Author

iandunn commented Feb 3, 2023

I think you raised some good points, and IMO #510 is also a good way to approach it. I would definitely rather see us do that than nothing.

I'm still a bit stuck on not resetting the password, though. It feels pretty bad to know that there will be situations where we have a high degree of confidence that the user's password is compromised, but will continue to allow it to be used.

Given the overlap between the PRs, and various threat models/risk tolerances/etc that users have, maybe this is a case where both approaches should be used, but configurable via filters?

In the interest of keeping things moving, though, I'm also fine w/ merging #510 for now and revisiting resets later.

@planetahuevo
Copy link

Hi, I know little about all this, but I want also add, in case it has not been considered on this and if it could affect, that sometimes the problem could be the mail provider.
I was not able to login into a website today because of this:
https://status.postmarkapp.com/notices/yv2pvsngp9ds5y8a-service-issue-outbound-sending-is-delayed
Until I realise of that, I clicked the "resend" button several times...

@jeffpaul jeffpaul removed this from the Future Release milestone Feb 24, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants