Introduction

Welcome to the very first lesson of the "Security Misconfiguration" course! In this lesson, we'll explore the concept of default credentials and their impact on web application security. Default credentials are pre-set usernames and passwords that come with many applications and devices. While convenient for initial setup, they pose significant security risks if not changed.

By the end of this lesson, you'll learn how to identify, exploit, and secure endpoints that use default credentials. Let's dive in! 🚀

Understanding Default Credentials

Default credentials are the factory-set usernames and passwords that come with many software frameworks, applications, and hardware devices. They are intended for the initial setup process, allowing an administrator to log in for the first time without having to go through a complex user creation flow. Common examples include admin/admin, root/password, or test/test.

This vulnerability falls under the broader OWASP Top 10 category A07:2021 - Identification and Authentication Failures. The core issue is that these credentials are well-known and publicly documented. Attackers often use automated tools to scan for systems that still have these defaults enabled.

Default credentials exist primarily to help developers quickly test and set up applications during development. However, they often find their way into production environments due to rushed deployments, poor documentation, or simple oversight. Sometimes, teams intentionally keep them unchanged for "easier maintenance," which creates significant security risks. It's crucial to change all default credentials immediately upon deployment to prevent unauthorized access and protect your application from potential breaches.

Vulnerable Code Example

Let's look at a code snippet that demonstrates the use of default credentials in an admin panel. This example shows how an attacker might exploit these credentials to gain unauthorized access.

# Default admin credentials (NEVER do this in production)
DEFAULT_ADMIN = {"username": "admin", "password": "admin123"}

@router.post("/login")
async def admin_login(request: Request):
    body = await request.json()
    username = body.get("username")
    password = body.get("password")

    # If default credentials are still active, an attacker can log in
    if username == DEFAULT_ADMIN["username"] and password == DEFAULT_ADMIN["password"]:
        return {"message": "Login successful", "access": "FULL_ADMIN"}

    raise HTTPException(status_code=401, detail="Unauthorized")

@router.get("/users")
async def admin_users(request: Request):
    access = request.headers.get("access")

    # If attacker logs in using default credentials, they can dump user data
    if access == "FULL_ADMIN":
        return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

    raise HTTPException(status_code=403, detail="Forbidden")

In this code, the admin panel uses hardcoded default credentials (admin and admin123). Let's break down the vulnerabilities:

  1. Hardcoded Credentials: The DEFAULT_ADMIN dictionary stores the username and password directly in the source code. This is a major security risk, as anyone with access to the code repository can see the credentials.
  2. Weak Authentication Logic: The /login endpoint performs a simple string comparison to check the credentials. If they match, it returns a success message that includes "access": "FULL_ADMIN". This response explicitly tells the user how to authenticate for other endpoints.
  3. Insecure Authorization: The /users endpoint is particularly vulnerable because it relies on a simple, predictable header check (access == "FULL_ADMIN") for authorization. An attacker who has logged in with default credentials can easily access all user data by including this header in their request. Worse, an attacker who understands the system could potentially skip the login step and directly query the /users endpoint with the required header.
Exploiting the Vulnerability

Now, let's see how an attacker might exploit the vulnerable code using simple curl commands. This two-step process demonstrates how easy it is to gain unauthorized access when default credentials are left unchanged.

#!/bin/bash

# Step 1: Login with default credentials to discover the access mechanism
curl -X POST http://localhost:3000/api/admin/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "admin123"}'
# Expected output: {"message":"Login successful","access":"FULL_ADMIN"}

# Step 2: Use the discovered access header to access a protected endpoint
curl -X GET http://localhost:3000/api/admin/users \
  -H "access: FULL_ADMIN"
# Expected output: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]

Let's analyze the attack:

  1. Authentication: The attacker sends a POST request to the /api/admin/login endpoint.

    • -H "Content-Type: application/json" tells the server that the request body is in JSON format.
    • -d '{"username": "admin", "password": "admin123"}' provides the default credentials as the payload.
    • The server responds with a success message, which crucially reveals the key to the next step: "access":"FULL_ADMIN".
  2. Authorization & Data Exfiltration: The attacker now knows how to access protected endpoints. They send a GET request to /api/admin/users.

    • -H "access: FULL_ADMIN" includes the custom header required by the endpoint's insecure authorization check.
    • The server validates this header and, since it matches, returns the sensitive list of users.

This exploit successfully bypasses the application's security controls by leveraging the predictable nature of default credentials and the flawed authorization mechanism.

Implementing Secure Admin User Creation

The first step to fixing this vulnerability is to stop hardcoding credentials. The best practice is to store secrets like passwords in environment variables, which are kept separate from the application's source code. This prevents them from being accidentally committed to version control systems like Git.

The use of .env files with admin credentials and JWT secrets in this lesson is for demonstration purposes only; never commit real secrets or sensitive credentials to version control.

First, your .env file should look like this:

ADMIN_USERNAME=admin
ADMIN_PASSWORD=SecureP@ssw0rd2024!

Note that admin credentials in .env are for bootstrap only; long-term admin users should be stored as hashed records in the database, with rotation and audit logging.

Now, let's update the code to load credentials from these environment variables. We will also add a placeholder for a password verification function, which in a real application should use a strong hashing algorithm like Argon2 or bcrypt.

# Load admin credentials from environment variables
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")

if not ADMIN_USERNAME or not ADMIN_PASSWORD:
    raise RuntimeError("Admin credentials not configured in .env file")

def verify_password(plain_password: str, stored_password: str) -> bool:
    """
    Verify a password.
    WARNING: In a real production environment, you MUST use a secure
    hashing algorithm like bcrypt or Argon2 instead of plain text comparison.
    """
    return plain_password == stored_password

@router.post("/login")
async def admin_login(request: Request):
    body = await request.json()
    username = body.get("username")
    password = body.get("password")

    # Verify credentials from environment variables
    if username == ADMIN_USERNAME and verify_password(password, ADMIN_PASSWORD):
        return {"message": "Login successful", "access": "FULL_ADMIN"}

    raise HTTPException(status_code=401, detail="Unauthorized")

@router.get("/users")
async def admin_users(request: Request):
    access = request.headers.get("access")

    if access == "FULL_ADMIN":
        return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

    raise HTTPException(status_code=403, detail="Forbidden")

When choosing credentials for your default admin user, use a complex username that doesn't reveal the admin role (avoid "admin" or "root"), and generate a strong password of at least 16 characters with a mix of uppercase, lowercase, numbers, and special characters.

Remember to change these credentials immediately after the first deployment to production.

Implementing JWT-based Authentication

While moving credentials to environment variables is a good first step, our authorization model is still flawed. Relying on a static, predictable header is not secure. A much better approach is to use a standard, token-based authentication mechanism like JSON Web Tokens (JWT).

A JWT is a compact, digitally signed token that contains "claims" (e.g., user ID, roles, expiration date). When a user logs in, the server generates a JWT and sends it to the client. The client then includes this token in the header of subsequent requests to prove its identity. Because the token is signed, the server can verify its authenticity without needing to store session state.

Here's how you might implement JWT authentication in Python using FastAPI and jose:

SECRET_KEY = os.getenv("JWT_SECRET_KEY", "defaultSecret")

def create_access_token(data: dict, expires_delta: int = 3600):
    to_encode = data.copy()
    expire = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_delta)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm="HS256")

@router.post("/login")
async def admin_login(request: Request):
    body = await request.json()
    username = body.get("username")
    password = body.get("password")

    if username == ADMIN_USERNAME and verify_password(password, ADMIN_PASSWORD):
        # Generate a JWT token with admin role and expiration
        token = create_access_token({"user_id": 1, "role": "admin"})
        return {"token": token}

    raise HTTPException(status_code=401, detail="Unauthorized")

In this updated /login endpoint, instead of returning a simple message, we now generate a short-lived JWT. The token's payload contains the user's role, and it's signed with a secret key. This token acts as a temporary, verifiable credential for the user.

Securing the User Data Endpoint

Finally, let's secure the /users endpoint by requiring a valid JWT with an admin role. We can achieve this in FastAPI by using dependency injection, creating a chain of verification functions that run before our endpoint logic.

def authenticate_token(authorization: str = Header(...)):
    try:
        scheme, token = authorization.split()
        if scheme.lower() != "bearer":
            raise ValueError("Invalid auth scheme")
        # Verify the token's signature and expiration
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        return payload
    except Exception:
        raise HTTPException(status_code=401, detail="Invalid or expired token")

def verify_admin_role(payload: dict = Depends(authenticate_token)):
    # Check for the 'admin' role within the token's payload
    if payload.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    return payload

@router.get("/users")
async def admin_users(payload: dict = Depends(verify_admin_role)):
    # This endpoint is now only accessible to authenticated admin users
    return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

This new implementation is far more secure:

  1. authenticate_token: This dependency extracts the token from the Authorization: Bearer <token> header. It decodes the JWT, which automatically verifies its signature and checks if it has expired. If the token is invalid in any way, it raises a 401 Unauthorized error.
  2. verify_admin_role: This dependency runs after authenticate_token. It inspects the payload of the now-validated token to ensure the user has the admin role. If not, it raises a 403 Forbidden error.
  3. admin_users Endpoint: The endpoint now Depends on verify_admin_role. This means its code will only execute if a valid, non-expired token is provided and that token contains the admin role claim.

With these security measures in place, we've significantly improved our application's security posture.

Conclusion and Next Steps

In this lesson, we explored the risks associated with default credentials and how they can be exploited. We learned how to identify vulnerable endpoints and secure them using environment variables and JWT-based authentication. By implementing these best practices, you can protect your application from unauthorized access and potential breaches.

As you move on to the practice exercises, remember the importance of securing your endpoints and managing credentials properly. Good luck, and see you in the next lesson! 🎉

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal