Password Spraying in Axum
How Password Spraying Manifests in Axum
Password spraying in Axum applications typically exploits weak authentication implementations that allow attackers to try common passwords across many accounts without triggering rate limits. In Axum, this often occurs when authentication middleware is improperly configured or when custom login handlers don't implement proper throttling.
A common vulnerable pattern in Axum looks like this:
async fn login(
Extension(pool): Extension<PgPool>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
let conn = pool.get().await?;
// Vulnerable: no rate limiting, no account lockout
let user = sqlx::query_as(
"SELECT * FROM users WHERE username = $1"
)
.bind(&payload.username)
.fetch_one(&conn)
.await
.ok();
if let Some(user) = user {
if verify_password(&payload.password, &user.password_hash) {
return Ok(Json(LoginResponse { token: generate_jwt(user) }));
}
}
Err(StatusCode::UNAUTHORIZED)
}
This handler is vulnerable because it responds identically whether the username exists or not, but more critically, it doesn't implement any rate limiting. An attacker can send thousands of requests with common passwords like "password123" across different usernames without detection.
The attack manifests through timing analysis. When a username doesn't exist, the database query fails immediately. When it does exist but the password is wrong, there's a slight delay. Attackers can exploit this to enumerate valid usernames before launching the spraying attack.
Another Axum-specific vulnerability occurs when using extractors that don't properly validate request volume:
async fn vulnerable_login(
Json(payload): Json<LoginRequest>,
State<AppState>: State<AppState>,
) -> Result<Json<LoginResponse>, StatusCode> {
// No rate limiting on the endpoint
// Attacker can send unlimited requests
process_login(&payload).await
}
Without middleware to limit requests per IP or per account, Axum applications become susceptible to credential stuffing at scale.
Axum-Specific Detection
Detecting password spraying in Axum requires monitoring both application logs and network traffic patterns. The most effective approach combines middleware-based monitoring with external scanning tools like middleBrick.
Implement middleware to track authentication attempts:
use axum_extra::rate_limit::RateLimiter;
use std::time::Duration;
async fn auth_middleware(
Extension(rate_limiter): Extension<RateLimiter>,
Json(payload): Json<LoginRequest>,
Extension(db): Extension<PgPool>,
) -> Result<Json<LoginResponse>, StatusCode> {
// Track attempts per IP and per username
let ip = extract_ip().await?;
let attempts = rate_limiter.get(&ip).await;
if attempts > 5 {
return Err(StatusCode::TOO_MANY_REQUESTS);
}
// Log all authentication attempts for analysis
sqlx::query(
"INSERT INTO auth_attempts (ip, username, success, timestamp) VALUES ($1, $2, $3, NOW())"
)
.bind(&ip)
.bind(&payload.username)
.bind(false)
.execute(&db)
.await?;
// Continue with authentication
process_login(&payload).await
}
middleBrick's black-box scanning approach is particularly effective for detecting password spraying vulnerabilities because it tests the unauthenticated attack surface without requiring credentials. The scanner sends multiple authentication attempts with common passwords and analyzes the responses for timing differences and error patterns.
For OpenAPI-based Axum applications, middleBrick can analyze your spec files to identify endpoints that lack proper authentication controls. The tool examines your axum::routing::post and axum::routing::get definitions to find authentication endpoints that might be vulnerable.
Key detection indicators include:
- Consistent response times regardless of credential validity
- Lack of account lockout mechanisms
- No IP-based rate limiting on authentication endpoints
- Detailed error messages that reveal whether usernames exist
- Missing multi-factor authentication requirements
Axum-Specific Remediation
Securing Axum applications against password spraying requires implementing proper rate limiting, account lockout mechanisms, and authentication best practices. Here's how to implement these protections using Axum's native features.
First, implement IP-based rate limiting using axum_extra:
use axum_extra::rate_limit::RateLimiter;
use std::time::Duration;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/login", post(login_with_rate_limit))
.layer(Extension(RateLimiter::new(10, Duration::from_secs(60))));
axum::Server::bind(&[([127, 0, 0, 1], 3000)])
.serve(app.into_make_service())
.await
.unwrap();
}
async fn login_with_rate_limit(
Extension(rate_limiter): Extension<RateLimiter>,
Json(payload): Json<LoginRequest>,
Extension(db): Extension<PgPool>,
) -> Result<Json<LoginResponse>, StatusCode> {
let ip = extract_ip().await?;
// Check rate limit before processing
if rate_limiter.check(&ip).await.is_some() {
return Err(StatusCode::TOO_MANY_REQUESTS);
}
// Check account-specific rate limiting
let account_attempts = get_auth_attempts(&db, &payload.username).await?;
if account_attempts > 3 {
return Err(StatusCode::TOO_MANY_REQUESTS);
}
// Process login
let result = verify_credentials(&db, &payload).await;
// Log attempt regardless of success
log_auth_attempt(&db, &payload.username, result.is_ok()).await;
result
}
Implement account lockout after failed attempts:
async fn verify_credentials(
db: &PgPool,
payload: &LoginRequest,
) -&; Result<LoginResponse, StatusCode> {
let conn = db.get().await?;
// Check if account is locked
let locked = sqlx::query_as(
"SELECT locked_until FROM users WHERE username = $1"
)
.bind(&payload.username)
.fetch_one(&conn)
.await
.optional()?;
if let Some(locked) = locked {
if locked.locked_until > chrono::Utc::now().naive_utc() {
return Err(StatusCode::LOCKED_OUT);
}
}
// Verify password
let user = sqlx::query_as(
"SELECT * FROM users WHERE username = $1"
)
.bind(&payload.username)
.fetch_one(&conn)
.await
.ok();
if let Some(user) = user {
if verify_password(&payload.password, &user.password_hash) {
// Reset failed attempts on success
reset_failed_attempts(db, &payload.username).await?;
return Ok(LoginResponse { token: generate_jwt(user) });
} else {
// Increment failed attempts
increment_failed_attempts(db, &payload.username).await?;
// Lock account after 5 failed attempts
if get_failed_attempts(db, &payload.username).await? >= 5 {
lock_account(db, &payload.username).await?;
}
}
}
Err(StatusCode::UNAUTHORIZED)
}
Always use constant-time comparison and uniform error responses:
use subtle::ConstantTimeEq;
fn verify_password(hashed: &str, provided: &str) -> bool {
// Use constant-time comparison to prevent timing attacks
let expected_hash = bcrypt::hash(provided, 12).unwrap();
let result = expected_hash.as_bytes().ct_eq(hashed.as_bytes());
result.unwrap_u8() == 1
}
Finally, integrate middleBrick into your development workflow to catch these issues early:
# Install middleBrick CLI
npm install -g middlebrick
# Scan your API before deployment
middlebrick scan https://api.yourdomain.com/login
# Add to CI/CD pipeline
# .github/workflows/security.yml
name: Security Scan
on: [push]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run middleBrick scan
run: middlebrick scan ${{ secrets.API_URL }} --fail-below B
These mitigations work together to prevent password spraying by limiting the volume of attempts, locking compromised accounts, and ensuring uniform response times that don't leak information to attackers.